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)) {