Browse Source

Merge c78d204944 into c2cb508f22

pull/8249/merge
Mister_Ideas 1 day ago
committed by GitHub
parent
commit
ac48cf70ba
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 2
      CHANGELOG.md
  2. 48
      apps/api/src/app/portfolio/portfolio.service.ts
  3. 1
      apps/client/src/app/components/header/header.component.html
  4. 2
      apps/client/src/app/components/header/header.component.ts
  5. 16
      apps/client/src/app/components/home-holdings/home-holdings.component.ts
  6. 97
      apps/client/src/app/components/home-overview/home-overview.component.ts
  7. 1
      apps/client/src/app/components/home-overview/home-overview.html
  8. 44
      apps/client/src/app/components/live-indicator/live-indicator.component.scss
  9. 67
      apps/client/src/app/components/live-indicator/live-indicator.component.ts
  10. 6
      apps/client/src/app/components/portfolio-performance/portfolio-performance.component.html
  11. 86
      apps/client/src/app/components/portfolio-performance/portfolio-performance.component.ts
  12. 15
      apps/client/src/app/pages/accounts/accounts-page.component.ts
  13. 87
      apps/client/src/app/pages/portfolio/analysis/analysis-page.component.ts
  14. 4
      apps/client/src/app/pages/portfolio/analysis/analysis-page.html
  15. 88
      apps/client/src/app/services/auto-refresh/auto-refresh.service.spec.ts
  16. 75
      apps/client/src/app/services/auto-refresh/auto-refresh.service.ts
  17. 45
      apps/client/src/styles.scss
  18. 3
      libs/common/src/lib/interfaces/portfolio-position.interface.ts
  19. 4
      libs/common/src/lib/interfaces/responses/portfolio-performance-response.interface.ts
  20. 1
      libs/ui/src/lib/accounts-table/accounts-table.component.html
  21. 5
      libs/ui/src/lib/accounts-table/accounts-table.component.ts
  22. 63
      libs/ui/src/lib/flash-on-change/flash-on-change.directive.spec.ts
  23. 71
      libs/ui/src/lib/flash-on-change/flash-on-change.directive.ts
  24. 1
      libs/ui/src/lib/flash-on-change/index.ts
  25. 5
      libs/ui/src/lib/holdings-table/holdings-table.component.html
  26. 5
      libs/ui/src/lib/holdings-table/holdings-table.component.ts
  27. 8
      libs/ui/src/lib/line-chart/line-chart.component.ts
  28. 51
      libs/ui/src/lib/value/value-change.helper.spec.ts
  29. 32
      libs/ui/src/lib/value/value-change.helper.ts
  30. 57
      libs/ui/src/lib/value/value.component.html
  31. 91
      libs/ui/src/lib/value/value.component.ts

2
CHANGELOG.md

@ -9,10 +9,12 @@ 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, briefly coloring changed values green or red, moving the chart of the home page without redrawing it, with a live indicator in the header
- Added a link to the holdings tab in the holdings table on the overview tab of the home page (experimental) - Added a link to the holdings tab in the holdings table on the overview tab of the home page (experimental)
### Changed ### Changed
- Colored the changes of the performance in green or red only while the market of a holding is open (in gray while it is closed) and highlighted a change in percent together with its absolute change
- Improved the market price in the benchmark detail dialog by adding the currency and reducing the precision on mobile - Improved the market price in the benchmark detail dialog by adding the currency and reducing the precision on mobile
- Improved the style of the close button in the dialog footer component - Improved the style of the close button in the dialog footer component
- Improved the style of the close button in the dialog header component - Improved the style of the close button in the dialog header component

48
apps/api/src/app/portfolio/portfolio.service.ts

