Compare commits

...

5 Commits

Author SHA1 Message Date
Thomas Kaul f14f98f766
Release 3.7.0 (#6980) 7 days ago
Thomas Kaul 8a93f1f301
Bugfix/asset class chart on allocations page (#6979) 7 days ago
Thomas Kaul 9996d8a8d2
Bugfix/base currency in impersonation mode (#6964) 7 days ago
Thomas Kaul 8acd77717c
Task/upgrade Nx to version 22.7.5 (#6962) 7 days ago
Kenrick Tandrian 60be4de9a5
Task/improve type safety in analysis page component (#6965) 7 days ago
  1. 4
      CHANGELOG.md
  2. 11
      apps/api/src/app/account/account.controller.ts
  3. 4
      apps/api/src/app/account/account.module.ts
  4. 11
      apps/api/src/app/portfolio/portfolio.controller.ts
  5. 7
      apps/api/src/app/portfolio/portfolio.service.ts
  6. 31
      apps/api/src/app/user/user.service.ts
  7. 145
      apps/client/src/app/pages/portfolio/analysis/analysis-page.component.ts
  8. 8
      apps/client/src/app/pages/portfolio/analysis/analysis-page.html
  9. 10
      libs/ui/src/lib/services/data.service.ts
  10. 682
      package-lock.json
  11. 24
      package.json

4
CHANGELOG.md

@ -5,7 +5,7 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## Unreleased
## 3.7.0 - 2026-06-02
### Added
@ -15,9 +15,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Extended the countries mapping in the data enhancer for asset profile data via _Trackinsight_
- Removed the deprecated attributes (`assetClass`, `assetClassLabel`, `assetSubClass`, `assetSubClassLabel`, `countries`, `currency`, `dataSource`, `holdings`, `name`, `sectors`, `symbol` and `url`) from the holdings of the portfolio details endpoint response
- Upgraded `Nx` from version `22.7.2` to `22.7.5`
### Fixed
- Resolved an issue in the impersonation mode where the values did not match the owner’s currency
- Fixed the environment variable expansion in the `.env` file when debugging via _Visual Studio Code_
## 3.6.0 - 2026-05-28

11
apps/api/src/app/account/account.controller.ts

@ -1,5 +1,6 @@
import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service';
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
import { UserService } from '@ghostfolio/api/app/user/user.service';
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response/redact-values-in-response.interceptor';
@ -50,7 +51,8 @@ export class AccountController {
private readonly apiService: ApiService,
private readonly impersonationService: ImpersonationService,
private readonly portfolioService: PortfolioService,
@Inject(REQUEST) private readonly request: RequestWithUser
@Inject(REQUEST) private readonly request: RequestWithUser,
private readonly userService: UserService
) {}
@Delete(':id')
@ -137,11 +139,14 @@ export class AccountController {
): Promise<AccountBalancesResponse> {
const impersonationUserId =
await this.impersonationService.validateImpersonationId(impersonationId);
const userId = impersonationUserId || this.request.user.id;
const { settings } = await this.userService.user({ id: userId });
return this.accountBalanceService.getAccountBalances({
userId,
filters: [{ id, type: 'ACCOUNT' }],
userCurrency: this.request.user.settings.settings.baseCurrency,
userId: impersonationUserId || this.request.user.id
userCurrency: settings.settings.baseCurrency
});
}

4
apps/api/src/app/account/account.module.ts

@ -1,5 +1,6 @@
import { AccountBalanceModule } from '@ghostfolio/api/app/account-balance/account-balance.module';
import { PortfolioModule } from '@ghostfolio/api/app/portfolio/portfolio.module';
import { UserModule } from '@ghostfolio/api/app/user/user.module';
import { RedactValuesInResponseModule } from '@ghostfolio/api/interceptors/redact-values-in-response/redact-values-in-response.module';
import { ApiModule } from '@ghostfolio/api/services/api/api.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
@ -23,7 +24,8 @@ import { AccountService } from './account.service';
ImpersonationModule,
PortfolioModule,
PrismaModule,
RedactValuesInResponseModule
RedactValuesInResponseModule,
UserModule
],
providers: [AccountService]
})

11
apps/api/src/app/portfolio/portfolio.controller.ts

@ -1,4 +1,5 @@
import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service';
import { UserService } from '@ghostfolio/api/app/user/user.service';
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import {
@ -70,7 +71,8 @@ export class PortfolioController {
private readonly configurationService: ConfigurationService,
private readonly impersonationService: ImpersonationService,
private readonly portfolioService: PortfolioService,
@Inject(REQUEST) private readonly request: RequestWithUser
@Inject(REQUEST) private readonly request: RequestWithUser,
private readonly userService: UserService
) {}
@Get('details')
@ -340,7 +342,10 @@ export class PortfolioController {
const impersonationUserId =
await this.impersonationService.validateImpersonationId(impersonationId);
const userCurrency = this.request.user.settings.settings.baseCurrency;
const userId = impersonationUserId || this.request.user.id;
const { settings } = await this.userService.user({ id: userId });
const userCurrency = settings.settings.baseCurrency;
const { endDate, startDate } = getIntervalFromDateRange({ dateRange });
@ -349,7 +354,7 @@ export class PortfolioController {
filters,
startDate,
userCurrency,
userId: impersonationUserId || this.request.user.id,
userId,
types: ['DIVIDEND']
});

7
apps/api/src/app/portfolio/portfolio.service.ts

@ -164,7 +164,7 @@ export class PortfolioService {
};
}
const [accounts, details] = await Promise.all([
const [accounts, details, user] = await Promise.all([
this.accountService.accounts({
where,
include: {
@ -178,10 +178,11 @@ export class PortfolioService {
withExcludedAccounts,
impersonationId: userId,
userId: this.request.user.id
})
}),
this.userService.user({ id: userId })
]);
const userCurrency = this.request.user.settings.settings.baseCurrency;
const userCurrency = this.getUserCurrency(user);
return Promise.all(
accounts.map(async (account) => {

31
apps/api/src/app/user/user.service.ts

@ -49,7 +49,7 @@ import { PerformanceCalculationType } from '@ghostfolio/common/types/performance
import { Injectable } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { Prisma, Role, User } from '@prisma/client';
import { Prisma, Role, Settings, User } from '@prisma/client';
import { differenceInDays, subDays } from 'date-fns';
import { without } from 'lodash';
import { createHmac } from 'node:crypto';
@ -109,7 +109,14 @@ export class UserService {
}): Promise<IUser> {
const { id, permissions, settings, subscription } = user;
const userData = await Promise.all([
const [
access,
accounts,
activitiesCount,
firstActivity,
impersonationUserSettings,
tagsForUser
] = await Promise.all([
this.prismaService.access.findMany({
include: {
user: true
@ -134,16 +141,17 @@ export class UserService {
},
where: { userId: impersonationUserId || user.id }
}),
impersonationUserId
? this.prismaService.settings.findUnique({
where: { userId: impersonationUserId }
})
: Promise.resolve<Settings>(null),
this.tagService.getTagsForUser(impersonationUserId || user.id)
]);
const access = userData[0];
const accounts = userData[1];
const activitiesCount = userData[2];
const firstActivity = userData[3];
let tags = userData[4].filter((tag) => {
return tag.id !== TAG_ID_EXCLUDE_FROM_ANALYSIS;
});
const baseCurrency =
(impersonationUserSettings?.settings as UserSettings)?.baseCurrency ??
(settings.settings as UserSettings)?.baseCurrency;
let systemMessage: SystemMessage;
@ -156,6 +164,10 @@ export class UserService {
systemMessage = systemMessageProperty;
}
let tags = tagsForUser.filter((tag) => {
return tag.id !== TAG_ID_EXCLUDE_FROM_ANALYSIS;
});
if (
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
subscription.type === SubscriptionType.Basic
@ -183,6 +195,7 @@ export class UserService {
dateOfFirstActivity: firstActivity?.date ?? new Date(),
settings: {
...(settings.settings as UserSettings),
baseCurrency,
locale: (settings.settings as UserSettings)?.locale ?? locale
}
};

145
apps/client/src/app/pages/portfolio/analysis/analysis-page.component.ts

@ -2,7 +2,10 @@ import { GfBenchmarkComparatorComponent } from '@ghostfolio/client/components/be
import { GfInvestmentChartComponent } from '@ghostfolio/client/components/investment-chart/investment-chart.component';
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { NUMERICAL_PRECISION_THRESHOLD_6_FIGURES } from '@ghostfolio/common/config';
import {
DEFAULT_DATE_RANGE,
NUMERICAL_PRECISION_THRESHOLD_6_FIGURES
} from '@ghostfolio/common/config';
import {
HistoricalDataItem,
InvestmentItem,
@ -24,9 +27,12 @@ import { Clipboard } from '@angular/cdk/clipboard';
import {
ChangeDetectorRef,
Component,
computed,
DestroyRef,
inject,
OnInit,
ViewChild
signal,
viewChild
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MatButtonModule } from '@angular/material/button';
@ -64,53 +70,57 @@ import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
templateUrl: './analysis-page.html'
})
export class GfAnalysisPageComponent implements OnInit {
@ViewChild(MatMenuTrigger) actionsMenuButton!: MatMenuTrigger;
public benchmark: Partial<SymbolProfile>;
public benchmarkDataItems: HistoricalDataItem[] = [];
public benchmarks: Partial<SymbolProfile>[];
public bottom3: PortfolioPosition[];
public deviceType: string;
public dividendsByGroup: InvestmentItem[];
public dividendTimelineDataLabel = $localize`Dividend`;
public firstOrderDate: Date;
public hasImpersonationId: boolean;
public hasPermissionToReadAiPrompt: boolean;
public investments: InvestmentItem[];
public investmentTimelineDataLabel = $localize`Investment`;
public investmentsByGroup: InvestmentItem[];
public isLoadingAnalysisPrompt: boolean;
public isLoadingBenchmarkComparator: boolean;
public isLoadingDividendTimelineChart: boolean;
public isLoadingInvestmentChart: boolean;
public isLoadingInvestmentTimelineChart: boolean;
public isLoadingPortfolioPrompt: boolean;
public mode: GroupBy = 'month';
public modeOptions: ToggleOption[] = [
protected benchmark?: Partial<SymbolProfile>;
protected benchmarkDataItems: HistoricalDataItem[] = [];
protected readonly benchmarks: Partial<SymbolProfile>[];
protected bottom3: PortfolioPosition[];
protected dividendsByGroup: InvestmentItem[];
protected readonly dividendTimelineDataLabel = $localize`Dividend`;
protected hasImpersonationId: boolean;
protected hasPermissionToReadAiPrompt: boolean;
protected investments: InvestmentItem[];
protected readonly investmentTimelineDataLabel = $localize`Investment`;
protected investmentsByGroup: InvestmentItem[];
protected isLoadingAnalysisPrompt: boolean;
protected isLoadingBenchmarkComparator: boolean;
protected isLoadingDividendTimelineChart: boolean;
protected isLoadingInvestmentChart: boolean;
protected isLoadingInvestmentTimelineChart: boolean;
protected isLoadingPortfolioPrompt: boolean;
protected readonly mode = signal<GroupBy>('month');
protected readonly modeOptions: ToggleOption[] = [
{ label: $localize`Monthly`, value: 'month' },
{ label: $localize`Yearly`, value: 'year' }
];
public performance: PortfolioPerformance;
public performanceDataItems: HistoricalDataItem[];
public performanceDataItemsInPercentage: HistoricalDataItem[];
public portfolioEvolutionDataLabel = $localize`Investment`;
public precision = 2;
public streaks: PortfolioInvestmentsResponse['streaks'];
public top3: PortfolioPosition[];
public unitCurrentStreak: string;
public unitLongestStreak: string;
public user: User;
public constructor(
private changeDetectorRef: ChangeDetectorRef,
private clipboard: Clipboard,
private dataService: DataService,
private destroyRef: DestroyRef,
private deviceDetectorService: DeviceDetectorService,
private impersonationStorageService: ImpersonationStorageService,
private snackBar: MatSnackBar,
private userService: UserService
) {
protected performance: PortfolioPerformance;
protected performanceDataItems: HistoricalDataItem[];
protected performanceDataItemsInPercentage: HistoricalDataItem[];
protected readonly portfolioEvolutionDataLabel = $localize`Investment`;
protected precision = 2;
protected streaks: PortfolioInvestmentsResponse['streaks'];
protected top3: PortfolioPosition[];
protected unitCurrentStreak: string;
protected unitLongestStreak: string;
protected user: User;
private readonly actionsMenuButton = viewChild.required(MatMenuTrigger);
private readonly deviceType = computed(
() => this.deviceDetectorService.deviceInfo().deviceType
);
private firstOrderDate: Date;
private readonly changeDetectorRef = inject(ChangeDetectorRef);
private readonly clipboard = inject(Clipboard);
private readonly dataService = inject(DataService);
private readonly destroyRef = inject(DestroyRef);
private readonly deviceDetectorService = inject(DeviceDetectorService);
private readonly impersonationStorageService = inject(
ImpersonationStorageService
);
private readonly snackBar = inject(MatSnackBar);
private readonly userService = inject(UserService);
public constructor() {
const { benchmarks } = this.dataService.fetchInfo();
this.benchmarks = benchmarks;
@ -123,14 +133,16 @@ export class GfAnalysisPageComponent implements OnInit {
? undefined
: this.user?.settings?.savingsRate;
return this.mode === 'year'
if (savingsRatePerMonth === undefined) {
return undefined;
}
return this.mode() === 'year'
? savingsRatePerMonth * 12
: savingsRatePerMonth;
}
public ngOnInit() {
this.deviceType = this.deviceDetectorService.getDeviceInfo().deviceType;
this.impersonationStorageService
.onChangeHasImpersonation()
.pipe(takeUntilDestroyed(this.destroyRef))
@ -158,7 +170,7 @@ export class GfAnalysisPageComponent implements OnInit {
});
}
public onChangeBenchmark(symbolProfileId: string) {
protected onChangeBenchmark(symbolProfileId: string) {
this.dataService
.putUserSetting({ benchmark: symbolProfileId })
.pipe(takeUntilDestroyed(this.destroyRef))
@ -174,12 +186,12 @@ export class GfAnalysisPageComponent implements OnInit {
});
}
public onChangeGroupBy(aMode: GroupBy) {
this.mode = aMode;
protected onChangeGroupBy(aMode: GroupBy) {
this.mode.set(aMode);
this.fetchDividendsAndInvestments();
}
public onCopyPromptToClipboard(mode: AiPromptMode) {
protected onCopyPromptToClipboard(mode: AiPromptMode) {
if (mode === 'analysis') {
this.isLoadingAnalysisPrompt = true;
} else if (mode === 'portfolio') {
@ -210,7 +222,7 @@ export class GfAnalysisPageComponent implements OnInit {
window.open('https://duck.ai', '_blank');
});
this.actionsMenuButton.closeMenu();
this.actionsMenuButton().closeMenu();
if (mode === 'analysis') {
this.isLoadingAnalysisPrompt = false;
@ -227,8 +239,8 @@ export class GfAnalysisPageComponent implements OnInit {
this.dataService
.fetchDividends({
filters: this.userService.getFilters(),
groupBy: this.mode,
range: this.user?.settings?.dateRange
groupBy: this.mode(),
range: this.user?.settings?.dateRange ?? DEFAULT_DATE_RANGE
})
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(({ dividends }) => {
@ -242,15 +254,15 @@ export class GfAnalysisPageComponent implements OnInit {
this.dataService
.fetchInvestments({
filters: this.userService.getFilters(),
groupBy: this.mode,
range: this.user?.settings?.dateRange
groupBy: this.mode(),
range: this.user?.settings?.dateRange ?? DEFAULT_DATE_RANGE
})
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(({ investments, streaks }) => {
this.investmentsByGroup = investments;
this.streaks = streaks;
this.unitCurrentStreak =
this.mode === 'year'
this.mode() === 'year'
? this.streaks?.currentStreak === 1
? translate('YEAR')
: translate('YEARS')
@ -258,7 +270,7 @@ export class GfAnalysisPageComponent implements OnInit {
? translate('MONTH')
: translate('MONTHS');
this.unitLongestStreak =
this.mode === 'year'
this.mode() === 'year'
? this.streaks?.longestStreak === 1
? translate('YEAR')
: translate('YEARS')
@ -278,7 +290,7 @@ export class GfAnalysisPageComponent implements OnInit {
this.dataService
.fetchPortfolioPerformance({
filters: this.userService.getFilters(),
range: this.user?.settings?.dateRange
range: this.user?.settings?.dateRange ?? DEFAULT_DATE_RANGE
})
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(({ chart, firstOrderDate, performance }) => {
@ -298,13 +310,16 @@ export class GfAnalysisPageComponent implements OnInit {
valueInPercentage,
valueWithCurrencyEffect
}
] of chart.entries()) {
if (index > 0 || this.user?.settings?.dateRange === 'max') {
] of (chart ?? []).entries()) {
// Ignore first item where value is 0
if (index > 0 || this.user?.settings?.dateRange === 'max') {
if (totalInvestmentValueWithCurrencyEffect !== undefined) {
this.investments.push({
date,
investment: totalInvestmentValueWithCurrencyEffect
});
}
this.performanceDataItems.push({
date,
value: isNumber(valueWithCurrencyEffect)
@ -320,7 +335,7 @@ export class GfAnalysisPageComponent implements OnInit {
}
if (
this.deviceType === 'mobile' &&
this.deviceType() === 'mobile' &&
this.performance.currentValueInBaseCurrency >=
NUMERICAL_PRECISION_THRESHOLD_6_FIGURES
) {
@ -387,7 +402,7 @@ export class GfAnalysisPageComponent implements OnInit {
dataSource,
symbol,
filters: this.userService.getFilters(),
range: this.user?.settings?.dateRange,
range: this.user?.settings?.dateRange ?? DEFAULT_DATE_RANGE,
startDate: this.firstOrderDate
})
.pipe(takeUntilDestroyed(this.destroyRef))

8
apps/client/src/app/pages/portfolio/analysis/analysis-page.html

@ -442,7 +442,7 @@
</div>
<gf-toggle
class="d-none d-lg-block"
[defaultValue]="mode"
[defaultValue]="mode()"
[isLoading]="false"
[options]="modeOptions"
(valueChange)="onChangeGroupBy($event.value)"
@ -476,7 +476,7 @@
[benchmarkDataItems]="investmentsByGroup"
[benchmarkDataLabel]="investmentTimelineDataLabel"
[currency]="user?.settings?.baseCurrency"
[groupBy]="mode"
[groupBy]="mode()"
[isInPercentage]="
hasImpersonationId || user.settings.isRestrictedView
"
@ -501,7 +501,7 @@
</div>
<gf-toggle
class="d-none d-lg-block"
[defaultValue]="mode"
[defaultValue]="mode()"
[isLoading]="false"
[options]="modeOptions"
(valueChange)="onChangeGroupBy($event.value)"
@ -513,7 +513,7 @@
[benchmarkDataItems]="dividendsByGroup"
[benchmarkDataLabel]="dividendTimelineDataLabel"
[currency]="user?.settings?.baseCurrency"
[groupBy]="mode"
[groupBy]="mode()"
[isInPercentage]="
hasImpersonationId || user.settings.isRestrictedView
"

10
libs/ui/src/lib/services/data.service.ts

@ -556,13 +556,11 @@ export class DataService {
map((response) => {
if (response.holdings) {
for (const symbol of Object.keys(response.holdings)) {
response.holdings[symbol].assetClassLabel = translate(
response.holdings[symbol].assetClass
);
response.holdings[symbol].assetProfile.assetClassLabel =
translate(response.holdings[symbol].assetProfile.assetClass);
response.holdings[symbol].assetSubClassLabel = translate(
response.holdings[symbol].assetSubClass
);
response.holdings[symbol].assetProfile.assetSubClassLabel =
translate(response.holdings[symbol].assetProfile.assetSubClass);
response.holdings[symbol].dateOfFirstActivity = response.holdings[
symbol

682
package-lock.json

File diff suppressed because it is too large

24
package.json

@ -1,6 +1,6 @@
{
"name": "ghostfolio",
"version": "3.6.0",
"version": "3.7.0",
"homepage": "https://ghostfol.io",
"license": "AGPL-3.0",
"repository": "https://github.com/ghostfolio/ghostfolio",
@ -158,16 +158,16 @@
"@eslint/js": "9.35.0",
"@nestjs/schematics": "11.1.0",
"@nestjs/testing": "11.1.21",
"@nx/angular": "22.7.2",
"@nx/eslint-plugin": "22.7.2",
"@nx/jest": "22.7.2",
"@nx/js": "22.7.2",
"@nx/module-federation": "22.7.2",
"@nx/nest": "22.7.2",
"@nx/node": "22.7.2",
"@nx/storybook": "22.7.2",
"@nx/web": "22.7.2",
"@nx/workspace": "22.7.2",
"@nx/angular": "22.7.5",
"@nx/eslint-plugin": "22.7.5",
"@nx/jest": "22.7.5",
"@nx/js": "22.7.5",
"@nx/module-federation": "22.7.5",
"@nx/nest": "22.7.5",
"@nx/node": "22.7.5",
"@nx/storybook": "22.7.5",
"@nx/web": "22.7.5",
"@nx/workspace": "22.7.5",
"@schematics/angular": "21.2.6",
"@storybook/addon-docs": "10.1.10",
"@storybook/addon-themes": "10.1.10",
@ -194,7 +194,7 @@
"jest": "30.2.0",
"jest-environment-jsdom": "30.2.0",
"jest-preset-angular": "16.0.0",
"nx": "22.7.2",
"nx": "22.7.5",
"prettier": "3.8.3",
"prettier-plugin-organize-attributes": "1.0.0",
"prisma": "7.8.0",

Loading…
Cancel
Save