Browse Source

Feature/color changed values instead of glowing and add a live indicator

pull/8249/head
mister-ideas 4 days ago
parent
commit
92ecc294de
  1. 2
      CHANGELOG.md
  2. 1
      apps/client/src/app/components/header/header.component.html
  3. 2
      apps/client/src/app/components/header/header.component.ts
  4. 1
      apps/client/src/app/components/home-holdings/home-holdings.component.ts
  5. 2
      apps/client/src/app/components/home-overview/home-overview.component.ts
  6. 44
      apps/client/src/app/components/live-indicator/live-indicator.component.scss
  7. 67
      apps/client/src/app/components/live-indicator/live-indicator.component.ts
  8. 1
      apps/client/src/app/pages/accounts/accounts-page.component.ts
  9. 1
      apps/client/src/app/pages/portfolio/analysis/analysis-page.component.ts
  10. 34
      apps/client/src/app/services/auto-refresh/auto-refresh.service.spec.ts
  11. 42
      apps/client/src/app/services/auto-refresh/auto-refresh.service.ts
  12. 85
      apps/client/src/styles.scss
  13. 1
      libs/ui/src/lib/accounts-table/accounts-table.component.html
  14. 2
      libs/ui/src/lib/accounts-table/accounts-table.component.ts
  15. 1
      libs/ui/src/lib/holdings-table/holdings-table.component.html
  16. 2
      libs/ui/src/lib/holdings-table/holdings-table.component.ts

2
CHANGELOG.md

@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added ### Added
- Added a periodic refresh of the numbers on the home, analysis and accounts pages while the page is visible, highlighting changed values and table rows - Added a periodic refresh of the numbers on the home, analysis and accounts pages while the page is visible, briefly coloring changed values green or red, with a live indicator in the header
### Changed ### Changed

1
apps/client/src/app/components/header/header.component.html

@ -12,6 +12,7 @@
</a> </a>
</div> </div>
<span class="gf-spacer"></span> <span class="gf-spacer"></span>
<gf-live-indicator class="px-2" />
<ul class="align-items-center d-flex list-inline m-0 px-2"> <ul class="align-items-center d-flex list-inline m-0 px-2">
<li class="list-inline-item"> <li class="list-inline-item">
<a <a

2
apps/client/src/app/components/header/header.component.ts

