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