Browse Source

Merge branch 'main' into bugfix/update-account-balance-of-activity-without-account

pull/7402/head
Thomas Kaul 1 month ago
committed by GitHub
parent
commit
4bb9f9165f
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 16
      CHANGELOG.md
  2. 17
      apps/api/src/app/portfolio/calculator/portfolio-calculator.ts
  3. 8
      apps/api/src/events/events.module.ts
  4. 55
      apps/api/src/events/portfolio-changed.listener.ts
  5. 16
      apps/api/src/services/api/api.service.ts
  6. 7
      apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.component.ts
  7. 9
      apps/client/src/app/pages/accounts/create-or-update-account-dialog/create-or-update-account-dialog.component.ts
  8. 9
      apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.component.ts
  9. 15
      apps/client/src/app/pages/portfolio/fire/fire-page.component.ts
  10. 150
      apps/client/src/app/pages/portfolio/fire/fire-page.html
  11. 444
      apps/client/src/locales/messages.ca.xlf
  12. 444
      apps/client/src/locales/messages.de.xlf
  13. 444
      apps/client/src/locales/messages.es.xlf
  14. 444
      apps/client/src/locales/messages.fr.xlf
  15. 444
      apps/client/src/locales/messages.it.xlf
  16. 444
      apps/client/src/locales/messages.ja.xlf
  17. 444
      apps/client/src/locales/messages.ko.xlf
  18. 444
      apps/client/src/locales/messages.nl.xlf
  19. 444
      apps/client/src/locales/messages.pl.xlf
  20. 444
      apps/client/src/locales/messages.pt.xlf
  21. 444
      apps/client/src/locales/messages.tr.xlf
  22. 444
      apps/client/src/locales/messages.uk.xlf
  23. 426
      apps/client/src/locales/messages.xlf
  24. 444
      apps/client/src/locales/messages.zh.xlf
  25. 13
      libs/common/src/lib/helper.ts
  26. 6
      libs/ui/src/lib/fire-calculator/fire-calculator.component.html
  27. 15
      libs/ui/src/lib/fire-calculator/fire-calculator.component.ts
  28. 12
      package-lock.json
  29. 4
      package.json

16
CHANGELOG.md

@ -7,18 +7,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## Unreleased
### Changed
- Upgraded `fuse.js` from version `7.3.0` to `7.5.0`
### Fixed
- Resolved an exception in the `POST api/v1/order` endpoint when creating an activity with the update account balance option but without an account
## 3.33.0 - 2026-07-25
### Added
- Added the stack trace logging for `MaxListenersExceededWarning` occurrences
### Changed
- Moved the support to create custom tags from experimental to general availability
- Recomputed the portfolio snapshot calculation in the background on a portfolio change
- Improved the deduplication of the portfolio snapshot calculation jobs by considering the filters
- Refactored the deprecated animation providers (`provideAnimations()` and `provideNoopAnimations()`)
- Improved the language localization for German (`de`)
- Improved the language localization for Polish (`pl`)
### Fixed
- Resolved an exception in the `POST api/v1/order` endpoint when creating an activity with the update account balance option but without an account
- Fixed an issue with the localization in the _FIRE_ page
- Improved the spacing in the testimonial section on the landing page
## 3.32.0 - 2026-07-22

17
apps/api/src/app/portfolio/calculator/portfolio-calculator.ts