@ -1,3 +1,4 @@
import { GfLiveIndicatorComponent } from '@ghostfolio/client/components/live-indicator/live-indicator.component';
import { import {
LoginWithAccessTokenDialogParams, LoginWithAccessTokenDialogParams,
LoginWithAccessTokenDialogResult LoginWithAccessTokenDialogResult
@ -61,6 +62,7 @@ import { catchError } from 'rxjs/operators';
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ imports: [
GfAssistantComponent, GfAssistantComponent,
GfLiveIndicatorComponent,
GfLogoComponent, GfLogoComponent,
GfPremiumIndicatorComponent, GfPremiumIndicatorComponent,
IonIcon, IonIcon,

1
apps/client/src/app/components/home-holdings/home-holdings.component.ts

@ -153,6 +153,7 @@ export class GfHomeHoldingsComponent implements OnInit {
.subscribe(({ holdings }) => { .subscribe(({ holdings }) => {
this.holdings = holdings; this.holdings = holdings;
this.autoRefreshService.markUpdated();
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
}); });
} }

2
apps/client/src/app/components/home-overview/home-overview.component.ts

@ -168,6 +168,8 @@ export class GfHomeOverviewComponent implements OnInit {
.subscribe(({ errors, performance }) => { .subscribe(({ errors, performance }) => {
this.errors.set(errors ?? []); this.errors.set(errors ?? []);
this.performance.set(performance); this.performance.set(performance);
this.autoRefreshService.markUpdated();
}); });
this.autoRefreshService.refresh$ this.autoRefreshService.refresh$

44
apps/client/src/app/components/live-indicator/live-indicator.component.scss

@ -0,0 +1,44 @@
:host {
display: inline-flex;
}
.dot {
background-color: var(--bs-success, #198754);
border-radius: 50%;
display: inline-block;
height: 0.5rem;
opacity: 0.65;
position: relative;
width: 0.5rem;
&::after {
border: 2px solid var(--bs-success, #198754);
border-radius: 50%;
content: '';
inset: -2px;
opacity: 0;
position: absolute;
}
&.pulse::after {
animation: gf-live-pulse 1.2s ease-out;
}
}
@keyframes gf-live-pulse {
from {
opacity: 0.8;
transform: scale(1);
}
to {
opacity: 0;
transform: scale(2.6);
}
}
@media (prefers-reduced-motion: reduce) {
.dot.pulse::after {
animation: none;
}
}

67
apps/client/src/app/components/live-indicator/live-indicator.component.ts

@ -0,0 +1,67 @@
import { AutoRefreshService } from '@ghostfolio/client/services/auto-refresh/auto-refresh.service';
import {
ChangeDetectionStrategy,
Component,
computed,
effect,
ElementRef,
inject,
viewChild
} from '@angular/core';
/**
* A small dot which pulses each time the numbers of the page have been
* refreshed in the background. The title shows the time of the last refresh.
*/
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: 'gf-live-indicator',
styleUrls: ['./live-indicator.component.scss'],
template: `
@if (lastUpdated()) {
<span
class="align-items-center d-flex"
role="status"
[attr.aria-label]="title()"
[title]="title()"
>
<span #dot class="dot"></span>
</span>
}
`
})
export class GfLiveIndicatorComponent {
protected readonly lastUpdated = inject(AutoRefreshService).lastUpdated;
protected readonly title = computed(() => {
const lastUpdated = this.lastUpdated();
return lastUpdated
? $localize`Live` +
' · ' +
$localize`Updated` +
' ' +
lastUpdated.toLocaleTimeString()
: '';
});
private readonly dot = viewChild<ElementRef<HTMLElement>>('dot');
public constructor() {
effect(() => {
this.lastUpdated();
const element = this.dot()?.nativeElement;
if (element) {
element.classList.remove('pulse');
// Force a reflow to restart the animation
void element.offsetWidth;
element.classList.add('pulse');
}
});
}
}

1
apps/client/src/app/pages/accounts/accounts-page.component.ts

@ -103,6 +103,7 @@ export class GfAccountsPageComponent implements OnInit {
) )
.subscribe((response) => { .subscribe((response) => {
this.updateAccounts(response); this.updateAccounts(response);
this.autoRefreshService.markUpdated();
}); });
} }

1
apps/client/src/app/pages/portfolio/analysis/analysis-page.component.ts

@ -196,6 +196,7 @@ export class GfAnalysisPageComponent implements OnInit {
this.updateTopAndBottomHoldings(holdings); this.updateTopAndBottomHoldings(holdings);
this.autoRefreshService.markUpdated();
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
}); });
} }

34
apps/client/src/app/services/auto-refresh/auto-refresh.service.spec.ts

@ -1,6 +1,8 @@
import { import {
AUTO_REFRESH_INTERVAL_MS, AUTO_REFRESH_INTERVAL_MS,
AutoRefreshService AUTO_REFRESH_INTERVAL_STORAGE_KEY,
AutoRefreshService,
getAutoRefreshInterval
} from './auto-refresh.service'; } from './auto-refresh.service';
describe('AutoRefreshService', () => { describe('AutoRefreshService', () => {
@ -54,3 +56,33 @@ describe('AutoRefreshService', () => {
subscription.unsubscribe(); subscription.unsubscribe();
}); });
}); });
describe('getAutoRefreshInterval', () => {
const storageWith = (value: string | null) => ({
getItem: (key: string) =>
key === AUTO_REFRESH_INTERVAL_STORAGE_KEY ? value : null
});
it('returns the default without an override', () => {
expect(getAutoRefreshInterval(storageWith(null))).toBe(
AUTO_REFRESH_INTERVAL_MS
);
});
it('returns a valid override', () => {
expect(getAutoRefreshInterval(storageWith('1000'))).toBe(1000);
expect(getAutoRefreshInterval(storageWith('30000'))).toBe(30_000);
});
it('ignores an override outside of 1 second to 1 minute', () => {
expect(getAutoRefreshInterval(storageWith('200'))).toBe(
AUTO_REFRESH_INTERVAL_MS
);
expect(getAutoRefreshInterval(storageWith('600000'))).toBe(
AUTO_REFRESH_INTERVAL_MS
);
expect(getAutoRefreshInterval(storageWith('abc'))).toBe(
AUTO_REFRESH_INTERVAL_MS
);
});
});

42
apps/client/src/app/services/auto-refresh/auto-refresh.service.ts

