From 4e4d7d0837761655fa7091328495e24d65cf0690 Mon Sep 17 00:00:00 2001 From: mister-ideas <36502095+mister-ideas@users.noreply.github.com> Date: Mon, 19 Oct 2026 13:55:38 +0200 Subject: [PATCH 1/7] Feature/refresh the numbers periodically and highlight changed values --- CHANGELOG.md | 4 + .../home-holdings/home-holdings.component.ts | 15 ++++ .../home-overview/home-overview.component.ts | 62 ++++++++++--- .../pages/accounts/accounts-page.component.ts | 14 ++- .../analysis/analysis-page.component.ts | 86 +++++++++++++------ .../auto-refresh/auto-refresh.service.spec.ts | 56 ++++++++++++ .../auto-refresh/auto-refresh.service.ts | 45 ++++++++++ .../src/lib/value/value-change.helper.spec.ts | 51 +++++++++++ libs/ui/src/lib/value/value-change.helper.ts | 32 +++++++ libs/ui/src/lib/value/value.component.scss | 35 ++++++++ libs/ui/src/lib/value/value.component.ts | 40 ++++++++- 11 files changed, 402 insertions(+), 38 deletions(-) create mode 100644 apps/client/src/app/services/auto-refresh/auto-refresh.service.spec.ts create mode 100644 apps/client/src/app/services/auto-refresh/auto-refresh.service.ts create mode 100644 libs/ui/src/lib/value/value-change.helper.spec.ts create mode 100644 libs/ui/src/lib/value/value-change.helper.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 7e35621f0b..6a92f3b5dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +### Added + +- Added a periodic refresh of the numbers on the home, analysis and accounts pages while the page is visible, highlighting changed values + ### Changed - Improved the usability of the activities page by eliminating the reload on canceling the import activities dialog diff --git a/apps/client/src/app/components/home-holdings/home-holdings.component.ts b/apps/client/src/app/components/home-holdings/home-holdings.component.ts index 333a24d5d6..70b3c622ea 100644 --- a/apps/client/src/app/components/home-holdings/home-holdings.component.ts +++ b/apps/client/src/app/components/home-holdings/home-holdings.component.ts @@ -1,3 +1,4 @@ +import { AutoRefreshService } from '@ghostfolio/client/services/auto-refresh/auto-refresh.service'; import { UserService } from '@ghostfolio/client/services/user/user.service'; import { DEFAULT_CURRENCY, @@ -39,6 +40,7 @@ import { Router, RouterModule } from '@angular/router'; import { addIcons } from 'ionicons'; import { gridOutline, reorderFourOutline } from 'ionicons/icons'; import { DeviceDetectorService } from 'ngx-device-detector'; +import { catchError, EMPTY, exhaustMap, filter } from 'rxjs'; @Component({ changeDetection: ChangeDetectionStrategy.OnPush, @@ -106,6 +108,7 @@ export class GfHomeHoldingsComponent implements OnInit { internalRoutes.portfolio.subRoutes.activities.routerLink; protected readonly user = signal(null); + private readonly autoRefreshService = inject(AutoRefreshService); private readonly changeDetectorRef = inject(ChangeDetectorRef); private readonly dataService = inject(DataService); private readonly destroyRef = inject(DestroyRef); @@ -138,6 +141,18 @@ export class GfHomeHoldingsComponent implements OnInit { this.initialize(); } + this.changeDetectorRef.markForCheck(); + }); + + this.autoRefreshService.refresh$ + .pipe( + filter(() => !!this.user() && !!this.holdings), + exhaustMap(() => this.fetchHoldings().pipe(catchError(() => EMPTY))), + takeUntilDestroyed(this.destroyRef) + ) + .subscribe(({ holdings }) => { + this.holdings = holdings; + this.changeDetectorRef.markForCheck(); }); } diff --git a/apps/client/src/app/components/home-overview/home-overview.component.ts b/apps/client/src/app/components/home-overview/home-overview.component.ts index c7b880efce..f0ccce9075 100644 --- a/apps/client/src/app/components/home-overview/home-overview.component.ts +++ b/apps/client/src/app/components/home-overview/home-overview.component.ts @@ -1,5 +1,6 @@ import { GfPortfolioPerformanceComponent } from '@ghostfolio/client/components/portfolio-performance/portfolio-performance.component'; import { LayoutService } from '@ghostfolio/client/core/layout.service'; +import { AutoRefreshService } from '@ghostfolio/client/services/auto-refresh/auto-refresh.service'; import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service'; import { UserService } from '@ghostfolio/client/services/user/user.service'; import { @@ -37,6 +38,7 @@ import { MatButtonModule } from '@angular/material/button'; import { MatCardModule } from '@angular/material/card'; import { Router, RouterModule } from '@angular/router'; import { DeviceDetectorService } from 'ngx-device-detector'; +import { catchError, EMPTY, exhaustMap, filter } from 'rxjs'; @Component({ changeDetection: ChangeDetectionStrategy.OnPush, @@ -115,6 +117,7 @@ export class GfHomeOverviewComponent implements OnInit { : (this.user()?.settings?.baseCurrency ?? DEFAULT_CURRENCY); }); + private readonly autoRefreshService = inject(AutoRefreshService); private readonly dataService = inject(DataService); private readonly destroyRef = inject(DestroyRef); private readonly deviceDetectorService = inject(DeviceDetectorService); @@ -149,6 +152,40 @@ export class GfHomeOverviewComponent implements OnInit { .subscribe(() => { this.update(); }); + + this.autoRefreshService.refresh$ + .pipe( + filter(() => !!this.user() && !this.isLoadingPerformance()), + exhaustMap(() => { + return this.dataService + .fetchPortfolioPerformance({ + range: this.user()?.settings?.dateRange ?? DEFAULT_DATE_RANGE + }) + .pipe(catchError(() => EMPTY)); + }), + takeUntilDestroyed(this.destroyRef) + ) + .subscribe(({ errors, performance }) => { + this.errors.set(errors ?? []); + this.performance.set(performance); + }); + + this.autoRefreshService.refresh$ + .pipe( + filter(() => this.showExperimentalHoldings() && !!this.holdings()), + exhaustMap(() => { + return this.dataService + .fetchPortfolioHoldings({ + filters: [{ id: 'ACTIVE', type: 'HOLDING_TYPE' }], + range: this.user()?.settings?.dateRange + }) + .pipe(catchError(() => EMPTY)); + }), + takeUntilDestroyed(this.destroyRef) + ) + .subscribe(({ holdings }) => { + this.holdings.set(this.filterCashPositions(holdings)); + }); } protected onHoldingClicked({ dataSource, symbol }: AssetProfileIdentifier) { @@ -159,6 +196,19 @@ export class GfHomeOverviewComponent implements OnInit { } } + private filterCashPositions(holdings: PortfolioPosition[]) { + const baseCurrency = + this.user()?.settings?.baseCurrency ?? DEFAULT_CURRENCY; + + return holdings.filter(({ assetProfile }) => { + return !( + isCashPosition(assetProfile) && + assetProfile.currency === baseCurrency && + assetProfile.symbol === baseCurrency + ); + }); + } + private update() { const userSettings = this.user()?.settings; @@ -175,17 +225,7 @@ export class GfHomeOverviewComponent implements OnInit { }) .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe(({ holdings }) => { - const baseCurrency = userSettings?.baseCurrency ?? DEFAULT_CURRENCY; - - this.holdings.set( - holdings.filter(({ assetProfile }) => { - return !( - isCashPosition(assetProfile) && - assetProfile.currency === baseCurrency && - assetProfile.symbol === baseCurrency - ); - }) - ); + this.holdings.set(this.filterCashPositions(holdings)); }); } diff --git a/apps/client/src/app/pages/accounts/accounts-page.component.ts b/apps/client/src/app/pages/accounts/accounts-page.component.ts index f731962a5e..2fba8a91b0 100644 --- a/apps/client/src/app/pages/accounts/accounts-page.component.ts +++ b/apps/client/src/app/pages/accounts/accounts-page.component.ts @@ -1,3 +1,4 @@ +import { AutoRefreshService } from '@ghostfolio/client/services/auto-refresh/auto-refresh.service'; import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service'; import { UserService } from '@ghostfolio/client/services/user/user.service'; import { DEFAULT_LOCALE } from '@ghostfolio/common/config'; @@ -20,7 +21,7 @@ import { } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { Router, RouterModule } from '@angular/router'; -import { filter, switchMap, tap } from 'rxjs'; +import { catchError, EMPTY, exhaustMap, filter, switchMap, tap } from 'rxjs'; @Component({ changeDetection: ChangeDetectionStrategy.OnPush, @@ -45,6 +46,7 @@ export class GfAccountsPageComponent implements OnInit { private hasImpersonationId: boolean; + private readonly autoRefreshService = inject(AutoRefreshService); private readonly changeDetectorRef = inject(ChangeDetectorRef); private readonly dataService = inject(DataService); private readonly destroyRef = inject(DestroyRef); @@ -92,6 +94,16 @@ export class GfAccountsPageComponent implements OnInit { .subscribe((response) => { this.updateAccounts(response); }); + + this.autoRefreshService.refresh$ + .pipe( + filter(() => !!this.accounts), + exhaustMap(() => this.fetchAccounts().pipe(catchError(() => EMPTY))), + takeUntilDestroyed(this.destroyRef) + ) + .subscribe((response) => { + this.updateAccounts(response); + }); } protected onDeleteAccount(aId: string) { diff --git a/apps/client/src/app/pages/portfolio/analysis/analysis-page.component.ts b/apps/client/src/app/pages/portfolio/analysis/analysis-page.component.ts index 8ba3f0fd8a..d3e179a7f3 100644 --- a/apps/client/src/app/pages/portfolio/analysis/analysis-page.component.ts +++ b/apps/client/src/app/pages/portfolio/analysis/analysis-page.component.ts @@ -1,4 +1,5 @@ import { GfBenchmarkComparatorComponent } from '@ghostfolio/client/components/benchmark-comparator/benchmark-comparator.component'; +import { AutoRefreshService } from '@ghostfolio/client/services/auto-refresh/auto-refresh.service'; import { UserService } from '@ghostfolio/client/services/user/user.service'; import { DEFAULT_DATE_RANGE, @@ -56,7 +57,7 @@ import { isNumber, keyBy, sortBy, union } from 'lodash-es'; import ms from 'ms'; import { DeviceDetectorService } from 'ngx-device-detector'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; -import { forkJoin } from 'rxjs'; +import { catchError, EMPTY, exhaustMap, filter, forkJoin } from 'rxjs'; @Component({ changeDetection: ChangeDetectionStrategy.OnPush, @@ -122,6 +123,7 @@ export class GfAnalysisPageComponent implements OnInit { ); private dateOfFirstActivity: Date; + private readonly autoRefreshService = inject(AutoRefreshService); private readonly changeDetectorRef = inject(ChangeDetectorRef); private readonly clipboard = inject(Clipboard); private readonly dataService = inject(DataService); @@ -166,6 +168,34 @@ export class GfAnalysisPageComponent implements OnInit { this.update(); } + this.changeDetectorRef.markForCheck(); + }); + + this.autoRefreshService.refresh$ + .pipe( + filter(() => !!this.user && !this.isLoadingInvestmentChart), + exhaustMap(() => { + return forkJoin({ + holdings: this.dataService.fetchPortfolioHoldings({ + filters: [ + ...this.userService.getFilters(), + { id: 'ACTIVE', type: 'HOLDING_TYPE' } + ], + range: this.user?.settings?.dateRange + }), + performance: this.dataService.fetchPortfolioPerformance({ + filters: this.userService.getFilters(), + range: this.user?.settings?.dateRange ?? DEFAULT_DATE_RANGE + }) + }).pipe(catchError(() => EMPTY)); + }), + takeUntilDestroyed(this.destroyRef) + ) + .subscribe(({ holdings: { holdings }, performance }) => { + this.performance = performance.performance; + + this.updateTopAndBottomHoldings(holdings); + this.changeDetectorRef.markForCheck(); }); } @@ -347,6 +377,35 @@ export class GfAnalysisPageComponent implements OnInit { }); } + private updateTopAndBottomHoldings(holdings: PortfolioPosition[]) { + const holdingsSorted = sortBy( + holdings.filter((holding) => { + return ( + canOpenHoldingDetail(holding) && + isNumber(holding.netPerformancePercentWithCurrencyEffect) + ); + }), + 'netPerformancePercentWithCurrencyEffect' + ).reverse(); + + this.top3 = holdingsSorted + .filter( + ({ netPerformancePercentWithCurrencyEffect }) => + netPerformancePercentWithCurrencyEffect > 0 + ) + .slice(0, 3); + + this.bottom3 = holdingsSorted + .filter( + ({ netPerformancePercentWithCurrencyEffect }) => + netPerformancePercentWithCurrencyEffect < 0 + ) + .slice(-3) + .reverse(); + + this.changeDetectorRef.markForCheck(); + } + private update() { this.isLoadingInvestmentChart = true; @@ -425,30 +484,7 @@ export class GfAnalysisPageComponent implements OnInit { }) .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe(({ holdings }) => { - const holdingsSorted = sortBy( - holdings.filter((holding) => { - return ( - canOpenHoldingDetail(holding) && - isNumber(holding.netPerformancePercentWithCurrencyEffect) - ); - }), - 'netPerformancePercentWithCurrencyEffect' - ).reverse(); - - this.top3 = holdingsSorted - .filter( - ({ netPerformancePercentWithCurrencyEffect }) => - netPerformancePercentWithCurrencyEffect > 0 - ) - .slice(0, 3); - - this.bottom3 = holdingsSorted - .filter( - ({ netPerformancePercentWithCurrencyEffect }) => - netPerformancePercentWithCurrencyEffect < 0 - ) - .slice(-3) - .reverse(); + this.updateTopAndBottomHoldings(holdings); this.changeDetectorRef.markForCheck(); }); diff --git a/apps/client/src/app/services/auto-refresh/auto-refresh.service.spec.ts b/apps/client/src/app/services/auto-refresh/auto-refresh.service.spec.ts new file mode 100644 index 0000000000..da83179492 --- /dev/null +++ b/apps/client/src/app/services/auto-refresh/auto-refresh.service.spec.ts @@ -0,0 +1,56 @@ +import { + AUTO_REFRESH_INTERVAL_MS, + AutoRefreshService +} from './auto-refresh.service'; + +describe('AutoRefreshService', () => { + const setVisibility = (state: 'hidden' | 'visible') => { + Object.defineProperty(document, 'visibilityState', { + configurable: true, + get: () => state + }); + document.dispatchEvent(new Event('visibilitychange')); + }; + + beforeEach(() => { + jest.useFakeTimers(); + setVisibility('visible'); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('emits at the interval while the page is visible', () => { + const service = new AutoRefreshService(); + const next = jest.fn(); + const subscription = service.refresh$.subscribe(next); + + jest.advanceTimersByTime(AUTO_REFRESH_INTERVAL_MS - 1); + expect(next).not.toHaveBeenCalled(); + + jest.advanceTimersByTime(1); + expect(next).toHaveBeenCalledTimes(1); + + jest.advanceTimersByTime(AUTO_REFRESH_INTERVAL_MS * 2); + expect(next).toHaveBeenCalledTimes(3); + + subscription.unsubscribe(); + }); + + it('pauses while hidden and refreshes immediately when visible again', () => { + const service = new AutoRefreshService(); + const next = jest.fn(); + const subscription = service.refresh$.subscribe(next); + + setVisibility('hidden'); + jest.advanceTimersByTime(AUTO_REFRESH_INTERVAL_MS * 3); + expect(next).not.toHaveBeenCalled(); + + setVisibility('visible'); + jest.advanceTimersByTime(0); + expect(next).toHaveBeenCalledTimes(1); + + subscription.unsubscribe(); + }); +}); diff --git a/apps/client/src/app/services/auto-refresh/auto-refresh.service.ts b/apps/client/src/app/services/auto-refresh/auto-refresh.service.ts new file mode 100644 index 0000000000..3905e3879e --- /dev/null +++ b/apps/client/src/app/services/auto-refresh/auto-refresh.service.ts @@ -0,0 +1,45 @@ +import { Service } from '@angular/core'; +import { EMPTY, fromEvent, Observable, timer } from 'rxjs'; +import { + distinctUntilChanged, + map, + share, + startWith, + switchMap +} from 'rxjs/operators'; + +export const AUTO_REFRESH_INTERVAL_MS = 15_000; + +/** + * Emits at a fixed interval while the page is visible so that pages can + * refresh their numbers silently. The timer stops while the tab is hidden + * and fires once immediately when the tab becomes visible again. + */ +@Service() +export class AutoRefreshService { + public readonly refresh$: Observable; + + public constructor() { + let isFirstActivation = true; + + this.refresh$ = fromEvent(document, 'visibilitychange').pipe( + startWith(undefined), + map(() => document.visibilityState === 'visible'), + distinctUntilChanged(), + switchMap((isVisible) => { + if (!isVisible) { + return EMPTY; + } + + const dueTime = isFirstActivation ? AUTO_REFRESH_INTERVAL_MS : 0; + + isFirstActivation = false; + + return timer(dueTime, AUTO_REFRESH_INTERVAL_MS).pipe( + map(() => undefined) + ); + }), + share() + ); + } +} diff --git a/libs/ui/src/lib/value/value-change.helper.spec.ts b/libs/ui/src/lib/value/value-change.helper.spec.ts new file mode 100644 index 0000000000..50bd3e7d32 --- /dev/null +++ b/libs/ui/src/lib/value/value-change.helper.spec.ts @@ -0,0 +1,51 @@ +import { getValueChangeDirection } from './value-change.helper'; + +describe('getValueChangeDirection', () => { + it('detects an increase', () => { + expect( + getValueChangeDirection({ + currentValue: 101, + digits: 2, + previousValue: 100 + }) + ).toBe('up'); + }); + + it('detects a decrease', () => { + expect( + getValueChangeDirection({ + currentValue: 99.5, + digits: 2, + previousValue: 100 + }) + ).toBe('down'); + }); + + it('ignores changes below the displayed precision', () => { + expect( + getValueChangeDirection({ + currentValue: 100.001, + digits: 2, + previousValue: 100 + }) + ).toBeUndefined(); + }); + + it('ignores values which are not finite numbers', () => { + expect( + getValueChangeDirection({ + currentValue: NaN, + digits: 2, + previousValue: 100 + }) + ).toBeUndefined(); + + expect( + getValueChangeDirection({ + currentValue: '101', + digits: 2, + previousValue: 100 + }) + ).toBeUndefined(); + }); +}); diff --git a/libs/ui/src/lib/value/value-change.helper.ts b/libs/ui/src/lib/value/value-change.helper.ts new file mode 100644 index 0000000000..feb582c2a9 --- /dev/null +++ b/libs/ui/src/lib/value/value-change.helper.ts @@ -0,0 +1,32 @@ +/** + * Returns the direction in which a displayed number has changed, or + * `undefined` if it is unchanged at the displayed precision. + */ +export function getValueChangeDirection({ + currentValue, + digits, + previousValue +}: { + currentValue: unknown; + digits: number; + previousValue: unknown; +}): 'down' | 'up' | undefined { + if ( + typeof currentValue !== 'number' || + typeof previousValue !== 'number' || + !Number.isFinite(currentValue) || + !Number.isFinite(previousValue) + ) { + return undefined; + } + + const factor = 10 ** digits; + const difference = + Math.round(currentValue * factor) - Math.round(previousValue * factor); + + if (difference === 0) { + return undefined; + } + + return difference > 0 ? 'up' : 'down'; +} diff --git a/libs/ui/src/lib/value/value.component.scss b/libs/ui/src/lib/value/value.component.scss index 087bf31e13..76cd562a29 100644 --- a/libs/ui/src/lib/value/value.component.scss +++ b/libs/ui/src/lib/value/value.component.scss @@ -8,3 +8,38 @@ line-height: 1; } } + +@keyframes gf-flash-up { + from { + background-color: rgba(var(--bs-success-rgb, 40, 167, 69), 0.35); + } + + to { + background-color: transparent; + } +} + +@keyframes gf-flash-down { + from { + background-color: rgba(var(--bs-danger-rgb, 220, 53, 69), 0.35); + } + + to { + background-color: transparent; + } +} + +:host(.gf-flash-up) { + animation: gf-flash-up 1.2s ease-out; +} + +:host(.gf-flash-down) { + animation: gf-flash-down 1.2s ease-out; +} + +@media (prefers-reduced-motion: reduce) { + :host(.gf-flash-down), + :host(.gf-flash-up) { + animation: none; + } +} diff --git a/libs/ui/src/lib/value/value.component.ts b/libs/ui/src/lib/value/value.component.ts index baa588bbd9..39deaa98b0 100644 --- a/libs/ui/src/lib/value/value.component.ts +++ b/libs/ui/src/lib/value/value.component.ts @@ -14,6 +14,7 @@ import { Input, OnChanges, OnDestroy, + SimpleChanges, ViewChild } from '@angular/core'; import { MatButtonModule } from '@angular/material/button'; @@ -25,6 +26,8 @@ import { isNumber } from 'lodash-es'; import ms from 'ms'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; +import { getValueChangeDirection } from './value-change.helper'; + @Component({ changeDetection: ChangeDetectionStrategy.OnPush, imports: [CommonModule, IonIcon, MatButtonModule, NgxSkeletonLoaderModule], @@ -68,6 +71,7 @@ export class GfValueComponent implements AfterViewInit, OnChanges, OnDestroy { public constructor( private changeDetectorRef: ChangeDetectorRef, private clipboard: Clipboard, + private elementRef: ElementRef, private snackBar: MatSnackBar ) { addIcons({ @@ -93,6 +97,39 @@ export class GfValueComponent implements AfterViewInit, OnChanges, OnDestroy { return precision !== undefined && precision >= 0; } + /** + * Briefly highlights the value in green or red if the displayed number + * has changed, e.g. after a background refresh. + */ + private flashOnChange(changes?: SimpleChanges) { + const change = changes?.['value']; + + if (!change || change.firstChange || this.isLoading()) { + return; + } + + const direction = getValueChangeDirection({ + currentValue: change.currentValue, + digits: + ((this.hasPrecision ? this.precision() : 2) ?? 2) + + (this.isPercent ? 2 : 0), + previousValue: change.previousValue + }); + + if (!direction) { + return; + } + + const element = this.elementRef.nativeElement; + + element.classList.remove('gf-flash-down', 'gf-flash-up'); + + // Force a reflow to restart the animation + void element.offsetWidth; + + element.classList.add(`gf-flash-${direction}`); + } + public ngAfterViewInit() { if (this.labelContent) { const element = this.labelContent.nativeElement; @@ -104,8 +141,9 @@ export class GfValueComponent implements AfterViewInit, OnChanges, OnDestroy { } } - public ngOnChanges() { + public ngOnChanges(changes?: SimpleChanges) { this.initializeVariables(); + this.flashOnChange(changes); if (this.value || this.value === 0) { if (isNumber(this.value)) { From 8fb22f490329e8f9286047ea8b700de1cd65b6bc Mon Sep 17 00:00:00 2001 From: mister-ideas <36502095+mister-ideas@users.noreply.github.com> Date: Mon, 19 Oct 2026 17:12:19 +0200 Subject: [PATCH 2/7] Feature/highlight changed table rows and roll the performance number on refresh --- CHANGELOG.md | 2 +- .../portfolio-performance.component.html | 2 + .../portfolio-performance.component.ts | 81 ++++++++++++++---- apps/client/src/styles.scss | 84 +++++++++++++++++++ .../accounts-table.component.html | 2 + .../accounts-table.component.ts | 7 ++ .../flash-on-change.directive.spec.ts | 63 ++++++++++++++ .../flash-on-change.directive.ts | 71 ++++++++++++++++ libs/ui/src/lib/flash-on-change/index.ts | 1 + .../holdings-table.component.html | 2 + .../holdings-table.component.ts | 7 ++ libs/ui/src/lib/value/value.component.scss | 35 -------- libs/ui/src/lib/value/value.component.ts | 16 +++- 13 files changed, 320 insertions(+), 53 deletions(-) create mode 100644 libs/ui/src/lib/flash-on-change/flash-on-change.directive.spec.ts create mode 100644 libs/ui/src/lib/flash-on-change/flash-on-change.directive.ts create mode 100644 libs/ui/src/lib/flash-on-change/index.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a92f3b5dd..1a12754c4f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Added a periodic refresh of the numbers on the home, analysis and accounts pages while the page is visible, highlighting changed values +- 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 ### Changed diff --git a/apps/client/src/app/components/portfolio-performance/portfolio-performance.component.html b/apps/client/src/app/components/portfolio-performance/portfolio-performance.component.html index 9be2be344e..4948f84ca9 100644 --- a/apps/client/src/app/components/portfolio-performance/portfolio-performance.component.html +++ b/apps/client/src/app/components/portfolio-performance/portfolio-performance.component.html @@ -24,6 +24,8 @@ }
diff --git a/apps/client/src/app/components/portfolio-performance/portfolio-performance.component.ts b/apps/client/src/app/components/portfolio-performance/portfolio-performance.component.ts index 04929e03a6..4f772e4993 100644 --- a/apps/client/src/app/components/portfolio-performance/portfolio-performance.component.ts +++ b/apps/client/src/app/components/portfolio-performance/portfolio-performance.component.ts @@ -7,12 +7,14 @@ import { PortfolioPerformance, ResponseError } from '@ghostfolio/common/interfaces'; +import { GfFlashOnChangeDirective } from '@ghostfolio/ui/flash-on-change'; import { NotificationService } from '@ghostfolio/ui/notifications'; import { GfValueComponent } from '@ghostfolio/ui/value'; import { ChangeDetectionStrategy, Component, + computed, effect, ElementRef, inject, @@ -28,7 +30,12 @@ import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; @Component({ changeDetection: ChangeDetectionStrategy.OnPush, - imports: [GfValueComponent, IonIcon, NgxSkeletonLoaderModule], + imports: [ + GfFlashOnChangeDirective, + GfValueComponent, + IonIcon, + NgxSkeletonLoaderModule + ], selector: 'gf-portfolio-performance', styleUrls: ['./portfolio-performance.component.scss'], templateUrl: './portfolio-performance.component.html' @@ -49,6 +56,21 @@ export class GfPortfolioPerformanceComponent { private readonly value = viewChild.required>('value'); + protected readonly displayedValue = computed(() => { + const performance = this.performance(); + + if (isNumber(performance?.currentValueInBaseCurrency)) { + return performance.currentValueInBaseCurrency; + } + + return this.showDetails() === false && performance + ? performance.netPerformancePercentageWithCurrencyEffect * 100 + : undefined; + }); + + private countUp?: CountUp; + private countUpTarget?: number; + private readonly notificationService = inject(NotificationService); public constructor() { @@ -56,6 +78,9 @@ export class GfPortfolioPerformanceComponent { effect(() => { if (this.isLoading()) { + this.countUp = undefined; + this.countUpTarget = undefined; + if (this.value().nativeElement) { this.value().nativeElement.innerHTML = ''; } @@ -63,30 +88,54 @@ export class GfPortfolioPerformanceComponent { const performance = this.performance(); if (isNumber(performance?.currentValueInBaseCurrency)) { - new CountUp('value', performance.currentValueInBaseCurrency, { - decimal: getNumberFormatDecimal(this.locale()), + this.showValue({ decimalPlaces: this.precision(), - duration: 1, - separator: getNumberFormatGroup(this.locale()) - }).start(); + value: performance.currentValueInBaseCurrency + }); } else if (this.showDetails() === false && performance) { - new CountUp( - 'value', - performance.netPerformancePercentageWithCurrencyEffect * 100, - { - decimal: getNumberFormatDecimal(this.locale()), - decimalPlaces: 2, - duration: 1, - separator: getNumberFormatGroup(this.locale()) - } - ).start(); + this.showValue({ + decimalPlaces: 2, + value: performance.netPerformancePercentageWithCurrencyEffect * 100 + }); } else { + this.countUp = undefined; + this.countUpTarget = undefined; this.value().nativeElement.innerHTML = '*****'; } } }); } + /** + * Counts up from zero on the initial value and rolls from the current to + * the new number on a refresh. Nothing happens if the number is unchanged. + */ + private showValue({ + decimalPlaces, + value + }: { + decimalPlaces: number; + value: number; + }) { + if (this.countUp && this.countUpTarget === value) { + return; + } + + const isUpdate = this.countUpTarget !== undefined; + + this.countUp = new CountUp('value', value, { + decimalPlaces, + decimal: getNumberFormatDecimal(this.locale()), + duration: isUpdate ? 0.6 : 1, + separator: getNumberFormatGroup(this.locale()), + startVal: isUpdate ? this.countUpTarget : 0 + }); + + this.countUp.start(); + + this.countUpTarget = value; + } + protected onShowErrors() { const errors = this.errors(); diff --git a/apps/client/src/styles.scss b/apps/client/src/styles.scss index 5771ebd6de..d74e58115f 100644 --- a/apps/client/src/styles.scss +++ b/apps/client/src/styles.scss @@ -502,3 +502,87 @@ ngx-skeleton-loader { } } } + +// Highlight of a changed value, e.g. a table row after a background refresh +@keyframes gf-flash-fade { + from { + opacity: 1; + } + + 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), +.gf-flash-up:not(tr) { + border-radius: 0.25rem; +} + +.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 { + from { + 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 +tr.gf-flash-down .gf-flash-down, +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) { + .gf-flash-down, + .gf-flash-up, + tr.gf-flash-down > td::after, + tr.gf-flash-up > td::after { + animation: none !important; + } +} diff --git a/libs/ui/src/lib/accounts-table/accounts-table.component.html b/libs/ui/src/lib/accounts-table/accounts-table.component.html index 43a6a429b4..997041fa48 100644 --- a/libs/ui/src/lib/accounts-table/accounts-table.component.html +++ b/libs/ui/src/lib/accounts-table/accounts-table.component.html @@ -20,6 +20,7 @@ matSortActive="name" matSortDirection="asc" [dataSource]="dataSource" + [trackBy]="trackByAccount" > id; + protected readonly accountDialogRouterLinks = computed(() => { const { detail, update } = internalRoutes.accounts.subRoutes; diff --git a/libs/ui/src/lib/flash-on-change/flash-on-change.directive.spec.ts b/libs/ui/src/lib/flash-on-change/flash-on-change.directive.spec.ts new file mode 100644 index 0000000000..71d495ba2b --- /dev/null +++ b/libs/ui/src/lib/flash-on-change/flash-on-change.directive.spec.ts @@ -0,0 +1,63 @@ +import { Component, signal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; + +import { GfFlashOnChangeDirective } from './flash-on-change.directive'; + +@Component({ + imports: [GfFlashOnChangeDirective], + template: `` +}) +class TestHostComponent { + public readonly value = signal(100); +} + +describe('GfFlashOnChangeDirective', () => { + const setup = () => { + const fixture = TestBed.createComponent(TestHostComponent); + fixture.detectChanges(); + + return { + fixture, + classes: () => fixture.nativeElement.querySelector('tr').classList + }; + }; + + beforeEach(() => { + TestBed.configureTestingModule({ imports: [TestHostComponent] }); + }); + + it('does not flash initially', () => { + const { classes } = setup(); + + expect(classes().contains('gf-flash-up')).toBe(false); + expect(classes().contains('gf-flash-down')).toBe(false); + }); + + it('flashes up on an increase', () => { + const { classes, fixture } = setup(); + + fixture.componentInstance.value.set(101); + fixture.detectChanges(); + + expect(classes().contains('gf-flash-up')).toBe(true); + }); + + it('flashes down on a decrease', () => { + const { classes, fixture } = setup(); + + fixture.componentInstance.value.set(99); + fixture.detectChanges(); + + expect(classes().contains('gf-flash-down')).toBe(true); + }); + + it('does not flash if the value becomes undefined', () => { + const { classes, fixture } = setup(); + + fixture.componentInstance.value.set(undefined); + fixture.detectChanges(); + + expect(classes().contains('gf-flash-up')).toBe(false); + expect(classes().contains('gf-flash-down')).toBe(false); + }); +}); diff --git a/libs/ui/src/lib/flash-on-change/flash-on-change.directive.ts b/libs/ui/src/lib/flash-on-change/flash-on-change.directive.ts new file mode 100644 index 0000000000..befc49569b --- /dev/null +++ b/libs/ui/src/lib/flash-on-change/flash-on-change.directive.ts @@ -0,0 +1,71 @@ +import { + Directive, + effect, + ElementRef, + inject, + input, + untracked +} from '@angular/core'; + +import { getValueChangeDirection } from '../value/value-change.helper'; + +/** + * Briefly highlights the host element (e.g. a table row) in green or red if + * the bound number changes, e.g. after a background refresh. The highlight + * is styled globally by the `gf-flash-up` and `gf-flash-down` classes. + */ +@Directive({ + selector: '[gfFlashOnChange]' +}) +export class GfFlashOnChangeDirective { + public readonly gfFlashDigits = input(2); + public readonly gfFlashOnChange = input(); + + private hasValue = false; + private previousValue: number | null | undefined; + + private readonly element = inject>(ElementRef); + + public constructor() { + effect(() => { + const digits = this.gfFlashDigits(); + const value = this.gfFlashOnChange(); + + untracked(() => { + if (this.hasValue) { + const direction = getValueChangeDirection({ + digits, + currentValue: value, + previousValue: this.previousValue + }); + + if (direction) { + this.flash(direction); + } + } + + this.hasValue = true; + this.previousValue = value; + }); + }); + } + + private flash(aDirection: 'down' | 'up') { + const element = this.element.nativeElement; + + element.classList.remove('gf-flash-down', 'gf-flash-up'); + + // Force a reflow to restart the animation + void element.offsetWidth; + + element.classList.add(`gf-flash-${aDirection}`); + + element.addEventListener( + 'animationend', + () => { + element.classList.remove('gf-flash-down', 'gf-flash-up'); + }, + { once: true } + ); + } +} diff --git a/libs/ui/src/lib/flash-on-change/index.ts b/libs/ui/src/lib/flash-on-change/index.ts new file mode 100644 index 0000000000..3577482978 --- /dev/null +++ b/libs/ui/src/lib/flash-on-change/index.ts @@ -0,0 +1 @@ +export * from './flash-on-change.directive'; diff --git a/libs/ui/src/lib/holdings-table/holdings-table.component.html b/libs/ui/src/lib/holdings-table/holdings-table.component.html index 025be8a723..55af6e5b90 100644 --- a/libs/ui/src/lib/holdings-table/holdings-table.component.html +++ b/libs/ui/src/lib/holdings-table/holdings-table.component.html @@ -7,6 +7,7 @@ [dataSource]="dataSource" [matSortActive]="sortActive()" [matSortDirection]="sortDirection()" + [trackBy]="trackByHolding" > @@ -219,6 +220,7 @@ *matRowDef="let row; columns: displayedColumns()" mat-row [class.cursor-pointer]="canShowDetails(row)" + [gfFlashOnChange]="row.netPerformancePercentWithCurrencyEffect * 100" (click)=" canShowDetails(row) && onOpenHoldingDialog({ diff --git a/libs/ui/src/lib/holdings-table/holdings-table.component.ts b/libs/ui/src/lib/holdings-table/holdings-table.component.ts index 3181cc1347..fa0add4336 100644 --- a/libs/ui/src/lib/holdings-table/holdings-table.component.ts +++ b/libs/ui/src/lib/holdings-table/holdings-table.component.ts @@ -27,6 +27,7 @@ import { MatTableDataSource, MatTableModule } from '@angular/material/table'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; 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 { HoldingsTableItem } from './interfaces/interfaces'; @@ -34,6 +35,7 @@ import { HoldingsTableItem } from './interfaces/interfaces'; changeDetection: ChangeDetectionStrategy.OnPush, imports: [ GfEntityLogoComponent, + GfFlashOnChangeDirective, GfValueComponent, MatButtonModule, MatDialogModule, @@ -95,6 +97,11 @@ export class GfHoldingsTableComponent { }); protected readonly getHoldingName = getHoldingName; + + protected readonly trackByHolding = ( + _index: number, + { assetProfile }: HoldingsTableItem + ) => `${assetProfile.dataSource}-${assetProfile.symbol}`; protected readonly isCashPosition = isCashPosition; protected readonly isLoading = computed(() => { diff --git a/libs/ui/src/lib/value/value.component.scss b/libs/ui/src/lib/value/value.component.scss index 76cd562a29..087bf31e13 100644 --- a/libs/ui/src/lib/value/value.component.scss +++ b/libs/ui/src/lib/value/value.component.scss @@ -8,38 +8,3 @@ line-height: 1; } } - -@keyframes gf-flash-up { - from { - background-color: rgba(var(--bs-success-rgb, 40, 167, 69), 0.35); - } - - to { - background-color: transparent; - } -} - -@keyframes gf-flash-down { - from { - background-color: rgba(var(--bs-danger-rgb, 220, 53, 69), 0.35); - } - - to { - background-color: transparent; - } -} - -:host(.gf-flash-up) { - animation: gf-flash-up 1.2s ease-out; -} - -:host(.gf-flash-down) { - animation: gf-flash-down 1.2s ease-out; -} - -@media (prefers-reduced-motion: reduce) { - :host(.gf-flash-down), - :host(.gf-flash-up) { - animation: none; - } -} diff --git a/libs/ui/src/lib/value/value.component.ts b/libs/ui/src/lib/value/value.component.ts index 39deaa98b0..5a50df4566 100644 --- a/libs/ui/src/lib/value/value.component.ts +++ b/libs/ui/src/lib/value/value.component.ts @@ -120,7 +120,13 @@ export class GfValueComponent implements AfterViewInit, OnChanges, OnDestroy { return; } - const element = this.elementRef.nativeElement; + // Highlight the number only, not the whole (full width) host element + const element = + this.elementRef.nativeElement.querySelector('.value'); + + if (!element) { + return; + } element.classList.remove('gf-flash-down', 'gf-flash-up'); @@ -128,6 +134,14 @@ export class GfValueComponent implements AfterViewInit, OnChanges, OnDestroy { void element.offsetWidth; element.classList.add(`gf-flash-${direction}`); + + element.addEventListener( + 'animationend', + () => { + element.classList.remove('gf-flash-down', 'gf-flash-up'); + }, + { once: true } + ); } public ngAfterViewInit() { From 89167d12ffda3bbb4834cdb83b79d2cf9401cc8e Mon Sep 17 00:00:00 2001 From: mister-ideas <36502095+mister-ideas@users.noreply.github.com> Date: Mon, 19 Oct 2026 18:52:10 +0200 Subject: [PATCH 3/7] Feature/skip the counting animation of the portfolio value on a background refresh --- .../portfolio-performance.component.ts | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/apps/client/src/app/components/portfolio-performance/portfolio-performance.component.ts b/apps/client/src/app/components/portfolio-performance/portfolio-performance.component.ts index 4f772e4993..3cc43a3927 100644 --- a/apps/client/src/app/components/portfolio-performance/portfolio-performance.component.ts +++ b/apps/client/src/app/components/portfolio-performance/portfolio-performance.component.ts @@ -107,8 +107,9 @@ export class GfPortfolioPerformanceComponent { } /** - * Counts up from zero on the initial value and rolls from the current to - * the new number on a refresh. Nothing happens if the number is unchanged. + * Counts up from zero on the initial load and after a reload (e.g. a changed + * date range). A background refresh swaps the number without any counting + * animation. Nothing happens if the number is unchanged. */ private showValue({ decimalPlaces, @@ -121,17 +122,20 @@ export class GfPortfolioPerformanceComponent { return; } - const isUpdate = this.countUpTarget !== undefined; + const isRefresh = this.countUpTarget !== undefined; this.countUp = new CountUp('value', value, { decimalPlaces, decimal: getNumberFormatDecimal(this.locale()), - duration: isUpdate ? 0.6 : 1, - separator: getNumberFormatGroup(this.locale()), - startVal: isUpdate ? this.countUpTarget : 0 + duration: 1, + separator: getNumberFormatGroup(this.locale()) }); - this.countUp.start(); + if (isRefresh) { + this.value().nativeElement.textContent = this.countUp.formatNumber(value); + } else { + this.countUp.start(); + } this.countUpTarget = value; } From 92ecc294de31e2f752b97cc6d0d27b2afc2ec130 Mon Sep 17 00:00:00 2001 From: mister-ideas <36502095+mister-ideas@users.noreply.github.com> Date: Mon, 19 Oct 2026 19:40:06 +0200 Subject: [PATCH 4/7] Feature/color changed values instead of glowing and add a live indicator --- CHANGELOG.md | 2 +- .../components/header/header.component.html | 1 + .../app/components/header/header.component.ts | 2 + .../home-holdings/home-holdings.component.ts | 1 + .../home-overview/home-overview.component.ts | 2 + .../live-indicator.component.scss | 44 ++++++++++ .../live-indicator.component.ts | 67 +++++++++++++++ .../pages/accounts/accounts-page.component.ts | 1 + .../analysis/analysis-page.component.ts | 1 + .../auto-refresh/auto-refresh.service.spec.ts | 34 +++++++- .../auto-refresh/auto-refresh.service.ts | 42 +++++++-- apps/client/src/styles.scss | 85 ++++--------------- .../accounts-table.component.html | 1 - .../accounts-table.component.ts | 2 - .../holdings-table.component.html | 1 - .../holdings-table.component.ts | 2 - 16 files changed, 205 insertions(+), 83 deletions(-) create mode 100644 apps/client/src/app/components/live-indicator/live-indicator.component.scss create mode 100644 apps/client/src/app/components/live-indicator/live-indicator.component.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a12754c4f..6d622252c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### 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 diff --git a/apps/client/src/app/components/header/header.component.html b/apps/client/src/app/components/header/header.component.html index 88ab9f09fc..eb7a578f4a 100644 --- a/apps/client/src/app/components/header/header.component.html +++ b/apps/client/src/app/components/header/header.component.html @@ -12,6 +12,7 @@
+