@ -1097,15 +1097,18 @@ export abstract class PortfolioCalculator {
let cachedPortfolioSnapshot: PortfolioSnapshot;
let isCachedPortfolioSnapshotExpired = false;
const jobId = this.userId;
const portfolioSnapshotKey = this.redisCacheService.getPortfolioSnapshotKey(
{
filters: this.filters,
userId: this.userId
}
);
const jobId = portfolioSnapshotKey;
try {
const cachedPortfolioSnapshotValue = await this.redisCacheService.get(
this.redisCacheService.getPortfolioSnapshotKey({
filters: this.filters,
userId: this.userId
})
);
const cachedPortfolioSnapshotValue =
await this.redisCacheService.get(portfolioSnapshotKey);
const { expiration, portfolioSnapshot }: PortfolioSnapshotValue =
JSON.parse(cachedPortfolioSnapshotValue);

8
apps/api/src/events/events.module.ts

@ -1,9 +1,12 @@
import { ActivitiesModule } from '@ghostfolio/api/app/activities/activities.module';
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { UserModule } from '@ghostfolio/api/app/user/user.module';
import { ApiModule } from '@ghostfolio/api/services/api/api.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
import { DataGatheringQueueModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module';
import { PortfolioSnapshotQueueModule } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.module';
import { Module } from '@nestjs/common';
@ -13,11 +16,14 @@ import { PortfolioChangedListener } from './portfolio-changed.listener';
@Module({
imports: [
ActivitiesModule,
ApiModule,
ConfigurationModule,
DataGatheringQueueModule,
DataProviderModule,
ExchangeRateDataModule,
RedisCacheModule
PortfolioSnapshotQueueModule,
RedisCacheModule,
UserModule
],
providers: [AssetProfileChangedListener, PortfolioChangedListener]
})

55
apps/api/src/events/portfolio-changed.listener.ts

@ -1,4 +1,12 @@
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { UserService } from '@ghostfolio/api/app/user/user.service';
import { ApiService } from '@ghostfolio/api/services/api/api.service';
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
import {
PORTFOLIO_SNAPSHOT_COMPUTATION_QUEUE_PRIORITY_LOW,
PORTFOLIO_SNAPSHOT_PROCESS_JOB_NAME,
PORTFOLIO_SNAPSHOT_PROCESS_JOB_OPTIONS
} from '@ghostfolio/common/config';
import { Injectable, Logger } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
@ -14,7 +22,12 @@ export class PortfolioChangedListener {
private debounceTimers = new Map<string, NodeJS.Timeout>();
public constructor(private readonly redisCacheService: RedisCacheService) {}
public constructor(
private readonly apiService: ApiService,
private readonly portfolioSnapshotService: PortfolioSnapshotService,
private readonly redisCacheService: RedisCacheService,
private readonly userService: UserService
) {}
@OnEvent(PortfolioChangedEvent.getName())
handlePortfolioChangedEvent(event: PortfolioChangedEvent) {
@ -39,6 +52,44 @@ export class PortfolioChangedListener {
private async processPortfolioChanged({ userId }: { userId: string }) {
this.logger.log(`Portfolio of user '${userId}' has changed`);
await this.redisCacheService.removePortfolioSnapshotsByUserId({ userId });
try {
await this.redisCacheService.removePortfolioSnapshotsByUserId({ userId });
const user = await this.userService.user({ id: userId });
if (!user) {
return;
}
const userSettings = user.settings.settings;
const filters = this.apiService.buildFiltersFromUserSettings({
userSettings
});
// Recompute in the background to avoid a cold start on the next request
await this.portfolioSnapshotService.addJobToQueue({
data: {
filters,
userId,
calculationType: userSettings.performanceCalculationType,
userCurrency: userSettings.baseCurrency
},
name: PORTFOLIO_SNAPSHOT_PROCESS_JOB_NAME,
opts: {
...PORTFOLIO_SNAPSHOT_PROCESS_JOB_OPTIONS,
jobId: this.redisCacheService.getPortfolioSnapshotKey({
filters,
userId
}),
priority: PORTFOLIO_SNAPSHOT_COMPUTATION_QUEUE_PRIORITY_LOW
}
});
} catch (error) {
this.logger.error(
`Portfolio snapshot of user '${userId}' could not be recomputed`,
error
);
}
}
}

16
apps/api/src/services/api/api.service.ts

@ -1,4 +1,4 @@
import { Filter } from '@ghostfolio/common/interfaces';
import { Filter, UserSettings } from '@ghostfolio/common/interfaces';
import { Injectable } from '@nestjs/common';
@ -89,4 +89,18 @@ export class ApiService {
return filters;
}
public buildFiltersFromUserSettings({
userSettings
}: {
userSettings: UserSettings;
}): Filter[] {
return this.buildFiltersFromQueryParams({
filterByAccounts: userSettings?.['filters.accounts']?.[0],
filterByAssetClasses: userSettings?.['filters.assetClasses']?.[0],
filterByDataSource: userSettings?.['filters.dataSource'],
filterBySymbol: userSettings?.['filters.symbol'],
filterByTags: userSettings?.['filters.tags']?.[0]
});
}
}

7
apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.component.ts

@ -581,9 +581,10 @@ export class GfHoldingDetailDialogComponent implements OnInit {
if (state?.user) {
this.user = state.user;
this.hasPermissionToCreateOwnTag =
hasPermission(this.user.permissions, permissions.createOwnTag) &&
(this.user?.settings?.isExperimentalFeatures ?? false);
this.hasPermissionToCreateOwnTag = hasPermission(
this.user?.permissions,
permissions.createOwnTag
);
this.tagsAvailable =
this.user?.tags?.map((tag) => {

9
apps/client/src/app/pages/accounts/create-or-update-account-dialog/create-or-update-account-dialog.component.ts

@ -65,7 +65,7 @@ export class GfCreateOrUpdateAccountDialogComponent {
protected accountForm: FormGroup;
protected currencies: string[] = [];
protected filteredPlatforms: Observable<Platform[]> | undefined;
protected hasPermissionToCreateOwnTag: boolean | undefined;
protected hasPermissionToCreateOwnTag: boolean;
protected platforms: Platform[] = [];
protected tagsAvailable: Tag[] = [];
@ -82,9 +82,10 @@ export class GfCreateOrUpdateAccountDialogComponent {
const { currencies } = this.dataService.fetchInfo();
this.currencies = currencies;
this.hasPermissionToCreateOwnTag =
this.data.user?.settings?.isExperimentalFeatures &&
hasPermission(this.data.user?.permissions, permissions.createOwnTag);
this.hasPermissionToCreateOwnTag = hasPermission(
this.data.user?.permissions,
permissions.createOwnTag
);
this.tagsAvailable = [
...(this.data.user?.tags ?? []),

9
apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.component.ts

@ -92,7 +92,7 @@ export class GfCreateOrUpdateActivityDialogComponent {
protected currentMarketPrice: number | null = null;
protected defaultDateFormat: string;
protected defaultLookupItems: LookupItem[] = [];
protected hasPermissionToCreateOwnTag: boolean | undefined;
protected hasPermissionToCreateOwnTag: boolean;
protected isLoading = false;
protected readonly isToday = isToday;
protected mode: 'create' | 'update';
@ -120,9 +120,10 @@ export class GfCreateOrUpdateActivityDialogComponent {
public ngOnInit() {
this.currencyOfAssetProfile = this.data.activity?.assetProfile?.currency;
this.hasPermissionToCreateOwnTag =
this.data.user?.settings?.isExperimentalFeatures &&
hasPermission(this.data.user?.permissions, permissions.createOwnTag);
this.hasPermissionToCreateOwnTag = hasPermission(
this.data.user?.permissions,
permissions.createOwnTag
);
this.locale = this.data.user.settings.locale ?? DEFAULT_LOCALE;
this.mode = this.data.activity?.id ? 'update' : 'create';

15
apps/client/src/app/pages/portfolio/fire/fire-page.component.ts

@ -1,6 +1,7 @@
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { SubscriptionType } from '@ghostfolio/common/enums';
import { formatMonthAndYear } from '@ghostfolio/common/helper';
import {
FireCalculationCompleteEvent,
FireWealth,
@ -77,6 +78,20 @@ export class GfFirePageComponent implements OnInit {
);
private readonly userService = inject(UserService);
protected get retirementDateLabel(): string {
const retirementDate =
this.user?.settings?.retirementDate ?? this.retirementDate;
if (!retirementDate) {
return '';
}
return formatMonthAndYear({
date: new Date(retirementDate),
locale: this.user?.settings?.locale
});
}
public ngOnInit() {
this.isLoading = true;

150
apps/client/src/app/pages/portfolio/fire/fire-page.html

@ -66,47 +66,38 @@
<div [class.text-muted]="user?.subscription?.type === 'Basic'">
<div class="mb-2">
<ng-container i18n
>If you retire today, you would be able to withdraw</ng-container
>
<ng-container>&nbsp;</ng-container>
<span class="font-weight-bold"
><gf-value
class="d-inline-block"
[isCurrency]="true"
[locale]="user?.settings?.locale"
[unit]="user?.settings?.baseCurrency"
[value]="withdrawalRatePerYear?.toNumber()"
/>
<ng-container>&nbsp;</ng-container>
<ng-container i18n>per year</ng-container></span
>
<ng-container>&nbsp;</ng-container>
<ng-container i18n>or</ng-container>
<ng-container>&nbsp;</ng-container>
<span class="font-weight-bold"
><gf-value
class="d-inline-block"
[isCurrency]="true"
[locale]="user?.settings?.locale"
[unit]="user?.settings?.baseCurrency"
[value]="withdrawalRatePerMonth?.toNumber()"
/>
<ng-container>&nbsp;</ng-container>
<ng-container i18n>per month</ng-container></span
>If you retire today, you would be able to withdraw
<span class="font-weight-bold"
><gf-value
class="d-inline-block"
[isCurrency]="true"
[locale]="user?.settings?.locale"
[unit]="user?.settings?.baseCurrency"
[value]="withdrawalRatePerYear?.toNumber()"
/>
per year</span
>
or
<span class="font-weight-bold"
><gf-value
class="d-inline-block"
[isCurrency]="true"
[locale]="user?.settings?.locale"
[unit]="user?.settings?.baseCurrency"
[value]="withdrawalRatePerMonth?.toNumber()"
/>
per month</span
>, based on your total assets of
<span class="font-weight-bold"
><gf-value
class="d-inline-block"
[isCurrency]="true"
[locale]="user?.settings?.locale"
[unit]="user?.settings?.baseCurrency"
[value]="fireWealth?.today.valueInBaseCurrency"
/></span>
and a safe withdrawal rate (SWR) of</ng-container
>
<ng-container i18n>, based on your total assets of</ng-container>
<ng-container>&nbsp;</ng-container>
<span class="font-weight-bold"
><gf-value
class="d-inline-block"
[isCurrency]="true"
[locale]="user?.settings?.locale"
[unit]="user?.settings?.baseCurrency"
[value]="fireWealth?.today.valueInBaseCurrency"
/>
</span>
<ng-container>&nbsp;</ng-container>
<ng-container i18n>and a safe withdrawal rate (SWR) of</ng-container>
@if (
!hasImpersonationId &&
hasPermissionToUpdateUserSettings &&
@ -137,53 +128,40 @@
@if (user?.settings?.isExperimentalFeatures) {
<div>
<ng-container i18n>By</ng-container>
<ng-container>&nbsp;</ng-container>
<span class="font-weight-bold">{{
user?.settings?.retirementDate ?? retirementDate
| date: 'MMMM yyyy'
}}</span>
<ng-container i18n="@@page.fire.projected.1">,</ng-container>
<ng-container>&nbsp;</ng-container>
<ng-container i18n>this is projected to increase to</ng-container>
<ng-container>&nbsp;</ng-container>
<span class="font-weight-bold"
><gf-value
class="d-inline-block"
[isCurrency]="true"
[locale]="user?.settings?.locale"
[unit]="user?.settings?.baseCurrency"
[value]="withdrawalRatePerYearProjected?.toNumber()"
/>
<ng-container>&nbsp;</ng-container>
<ng-container i18n>per year</ng-container></span
<ng-container i18n
>By <span class="font-weight-bold">{{ retirementDateLabel }}</span
>, this is projected to increase to
<span class="font-weight-bold"
><gf-value
class="d-inline-block"
[isCurrency]="true"
[locale]="user?.settings?.locale"
[unit]="user?.settings?.baseCurrency"
[value]="withdrawalRatePerYearProjected?.toNumber()"
/>
per year</span
>
or
<span class="font-weight-bold"
><gf-value
class="d-inline-block"
[isCurrency]="true"
[locale]="user?.settings?.locale"
[unit]="user?.settings?.baseCurrency"
[value]="withdrawalRatePerMonthProjected?.toNumber()"
/>
per month</span
>, assuming a
<span class="font-weight-bold"
><gf-value
class="d-inline-block"
[isPercent]="true"
[locale]="user?.settings?.locale"
[precision]="2"
[value]="user?.settings?.annualInterestRate / 100"
/></span>
annual interest rate.</ng-container
>
<ng-container>&nbsp;</ng-container>
<ng-container i18n>or</ng-container>
<ng-container>&nbsp;</ng-container>
<span class="font-weight-bold"
><gf-value
class="d-inline-block"
[isCurrency]="true"
[locale]="user?.settings?.locale"
[unit]="user?.settings?.baseCurrency"
[value]="withdrawalRatePerMonthProjected?.toNumber()"
/>
<ng-container>&nbsp;</ng-container>
<ng-container i18n>per month</ng-container></span
>
<ng-container i18n>, assuming a</ng-container>
<ng-container>&nbsp;</ng-container>
<span class="font-weight-bold"
><gf-value
class="d-inline-block"
[isPercent]="true"
[locale]="user?.settings?.locale"
[precision]="2"
[value]="user?.settings?.annualInterestRate / 100"
/></span>
<ng-container>&nbsp;</ng-container>
<ng-container i18n>annual interest rate</ng-container>.
</div>
}
</div>

444
apps/client/src/locales/messages.ca.xlf

File diff suppressed because it is too large

444
apps/client/src/locales/messages.de.xlf

File diff suppressed because it is too large

444
apps/client/src/locales/messages.es.xlf

File diff suppressed because it is too large

444
apps/client/src/locales/messages.fr.xlf

File diff suppressed because it is too large

444
apps/client/src/locales/messages.it.xlf

File diff suppressed because it is too large

444
apps/client/src/locales/messages.ja.xlf

File diff suppressed because it is too large

444
apps/client/src/locales/messages.ko.xlf

File diff suppressed because it is too large

444
apps/client/src/locales/messages.nl.xlf

File diff suppressed because it is too large

444
apps/client/src/locales/messages.pl.xlf

File diff suppressed because it is too large

444
apps/client/src/locales/messages.pt.xlf

File diff suppressed because it is too large

444
apps/client/src/locales/messages.tr.xlf

File diff suppressed because it is too large

444
apps/client/src/locales/messages.uk.xlf

File diff suppressed because it is too large

426
apps/client/src/locales/messages.xlf

File diff suppressed because it is too large

444
apps/client/src/locales/messages.zh.xlf

File diff suppressed because it is too large

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

@ -233,6 +233,19 @@ export function extractNumberFromString({
}
}
export function formatMonthAndYear({
date,
locale
}: {
date: Date;
locale?: string;
}) {
return new Intl.DateTimeFormat(locale, {
month: 'long',
year: 'numeric'
}).format(date);
}
export function getAllActivityTypes(): ActivityType[] {
return Object.values(ActivityType);
}

6
libs/ui/src/lib/fire-calculator/fire-calculator.component.html

@ -30,11 +30,7 @@
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Retirement Date</mat-label>
<div>
{{
calculatorForm.get('retirementDate')?.value | date: 'MMMM yyyy'
}}
</div>
<div>{{ retirementDateLabel }}</div>
<input
class="d-none"
formControlName="retirementDate"

15
libs/ui/src/lib/fire-calculator/fire-calculator.component.ts

@ -3,7 +3,7 @@ import {
transformTickToAbbreviation
} from '@ghostfolio/common/chart-helper';
import { primaryColorRgb } from '@ghostfolio/common/config';
import { getLocale } from '@ghostfolio/common/helper';
import { formatMonthAndYear, getLocale } from '@ghostfolio/common/helper';
import { FireCalculationCompleteEvent } from '@ghostfolio/common/interfaces';
import { ColorScheme } from '@ghostfolio/common/types';
@ -208,6 +208,19 @@ export class GfFireCalculatorComponent implements OnChanges, OnDestroy {
});
}
protected get retirementDateLabel(): string {
const retirementDate = this.calculatorForm.get('retirementDate')?.value;
if (!retirementDate) {
return '';
}
return formatMonthAndYear({
date: retirementDate,
locale: this.locale
});
}
public ngOnChanges() {
if (isNumber(this.fireWealth) && this.fireWealth >= 0) {
this.calculatorForm.setValue(

12
package-lock.json

@ -1,12 +1,12 @@
{
"name": "ghostfolio",
"version": "3.32.0",
"version": "3.33.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ghostfolio",
"version": "3.32.0",
"version": "3.33.0",
"hasInstallScript": true,
"license": "AGPL-3.0",
"dependencies": {
@ -70,7 +70,7 @@
"dotenv-expand": "12.0.3",
"envalid": "8.2.0",
"fast-redact": "3.5.0",
"fuse.js": "7.3.0",
"fuse.js": "7.5.0",
"google-spreadsheet": "3.2.0",
"helmet": "8.2.0",
"http-status-codes": "2.3.0",
@ -21313,9 +21313,9 @@
}
},
"node_modules/fuse.js": {
"version": "7.3.0",
"resolved": "https://registry.npmjs.org/fuse.js/-/fuse.js-7.3.0.tgz",
"integrity": "sha512-plz8RVjfcDedTGfVngWH1jmJvBvAwi1v2jecfDerbEnMcmOYUEEwKFTHbNoCiYyzaK2Ws8lABkTCcRSqCY1q4w==",
"version": "7.5.0",
"resolved": "https://registry.npmjs.org/fuse.js/-/fuse.js-7.5.0.tgz",
"integrity": "sha512-sQtrEfA+ez/3G0cCZecF70oqpCRttCexYUG4mUrtWL49ULUzUyxokt5kyqwtKzj1270RaKih+hcP3qLcumccow==",
"license": "Apache-2.0",
"engines": {
"node": ">=10"

4
package.json

@ -1,6 +1,6 @@
{
"name": "ghostfolio",
"version": "3.32.0",
"version": "3.33.0",
"homepage": "https://ghostfol.io",
"license": "AGPL-3.0",
"repository": "https://github.com/ghostfolio/ghostfolio",
@ -114,7 +114,7 @@
"dotenv-expand": "12.0.3",
"envalid": "8.2.0",
"fast-redact": "3.5.0",
"fuse.js": "7.3.0",
"fuse.js": "7.5.0",
"google-spreadsheet": "3.2.0",
"helmet": "8.2.0",
"http-status-codes": "2.3.0",

Loading…
Cancel
Save