Browse Source

Feature/refresh the numbers periodically and highlight changed values

pull/8249/head
mister-ideas 5 days ago
parent
commit
4e4d7d0837
  1. 4
      CHANGELOG.md
  2. 15
      apps/client/src/app/components/home-holdings/home-holdings.component.ts
  3. 62
      apps/client/src/app/components/home-overview/home-overview.component.ts
  4. 14
      apps/client/src/app/pages/accounts/accounts-page.component.ts
  5. 86
      apps/client/src/app/pages/portfolio/analysis/analysis-page.component.ts
  6. 56
      apps/client/src/app/services/auto-refresh/auto-refresh.service.spec.ts
  7. 45
      apps/client/src/app/services/auto-refresh/auto-refresh.service.ts
  8. 51
      libs/ui/src/lib/value/value-change.helper.spec.ts
  9. 32
      libs/ui/src/lib/value/value-change.helper.ts
  10. 35
      libs/ui/src/lib/value/value.component.scss
  11. 40
      libs/ui/src/lib/value/value.component.ts

4
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

15
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<User | null>(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();
});
}

62
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));
});
}

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

86
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();
});

56
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();
});
});

45
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<void>;
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()
);
}
}

51
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();
});
});

32
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';
}

35
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;
}
}

40
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<HTMLElement>,
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)) {

Loading…
Cancel
Save