From 22635d4eea59d30f77787345157cda5ad0ccbf6a Mon Sep 17 00:00:00 2001 From: Jaime Gancedo <17992965+jgancedo@users.noreply.github.com> Date: Thu, 27 Aug 2026 18:26:58 +0100 Subject: [PATCH] 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. --- CHANGELOG.md | 4 + .../home-overview/home-overview.component.ts | 164 +++++++++++++++--- .../home-overview/home-overview.html | 17 +- .../portfolio-performance.component.html | 37 ++-- .../portfolio-performance.component.ts | 24 ++- libs/common/src/lib/config.ts | 3 +- .../src/lib/dtos/update-user-setting.dto.ts | 5 + libs/common/src/lib/helper.ts | 1 + .../lib/interfaces/user-settings.interface.ts | 2 + libs/common/src/lib/types/index.ts | 2 + .../src/lib/types/overview-chart-mode.type.ts | 1 + 11 files changed, 216 insertions(+), 44 deletions(-) create mode 100644 libs/common/src/lib/types/overview-chart-mode.type.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f14bb8d9..9a9da3bbf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +### Added + +- Added a 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) diff --git a/apps/client/src/app/components/home-overview/home-overview.component.ts b/apps/client/src/app/components/home-overview/home-overview.component.ts index de83a3228..47fc82b95 100644 --- a/apps/client/src/app/components/home-overview/home-overview.component.ts +++ b/apps/client/src/app/components/home-overview/home-overview.component.ts @@ -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(null); protected readonly errors = signal([]); protected readonly hasImpersonationId = signal(false); - protected readonly historicalDataItems = signal(null); protected readonly isLoadingPerformance = signal(true); + protected readonly netWorthLabel = $localize`Net Worth`; + protected readonly overviewChartMode = signal( + 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(null); protected readonly performanceLabel = $localize`Performance`; - protected readonly precision = signal(2); protected readonly user = signal(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( + () => { + 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; + 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); }); diff --git a/apps/client/src/app/components/home-overview/home-overview.html b/apps/client/src/app/components/home-overview/home-overview.html index 90a628a17..181c21291 100644 --- a/apps/client/src/app/components/home-overview/home-overview.html +++ b/apps/client/src/app/components/home-overview/home-overview.html @@ -63,21 +63,31 @@ } @else {
+ @if (showDetails()) { +
+ +
+ }
@@ -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()" />
diff --git a/apps/client/src/app/components/portfolio-performance/portfolio-performance.component.html b/apps/client/src/app/components/portfolio-performance/portfolio-performance.component.html index 8aeb0c433..90916df1b 100644 --- a/apps/client/src/app/components/portfolio-performance/portfolio-performance.component.html +++ b/apps/client/src/app/components/portfolio-performance/portfolio-performance.component.html @@ -35,22 +35,31 @@ @if (showDetails()) {
- + @if (isLoading() || change() !== undefined) { + + }
- + + @if (isLoading() || changeInPercentage() !== undefined) { + + }
} diff --git a/apps/client/src/app/components/portfolio-performance/portfolio-performance.component.ts b/apps/client/src/app/components/portfolio-performance/portfolio-performance.component.ts index a48d77a2d..58a8a74c6 100644 --- a/apps/client/src/app/components/portfolio-performance/portfolio-performance.component.ts +++ b/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(); public readonly isLoading = input(); public readonly locale = input(getLocale()); + public readonly netWorthChange = input(); + public readonly netWorthChangeInPercentage = input(); public readonly performance = input.required(); public readonly precision = input.required({ transform: (value) => { @@ -44,8 +47,21 @@ export class GfPortfolioPerformanceComponent { } }); public readonly showDetails = input(false); + public readonly showNetWorth = input(false); public readonly unit = input.required(); + 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>('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, diff --git a/libs/common/src/lib/config.ts b/libs/common/src/lib/config.ts index 5e11f8ae1..e45d7ea8f 100644 --- a/libs/common/src/lib/config.ts +++ b/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; diff --git a/libs/common/src/lib/dtos/update-user-setting.dto.ts b/libs/common/src/lib/dtos/update-user-setting.dto.ts index d46982e70..42c7249a3 100644 --- a/libs/common/src/lib/dtos/update-user-setting.dto.ts +++ b/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. diff --git a/libs/common/src/lib/helper.ts b/libs/common/src/lib/helper.ts index b6b097be9..a08c31340 100644 --- a/libs/common/src/lib/helper.ts +++ b/libs/common/src/lib/helper.ts @@ -82,6 +82,7 @@ const USER_SETTINGS_KEYS_OF_AUTHENTICATED_USER: (keyof UserSettings)[] = [ 'isRestrictedView', 'language', 'locale', + 'overviewChartMode', 'viewMode' ]; diff --git a/libs/common/src/lib/interfaces/user-settings.interface.ts b/libs/common/src/lib/interfaces/user-settings.interface.ts index 65325a42f..a112c8e4b 100644 --- a/libs/common/src/lib/interfaces/user-settings.interface.ts +++ b/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; diff --git a/libs/common/src/lib/types/index.ts b/libs/common/src/lib/types/index.ts index 9a44efc1a..7f6dc59d9 100644 --- a/libs/common/src/lib/types/index.ts +++ b/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, diff --git a/libs/common/src/lib/types/overview-chart-mode.type.ts b/libs/common/src/lib/types/overview-chart-mode.type.ts new file mode 100644 index 000000000..675278834 --- /dev/null +++ b/libs/common/src/lib/types/overview-chart-mode.type.ts @@ -0,0 +1 @@ +export type OverviewChartMode = 'NET_WORTH' | 'PERFORMANCE';