@ -679,9 +679,24 @@ export class PortfolioService {
}; };
}); });
const symbolProfiles = await this.symbolProfileService.getSymbolProfiles( const [symbolProfiles, quotes] = await Promise.all([
assetProfileIdentifiers this.symbolProfileService.getSymbolProfiles(assetProfileIdentifiers),
); // The quotes are cached, they only provide the state of the markets
this.dataProviderService
.getQuotes({
items: positions
.filter(({ quantity }) => {
return !quantity.eq(0);
})
.map(({ dataSource, symbol }) => {
return { dataSource, symbol };
}),
subscriptionType: user.subscription?.type
})
.catch(() => {
return {} as Awaited<ReturnType<DataProviderService['getQuotes']>>;
})
]);
const cashSymbolProfiles = this.getCashSymbolProfiles(cashDetails); const cashSymbolProfiles = this.getCashSymbolProfiles(cashDetails);
symbolProfiles.push(...cashSymbolProfiles); symbolProfiles.push(...cashSymbolProfiles);
@ -764,6 +779,9 @@ export class PortfolioService {
markets, markets,
marketsAdvanced, marketsAdvanced,
marketPrice, marketPrice,
marketState:
quotes[getAssetProfileIdentifier({ dataSource, symbol })]
?.marketState,
tags, tags,
allocationInPercentage: filteredValueInBaseCurrency.eq(0) allocationInPercentage: filteredValueInBaseCurrency.eq(0)
? 0 ? 0
@ -1223,11 +1241,32 @@ export class PortfolioService {
subscriptionType: user.subscription?.type subscriptionType: user.subscription?.type
}); });
const { errors, hasErrors, historicalData } = const { errors, hasErrors, historicalData, positions } =
await portfolioCalculator.getSnapshot(); await portfolioCalculator.getSnapshot();
const { endDate, startDate } = getIntervalFromDateRange({ dateRange }); const { endDate, startDate } = getIntervalFromDateRange({ dateRange });
// The quotes are cached, they only provide the state of the markets
const isMarketOpen = await this.dataProviderService
.getQuotes({
items: positions
.filter(({ quantity }) => {
return !quantity.eq(0);
})
.map(({ dataSource, symbol }) => {
return { dataSource, symbol };
}),
subscriptionType: user.subscription?.type
})
.then((quotes) => {
return Object.values(quotes).some(({ marketState }) => {
return marketState === 'open';
});
})
.catch(() => {
return undefined;
});
const { chart: chartOfDateRange } = const { chart: chartOfDateRange } =
await portfolioCalculator.getPerformance({ await portfolioCalculator.getPerformance({
end: endDate, end: endDate,
@ -1271,6 +1310,7 @@ export class PortfolioService {
chart, chart,
errors, errors,
hasErrors, hasErrors,
isMarketOpen,
dateOfFirstActivity: parseDate(historicalData[0]?.date), dateOfFirstActivity: parseDate(historicalData[0]?.date),
performance: { performance: {
dividendInBaseCurrency, dividendInBaseCurrency,

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,

16
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 { UserService } from '@ghostfolio/client/services/user/user.service';
import { import {
DEFAULT_CURRENCY, DEFAULT_CURRENCY,
@ -39,6 +40,7 @@ import { Router, RouterModule } from '@angular/router';
import { addIcons } from 'ionicons'; import { addIcons } from 'ionicons';
import { gridOutline, reorderFourOutline } from 'ionicons/icons'; import { gridOutline, reorderFourOutline } from 'ionicons/icons';
import { DeviceDetectorService } from 'ngx-device-detector'; import { DeviceDetectorService } from 'ngx-device-detector';
import { catchError, EMPTY, exhaustMap, filter } from 'rxjs';
@Component({ @Component({
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
@ -106,6 +108,7 @@ export class GfHomeHoldingsComponent implements OnInit {
internalRoutes.portfolio.subRoutes.activities.routerLink; internalRoutes.portfolio.subRoutes.activities.routerLink;
protected readonly user = signal<User | null>(null); protected readonly user = signal<User | null>(null);
private readonly autoRefreshService = inject(AutoRefreshService);
private readonly changeDetectorRef = inject(ChangeDetectorRef); private readonly changeDetectorRef = inject(ChangeDetectorRef);
private readonly dataService = inject(DataService); private readonly dataService = inject(DataService);
private readonly destroyRef = inject(DestroyRef); private readonly destroyRef = inject(DestroyRef);
@ -140,6 +143,19 @@ export class GfHomeHoldingsComponent implements OnInit {
this.changeDetectorRef.markForCheck(); 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.autoRefreshService.markUpdated();
this.changeDetectorRef.markForCheck();
});
} }
protected onChangeHoldingsViewMode(aHoldingsViewMode: HoldingsViewMode) { protected onChangeHoldingsViewMode(aHoldingsViewMode: HoldingsViewMode) {

97
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 { GfPortfolioPerformanceComponent } from '@ghostfolio/client/components/portfolio-performance/portfolio-performance.component';
import { LayoutService } from '@ghostfolio/client/core/layout.service'; 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 { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { import {
@ -13,6 +14,7 @@ import {
AssetProfileIdentifier, AssetProfileIdentifier,
LineChartItem, LineChartItem,
PortfolioPerformance, PortfolioPerformance,
PortfolioPerformanceResponse,
PortfolioPosition, PortfolioPosition,
User User
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
@ -40,6 +42,7 @@ import { IonIcon } from '@ionic/angular/standalone';
import { addIcons } from 'ionicons'; import { addIcons } from 'ionicons';
import { arrowForwardOutline } from 'ionicons/icons'; import { arrowForwardOutline } from 'ionicons/icons';
import { DeviceDetectorService } from 'ngx-device-detector'; import { DeviceDetectorService } from 'ngx-device-detector';
import { catchError, EMPTY, exhaustMap, filter } from 'rxjs';
@Component({ @Component({
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
@ -64,6 +67,7 @@ export class GfHomeOverviewComponent implements OnInit {
undefined undefined
); );
protected readonly isLoadingPerformance = signal(true); protected readonly isLoadingPerformance = signal(true);
protected readonly isMarketOpen = signal<boolean | undefined>(undefined);
protected readonly locale = computed(() => { protected readonly locale = computed(() => {
return this.user()?.settings?.locale ?? DEFAULT_LOCALE; return this.user()?.settings?.locale ?? DEFAULT_LOCALE;
@ -125,6 +129,7 @@ export class GfHomeOverviewComponent implements OnInit {
: (this.user()?.settings?.baseCurrency ?? DEFAULT_CURRENCY); : (this.user()?.settings?.baseCurrency ?? DEFAULT_CURRENCY);
}); });
private readonly autoRefreshService = inject(AutoRefreshService);
private readonly dataService = inject(DataService); private readonly dataService = inject(DataService);
private readonly destroyRef = inject(DestroyRef); private readonly destroyRef = inject(DestroyRef);
private readonly deviceDetectorService = inject(DeviceDetectorService); private readonly deviceDetectorService = inject(DeviceDetectorService);
@ -161,6 +166,46 @@ export class GfHomeOverviewComponent implements OnInit {
.subscribe(() => { .subscribe(() => {
this.update(); 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(({ chart, errors, isMarketOpen, performance }) => {
this.errors.set(errors ?? []);
this.isMarketOpen.set(isMarketOpen);
this.performance.set(performance);
// Same series: the line chart moves without the drawing animation
this.historicalDataItems.set(this.getChartItems(chart));
this.autoRefreshService.markUpdated();
});
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) { protected onHoldingClicked({ dataSource, symbol }: AssetProfileIdentifier) {
@ -171,6 +216,32 @@ 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 getChartItems(
chart?: PortfolioPerformanceResponse['chart']
): LineChartItem[] | null {
return (
chart?.map(({ date, netPerformanceInPercentageWithCurrencyEffect }) => {
return {
date,
value: (netPerformanceInPercentageWithCurrencyEffect ?? 0) * 100
};
}) ?? null
);
}
private update() { private update() {
const userSettings = this.user()?.settings; const userSettings = this.user()?.settings;
@ -187,17 +258,7 @@ export class GfHomeOverviewComponent implements OnInit {
}) })
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(({ holdings }) => { .subscribe(({ holdings }) => {
const baseCurrency = userSettings?.baseCurrency ?? DEFAULT_CURRENCY; this.holdings.set(this.filterCashPositions(holdings));
this.holdings.set(
holdings.filter(({ assetProfile }) => {
return !(
isCashPosition(assetProfile) &&
assetProfile.currency === baseCurrency &&
assetProfile.symbol === baseCurrency
);
})
);
}); });
} }
@ -206,20 +267,12 @@ export class GfHomeOverviewComponent implements OnInit {
range: userSettings?.dateRange ?? DEFAULT_DATE_RANGE range: userSettings?.dateRange ?? DEFAULT_DATE_RANGE
}) })
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(({ chart, errors, performance }) => { .subscribe(({ chart, errors, isMarketOpen, performance }) => {
this.errors.set(errors ?? []); this.errors.set(errors ?? []);
this.isMarketOpen.set(isMarketOpen);
this.performance.set(performance); this.performance.set(performance);
this.historicalDataItems.set( this.historicalDataItems.set(this.getChartItems(chart));
chart?.map(
({ date, netPerformanceInPercentageWithCurrencyEffect }) => {
return {
date,
value: (netPerformanceInPercentageWithCurrencyEffect ?? 0) * 100
};
}
) ?? null
);
this.precision.set(2); this.precision.set(2);

1
apps/client/src/app/components/home-overview/home-overview.html

@ -87,6 +87,7 @@
<gf-portfolio-performance <gf-portfolio-performance
[errors]="errors()" [errors]="errors()"
[isLoading]="isLoadingPerformance()" [isLoading]="isLoadingPerformance()"
[isMarketOpen]="isMarketOpen()"
[locale]="locale()" [locale]="locale()"
[performance]="performance()" [performance]="performance()"
[precision]="precision()" [precision]="precision()"

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

6
apps/client/src/app/components/portfolio-performance/portfolio-performance.component.html

@ -25,6 +25,8 @@
} }
<div <div
class="display-4 fw-bold m-0 text-center value-container" class="display-4 fw-bold m-0 text-center value-container"
[gfFlashDigits]="precision()"
[gfFlashOnChange]="displayedValue()"
[hidden]="isLoading()" [hidden]="isLoading()"
> >
<span #value id="value"></span> <span #value id="value"></span>
@ -40,14 +42,18 @@
[colorizeSign]="true" [colorizeSign]="true"
[isCurrency]="true" [isCurrency]="true"
[isLoading]="isLoading()" [isLoading]="isLoading()"
[isMarketOpen]="isMarketOpen()"
[locale]="locale()" [locale]="locale()"
[value]="performance()?.netPerformanceWithCurrencyEffect" [value]="performance()?.netPerformanceWithCurrencyEffect"
/> />
</div> </div>
<div class="col"> <div class="col">
<!-- Highlighted together with the absolute change -->
<gf-value <gf-value
[colorizeSign]="true" [colorizeSign]="true"
[flashValue]="performance()?.netPerformanceWithCurrencyEffect"
[isLoading]="isLoading()" [isLoading]="isLoading()"
[isMarketOpen]="isMarketOpen()"
[isPercent]="true" [isPercent]="true"
[locale]="locale()" [locale]="locale()"
[value]="performance()?.netPerformancePercentageWithCurrencyEffect" [value]="performance()?.netPerformancePercentageWithCurrencyEffect"

86
apps/client/src/app/components/portfolio-performance/portfolio-performance.component.ts

@ -7,12 +7,14 @@ import {
PortfolioPerformance, PortfolioPerformance,
ResponseError ResponseError
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
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';
import { import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
Component, Component,
computed,
effect, effect,
ElementRef, ElementRef,
inject, inject,
@ -28,7 +30,12 @@ import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
@Component({ @Component({
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [GfValueComponent, IonIcon, NgxSkeletonLoaderModule], imports: [
GfFlashOnChangeDirective,
GfValueComponent,
IonIcon,
NgxSkeletonLoaderModule
],
selector: 'gf-portfolio-performance', selector: 'gf-portfolio-performance',
styleUrls: ['./portfolio-performance.component.scss'], styleUrls: ['./portfolio-performance.component.scss'],
templateUrl: './portfolio-performance.component.html' templateUrl: './portfolio-performance.component.html'
@ -36,6 +43,7 @@ import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
export class GfPortfolioPerformanceComponent { export class GfPortfolioPerformanceComponent {
public readonly errors = input<ResponseError['errors']>(); public readonly errors = input<ResponseError['errors']>();
public readonly isLoading = input<boolean>(false); public readonly isLoading = input<boolean>(false);
public readonly isMarketOpen = input<boolean>();
public readonly locale = input<string>(getLocale()); public readonly locale = input<string>(getLocale());
public readonly performance = input.required<PortfolioPerformance | null>(); public readonly performance = input.required<PortfolioPerformance | null>();
public readonly precision = input.required<number, number>({ public readonly precision = input.required<number, number>({
@ -49,6 +57,21 @@ export class GfPortfolioPerformanceComponent {
private readonly value = private readonly value =
viewChild.required<ElementRef<HTMLSpanElement>>('value'); viewChild.required<ElementRef<HTMLSpanElement>>('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); private readonly notificationService = inject(NotificationService);
public constructor() { public constructor() {
@ -56,6 +79,9 @@ export class GfPortfolioPerformanceComponent {
effect(() => { effect(() => {
if (this.isLoading()) { if (this.isLoading()) {
this.countUp = undefined;
this.countUpTarget = undefined;
if (this.value().nativeElement) { if (this.value().nativeElement) {
this.value().nativeElement.innerHTML = ''; this.value().nativeElement.innerHTML = '';
} }
@ -63,30 +89,58 @@ export class GfPortfolioPerformanceComponent {
const performance = this.performance(); const performance = this.performance();
if (isNumber(performance?.currentValueInBaseCurrency)) { if (isNumber(performance?.currentValueInBaseCurrency)) {
new CountUp('value', performance.currentValueInBaseCurrency, { this.showValue({
decimal: getNumberFormatDecimal(this.locale()),
decimalPlaces: this.precision(), decimalPlaces: this.precision(),
duration: 1, value: performance.currentValueInBaseCurrency
separator: getNumberFormatGroup(this.locale()) });
}).start();
} else if (this.showDetails() === false && performance) { } else if (this.showDetails() === false && performance) {
new CountUp( this.showValue({
'value', decimalPlaces: 2,
performance.netPerformancePercentageWithCurrencyEffect * 100, value: performance.netPerformancePercentageWithCurrencyEffect * 100
{ });
decimal: getNumberFormatDecimal(this.locale()),
decimalPlaces: 2,
duration: 1,
separator: getNumberFormatGroup(this.locale())
}
).start();
} else { } else {
this.countUp = undefined;
this.countUpTarget = undefined;
this.value().nativeElement.innerHTML = '*****'; this.value().nativeElement.innerHTML = '*****';
} }
} }
}); });
} }
/**
* 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,
value
}: {
decimalPlaces: number;
value: number;
}) {
if (this.countUp && this.countUpTarget === value) {
return;
}
const isRefresh = this.countUpTarget !== undefined;
this.countUp = new CountUp('value', value, {
decimalPlaces,
decimal: getNumberFormatDecimal(this.locale()),
duration: 1,
separator: getNumberFormatGroup(this.locale())
});
if (isRefresh) {
this.value().nativeElement.textContent = this.countUp.formatNumber(value);
} else {
this.countUp.start();
}
this.countUpTarget = value;
}
protected onShowErrors() { protected onShowErrors() {
const errors = this.errors(); const errors = this.errors();

15
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 { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { DEFAULT_LOCALE } from '@ghostfolio/common/config'; import { DEFAULT_LOCALE } from '@ghostfolio/common/config';
@ -20,7 +21,7 @@ import {
} from '@angular/core'; } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { Router, RouterModule } from '@angular/router'; import { Router, RouterModule } from '@angular/router';
import { filter, switchMap, tap } from 'rxjs'; import { catchError, EMPTY, exhaustMap, filter, switchMap, tap } from 'rxjs';
@Component({ @Component({
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
@ -45,6 +46,7 @@ export class GfAccountsPageComponent implements OnInit {
private hasImpersonationId: boolean; private hasImpersonationId: boolean;
private readonly autoRefreshService = inject(AutoRefreshService);
private readonly changeDetectorRef = inject(ChangeDetectorRef); private readonly changeDetectorRef = inject(ChangeDetectorRef);
private readonly dataService = inject(DataService); private readonly dataService = inject(DataService);
private readonly destroyRef = inject(DestroyRef); private readonly destroyRef = inject(DestroyRef);
@ -92,6 +94,17 @@ export class GfAccountsPageComponent implements OnInit {
.subscribe((response) => { .subscribe((response) => {
this.updateAccounts(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);
this.autoRefreshService.markUpdated();
});
} }
protected onDeleteAccount(aId: string) { protected onDeleteAccount(aId: string) {

87
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 { 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 { UserService } from '@ghostfolio/client/services/user/user.service';
import { import {
DEFAULT_DATE_RANGE, DEFAULT_DATE_RANGE,
@ -56,7 +57,7 @@ import { isNumber, keyBy, sortBy, union } from 'lodash-es';
import ms from 'ms'; import ms from 'ms';
import { DeviceDetectorService } from 'ngx-device-detector'; import { DeviceDetectorService } from 'ngx-device-detector';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { forkJoin } from 'rxjs'; import { catchError, EMPTY, exhaustMap, filter, forkJoin } from 'rxjs';
@Component({ @Component({
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
@ -122,6 +123,7 @@ export class GfAnalysisPageComponent implements OnInit {
); );
private dateOfFirstActivity: Date; private dateOfFirstActivity: Date;
private readonly autoRefreshService = inject(AutoRefreshService);
private readonly changeDetectorRef = inject(ChangeDetectorRef); private readonly changeDetectorRef = inject(ChangeDetectorRef);
private readonly clipboard = inject(Clipboard); private readonly clipboard = inject(Clipboard);
private readonly dataService = inject(DataService); private readonly dataService = inject(DataService);
@ -168,6 +170,35 @@ export class GfAnalysisPageComponent implements OnInit {
this.changeDetectorRef.markForCheck(); 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.autoRefreshService.markUpdated();
this.changeDetectorRef.markForCheck();
});
} }
protected onChangeBenchmark(symbolProfileId: string) { protected onChangeBenchmark(symbolProfileId: string) {
@ -347,6 +378,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() { private update() {
this.isLoadingInvestmentChart = true; this.isLoadingInvestmentChart = true;
@ -425,30 +485,7 @@ export class GfAnalysisPageComponent implements OnInit {
}) })
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(({ holdings }) => { .subscribe(({ holdings }) => {
const holdingsSorted = sortBy( this.updateTopAndBottomHoldings(holdings);
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(); this.changeDetectorRef.markForCheck();
}); });

4
apps/client/src/app/pages/portfolio/analysis/analysis-page.html

@ -359,6 +359,8 @@
class="justify-content-end" class="justify-content-end"
position="end" position="end"
[colorizeSign]="true" [colorizeSign]="true"
[flashValue]="holding.netPerformanceWithCurrencyEffect"
[isMarketOpen]="holding.marketState === 'open'"
[isPercent]="true" [isPercent]="true"
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"
[value]="holding.netPerformancePercentWithCurrencyEffect" [value]="holding.netPerformancePercentWithCurrencyEffect"
@ -412,6 +414,8 @@
class="justify-content-end" class="justify-content-end"
position="end" position="end"
[colorizeSign]="true" [colorizeSign]="true"
[flashValue]="holding.netPerformanceWithCurrencyEffect"
[isMarketOpen]="holding.marketState === 'open'"
[isPercent]="true" [isPercent]="true"
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"
[value]="holding.netPerformancePercentWithCurrencyEffect" [value]="holding.netPerformancePercentWithCurrencyEffect"

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

@ -0,0 +1,88 @@
import {
AUTO_REFRESH_INTERVAL_MS,
AUTO_REFRESH_INTERVAL_STORAGE_KEY,
AutoRefreshService,
getAutoRefreshInterval
} 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();
});
});
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
);
});
});

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

@ -0,0 +1,75 @@
import { Service, signal } 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 = 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
* 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 lastUpdated = signal<Date | null>(null);
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 interval = getAutoRefreshInterval();
const dueTime = isFirstActivation ? interval : 0;
isFirstActivation = false;
return timer(dueTime, interval).pipe(map(() => undefined));
}),
share()
);
}
/**
* To be called by a page after it has silently refreshed its numbers.
*/
public markUpdated() {
this.lastUpdated.set(new Date());
}
}

45
apps/client/src/styles.scss

@ -502,3 +502,48 @@ ngx-skeleton-loader {
} }
} }
} }
// Highlight of a changed value: the whole figure (sign, number and unit) gets
// a soft background tint in the direction of the change, which fades out. The
// text colors stay untouched, so the persistent colors of the sign (positive
// / negative) and the temporary highlight (value just changed) never mix up
@keyframes gf-flash-up {
from {
background-color: color-mix(
in srgb,
var(--bs-success, #198754) 30%,
transparent
);
}
}
@keyframes gf-flash-down {
from {
background-color: color-mix(
in srgb,
var(--bs-danger, #dc3545) 30%,
transparent
);
}
}
.gf-flash-up,
.gf-flash-down {
border-radius: 0.25rem;
box-shadow: 0 0 0 0.125rem transparent;
}
.gf-flash-up {
animation: gf-flash-up 1.2s ease-out;
}
.gf-flash-down {
animation: gf-flash-down 1.2s ease-out;
}
@media (prefers-reduced-motion: reduce) {
.gf-flash-down,
.gf-flash-up {
animation: none;
}
}

3
libs/common/src/lib/interfaces/portfolio-position.interface.ts

@ -1,4 +1,4 @@
import { Market, MarketAdvanced } from '@ghostfolio/common/types'; import { Market, MarketAdvanced, MarketState } from '@ghostfolio/common/types';
import type { Tag } from '@ghostfolio/prisma/browser'; import type { Tag } from '@ghostfolio/prisma/browser';
import { EnhancedAssetProfile } from './enhanced-asset-profile.interface'; import { EnhancedAssetProfile } from './enhanced-asset-profile.interface';
@ -33,6 +33,7 @@ export interface PortfolioPosition {
marketChange?: number; marketChange?: number;
marketChangePercent?: number; marketChangePercent?: number;
marketPrice: number; marketPrice: number;
marketState?: MarketState;
markets?: { [key in Market]: number }; markets?: { [key in Market]: number };
marketsAdvanced?: { [key in MarketAdvanced]: number }; marketsAdvanced?: { [key in MarketAdvanced]: number };
netPerformance: number; netPerformance: number;

4
libs/common/src/lib/interfaces/responses/portfolio-performance-response.interface.ts

@ -5,5 +5,9 @@ import { ResponseError } from './errors.interface';
export interface PortfolioPerformanceResponse extends ResponseError { export interface PortfolioPerformanceResponse extends ResponseError {
chart?: HistoricalDataItem[]; chart?: HistoricalDataItem[];
dateOfFirstActivity: Date; dateOfFirstActivity: Date;
/** Whether a market (including the pre-market and the post-market) of a holding is open */
isMarketOpen?: boolean;
performance: PortfolioPerformance; performance: PortfolioPerformance;
} }

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

@ -20,6 +20,7 @@
matSortActive="name" matSortActive="name"
matSortDirection="asc" matSortDirection="asc"
[dataSource]="dataSource" [dataSource]="dataSource"
[trackBy]="trackByAccount"
> >
<ng-container matColumnDef="status"> <ng-container matColumnDef="status">
<th <th

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

@ -84,6 +84,11 @@ export class GfAccountsTableComponent {
public readonly sort = viewChild.required(MatSort); public readonly sort = viewChild.required(MatSort);
protected readonly trackByAccount = (
_index: number,
{ id }: AccountWithValue
) => id;
protected readonly accountDialogRouterLinks = computed(() => { protected readonly accountDialogRouterLinks = computed(() => {
const { detail, update } = internalRoutes.accounts.subRoutes; const { detail, update } = internalRoutes.accounts.subRoutes;

63
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: `<tr [gfFlashOnChange]="value()"></tr>`
})
class TestHostComponent {
public readonly value = signal<number | undefined>(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);
});
});

71
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<number | null | undefined>();
private hasValue = false;
private previousValue: number | null | undefined;
private readonly element = inject<ElementRef<HTMLElement>>(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 }
);
}
}

1
libs/ui/src/lib/flash-on-change/index.ts

@ -0,0 +1 @@
export * from './flash-on-change.directive';

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

@ -7,6 +7,7 @@
[dataSource]="dataSource" [dataSource]="dataSource"
[matSortActive]="sortActive()" [matSortActive]="sortActive()"
[matSortDirection]="sortDirection()" [matSortDirection]="sortDirection()"
[trackBy]="trackByHolding"
> >
<ng-container matColumnDef="icon" [sticky]="mode() === 'default'"> <ng-container matColumnDef="icon" [sticky]="mode() === 'default'">
<th *matHeaderCellDef class="px-1" mat-header-cell></th> <th *matHeaderCellDef class="px-1" mat-header-cell></th>
@ -172,6 +173,7 @@
[colorizeSign]="true" [colorizeSign]="true"
[isCurrency]="true" [isCurrency]="true"
[isLoading]="isLoading()" [isLoading]="isLoading()"
[isMarketOpen]="element.marketState === 'open'"
[locale]="locale()" [locale]="locale()"
[value]="element.netPerformanceWithCurrencyEffect" [value]="element.netPerformanceWithCurrencyEffect"
/> />
@ -199,9 +201,12 @@
[class.text-nowrap]="mode() === 'simple'" [class.text-nowrap]="mode() === 'simple'"
> >
<div class="d-flex justify-content-end"> <div class="d-flex justify-content-end">
<!-- Highlighted together with the absolute change -->
<gf-value <gf-value
[colorizeSign]="true" [colorizeSign]="true"
[flashValue]="element.netPerformanceWithCurrencyEffect"
[isLoading]="isLoading()" [isLoading]="isLoading()"
[isMarketOpen]="element.marketState === 'open'"
[isPercent]="true" [isPercent]="true"
[locale]="locale()" [locale]="locale()"
[value]="element.netPerformancePercentWithCurrencyEffect" [value]="element.netPerformancePercentWithCurrencyEffect"

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

@ -95,6 +95,11 @@ export class GfHoldingsTableComponent {
}); });
protected readonly getHoldingName = getHoldingName; protected readonly getHoldingName = getHoldingName;
protected readonly trackByHolding = (
_index: number,
{ assetProfile }: HoldingsTableItem
) => `${assetProfile.dataSource}-${assetProfile.symbol}`;
protected readonly isCashPosition = isCashPosition; protected readonly isCashPosition = isCashPosition;
protected readonly isLoading = computed(() => { protected readonly isLoading = computed(() => {

8
libs/ui/src/lib/line-chart/line-chart.component.ts

@ -199,6 +199,12 @@ export class GfLineChartComponent
}; };
if (this.chart) { if (this.chart) {
// A refresh of the same series (e.g. a background refresh) only moves
// the line, the drawing animation is kept for a new series
const isRefresh =
this.chart.data.labels?.length === labels.length &&
this.chart.data.labels?.[0] === labels[0];
this.chart.data = data; this.chart.data = data;
this.chart.options.plugins ??= {}; this.chart.options.plugins ??= {};
this.chart.options.plugins.tooltip = this.chart.options.plugins.tooltip =
@ -207,7 +213,7 @@ export class GfLineChartComponent
? animations ? animations
: undefined; : undefined;
this.chart.update(); this.chart.update(isRefresh ? 'none' : undefined);
} else { } else {
this.chart = new Chart<'line'>(this.chartCanvas.nativeElement, { this.chart = new Chart<'line'>(this.chartCanvas.nativeElement, {
data, data,

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

57
libs/ui/src/lib/value/value.component.html

@ -42,31 +42,46 @@
[class.justify-content-end]="position === 'end'" [class.justify-content-end]="position === 'end'"
> >
@if (isNumber || value === null) { @if (isNumber || value === null) {
@if (colorizeSign && !useAbsoluteValue && value !== null) {
@if (+value > 0) {
<div class="me-1 text-success">+</div>
}
@if (+value < 0) {
<div class="me-1 text-danger">-</div>
}
}
<div <div
class="mb-0 value" class="align-items-center d-flex gf-value-line"
[class.fw-bold]="size === 'large'" [ngClass]="trendColorClass"
[class.h2]="size === 'large'"
[class.h4]="size === 'medium'"
> >
@if (value === null) { @if (colorizeSign && !useAbsoluteValue && value !== null) {
<span class="font-monospace text-body-secondary">*****</span> @if (+value > 0) {
} @else { <div
{{ formattedValue }} class="me-1"
[class.text-success]="isMarketOpen === undefined"
>
+
</div>
}
@if (+value < 0) {
<div
class="me-1"
[class.text-danger]="isMarketOpen === undefined"
>
-
</div>
}
} }
</div> <div
@if (isPercent || unit) { class="mb-0 value"
<div class="ms-1"> [class.fw-bold]="size === 'large'"
{{ isPercent ? '%' : unit }} [class.h2]="size === 'large'"
[class.h4]="size === 'medium'"
>
@if (value === null) {
<span class="font-monospace text-body-secondary">*****</span>
} @else {
{{ formattedValue }}
}
</div> </div>
} @if (isPercent || unit) {
<div class="ms-1">
{{ isPercent ? '%' : unit }}
</div>
}
</div>
} }
@if (isString) { @if (isString) {
<div <div

91
libs/ui/src/lib/value/value.component.ts

@ -14,6 +14,7 @@ import {
Input, Input,
OnChanges, OnChanges,
OnDestroy, OnDestroy,
SimpleChanges,
ViewChild ViewChild
} from '@angular/core'; } from '@angular/core';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
@ -25,6 +26,8 @@ import { isNumber } from 'lodash-es';
import ms from 'ms'; import ms from 'ms';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { getValueChangeDirection } from './value-change.helper';
@Component({ @Component({
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule, IonIcon, MatButtonModule, NgxSkeletonLoaderModule], imports: [CommonModule, IonIcon, MatButtonModule, NgxSkeletonLoaderModule],
@ -37,10 +40,23 @@ export class GfValueComponent implements AfterViewInit, OnChanges, OnDestroy {
@Input() colorizeSign = false; @Input() colorizeSign = false;
@Input() deviceType: string; @Input() deviceType: string;
@Input() enableCopyToClipboardButton = false; @Input() enableCopyToClipboardButton = false;
/**
* Number which triggers the highlight of a change instead of the value,
* e.g. the absolute change for a change in percent which is displayed
* with fewer digits
*/
@Input() flashValue?: number | null;
@Input() icon = ''; @Input() icon = '';
@Input() isAbsolute = false; @Input() isAbsolute = false;
@Input() isCurrency = false; @Input() isCurrency = false;
@Input() isDate = false; @Input() isDate = false;
/**
* Colors the whole figure (with `colorizeSign`) in green or red while the
* market is open and in grey while it is closed
*/
@Input() isMarketOpen?: boolean;
@Input() isPercent = false; @Input() isPercent = false;
@Input() locale?: string; @Input() locale?: string;
@Input() position = ''; @Input() position = '';
@ -68,6 +84,7 @@ export class GfValueComponent implements AfterViewInit, OnChanges, OnDestroy {
public constructor( public constructor(
private changeDetectorRef: ChangeDetectorRef, private changeDetectorRef: ChangeDetectorRef,
private clipboard: Clipboard, private clipboard: Clipboard,
private elementRef: ElementRef<HTMLElement>,
private snackBar: MatSnackBar private snackBar: MatSnackBar
) { ) {
addIcons({ addIcons({
@ -88,11 +105,82 @@ export class GfValueComponent implements AfterViewInit, OnChanges, OnDestroy {
}; };
}); });
protected get trendColorClass() {
if (
!this.colorizeSign ||
this.isMarketOpen === undefined ||
!isNumber(this.value)
) {
return '';
}
if (!this.isMarketOpen) {
return 'text-body-secondary';
}
if (this.value > 0) {
return 'text-success';
}
return this.value < 0 ? 'text-danger' : '';
}
private get hasPrecision() { private get hasPrecision() {
const precision = this.precision(); const precision = this.precision();
return precision !== undefined && precision >= 0; 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 hasFlashValue = this.flashValue !== undefined;
const change = changes?.[hasFlashValue ? 'flashValue' : 'value'];
if (!change || change.firstChange || this.isLoading()) {
return;
}
const direction = getValueChangeDirection({
currentValue: change.currentValue,
digits: hasFlashValue
? 2
: ((this.hasPrecision ? this.precision() : 2) ?? 2) +
(this.isPercent ? 2 : 0),
previousValue: change.previousValue
});
if (!direction) {
return;
}
// Highlight the whole figure (sign, number and unit) but not the full width host element
const element =
this.elementRef.nativeElement.querySelector<HTMLElement>(
'.gf-value-line'
);
if (!element) {
return;
}
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}`);
element.addEventListener(
'animationend',
() => {
element.classList.remove('gf-flash-down', 'gf-flash-up');
},
{ once: true }
);
}
public ngAfterViewInit() { public ngAfterViewInit() {
if (this.labelContent) { if (this.labelContent) {
const element = this.labelContent.nativeElement; const element = this.labelContent.nativeElement;
@ -104,8 +192,9 @@ export class GfValueComponent implements AfterViewInit, OnChanges, OnDestroy {
} }
} }
public ngOnChanges() { public ngOnChanges(changes?: SimpleChanges) {
this.initializeVariables(); this.initializeVariables();
this.flashOnChange(changes);
if (this.value || this.value === 0) { if (this.value || this.value === 0) {
if (isNumber(this.value)) { if (isNumber(this.value)) {

Loading…
Cancel
Save