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 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)
### 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 style of the close button in the dialog footer 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(
assetProfileIdentifiers
);
const [symbolProfiles, quotes] = await Promise.all([
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);
symbolProfiles.push(...cashSymbolProfiles);
@ -764,6 +779,9 @@ export class PortfolioService {
markets,
marketsAdvanced,
marketPrice,
marketState:
quotes[getAssetProfileIdentifier({ dataSource, symbol })]
?.marketState,
tags,
allocationInPercentage: filteredValueInBaseCurrency.eq(0)
? 0
@ -1223,11 +1241,32 @@ export class PortfolioService {
subscriptionType: user.subscription?.type
});
const { errors, hasErrors, historicalData } =
const { errors, hasErrors, historicalData, positions } =
await portfolioCalculator.getSnapshot();
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 } =
await portfolioCalculator.getPerformance({
end: endDate,
@ -1271,6 +1310,7 @@ export class PortfolioService {
chart,
errors,
hasErrors,
isMarketOpen,
dateOfFirstActivity: parseDate(historicalData[0]?.date),
performance: {
dividendInBaseCurrency,

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

@ -12,6 +12,7 @@
</a>
</div>
<span class="gf-spacer"></span>
<gf-live-indicator class="px-2" />
<ul class="align-items-center d-flex list-inline m-0 px-2">
<li class="list-inline-item">
<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 {
LoginWithAccessTokenDialogParams,
LoginWithAccessTokenDialogResult
@ -61,6 +62,7 @@ import { catchError } from 'rxjs/operators';
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
GfAssistantComponent,
GfLiveIndicatorComponent,
GfLogoComponent,
GfPremiumIndicatorComponent,
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 {
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);
@ -140,6 +143,19 @@ export class GfHomeHoldingsComponent implements OnInit {
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) {

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 { 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 {
@ -13,6 +14,7 @@ import {
AssetProfileIdentifier,
LineChartItem,
PortfolioPerformance,
PortfolioPerformanceResponse,
PortfolioPosition,
User
} from '@ghostfolio/common/interfaces';
@ -40,6 +42,7 @@ import { IonIcon } from '@ionic/angular/standalone';
import { addIcons } from 'ionicons';
import { arrowForwardOutline } from 'ionicons/icons';
import { DeviceDetectorService } from 'ngx-device-detector';
import { catchError, EMPTY, exhaustMap, filter } from 'rxjs';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
@ -64,6 +67,7 @@ export class GfHomeOverviewComponent implements OnInit {
undefined
);
protected readonly isLoadingPerformance = signal(true);
protected readonly isMarketOpen = signal<boolean | undefined>(undefined);
protected readonly locale = computed(() => {
return this.user()?.settings?.locale ?? DEFAULT_LOCALE;
@ -125,6 +129,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);
@ -161,6 +166,46 @@ 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(({ 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) {
@ -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() {
const userSettings = this.user()?.settings;
@ -187,17 +258,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));
});
}
@ -206,20 +267,12 @@ export class GfHomeOverviewComponent implements OnInit {
range: userSettings?.dateRange ?? DEFAULT_DATE_RANGE
})
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(({ chart, errors, performance }) => {
.subscribe(({ chart, errors, isMarketOpen, performance }) => {
this.errors.set(errors ?? []);
this.isMarketOpen.set(isMarketOpen);
this.performance.set(performance);
this.historicalDataItems.set(
chart?.map(
({ date, netPerformanceInPercentageWithCurrencyEffect }) => {
return {
date,
value: (netPerformanceInPercentageWithCurrencyEffect ?? 0) * 100
};
}
) ?? null
);
this.historicalDataItems.set(this.getChartItems(chart));
this.precision.set(2);

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

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

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

@ -7,12 +7,14 @@ import {
PortfolioPerformance,
ResponseError
} from '@ghostfolio/common/interfaces';
import { GfFlashOnChangeDirective } from '@ghostfolio/ui/flash-on-change';
import { NotificationService } from '@ghostfolio/ui/notifications';
import { GfValueComponent } from '@ghostfolio/ui/value';
import {
ChangeDetectionStrategy,
Component,
computed,
effect,
ElementRef,
inject,
@ -28,7 +30,12 @@ import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [GfValueComponent, IonIcon, NgxSkeletonLoaderModule],
imports: [
GfFlashOnChangeDirective,
GfValueComponent,
IonIcon,
NgxSkeletonLoaderModule
],
selector: 'gf-portfolio-performance',
styleUrls: ['./portfolio-performance.component.scss'],
templateUrl: './portfolio-performance.component.html'
@ -36,6 +43,7 @@ import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
export class GfPortfolioPerformanceComponent {
public readonly errors = input<ResponseError['errors']>();
public readonly isLoading = input<boolean>(false);
public readonly isMarketOpen = input<boolean>();
public readonly locale = input<string>(getLocale());
public readonly performance = input.required<PortfolioPerformance | null>();
public readonly precision = input.required<number, number>({
@ -49,6 +57,21 @@ export class GfPortfolioPerformanceComponent {
private readonly 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);
public constructor() {
@ -56,6 +79,9 @@ export class GfPortfolioPerformanceComponent {
effect(() => {
if (this.isLoading()) {
this.countUp = undefined;
this.countUpTarget = undefined;
if (this.value().nativeElement) {
this.value().nativeElement.innerHTML = '';
}
@ -63,30 +89,58 @@ export class GfPortfolioPerformanceComponent {
const performance = this.performance();
if (isNumber(performance?.currentValueInBaseCurrency)) {
new CountUp('value', performance.currentValueInBaseCurrency, {
decimal: getNumberFormatDecimal(this.locale()),
this.showValue({
decimalPlaces: this.precision(),
duration: 1,
separator: getNumberFormatGroup(this.locale())
}).start();
value: performance.currentValueInBaseCurrency
});
} else if (this.showDetails() === false && performance) {
new CountUp(
'value',
performance.netPerformancePercentageWithCurrencyEffect * 100,
{
decimal: getNumberFormatDecimal(this.locale()),
decimalPlaces: 2,
duration: 1,
separator: getNumberFormatGroup(this.locale())
}
).start();
this.showValue({
decimalPlaces: 2,
value: performance.netPerformancePercentageWithCurrencyEffect * 100
});
} else {
this.countUp = undefined;
this.countUpTarget = undefined;
this.value().nativeElement.innerHTML = '*****';
}
}
});
}
/**
* Counts up from zero on the initial 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() {
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 { 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,17 @@ 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);
this.autoRefreshService.markUpdated();
});
}
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 { 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);
@ -168,6 +170,35 @@ export class GfAnalysisPageComponent implements OnInit {
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) {
@ -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() {
this.isLoadingInvestmentChart = true;
@ -425,30 +485,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();
});

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

@ -359,6 +359,8 @@
class="justify-content-end"
position="end"
[colorizeSign]="true"
[flashValue]="holding.netPerformanceWithCurrencyEffect"
[isMarketOpen]="holding.marketState === 'open'"
[isPercent]="true"
[locale]="user?.settings?.locale"
[value]="holding.netPerformancePercentWithCurrencyEffect"
@ -412,6 +414,8 @@
class="justify-content-end"
position="end"
[colorizeSign]="true"
[flashValue]="holding.netPerformanceWithCurrencyEffect"
[isMarketOpen]="holding.marketState === 'open'"
[isPercent]="true"
[locale]="user?.settings?.locale"
[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 { EnhancedAssetProfile } from './enhanced-asset-profile.interface';
@ -33,6 +33,7 @@ export interface PortfolioPosition {
marketChange?: number;
marketChangePercent?: number;
marketPrice: number;
marketState?: MarketState;
markets?: { [key in Market]: number };
marketsAdvanced?: { [key in MarketAdvanced]: 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 {
chart?: HistoricalDataItem[];
dateOfFirstActivity: Date;
/** Whether a market (including the pre-market and the post-market) of a holding is open */
isMarketOpen?: boolean;
performance: PortfolioPerformance;
}

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

@ -20,6 +20,7 @@
matSortActive="name"
matSortDirection="asc"
[dataSource]="dataSource"
[trackBy]="trackByAccount"
>
<ng-container matColumnDef="status">
<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);
protected readonly trackByAccount = (
_index: number,
{ id }: AccountWithValue
) => id;
protected readonly accountDialogRouterLinks = computed(() => {
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"
[matSortActive]="sortActive()"
[matSortDirection]="sortDirection()"
[trackBy]="trackByHolding"
>
<ng-container matColumnDef="icon" [sticky]="mode() === 'default'">
<th *matHeaderCellDef class="px-1" mat-header-cell></th>
@ -172,6 +173,7 @@
[colorizeSign]="true"
[isCurrency]="true"
[isLoading]="isLoading()"
[isMarketOpen]="element.marketState === 'open'"
[locale]="locale()"
[value]="element.netPerformanceWithCurrencyEffect"
/>
@ -199,9 +201,12 @@
[class.text-nowrap]="mode() === 'simple'"
>
<div class="d-flex justify-content-end">
<!-- Highlighted together with the absolute change -->
<gf-value
[colorizeSign]="true"
[flashValue]="element.netPerformanceWithCurrencyEffect"
[isLoading]="isLoading()"
[isMarketOpen]="element.marketState === 'open'"
[isPercent]="true"
[locale]="locale()"
[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 trackByHolding = (
_index: number,
{ assetProfile }: HoldingsTableItem
) => `${assetProfile.dataSource}-${assetProfile.symbol}`;
protected readonly isCashPosition = isCashPosition;
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) {
// 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.options.plugins ??= {};
this.chart.options.plugins.tooltip =
@ -207,7 +213,7 @@ export class GfLineChartComponent
? animations
: undefined;
this.chart.update();
this.chart.update(isRefresh ? 'none' : undefined);
} else {
this.chart = new Chart<'line'>(this.chartCanvas.nativeElement, {
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'"
>
@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
class="mb-0 value"
[class.fw-bold]="size === 'large'"
[class.h2]="size === 'large'"
[class.h4]="size === 'medium'"
class="align-items-center d-flex gf-value-line"
[ngClass]="trendColorClass"
>
@if (value === null) {
<span class="font-monospace text-body-secondary">*****</span>
} @else {
{{ formattedValue }}
@if (colorizeSign && !useAbsoluteValue && value !== null) {
@if (+value > 0) {
<div
class="me-1"
[class.text-success]="isMarketOpen === undefined"
>
+
</div>
}
@if (+value < 0) {
<div
class="me-1"
[class.text-danger]="isMarketOpen === undefined"
>
-
</div>
}
}
</div>
@if (isPercent || unit) {
<div class="ms-1">
{{ isPercent ? '%' : unit }}
<div
class="mb-0 value"
[class.fw-bold]="size === 'large'"
[class.h2]="size === 'large'"
[class.h4]="size === 'medium'"
>
@if (value === null) {
<span class="font-monospace text-body-secondary">*****</span>
} @else {
{{ formattedValue }}
}
</div>
}
@if (isPercent || unit) {
<div class="ms-1">
{{ isPercent ? '%' : unit }}
</div>
}
</div>
}
@if (isString) {
<div

91
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],
@ -37,10 +40,23 @@ export class GfValueComponent implements AfterViewInit, OnChanges, OnDestroy {
@Input() colorizeSign = false;
@Input() deviceType: string;
@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() isAbsolute = false;
@Input() isCurrency = 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() locale?: string;
@Input() position = '';
@ -68,6 +84,7 @@ export class GfValueComponent implements AfterViewInit, OnChanges, OnDestroy {
public constructor(
private changeDetectorRef: ChangeDetectorRef,
private clipboard: Clipboard,
private elementRef: ElementRef<HTMLElement>,
private snackBar: MatSnackBar
) {
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() {
const precision = this.precision();
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() {
if (this.labelContent) {
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.flashOnChange(changes);
if (this.value || this.value === 0) {
if (isNumber(this.value)) {

Loading…
Cancel
Save