Jaime Gancedo 2 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 symbol lookup endpoints
### Added
- Added a toggle to switch between the performance chart and the net worth chart on the home page
### Changed
- 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 {
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';
@ -102,6 +102,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

@ -84,6 +84,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