@ -1,4 +1,4 @@
import { Service } from '@angular/core'; import { Service, signal } from '@angular/core';
import { EMPTY, fromEvent, Observable, timer } from 'rxjs'; import { EMPTY, fromEvent, Observable, timer } from 'rxjs';
import { import {
distinctUntilChanged, distinctUntilChanged,
@ -8,7 +8,30 @@ import {
switchMap switchMap
} from 'rxjs/operators'; } from 'rxjs/operators';
export const AUTO_REFRESH_INTERVAL_MS = 15_000; export const AUTO_REFRESH_INTERVAL_MS = 5000;
export const AUTO_REFRESH_INTERVAL_STORAGE_KEY = 'gf-auto-refresh-ms';
/**
* Returns the refresh interval of this device: the stored override (between
* 1 second and 1 minute) or the default.
*/
export function getAutoRefreshInterval(
storage?: Pick<Storage, 'getItem'>
): number {
try {
const value = Number(
(storage ?? globalThis.localStorage)?.getItem(
AUTO_REFRESH_INTERVAL_STORAGE_KEY
)
);
return Number.isFinite(value) && value >= 1000 && value <= 60_000
? value
: AUTO_REFRESH_INTERVAL_MS;
} catch {
return AUTO_REFRESH_INTERVAL_MS;
}
}
/** /**
* Emits at a fixed interval while the page is visible so that pages can * Emits at a fixed interval while the page is visible so that pages can
@ -17,6 +40,7 @@ export const AUTO_REFRESH_INTERVAL_MS = 15_000;
*/ */
@Service() @Service()
export class AutoRefreshService { export class AutoRefreshService {
public readonly lastUpdated = signal<Date | null>(null);
public readonly refresh$: Observable<void>; public readonly refresh$: Observable<void>;
public constructor() { public constructor() {
@ -31,15 +55,21 @@ export class AutoRefreshService {
return EMPTY; return EMPTY;
} }
const dueTime = isFirstActivation ? AUTO_REFRESH_INTERVAL_MS : 0; const interval = getAutoRefreshInterval();
const dueTime = isFirstActivation ? interval : 0;
isFirstActivation = false; isFirstActivation = false;
return timer(dueTime, AUTO_REFRESH_INTERVAL_MS).pipe( return timer(dueTime, interval).pipe(map(() => undefined));
map(() => undefined)
);
}), }),
share() share()
); );
} }
/**
* To be called by a page after it has silently refreshed its numbers.
*/
public markUpdated() {
this.lastUpdated.set(new Date());
}
} }

85
apps/client/src/styles.scss

@ -503,86 +503,33 @@ ngx-skeleton-loader {
} }
} }
// Highlight of a changed value, e.g. a table row after a background refresh // Highlight of a changed value: the number briefly takes the colour of the
@keyframes gf-flash-fade { // change (green up, red down) and fades back to its normal colour
from { @keyframes gf-flash-up {
opacity: 1; 0%,
35% {
color: var(--bs-success, #198754);
} }
to {
opacity: 0;
}
}
tr.gf-flash-down > td:not(.mat-mdc-table-sticky),
tr.gf-flash-up > td:not(.mat-mdc-table-sticky) {
position: relative;
}
tr.gf-flash-down > td::after,
tr.gf-flash-up > td::after {
animation: gf-flash-fade 1.4s ease-out forwards;
content: '';
inset: 0;
pointer-events: none;
position: absolute;
}
tr.gf-flash-up > td::after {
background-color: rgba(var(--bs-success-rgb, 40, 167, 69), 0.14);
}
tr.gf-flash-down > td::after {
background-color: rgba(var(--bs-danger-rgb, 220, 53, 69), 0.14);
} }
.gf-flash-down:not(tr), @keyframes gf-flash-down {
.gf-flash-up:not(tr) { 0%,
border-radius: 0.25rem; 35% {
} color: var(--bs-danger, #dc3545);
.gf-flash-up:not(tr) {
animation: gf-flash-up-shadow 1.4s ease-out;
}
.gf-flash-down:not(tr) {
animation: gf-flash-down-shadow 1.4s ease-out;
}
@keyframes gf-flash-up-shadow {
from {
box-shadow: 0 0 0 0.25rem rgba(var(--bs-success-rgb, 40, 167, 69), 0.3);
}
to {
box-shadow: 0 0 0 0.25rem rgba(var(--bs-success-rgb, 40, 167, 69), 0);
} }
} }
@keyframes gf-flash-down-shadow { .gf-flash-up {
from { animation: gf-flash-up 1.8s ease-out;
box-shadow: 0 0 0 0.25rem rgba(var(--bs-danger-rgb, 220, 53, 69), 0.3);
}
to {
box-shadow: 0 0 0 0.25rem rgba(var(--bs-danger-rgb, 220, 53, 69), 0);
}
} }
// The highlight of a row replaces the highlight of its numbers .gf-flash-down {
tr.gf-flash-down .gf-flash-down, animation: gf-flash-down 1.8s ease-out;
tr.gf-flash-down .gf-flash-up,
tr.gf-flash-up .gf-flash-down,
tr.gf-flash-up .gf-flash-up {
animation: none;
box-shadow: none;
} }
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.gf-flash-down, .gf-flash-down,
.gf-flash-up, .gf-flash-up {
tr.gf-flash-down > td::after, animation: none;
tr.gf-flash-up > td::after {
animation: none !important;
} }
} }

