Browse Source

Feature/add net worth chart toggle on home page

Add a toggle on the home page to switch the overview chart between the
investments and the net worth time series. The headline value and the change
below it follow the selection, showing the net worth and its change over the
selected date range instead of the portfolio value and its performance.

The toggle is omitted in the restricted view and in the ZEN mode, where the
net worth is not exposed.

The net worth is already part of the portfolio performance response, so
switching does not refetch. The selection is persisted in the user settings
as overviewChartMode.
pull/7777/head
Jaime Gancedo 2 weeks ago
parent
commit
22635d4eea
  1. 4
      CHANGELOG.md
  2. 164
      apps/client/src/app/components/home-overview/home-overview.component.ts
  3. 17
      apps/client/src/app/components/home-overview/home-overview.html
  4. 37
      apps/client/src/app/components/portfolio-performance/portfolio-performance.component.html
  5. 24
      apps/client/src/app/components/portfolio-performance/portfolio-performance.component.ts
  6. 3
      libs/common/src/lib/config.ts
  7. 5
      libs/common/src/lib/dtos/update-user-setting.dto.ts
  8. 1
      libs/common/src/lib/helper.ts
  9. 2
      libs/common/src/lib/interfaces/user-settings.interface.ts
  10. 2
      libs/common/src/lib/types/index.ts
  11. 1
      libs/common/src/lib/types/overview-chart-mode.type.ts

4
CHANGELOG.md

@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## Unreleased
### Added
- Added a toggle to switch between the performance chart and the net worth chart on the home page
### Changed
- Extended the tool to get the accounts of the portfolio in the server of the Model Context Protocol (MCP) to support the filtering by account (experimental)

164
apps/client/src/app/components/home-overview/home-overview.component.ts

