Jaime Gancedo 3 days ago
committed by GitHub
parent
commit
4335fb073f
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  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

@ -12,6 +12,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Introduced a DTO for the query parameters of the asset profiles endpoint - Introduced a DTO for the query parameters of the asset profiles endpoint
- Introduced a DTO for the query parameters of the symbol lookup endpoints - Introduced a DTO for the query parameters of the symbol lookup endpoints
### Added
- Added a toggle to switch between the performance chart and the net worth chart on the home page
### Changed ### Changed
- Improved the server of the Model Context Protocol (MCP) (experimental) - Improved the server of the Model Context Protocol (MCP) (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 { import {
DEFAULT_CURRENCY, DEFAULT_CURRENCY,
DEFAULT_DATE_RANGE, DEFAULT_DATE_RANGE,
DEFAULT_OVERVIEW_CHART_MODE,
NUMERICAL_PRECISION_THRESHOLD_6_FIGURES NUMERICAL_PRECISION_THRESHOLD_6_FIGURES
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { import {
AssetProfileIdentifier, AssetProfileIdentifier,
HistoricalDataItem,
LineChartItem, LineChartItem,
PortfolioPerformance, PortfolioPerformance,
User User,
UserSettings
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { internalRoutes } from '@ghostfolio/common/routes/routes'; import { internalRoutes } from '@ghostfolio/common/routes/routes';
import { hasScope, scopes } from '@ghostfolio/common/scopes'; import { hasScope, scopes } from '@ghostfolio/common/scopes';
import { OverviewChartMode, ToggleOption } from '@ghostfolio/common/types';
import { GfLineChartComponent } from '@ghostfolio/ui/line-chart'; import { GfLineChartComponent } from '@ghostfolio/ui/line-chart';
import { DataService } from '@ghostfolio/ui/services'; import { DataService } from '@ghostfolio/ui/services';
import { GfToggleComponent } from '@ghostfolio/ui/toggle';
import { import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
@ -31,6 +36,7 @@ import {
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { RouterModule } from '@angular/router'; import { RouterModule } from '@angular/router';
import { isEqual, isNumber, omit } from 'lodash';
import { DeviceDetectorService } from 'ngx-device-detector'; import { DeviceDetectorService } from 'ngx-device-detector';
@Component({ @Component({
@ -38,6 +44,7 @@ import { DeviceDetectorService } from 'ngx-device-detector';
imports: [ imports: [
GfLineChartComponent, GfLineChartComponent,
GfPortfolioPerformanceComponent, GfPortfolioPerformanceComponent,
GfToggleComponent,
MatButtonModule, MatButtonModule,
RouterModule RouterModule
], ],
@ -46,13 +53,26 @@ import { DeviceDetectorService } from 'ngx-device-detector';
templateUrl: './home-overview.html' templateUrl: './home-overview.html'
}) })
export class GfHomeOverviewComponent implements OnInit { export class GfHomeOverviewComponent implements OnInit {
protected readonly chart = signal<HistoricalDataItem[] | null>(null);
protected readonly errors = signal<AssetProfileIdentifier[]>([]); protected readonly errors = signal<AssetProfileIdentifier[]>([]);
protected readonly hasImpersonationId = signal(false); protected readonly hasImpersonationId = signal(false);
protected readonly historicalDataItems = signal<LineChartItem[] | null>(null);
protected readonly isLoadingPerformance = signal(true); 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 performance = signal<PortfolioPerformance | null>(null);
protected readonly performanceLabel = $localize`Performance`; protected readonly performanceLabel = $localize`Performance`;
protected readonly precision = signal(2);
protected readonly user = signal<User | null>(null); protected readonly user = signal<User | null>(null);
protected readonly routerLinkAccounts = internalRoutes.accounts.routerLink; 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 dataService = inject(DataService);
private readonly destroyRef = inject(DestroyRef); private readonly destroyRef = inject(DestroyRef);
private readonly deviceDetectorService = inject(DeviceDetectorService); private readonly deviceDetectorService = inject(DeviceDetectorService);
@ -102,7 +203,25 @@ export class GfHomeOverviewComponent implements OnInit {
.subscribe((state) => { .subscribe((state) => {
if (state?.user) { if (state?.user) {
this.user.set(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() { private update() {
this.historicalDataItems.set(null); this.chart.set(null);
this.isLoadingPerformance.set(true); this.isLoadingPerformance.set(true);
this.dataService this.dataService
@ -135,26 +268,7 @@ export class GfHomeOverviewComponent implements OnInit {
this.errors.set(errors ?? []); this.errors.set(errors ?? []);
this.performance.set(performance); this.performance.set(performance);
this.historicalDataItems.set( this.chart.set(chart ?? null);
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.isLoadingPerformance.set(false); this.isLoadingPerformance.set(false);
}); });

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

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

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

@ -35,22 +35,31 @@
@if (showDetails()) { @if (showDetails()) {
<div class="row"> <div class="row">
<div class="d-flex col justify-content-end"> <div class="d-flex col justify-content-end">
<gf-value @if (isLoading() || change() !== undefined) {
[colorizeSign]="true" <gf-value
[isCurrency]="true" [colorizeSign]="true"
[isLoading]="isLoading()" [isCurrency]="true"
[locale]="locale()" [isLoading]="isLoading()"
[value]="performance()?.netPerformanceWithCurrencyEffect" [locale]="locale()"
/> [value]="change()"
/>
}
</div> </div>
<div class="col"> <div class="col">
<gf-value <!--
[colorizeSign]="true" A relative change is not defined for a date range starting at a net
[isLoading]="isLoading()" worth of 0, in which case the value is omitted rather than rendered
[isPercent]="true" as a loading state
[locale]="locale()" -->
[value]="performance()?.netPerformancePercentageWithCurrencyEffect" @if (isLoading() || changeInPercentage() !== undefined) {
/> <gf-value
[colorizeSign]="true"
[isLoading]="isLoading()"
[isPercent]="true"
[locale]="locale()"
[value]="changeInPercentage()"
/>
}
</div> </div>
</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 { import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
Component, Component,
computed,
effect, effect,
ElementRef, ElementRef,
inject, inject,
@ -37,6 +38,8 @@ export class GfPortfolioPerformanceComponent {
public readonly errors = input<ResponseError['errors']>(); public readonly errors = input<ResponseError['errors']>();
public readonly isLoading = input<boolean>(); public readonly isLoading = input<boolean>();
public readonly locale = input<string>(getLocale()); public readonly locale = input<string>(getLocale());
public readonly netWorthChange = input<number>();
public readonly netWorthChangeInPercentage = input<number>();
public readonly performance = input.required<PortfolioPerformance>(); public readonly performance = input.required<PortfolioPerformance>();
public readonly precision = input.required<number, number>({ public readonly precision = input.required<number, number>({
transform: (value) => { transform: (value) => {
@ -44,8 +47,21 @@ export class GfPortfolioPerformanceComponent {
} }
}); });
public readonly showDetails = input<boolean>(false); public readonly showDetails = input<boolean>(false);
public readonly showNetWorth = input<boolean>(false);
public readonly unit = input.required<string>(); 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 = private readonly value =
viewChild.required<ElementRef<HTMLSpanElement>>('value'); viewChild.required<ElementRef<HTMLSpanElement>>('value');
@ -60,8 +76,12 @@ export class GfPortfolioPerformanceComponent {
this.value().nativeElement.innerHTML = ''; this.value().nativeElement.innerHTML = '';
} }
} else { } else {
if (isNumber(this.performance().currentValueInBaseCurrency)) { const currentValue = this.showNetWorth()
new CountUp('value', this.performance().currentValueInBaseCurrency, { ? this.performance().currentNetWorth
: this.performance().currentValueInBaseCurrency;
if (isNumber(currentValue)) {
new CountUp('value', currentValue, {
decimal: getNumberFormatDecimal(this.locale()), decimal: getNumberFormatDecimal(this.locale()),
decimalPlaces: this.precision(), decimalPlaces: this.precision(),
duration: 1, 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 { JobOptions, JobStatus } from 'bull';
import ms from 'ms'; import ms from 'ms';
import { ColorScheme, DateRange } from './types'; import { ColorScheme, DateRange, OverviewChartMode } from './types';
export const ghostfolioPrefix = 'GF'; export const ghostfolioPrefix = 'GF';
@ -102,6 +102,7 @@ export const DEFAULT_HOST = '0.0.0.0';
export const DEFAULT_LANGUAGE_CODE = 'en'; export const DEFAULT_LANGUAGE_CODE = 'en';
export const DEFAULT_LOCALE = 'en-US'; export const DEFAULT_LOCALE = 'en-US';
export const DEFAULT_OPENROUTER_ENGINE_WEB_FETCH = 'openrouter'; 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_PAGE_SIZE = 50;
export const DEFAULT_PORT = 3333; export const DEFAULT_PORT = 3333;
export const DEFAULT_PROCESSOR_GATHER_ASSET_PROFILE_CONCURRENCY = 1; 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, ColorScheme,
DateRange, DateRange,
HoldingsViewMode, HoldingsViewMode,
OverviewChartMode,
ViewMode ViewMode
} from '@ghostfolio/common/types'; } from '@ghostfolio/common/types';
import { IsCurrencyCode } from '@ghostfolio/common/validators/is-currency-code'; import { IsCurrencyCode } from '@ghostfolio/common/validators/is-currency-code';
@ -96,6 +97,10 @@ export class UpdateUserSettingDto {
@IsOptional() @IsOptional()
locale?: string; locale?: string;
@IsIn(['NET_WORTH', 'PERFORMANCE'] as OverviewChartMode[])
@IsOptional()
overviewChartMode?: OverviewChartMode;
/** /**
* The target financial amount the user aims to reach before retiring. * 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. * Can be explicitly set to null to clear the value and calculate it dynamically.

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

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

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

@ -3,6 +3,7 @@ import {
ColorScheme, ColorScheme,
DateRange, DateRange,
HoldingsViewMode, HoldingsViewMode,
OverviewChartMode,
ViewMode ViewMode
} from '@ghostfolio/common/types'; } from '@ghostfolio/common/types';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
@ -26,6 +27,7 @@ export interface UserSettings {
isRestrictedView?: boolean; isRestrictedView?: boolean;
language?: string; language?: string;
locale?: string; locale?: string;
overviewChartMode?: OverviewChartMode;
performanceCalculationType?: PerformanceCalculationType; performanceCalculationType?: PerformanceCalculationType;
projectedTotalAmount?: number; projectedTotalAmount?: number;
retirementDate?: string; 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 { MarketState } from './market-state.type';
import type { Market } from './market.type'; import type { Market } from './market.type';
import type { OrderWithAccount } from './order-with-account.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 { ProductCategory } from './product-category.type';
import type { ProductPlatform } from './product-platform.type'; import type { ProductPlatform } from './product-platform.type';
import type { PropertyKey } from './property-key.type'; import type { PropertyKey } from './property-key.type';
@ -49,6 +50,7 @@ export type {
MarketDataPreset, MarketDataPreset,
MarketState, MarketState,
OrderWithAccount, OrderWithAccount,
OverviewChartMode,
ProductCategory, ProductCategory,
ProductPlatform, ProductPlatform,
PropertyKey, PropertyKey,

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

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