1
libs/ui/src/lib/accounts-table/accounts-table.component.html

@ -375,7 +375,6 @@
*matRowDef="let row; columns: displayedColumns()" *matRowDef="let row; columns: displayedColumns()"
mat-row mat-row
[class.cursor-pointer]="hasPermissionToOpenDetails()" [class.cursor-pointer]="hasPermissionToOpenDetails()"
[gfFlashOnChange]="row.valueInBaseCurrency"
(click)="onOpenAccountDetailDialog(row.id)" (click)="onOpenAccountDetailDialog(row.id)"
></tr> ></tr>
<tr <tr

2
libs/ui/src/lib/accounts-table/accounts-table.component.ts

@ -7,7 +7,6 @@ import {
import { internalRoutes } from '@ghostfolio/common/routes/routes'; import { internalRoutes } from '@ghostfolio/common/routes/routes';
import { AccountWithValue } from '@ghostfolio/common/types'; import { AccountWithValue } from '@ghostfolio/common/types';
import { GfEntityLogoComponent } from '@ghostfolio/ui/entity-logo'; import { GfEntityLogoComponent } from '@ghostfolio/ui/entity-logo';
import { GfFlashOnChangeDirective } from '@ghostfolio/ui/flash-on-change';
import { NotificationService } from '@ghostfolio/ui/notifications'; import { NotificationService } from '@ghostfolio/ui/notifications';
import { GfValueComponent } from '@ghostfolio/ui/value'; import { GfValueComponent } from '@ghostfolio/ui/value';
@ -43,7 +42,6 @@ import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ imports: [
GfEntityLogoComponent, GfEntityLogoComponent,
GfFlashOnChangeDirective,
GfValueComponent, GfValueComponent,
IonIcon, IonIcon,
MatButtonModule, MatButtonModule,

1
libs/ui/src/lib/holdings-table/holdings-table.component.html

@ -220,7 +220,6 @@
*matRowDef="let row; columns: displayedColumns()" *matRowDef="let row; columns: displayedColumns()"
mat-row mat-row
[class.cursor-pointer]="canShowDetails(row)" [class.cursor-pointer]="canShowDetails(row)"
[gfFlashOnChange]="row.netPerformancePercentWithCurrencyEffect * 100"
(click)=" (click)="
canShowDetails(row) && canShowDetails(row) &&
onOpenHoldingDialog({ onOpenHoldingDialog({

2
libs/ui/src/lib/holdings-table/holdings-table.component.ts

@ -27,7 +27,6 @@ import { MatTableDataSource, MatTableModule } from '@angular/material/table';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { GfEntityLogoComponent } from '../entity-logo/entity-logo.component'; import { GfEntityLogoComponent } from '../entity-logo/entity-logo.component';
import { GfFlashOnChangeDirective } from '../flash-on-change/flash-on-change.directive';
import { GfValueComponent } from '../value/value.component'; import { GfValueComponent } from '../value/value.component';
import { HoldingsTableItem } from './interfaces/interfaces'; import { HoldingsTableItem } from './interfaces/interfaces';
@ -35,7 +34,6 @@ import { HoldingsTableItem } from './interfaces/interfaces';
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ imports: [
GfEntityLogoComponent, GfEntityLogoComponent,
GfFlashOnChangeDirective,
GfValueComponent, GfValueComponent,
MatButtonModule, MatButtonModule,
MatDialogModule, MatDialogModule,

Loading…
Cancel
Save