@ -5,19 +5,24 @@ import { UserService } from '@ghostfolio/client/services/user/user.service';
import {
DEFAULT_CURRENCY,
DEFAULT_DATE_RANGE,
DEFAULT_OVERVIEW_CHART_MODE,
NUMERICAL_PRECISION_THRESHOLD_6_FIGURES
} from '@ghostfolio/common/config';
import {
AssetProfileIdentifier,
HistoricalDataItem,
LineChartItem,
PortfolioPerformance,
User
User,
UserSettings
} from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { internalRoutes } from '@ghostfolio/common/routes/routes';
import { hasScope, scopes } from '@ghostfolio/common/scopes';
import { OverviewChartMode, ToggleOption } from '@ghostfolio/common/types';
import { GfLineChartComponent } from '@ghostfolio/ui/line-chart';
import { DataService } from '@ghostfolio/ui/services';
import { GfToggleComponent } from '@ghostfolio/ui/toggle';
import {
ChangeDetectionStrategy,
@ -31,6 +36,7 @@ import {
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MatButtonModule } from '@angular/material/button';
import { RouterModule } from '@angular/router';
import { isEqual, isNumber, omit } from 'lodash';
import { DeviceDetectorService } from 'ngx-device-detector';
@Component({
@ -38,6 +44,7 @@ import { DeviceDetectorService } from 'ngx-device-detector';
imports: [
GfLineChartComponent,
GfPortfolioPerformanceComponent,
GfToggleComponent,
MatButtonModule,
RouterModule
],
@ -46,13 +53,26 @@ import { DeviceDetectorService } from 'ngx-device-detector';
templateUrl: './home-overview.html'
})
export class GfHomeOverviewComponent implements OnInit {
protected readonly chart = signal<HistoricalDataItem[] | null>(null);
protected readonly errors = signal<AssetProfileIdentifier[]>([]);
protected readonly hasImpersonationId = signal(false);
protected readonly historicalDataItems = signal<LineChartItem[] | null>(null);
protected readonly isLoadingPerformance = signal(true);
protected readonly netWorthLabel = $localize`Net Worth`;
protected readonly overviewChartMode = signal<OverviewChartMode>(
DEFAULT_OVERVIEW_CHART_MODE
);
protected readonly overviewChartModeOptions: ToggleOption[] = [
{
label: $localize`Investments`,
value: 'PERFORMANCE' satisfies OverviewChartMode
},
{
label: $localize`Net Worth`,
value: 'NET_WORTH' satisfies OverviewChartMode
}
];
protected readonly performance = signal<PortfolioPerformance | null>(null);
protected readonly performanceLabel = $localize`Performance`;
protected readonly precision = signal(2);
protected readonly user = signal<User | null>(null);
protected readonly routerLinkAccounts = internalRoutes.accounts.routerLink;
@ -87,6 +107,87 @@ export class GfHomeOverviewComponent implements OnInit {
: '%';
});
protected readonly chartCurrency = computed(() => {
return this.overviewChartMode() === 'NET_WORTH'
? (this.user()?.settings?.baseCurrency ?? DEFAULT_CURRENCY)
: undefined;
});
protected readonly chartLabel = computed(() => {
return this.overviewChartMode() === 'NET_WORTH'
? this.netWorthLabel
: this.performanceLabel;
});
protected readonly chartUnit = computed(() => {
return this.chartCurrency() ? undefined : '%';
});
protected readonly precision = computed(() => {
const currentValue =
(this.overviewChartMode() === 'NET_WORTH'
? this.performance()?.currentNetWorth
: this.performance()?.currentValueInBaseCurrency) ?? 0;
return this.deviceType() === 'mobile' &&
currentValue >= NUMERICAL_PRECISION_THRESHOLD_6_FIGURES
? 0
: 2;
});
protected readonly netWorthChange = computed(() => {
const chart = this.chart();
if (!chart?.length) {
return undefined;
}
const netWorthEnd = chart[chart.length - 1].netWorth;
const netWorthStart = chart[0].netWorth;
return isNumber(netWorthStart) && isNumber(netWorthEnd)
? netWorthEnd - netWorthStart
: undefined;
});
protected readonly netWorthChangeInPercentage = computed(() => {
const netWorthChange = this.netWorthChange();
const netWorthStart = this.chart()?.[0]?.netWorth;
// A date range starting before the first activity has a net worth of 0,
// for which a relative change is not defined
return isNumber(netWorthChange) && netWorthStart
? netWorthChange / netWorthStart
: undefined;
});
protected readonly historicalDataItems = computed<LineChartItem[] | null>(
() => {
const chart = this.chart();
if (!chart) {
return null;
}
if (this.overviewChartMode() === 'NET_WORTH') {
return chart.map(({ date, netWorth }) => {
return { date, value: netWorth ?? 0 };
});
}
return chart.map(
({ date, netPerformanceInPercentageWithCurrencyEffect }) => {
return {
date,
value: (netPerformanceInPercentageWithCurrencyEffect ?? 0) * 100
};
}
);
}
);
private previousUserSettings: Omit<UserSettings, 'overviewChartMode'>;
private readonly dataService = inject(DataService);
private readonly destroyRef = inject(DestroyRef);
private readonly deviceDetectorService = inject(DeviceDetectorService);
@ -102,7 +203,25 @@ export class GfHomeOverviewComponent implements OnInit {
.subscribe((state) => {
if (state?.user) {
this.user.set(state.user);
this.update();
// The net worth is not exposed in the restricted view and in the
// ZEN mode, where the chart mode is therefore not offered
this.overviewChartMode.set(
this.showDetails()
? (state.user.settings?.overviewChartMode ??
DEFAULT_OVERVIEW_CHART_MODE)
: DEFAULT_OVERVIEW_CHART_MODE
);
// The chart mode is applied on the client, so changing it alone must
// not refetch the portfolio performance
const userSettings = omit(state.user.settings, 'overviewChartMode');
if (!isEqual(userSettings, this.previousUserSettings)) {
this.previousUserSettings = userSettings;
this.update();
}
}
});
}
@ -122,8 +241,22 @@ export class GfHomeOverviewComponent implements OnInit {
});
}
protected onChangeOverviewChartMode(overviewChartMode: OverviewChartMode) {
this.overviewChartMode.set(overviewChartMode);
this.dataService
.putUserSetting({ overviewChartMode })
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => {
this.userService
.get(true)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe();
});
}
private update() {
this.historicalDataItems.set(null);
this.chart.set(null);
this.isLoadingPerformance.set(true);
this.dataService
@ -135,26 +268,7 @@ export class GfHomeOverviewComponent implements OnInit {
this.errors.set(errors ?? []);
this.performance.set(performance);
this.historicalDataItems.set(
chart?.map(
({ date, netPerformanceInPercentageWithCurrencyEffect }) => {
return {
date,
value: (netPerformanceInPercentageWithCurrencyEffect ?? 0) * 100
};
}
) ?? null
);
this.precision.set(2);
if (
this.deviceType() === 'mobile' &&
performance.currentValueInBaseCurrency >=
NUMERICAL_PRECISION_THRESHOLD_6_FIGURES
) {
this.precision.set(0);
}
this.chart.set(chart ?? null);
this.isLoadingPerformance.set(false);
});

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

@ -63,21 +63,31 @@
} @else {
<div class="row w-100">
<div class="col p-0">
@if (showDetails()) {
<div class="d-flex justify-content-center">
<gf-toggle
[defaultValue]="overviewChartMode()"
[options]="overviewChartModeOptions"
(valueChange)="onChangeOverviewChartMode($event.value)"
/>
</div>
}
<div class="chart-container mx-auto position-relative">
<gf-line-chart
class="position-absolute"
unit="%"
[class.pr-3]="deviceType() === 'mobile'"
[colorScheme]="user()?.settings?.colorScheme"
[currency]="chartCurrency()"
[hidden]="historicalDataItems()?.length === 0"
[historicalDataItems]="historicalDataItems()"
[isAnimated]="user()?.settings?.dateRange === '1d' ? false : true"
[label]="performanceLabel"
[label]="chartLabel()"
[locale]="user()?.settings?.locale"
[showGradient]="true"
[showLoader]="false"
[showXAxis]="false"
[showYAxis]="false"
[unit]="chartUnit()"
/>
</div>
</div>
@ -89,9 +99,12 @@
[errors]="errors()"
[isLoading]="isLoadingPerformance()"
[locale]="user()?.settings?.locale"
[netWorthChange]="netWorthChange()"
[netWorthChangeInPercentage]="netWorthChangeInPercentage()"
[performance]="performance()"
[precision]="precision()"
[showDetails]="showDetails()"
[showNetWorth]="overviewChartMode() === 'NET_WORTH'"
[unit]="unit()"
/>
</div>

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

@ -35,22 +35,31 @@
@if (showDetails()) {
<div class="row">
<div class="d-flex col justify-content-end">
<gf-value
[colorizeSign]="true"
[isCurrency]="true"
[isLoading]="isLoading()"
[locale]="locale()"
[value]="performance()?.netPerformanceWithCurrencyEffect"
/>
@if (isLoading() || change() !== undefined) {
<gf-value
[colorizeSign]="true"
[isCurrency]="true"
[isLoading]="isLoading()"
[locale]="locale()"
[value]="change()"
/>
}
</div>
<div class="col">
<gf-value
[colorizeSign]="true"
[isLoading]="isLoading()"
[isPercent]="true"
[locale]="locale()"
[value]="performance()?.netPerformancePercentageWithCurrencyEffect"
/>
<!--
A relative change is not defined for a date range starting at a net
worth of 0, in which case the value is omitted rather than rendered
as a loading state
-->
@if (isLoading() || changeInPercentage() !== undefined) {
<gf-value
[colorizeSign]="true"
[isLoading]="isLoading()"
[isPercent]="true"
[locale]="locale()"
[value]="changeInPercentage()"
/>
}
</div>
</div>
}

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

@ -13,6 +13,7 @@ import { GfValueComponent } from '@ghostfolio/ui/value';
import {
ChangeDetectionStrategy,
Component,
computed,
effect,
ElementRef,
inject,
@ -37,6 +38,8 @@ export class GfPortfolioPerformanceComponent {
public readonly errors = input<ResponseError['errors']>();
public readonly isLoading = input<boolean>();
public readonly locale = input<string>(getLocale());
public readonly netWorthChange = input<number>();
public readonly netWorthChangeInPercentage = input<number>();
public readonly performance = input.required<PortfolioPerformance>();
public readonly precision = input.required<number, number>({
transform: (value) => {
@ -44,8 +47,21 @@ export class GfPortfolioPerformanceComponent {
}
});
public readonly showDetails = input<boolean>(false);
public readonly showNetWorth = input<boolean>(false);
public readonly unit = input.required<string>();
protected readonly change = computed(() => {
return this.showNetWorth()
? this.netWorthChange()
: this.performance()?.netPerformanceWithCurrencyEffect;
});
protected readonly changeInPercentage = computed(() => {
return this.showNetWorth()
? this.netWorthChangeInPercentage()
: this.performance()?.netPerformancePercentageWithCurrencyEffect;
});
private readonly value =
viewChild.required<ElementRef<HTMLSpanElement>>('value');
@ -60,8 +76,12 @@ export class GfPortfolioPerformanceComponent {
this.value().nativeElement.innerHTML = '';
}
} else {
if (isNumber(this.performance().currentValueInBaseCurrency)) {
new CountUp('value', this.performance().currentValueInBaseCurrency, {
const currentValue = this.showNetWorth()
? this.performance().currentNetWorth
: this.performance().currentValueInBaseCurrency;
if (isNumber(currentValue)) {
new CountUp('value', currentValue, {
decimal: getNumberFormatDecimal(this.locale()),
decimalPlaces: this.precision(),
duration: 1,

3
libs/common/src/lib/config.ts

@ -2,7 +2,7 @@ import { AssetClass, AssetSubClass, DataSource, Type } from '@prisma/client';
import { JobOptions, JobStatus } from 'bull';
import ms from 'ms';
import { ColorScheme, DateRange } from './types';
import { ColorScheme, DateRange, OverviewChartMode } from './types';
export const ghostfolioPrefix = 'GF';
@ -100,6 +100,7 @@ export const DEFAULT_HOST = '0.0.0.0';
export const DEFAULT_LANGUAGE_CODE = 'en';
export const DEFAULT_LOCALE = 'en-US';
export const DEFAULT_OPENROUTER_ENGINE_WEB_FETCH = 'openrouter';
export const DEFAULT_OVERVIEW_CHART_MODE: OverviewChartMode = 'PERFORMANCE';
export const DEFAULT_PAGE_SIZE = 50;
export const DEFAULT_PORT = 3333;
export const DEFAULT_PROCESSOR_GATHER_ASSET_PROFILE_CONCURRENCY = 1;

5
libs/common/src/lib/dtos/update-user-setting.dto.ts

@ -3,6 +3,7 @@ import type {
ColorScheme,
DateRange,
HoldingsViewMode,
OverviewChartMode,
ViewMode
} from '@ghostfolio/common/types';
import { IsCurrencyCode } from '@ghostfolio/common/validators/is-currency-code';
@ -96,6 +97,10 @@ export class UpdateUserSettingDto {
@IsOptional()
locale?: string;
@IsIn(['NET_WORTH', 'PERFORMANCE'] as OverviewChartMode[])
@IsOptional()
overviewChartMode?: OverviewChartMode;
/**
* The target financial amount the user aims to reach before retiring.
* Can be explicitly set to null to clear the value and calculate it dynamically.

1
libs/common/src/lib/helper.ts

@ -82,6 +82,7 @@ const USER_SETTINGS_KEYS_OF_AUTHENTICATED_USER: (keyof UserSettings)[] = [
'isRestrictedView',
'language',
'locale',
'overviewChartMode',
'viewMode'
];

2
libs/common/src/lib/interfaces/user-settings.interface.ts

@ -3,6 +3,7 @@ import {
ColorScheme,
DateRange,
HoldingsViewMode,
OverviewChartMode,
ViewMode
} from '@ghostfolio/common/types';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
@ -26,6 +27,7 @@ export interface UserSettings {
isRestrictedView?: boolean;
language?: string;
locale?: string;
overviewChartMode?: OverviewChartMode;
performanceCalculationType?: PerformanceCalculationType;
projectedTotalAmount?: number;
retirementDate?: string;

2
libs/common/src/lib/types/index.ts

@ -18,6 +18,7 @@ import type { MarketDataPreset } from './market-data-preset.type';
import type { MarketState } from './market-state.type';
import type { Market } from './market.type';
import type { OrderWithAccount } from './order-with-account.type';
import type { OverviewChartMode } from './overview-chart-mode.type';
import type { ProductCategory } from './product-category.type';
import type { ProductPlatform } from './product-platform.type';
import type { PropertyKey } from './property-key.type';
@ -49,6 +50,7 @@ export type {
MarketDataPreset,
MarketState,
OrderWithAccount,
OverviewChartMode,
ProductCategory,
ProductPlatform,
PropertyKey,

1
libs/common/src/lib/types/overview-chart-mode.type.ts

@ -0,0 +1 @@
export type OverviewChartMode = 'NET_WORTH' | 'PERFORMANCE';
Loading…
Cancel
Save