From 6666f110f23cd2f1fbe21e40b8250287632e8710 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Tue, 14 Jul 2026 19:16:59 +0200 Subject: [PATCH 01/17] Task/set change detection strategy to OnPush in various page components (part II) (#7329) * Set change detection strategy to OnPush * Update changelog --- CHANGELOG.md | 17 +++++++++++++++++ .../about/changelog/changelog-page.component.ts | 3 ++- .../about/license/license-page.component.ts | 3 ++- .../oss-friends/oss-friends-page.component.ts | 3 ++- .../overview/about-overview-page.component.ts | 6 ++++-- .../privacy-policy-page.component.ts | 3 ++- .../terms-of-service-page.component.ts | 3 ++- .../pages/accounts/accounts-page.component.ts | 6 ++++-- .../src/app/pages/api/api-page.component.ts | 9 ++++++++- .../src/app/pages/auth/auth-page.component.ts | 8 +++++++- .../src/app/pages/demo/demo-page.component.ts | 3 ++- .../faq/overview/faq-overview-page.component.ts | 6 ++++-- .../app/pages/faq/saas/saas-page.component.ts | 6 ++++-- .../self-hosting/self-hosting-page.component.ts | 7 ++++++- .../pages/features/features-page.component.ts | 12 +++++++++--- .../src/app/pages/i18n/i18n-page.component.ts | 3 ++- .../app/pages/landing/landing-page.component.ts | 3 ++- .../app/pages/markets/markets-page.component.ts | 3 ++- .../src/app/pages/open/open-page.component.ts | 6 ++++-- .../app/pages/pricing/pricing-page.component.ts | 6 ++++-- .../app/pages/public/public-page.component.ts | 2 ++ .../pages/register/register-page.component.ts | 2 ++ .../glossary/resources-glossary.component.ts | 3 ++- .../guides/resources-guides.component.ts | 3 ++- .../markets/resources-markets.component.ts | 3 ++- .../overview/resources-overview.component.ts | 3 ++- .../personal-finance-tools-page.component.ts | 3 ++- .../pages/webauthn/webauthn-page.component.ts | 4 ++++ 28 files changed, 107 insertions(+), 32 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 35c87f60f..2c3caf990 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,23 @@ 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 + +### Changed + +- Set the change detection strategy to `OnPush` in the about pages +- Set the change detection strategy to `OnPush` in the accounts page +- Set the change detection strategy to `OnPush` in the demo page +- Set the change detection strategy to `OnPush` in the features page +- Set the change detection strategy to `OnPush` in the Frequently Asked Questions (FAQ) pages +- Set the change detection strategy to `OnPush` in the landing page +- Set the change detection strategy to `OnPush` in the markets page +- Set the change detection strategy to `OnPush` in the _Open Startup_ (`/open`) page +- Set the change detection strategy to `OnPush` in the pricing page +- Set the change detection strategy to `OnPush` in the public page +- Set the change detection strategy to `OnPush` in the registration page +- Set the change detection strategy to `OnPush` in the resources pages + ## 3.26.0 - 2026-07-14 ### Added diff --git a/apps/client/src/app/pages/about/changelog/changelog-page.component.ts b/apps/client/src/app/pages/about/changelog/changelog-page.component.ts index d7f583bd1..24265f2ef 100644 --- a/apps/client/src/app/pages/about/changelog/changelog-page.component.ts +++ b/apps/client/src/app/pages/about/changelog/changelog-page.component.ts @@ -1,8 +1,9 @@ -import { Component } from '@angular/core'; +import { ChangeDetectionStrategy, Component } from '@angular/core'; import { MarkdownModule } from 'ngx-markdown'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; @Component({ + changeDetection: ChangeDetectionStrategy.OnPush, imports: [MarkdownModule, NgxSkeletonLoaderModule], selector: 'gf-changelog-page', styleUrls: ['./changelog-page.scss'], diff --git a/apps/client/src/app/pages/about/license/license-page.component.ts b/apps/client/src/app/pages/about/license/license-page.component.ts index d530d0418..80f4eb0c0 100644 --- a/apps/client/src/app/pages/about/license/license-page.component.ts +++ b/apps/client/src/app/pages/about/license/license-page.component.ts @@ -1,7 +1,8 @@ -import { Component } from '@angular/core'; +import { ChangeDetectionStrategy, Component } from '@angular/core'; import { MarkdownModule } from 'ngx-markdown'; @Component({ + changeDetection: ChangeDetectionStrategy.OnPush, imports: [MarkdownModule], selector: 'gf-license-page', styleUrls: ['./license-page.scss'], diff --git a/apps/client/src/app/pages/about/oss-friends/oss-friends-page.component.ts b/apps/client/src/app/pages/about/oss-friends/oss-friends-page.component.ts index c2e500a52..cb2aa6b07 100644 --- a/apps/client/src/app/pages/about/oss-friends/oss-friends-page.component.ts +++ b/apps/client/src/app/pages/about/oss-friends/oss-friends-page.component.ts @@ -1,4 +1,4 @@ -import { Component } from '@angular/core'; +import { ChangeDetectionStrategy, Component } from '@angular/core'; import { MatButtonModule } from '@angular/material/button'; import { MatCardModule } from '@angular/material/card'; import { IonIcon } from '@ionic/angular/standalone'; @@ -8,6 +8,7 @@ import { arrowForwardOutline } from 'ionicons/icons'; const ossFriends = require('../../../../assets/oss-friends.json'); @Component({ + changeDetection: ChangeDetectionStrategy.OnPush, imports: [IonIcon, MatButtonModule, MatCardModule], selector: 'gf-oss-friends-page', styleUrls: ['./oss-friends-page.scss'], diff --git a/apps/client/src/app/pages/about/overview/about-overview-page.component.ts b/apps/client/src/app/pages/about/overview/about-overview-page.component.ts index 92a98598b..5af3c48ec 100644 --- a/apps/client/src/app/pages/about/overview/about-overview-page.component.ts +++ b/apps/client/src/app/pages/about/overview/about-overview-page.component.ts @@ -5,6 +5,7 @@ import { publicRoutes } from '@ghostfolio/common/routes/routes'; import { DataService } from '@ghostfolio/ui/services'; import { + ChangeDetectionStrategy, ChangeDetectorRef, Component, CUSTOM_ELEMENTS_SCHEMA, @@ -25,6 +26,7 @@ import { } from 'ionicons/icons'; @Component({ + changeDetection: ChangeDetectionStrategy.OnPush, imports: [IonIcon, MatButtonModule, RouterModule], schemas: [CUSTOM_ELEMENTS_SCHEMA], selector: 'gf-about-overview-page', @@ -68,9 +70,9 @@ export class GfAboutOverviewPageComponent implements OnInit { .subscribe((state) => { if (state?.user) { this.user = state.user; - - this.changeDetectorRef.markForCheck(); } + + this.changeDetectorRef.markForCheck(); }); } } diff --git a/apps/client/src/app/pages/about/privacy-policy/privacy-policy-page.component.ts b/apps/client/src/app/pages/about/privacy-policy/privacy-policy-page.component.ts index 50e4e3e2f..cf205533a 100644 --- a/apps/client/src/app/pages/about/privacy-policy/privacy-policy-page.component.ts +++ b/apps/client/src/app/pages/about/privacy-policy/privacy-policy-page.component.ts @@ -1,7 +1,8 @@ -import { Component } from '@angular/core'; +import { ChangeDetectionStrategy, Component } from '@angular/core'; import { MarkdownModule } from 'ngx-markdown'; @Component({ + changeDetection: ChangeDetectionStrategy.OnPush, imports: [MarkdownModule], selector: 'gf-privacy-policy-page', styleUrls: ['./privacy-policy-page.scss'], diff --git a/apps/client/src/app/pages/about/terms-of-service/terms-of-service-page.component.ts b/apps/client/src/app/pages/about/terms-of-service/terms-of-service-page.component.ts index 7899f0187..77f29c07e 100644 --- a/apps/client/src/app/pages/about/terms-of-service/terms-of-service-page.component.ts +++ b/apps/client/src/app/pages/about/terms-of-service/terms-of-service-page.component.ts @@ -1,7 +1,8 @@ -import { Component } from '@angular/core'; +import { ChangeDetectionStrategy, Component } from '@angular/core'; import { MarkdownModule } from 'ngx-markdown'; @Component({ + changeDetection: ChangeDetectionStrategy.OnPush, imports: [MarkdownModule], selector: 'gf-terms-of-service-page', styleUrls: ['./terms-of-service-page.scss'], diff --git a/apps/client/src/app/pages/accounts/accounts-page.component.ts b/apps/client/src/app/pages/accounts/accounts-page.component.ts index 907b39d37..7d5e2fff7 100644 --- a/apps/client/src/app/pages/accounts/accounts-page.component.ts +++ b/apps/client/src/app/pages/accounts/accounts-page.component.ts @@ -15,6 +15,7 @@ import { NotificationService } from '@ghostfolio/ui/notifications'; import { DataService } from '@ghostfolio/ui/services'; import { + ChangeDetectionStrategy, ChangeDetectorRef, Component, computed, @@ -36,6 +37,7 @@ import { TransferBalanceDialogParams } from './transfer-balance/interfaces/inter import { GfTransferBalanceDialogComponent } from './transfer-balance/transfer-balance-dialog.component'; @Component({ + changeDetection: ChangeDetectionStrategy.OnPush, host: { class: 'page' }, imports: [GfAccountsTableComponent, GfFabComponent, RouterModule], selector: 'gf-accounts-page', @@ -120,9 +122,9 @@ export class GfAccountsPageComponent implements OnInit { this.user.permissions, permissions.updateAccount ); - - this.changeDetectorRef.markForCheck(); } + + this.changeDetectorRef.markForCheck(); }); this.fetchAccounts(); diff --git a/apps/client/src/app/pages/api/api-page.component.ts b/apps/client/src/app/pages/api/api-page.component.ts index 3373977cc..556890271 100644 --- a/apps/client/src/app/pages/api/api-page.component.ts +++ b/apps/client/src/app/pages/api/api-page.component.ts @@ -22,7 +22,13 @@ import { HttpHeaders, HttpParams } from '@angular/common/http'; -import { Component, DestroyRef, inject, OnInit } from '@angular/core'; +import { + ChangeDetectionStrategy, + Component, + DestroyRef, + inject, + OnInit +} from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { MatCardModule } from '@angular/material/card'; import { format, startOfYear } from 'date-fns'; @@ -33,6 +39,7 @@ import { catchError, map, Observable, of, OperatorFunction } from 'rxjs'; import { FetchFailure, FetchResult } from './interfaces/interfaces'; @Component({ + changeDetection: ChangeDetectionStrategy.OnPush, host: { class: 'page' }, imports: [ CommonModule, diff --git a/apps/client/src/app/pages/auth/auth-page.component.ts b/apps/client/src/app/pages/auth/auth-page.component.ts index 1b0b4a67c..7c501403e 100644 --- a/apps/client/src/app/pages/auth/auth-page.component.ts +++ b/apps/client/src/app/pages/auth/auth-page.component.ts @@ -4,11 +4,17 @@ import { } from '@ghostfolio/client/services/settings-storage.service'; import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service'; -import { Component, DestroyRef, OnInit } from '@angular/core'; +import { + ChangeDetectionStrategy, + Component, + DestroyRef, + OnInit +} from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { ActivatedRoute, Router } from '@angular/router'; @Component({ + changeDetection: ChangeDetectionStrategy.OnPush, selector: 'gf-auth-page', styleUrls: ['./auth-page.scss'], templateUrl: './auth-page.html' diff --git a/apps/client/src/app/pages/demo/demo-page.component.ts b/apps/client/src/app/pages/demo/demo-page.component.ts index 1f49039ce..a4482e36c 100644 --- a/apps/client/src/app/pages/demo/demo-page.component.ts +++ b/apps/client/src/app/pages/demo/demo-page.component.ts @@ -3,10 +3,11 @@ import { InfoItem } from '@ghostfolio/common/interfaces'; import { NotificationService } from '@ghostfolio/ui/notifications'; import { DataService } from '@ghostfolio/ui/services'; -import { Component } from '@angular/core'; +import { ChangeDetectionStrategy, Component } from '@angular/core'; import { Router } from '@angular/router'; @Component({ + changeDetection: ChangeDetectionStrategy.OnPush, host: { class: 'page' }, selector: 'gf-demo-page', standalone: true, diff --git a/apps/client/src/app/pages/faq/overview/faq-overview-page.component.ts b/apps/client/src/app/pages/faq/overview/faq-overview-page.component.ts index 1d59e67e8..17d93e795 100644 --- a/apps/client/src/app/pages/faq/overview/faq-overview-page.component.ts +++ b/apps/client/src/app/pages/faq/overview/faq-overview-page.component.ts @@ -4,6 +4,7 @@ import { publicRoutes } from '@ghostfolio/common/routes/routes'; import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator'; import { + ChangeDetectionStrategy, ChangeDetectorRef, Component, CUSTOM_ELEMENTS_SCHEMA, @@ -14,6 +15,7 @@ import { MatCardModule } from '@angular/material/card'; import { RouterModule } from '@angular/router'; @Component({ + changeDetection: ChangeDetectionStrategy.OnPush, host: { class: 'page' }, imports: [GfPremiumIndicatorComponent, MatCardModule, RouterModule], schemas: [CUSTOM_ELEMENTS_SCHEMA], @@ -38,9 +40,9 @@ export class GfFaqOverviewPageComponent { .subscribe((state) => { if (state?.user) { this.user = state.user; - - this.changeDetectorRef.markForCheck(); } + + this.changeDetectorRef.markForCheck(); }); } } diff --git a/apps/client/src/app/pages/faq/saas/saas-page.component.ts b/apps/client/src/app/pages/faq/saas/saas-page.component.ts index 3f44653d2..282ade32c 100644 --- a/apps/client/src/app/pages/faq/saas/saas-page.component.ts +++ b/apps/client/src/app/pages/faq/saas/saas-page.component.ts @@ -4,6 +4,7 @@ import { internalRoutes, publicRoutes } from '@ghostfolio/common/routes/routes'; import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator'; import { + ChangeDetectionStrategy, ChangeDetectorRef, Component, CUSTOM_ELEMENTS_SCHEMA, @@ -14,6 +15,7 @@ import { MatCardModule } from '@angular/material/card'; import { RouterModule } from '@angular/router'; @Component({ + changeDetection: ChangeDetectionStrategy.OnPush, host: { class: 'page' }, imports: [GfPremiumIndicatorComponent, MatCardModule, RouterModule], schemas: [CUSTOM_ELEMENTS_SCHEMA], @@ -42,9 +44,9 @@ export class GfSaasPageComponent { .subscribe((state) => { if (state?.user) { this.user = state.user; - - this.changeDetectorRef.markForCheck(); } + + this.changeDetectorRef.markForCheck(); }); } } diff --git a/apps/client/src/app/pages/faq/self-hosting/self-hosting-page.component.ts b/apps/client/src/app/pages/faq/self-hosting/self-hosting-page.component.ts index f2468c7d4..dcfe9e2a4 100644 --- a/apps/client/src/app/pages/faq/self-hosting/self-hosting-page.component.ts +++ b/apps/client/src/app/pages/faq/self-hosting/self-hosting-page.component.ts @@ -1,11 +1,16 @@ import { publicRoutes } from '@ghostfolio/common/routes/routes'; import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator'; -import { CUSTOM_ELEMENTS_SCHEMA, Component } from '@angular/core'; +import { + ChangeDetectionStrategy, + Component, + CUSTOM_ELEMENTS_SCHEMA +} from '@angular/core'; import { MatCardModule } from '@angular/material/card'; import { RouterModule } from '@angular/router'; @Component({ + changeDetection: ChangeDetectionStrategy.OnPush, host: { class: 'page' }, imports: [GfPremiumIndicatorComponent, MatCardModule, RouterModule], schemas: [CUSTOM_ELEMENTS_SCHEMA], diff --git a/apps/client/src/app/pages/features/features-page.component.ts b/apps/client/src/app/pages/features/features-page.component.ts index cef90f2e5..dabfa47ef 100644 --- a/apps/client/src/app/pages/features/features-page.component.ts +++ b/apps/client/src/app/pages/features/features-page.component.ts @@ -5,13 +5,19 @@ import { publicRoutes } from '@ghostfolio/common/routes/routes'; import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator'; import { DataService } from '@ghostfolio/ui/services'; -import { ChangeDetectorRef, Component, DestroyRef } from '@angular/core'; +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + DestroyRef +} from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { MatButtonModule } from '@angular/material/button'; import { MatCardModule } from '@angular/material/card'; import { RouterModule } from '@angular/router'; @Component({ + changeDetection: ChangeDetectionStrategy.OnPush, host: { class: 'page' }, imports: [ GfPremiumIndicatorComponent, @@ -46,9 +52,9 @@ export class GfFeaturesPageComponent { .subscribe((state) => { if (state?.user) { this.user = state.user; - - this.changeDetectorRef.markForCheck(); } + + this.changeDetectorRef.markForCheck(); }); this.hasPermissionForSubscription = hasPermission( diff --git a/apps/client/src/app/pages/i18n/i18n-page.component.ts b/apps/client/src/app/pages/i18n/i18n-page.component.ts index 76d123914..c1f394b00 100644 --- a/apps/client/src/app/pages/i18n/i18n-page.component.ts +++ b/apps/client/src/app/pages/i18n/i18n-page.component.ts @@ -1,6 +1,7 @@ -import { Component } from '@angular/core'; +import { ChangeDetectionStrategy, Component } from '@angular/core'; @Component({ + changeDetection: ChangeDetectionStrategy.OnPush, host: { class: 'page' }, selector: 'gf-i18n-page', standalone: true, diff --git a/apps/client/src/app/pages/landing/landing-page.component.ts b/apps/client/src/app/pages/landing/landing-page.component.ts index 386992a6e..8b8d4dcfd 100644 --- a/apps/client/src/app/pages/landing/landing-page.component.ts +++ b/apps/client/src/app/pages/landing/landing-page.component.ts @@ -8,7 +8,7 @@ import { DataService } from '@ghostfolio/ui/services'; import { GfValueComponent } from '@ghostfolio/ui/value'; import { GfWorldMapChartComponent } from '@ghostfolio/ui/world-map-chart'; -import { Component, OnInit } from '@angular/core'; +import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core'; import { MatButtonModule } from '@angular/material/button'; import { MatCardModule } from '@angular/material/card'; import { RouterModule } from '@angular/router'; @@ -22,6 +22,7 @@ import { import { DeviceDetectorService } from 'ngx-device-detector'; @Component({ + changeDetection: ChangeDetectionStrategy.OnPush, host: { class: 'page' }, imports: [ GfCarouselComponent, diff --git a/apps/client/src/app/pages/markets/markets-page.component.ts b/apps/client/src/app/pages/markets/markets-page.component.ts index c70cb8120..9a7576a95 100644 --- a/apps/client/src/app/pages/markets/markets-page.component.ts +++ b/apps/client/src/app/pages/markets/markets-page.component.ts @@ -1,8 +1,9 @@ import { GfHomeMarketComponent } from '@ghostfolio/client/components/home-market/home-market.component'; -import { Component } from '@angular/core'; +import { ChangeDetectionStrategy, Component } from '@angular/core'; @Component({ + changeDetection: ChangeDetectionStrategy.OnPush, host: { class: 'page' }, imports: [GfHomeMarketComponent], selector: 'gf-markets-page', diff --git a/apps/client/src/app/pages/open/open-page.component.ts b/apps/client/src/app/pages/open/open-page.component.ts index 090588d7d..93346ecaa 100644 --- a/apps/client/src/app/pages/open/open-page.component.ts +++ b/apps/client/src/app/pages/open/open-page.component.ts @@ -4,6 +4,7 @@ import { DataService } from '@ghostfolio/ui/services'; import { GfValueComponent } from '@ghostfolio/ui/value'; import { + ChangeDetectionStrategy, ChangeDetectorRef, Component, CUSTOM_ELEMENTS_SCHEMA, @@ -14,6 +15,7 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { MatCardModule } from '@angular/material/card'; @Component({ + changeDetection: ChangeDetectionStrategy.OnPush, host: { class: 'page' }, imports: [GfValueComponent, MatCardModule], schemas: [CUSTOM_ELEMENTS_SCHEMA], @@ -42,9 +44,9 @@ export class GfOpenPageComponent implements OnInit { .subscribe((state) => { if (state?.user) { this.user = state.user; - - this.changeDetectorRef.markForCheck(); } + + this.changeDetectorRef.markForCheck(); }); } } diff --git a/apps/client/src/app/pages/pricing/pricing-page.component.ts b/apps/client/src/app/pages/pricing/pricing-page.component.ts index c0e3848bb..8b4e07ffb 100644 --- a/apps/client/src/app/pages/pricing/pricing-page.component.ts +++ b/apps/client/src/app/pages/pricing/pricing-page.component.ts @@ -8,6 +8,7 @@ import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator'; import { DataService } from '@ghostfolio/ui/services'; import { + ChangeDetectionStrategy, ChangeDetectorRef, Component, CUSTOM_ELEMENTS_SCHEMA, @@ -32,6 +33,7 @@ import { EMPTY } from 'rxjs'; import { catchError } from 'rxjs/operators'; @Component({ + changeDetection: ChangeDetectionStrategy.OnPush, host: { class: 'page' }, imports: [ GfPremiumIndicatorComponent, @@ -123,9 +125,9 @@ export class GfPricingPageComponent implements OnInit { this.label = this.user?.subscription?.offer?.label; this.price = this.user?.subscription?.offer?.price; this.priceId = this.user?.subscription?.offer?.priceId; - - this.changeDetectorRef.markForCheck(); } + + this.changeDetectorRef.markForCheck(); }); } diff --git a/apps/client/src/app/pages/public/public-page.component.ts b/apps/client/src/app/pages/public/public-page.component.ts index 91410d5f7..52d295dfa 100644 --- a/apps/client/src/app/pages/public/public-page.component.ts +++ b/apps/client/src/app/pages/public/public-page.component.ts @@ -17,6 +17,7 @@ import { GfWorldMapChartComponent } from '@ghostfolio/ui/world-map-chart'; import { HttpErrorResponse } from '@angular/common/http'; import { + ChangeDetectionStrategy, ChangeDetectorRef, Component, computed, @@ -38,6 +39,7 @@ import { EMPTY } from 'rxjs'; import { catchError } from 'rxjs/operators'; @Component({ + changeDetection: ChangeDetectionStrategy.OnPush, host: { class: 'page' }, imports: [ GfActivitiesTableComponent, diff --git a/apps/client/src/app/pages/register/register-page.component.ts b/apps/client/src/app/pages/register/register-page.component.ts index ecc83d8f3..6a4825c4d 100644 --- a/apps/client/src/app/pages/register/register-page.component.ts +++ b/apps/client/src/app/pages/register/register-page.component.ts @@ -6,6 +6,7 @@ import { GfLogoComponent } from '@ghostfolio/ui/logo'; import { DataService } from '@ghostfolio/ui/services'; import { + ChangeDetectionStrategy, Component, CUSTOM_ELEMENTS_SCHEMA, DestroyRef, @@ -21,6 +22,7 @@ import { UserAccountRegistrationDialogParams } from './user-account-registration import { GfUserAccountRegistrationDialogComponent } from './user-account-registration-dialog/user-account-registration-dialog.component'; @Component({ + changeDetection: ChangeDetectionStrategy.OnPush, host: { class: 'page' }, imports: [GfLogoComponent, MatButtonModule, RouterModule], schemas: [CUSTOM_ELEMENTS_SCHEMA], diff --git a/apps/client/src/app/pages/resources/glossary/resources-glossary.component.ts b/apps/client/src/app/pages/resources/glossary/resources-glossary.component.ts index 112619239..d0aa5e923 100644 --- a/apps/client/src/app/pages/resources/glossary/resources-glossary.component.ts +++ b/apps/client/src/app/pages/resources/glossary/resources-glossary.component.ts @@ -3,10 +3,11 @@ import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { publicRoutes } from '@ghostfolio/common/routes/routes'; import { DataService } from '@ghostfolio/ui/services'; -import { Component, OnInit } from '@angular/core'; +import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core'; import { RouterModule } from '@angular/router'; @Component({ + changeDetection: ChangeDetectionStrategy.OnPush, imports: [RouterModule], selector: 'gf-resources-glossary', styleUrls: ['./resources-glossary.component.scss'], diff --git a/apps/client/src/app/pages/resources/guides/resources-guides.component.ts b/apps/client/src/app/pages/resources/guides/resources-guides.component.ts index 52c317cea..9f2556195 100644 --- a/apps/client/src/app/pages/resources/guides/resources-guides.component.ts +++ b/apps/client/src/app/pages/resources/guides/resources-guides.component.ts @@ -1,7 +1,8 @@ -import { Component } from '@angular/core'; +import { ChangeDetectionStrategy, Component } from '@angular/core'; import { RouterModule } from '@angular/router'; @Component({ + changeDetection: ChangeDetectionStrategy.OnPush, imports: [RouterModule], selector: 'gf-resources-guides', styleUrls: ['./resources-guides.component.scss'], diff --git a/apps/client/src/app/pages/resources/markets/resources-markets.component.ts b/apps/client/src/app/pages/resources/markets/resources-markets.component.ts index 79c185959..71660504a 100644 --- a/apps/client/src/app/pages/resources/markets/resources-markets.component.ts +++ b/apps/client/src/app/pages/resources/markets/resources-markets.component.ts @@ -1,6 +1,7 @@ -import { Component } from '@angular/core'; +import { ChangeDetectionStrategy, Component } from '@angular/core'; @Component({ + changeDetection: ChangeDetectionStrategy.OnPush, selector: 'gf-resources-markets', styleUrls: ['./resources-markets.component.scss'], templateUrl: './resources-markets.component.html' diff --git a/apps/client/src/app/pages/resources/overview/resources-overview.component.ts b/apps/client/src/app/pages/resources/overview/resources-overview.component.ts index 81338200f..7830b2eec 100644 --- a/apps/client/src/app/pages/resources/overview/resources-overview.component.ts +++ b/apps/client/src/app/pages/resources/overview/resources-overview.component.ts @@ -1,9 +1,10 @@ import { publicRoutes } from '@ghostfolio/common/routes/routes'; -import { Component } from '@angular/core'; +import { ChangeDetectionStrategy, Component } from '@angular/core'; import { RouterModule } from '@angular/router'; @Component({ + changeDetection: ChangeDetectionStrategy.OnPush, imports: [RouterModule], selector: 'gf-resources-overview', styleUrls: ['./resources-overview.component.scss'], diff --git a/apps/client/src/app/pages/resources/personal-finance-tools/personal-finance-tools-page.component.ts b/apps/client/src/app/pages/resources/personal-finance-tools/personal-finance-tools-page.component.ts index bb4ae3889..27a4f3265 100644 --- a/apps/client/src/app/pages/resources/personal-finance-tools/personal-finance-tools-page.component.ts +++ b/apps/client/src/app/pages/resources/personal-finance-tools/personal-finance-tools-page.component.ts @@ -1,7 +1,7 @@ import { personalFinanceTools } from '@ghostfolio/common/personal-finance-tools'; import { publicRoutes } from '@ghostfolio/common/routes/routes'; -import { Component } from '@angular/core'; +import { ChangeDetectionStrategy, Component } from '@angular/core'; import { MatCardModule } from '@angular/material/card'; import { RouterModule } from '@angular/router'; import { IonIcon } from '@ionic/angular/standalone'; @@ -9,6 +9,7 @@ import { addIcons } from 'ionicons'; import { chevronForwardOutline } from 'ionicons/icons'; @Component({ + changeDetection: ChangeDetectionStrategy.OnPush, host: { class: 'page' }, imports: [IonIcon, MatCardModule, RouterModule], selector: 'gf-personal-finance-tools-page', diff --git a/apps/client/src/app/pages/webauthn/webauthn-page.component.ts b/apps/client/src/app/pages/webauthn/webauthn-page.component.ts index d988e1bb9..05a450239 100644 --- a/apps/client/src/app/pages/webauthn/webauthn-page.component.ts +++ b/apps/client/src/app/pages/webauthn/webauthn-page.component.ts @@ -2,6 +2,7 @@ import { TokenStorageService } from '@ghostfolio/client/services/token-storage.s import { WebAuthnService } from '@ghostfolio/client/services/web-authn.service'; import { + ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, @@ -13,6 +14,7 @@ import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { Router } from '@angular/router'; @Component({ + changeDetection: ChangeDetectionStrategy.OnPush, host: { class: 'page' }, imports: [MatButtonModule, MatProgressSpinnerModule], selector: 'gf-webauthn-page', @@ -56,7 +58,9 @@ export class GfWebauthnPageComponent implements OnInit { }, (error) => { console.error(error); + this.hasError = true; + this.changeDetectorRef.markForCheck(); } ); From 7a2e25d583217df5e3f2f5a9da56f8c9d5747778 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Wed, 15 Jul 2026 11:49:55 +0200 Subject: [PATCH 02/17] Bugfix/improve error handling in get quotes of FMP service (#7334) * Improve error handling * Update changelog --- CHANGELOG.md | 4 ++++ .../financial-modeling-prep.service.ts | 14 +++++++++----- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c3caf990..7827af0c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Set the change detection strategy to `OnPush` in the registration page - Set the change detection strategy to `OnPush` in the resources pages +### Fixed + +- Improved the error handling in the get quotes functionality of the _Financial Modeling Prep_ service + ## 3.26.0 - 2026-07-14 ### Added diff --git a/apps/api/src/services/data-provider/financial-modeling-prep/financial-modeling-prep.service.ts b/apps/api/src/services/data-provider/financial-modeling-prep/financial-modeling-prep.service.ts index ec4aa39c0..4e3502033 100644 --- a/apps/api/src/services/data-provider/financial-modeling-prep/financial-modeling-prep.service.ts +++ b/apps/api/src/services/data-provider/financial-modeling-prep/financial-modeling-prep.service.ts @@ -47,7 +47,7 @@ import { isSameDay, parseISO } from 'date-fns'; -import { uniqBy } from 'lodash'; +import { isArray, uniqBy } from 'lodash'; @Injectable() export class FinancialModelingPrepService @@ -440,10 +440,14 @@ export class FinancialModelingPrepService signal: AbortSignal.timeout(requestTimeout) } ) - .then( - (res) => - res.json() as unknown as { price: number; symbol: string }[] - ) + .then(async (res) => { + const json = (await res.json()) as unknown as { + price: number; + symbol: string; + }[]; + + return isArray(json) ? json : []; + }) ]); for (const { currency, symbolTarget } of assetProfileResolutions) { From 9ecb5909c706f5dc81405f33554e3d7cebe919fb Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Wed, 15 Jul 2026 12:06:47 +0200 Subject: [PATCH 03/17] Bugfix/startup error in data gathering caused by uninitialized data provider mappings (#7333) * Initialize data provider mappings * Update changelog --- CHANGELOG.md | 1 + apps/api/src/services/data-provider/data-provider.service.ts | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7827af0c8..e218513b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Resolved a startup error in data gathering caused by uninitialized data provider mappings - Improved the error handling in the get quotes functionality of the _Financial Modeling Prep_ service ## 3.26.0 - 2026-07-14 diff --git a/apps/api/src/services/data-provider/data-provider.service.ts b/apps/api/src/services/data-provider/data-provider.service.ts index 8723466b0..09bf1108e 100644 --- a/apps/api/src/services/data-provider/data-provider.service.ts +++ b/apps/api/src/services/data-provider/data-provider.service.ts @@ -44,7 +44,7 @@ import { AssetProfileInvalidError } from './errors/asset-profile-invalid.error'; export class DataProviderService implements OnModuleInit { private readonly logger = new Logger(DataProviderService.name); - private dataProviderMapping: { [dataProviderName: string]: string }; + private dataProviderMapping: { [dataProviderName: string]: string } = {}; public constructor( private readonly configurationService: ConfigurationService, From 465798f6c2d566670b9f60200d59a00b6e3387d3 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Wed, 15 Jul 2026 12:08:12 +0200 Subject: [PATCH 04/17] Task/improve line breaks in email templates (#7332) Improve line breaks --- .../admin-settings.component.html | 2 +- .../admin-settings/admin-settings.component.ts | 12 +++++++++++- .../holding-detail-dialog.component.ts | 18 ++++++++++++++++-- .../holding-detail-dialog.html | 5 ++++- .../user-account-membership.component.ts | 10 ++++++++-- .../user-account-membership.html | 5 ++++- .../user-account-settings.component.ts | 13 +++++++++++-- .../user-account-settings.html | 6 +++++- libs/common/src/lib/config.ts | 2 ++ 9 files changed, 62 insertions(+), 11 deletions(-) diff --git a/apps/client/src/app/components/admin-settings/admin-settings.component.html b/apps/client/src/app/components/admin-settings/admin-settings.component.html index 643737c4d..c2ed2f8f5 100644 --- a/apps/client/src/app/components/admin-settings/admin-settings.component.html +++ b/apps/client/src/app/components/admin-settings/admin-settings.component.html @@ -21,8 +21,8 @@ Get Access diff --git a/apps/client/src/app/components/admin-settings/admin-settings.component.ts b/apps/client/src/app/components/admin-settings/admin-settings.component.ts index d030abb82..c08f2d473 100644 --- a/apps/client/src/app/components/admin-settings/admin-settings.component.ts +++ b/apps/client/src/app/components/admin-settings/admin-settings.component.ts @@ -2,7 +2,10 @@ import { GfAdminPlatformComponent } from '@ghostfolio/client/components/admin-pl import { GfAdminTagComponent } from '@ghostfolio/client/components/admin-tag/admin-tag.component'; import { GfDataProviderStatusComponent } from '@ghostfolio/client/components/data-provider-status/data-provider-status.component'; import { UserService } from '@ghostfolio/client/services/user/user.service'; -import { PROPERTY_API_KEY_GHOSTFOLIO } from '@ghostfolio/common/config'; +import { + E_MAIL_LINE_BREAK, + PROPERTY_API_KEY_GHOSTFOLIO +} from '@ghostfolio/common/config'; import { ConfirmationDialogType } from '@ghostfolio/common/enums'; import { getDateFormatString } from '@ghostfolio/common/helper'; import { @@ -80,6 +83,13 @@ export class GfAdminSettingsComponent implements OnInit { public ghostfolioApiStatus: DataProviderGhostfolioStatusResponse; public isGhostfolioApiKeyValid: boolean; public isLoading = false; + public readonly premiumDataProviderMailHref = `mailto:hi@ghostfol.io?subject=Ghostfolio Premium Data Provider&body=${[ + 'Hello,', + '', + 'I am interested in the Ghostfolio Premium data provider. Could you please give me access so I can try it for some time?', + '', + 'Kind regards' + ].join(E_MAIL_LINE_BREAK)}`; public pricingUrl: string; public user: User; diff --git a/apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.component.ts b/apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.component.ts index c5beb6c2d..52e0a14d4 100644 --- a/apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.component.ts +++ b/apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.component.ts @@ -1,6 +1,7 @@ import { UserService } from '@ghostfolio/client/services/user/user.service'; import { DEFAULT_PAGE_SIZE, + E_MAIL_LINE_BREAK, NUMERICAL_PRECISION_THRESHOLD_3_FIGURES, NUMERICAL_PRECISION_THRESHOLD_4_FIGURES } from '@ghostfolio/common/config'; @@ -169,7 +170,7 @@ export class GfHoldingDetailDialogComponent implements OnInit { protected readonly pageSize = DEFAULT_PAGE_SIZE; protected quantity: number; protected quantityPrecision = 2; - protected reportDataGlitchMail: string; + protected reportDataGlitchMailHref: string; protected readonly round = round; protected readonly routerLinkAdminControlMarketData = internalRoutes.adminControl.subRoutes.marketData.routerLink; @@ -456,7 +457,20 @@ export class GfHoldingDetailDialogComponent implements OnInit { this.assetProfile?.symbol ? ` (${this.assetProfile.symbol})` : '' }`; - this.reportDataGlitchMail = `mailto:hi@ghostfol.io?subject=${reportDataGlitchSubject}&body=Hello%0D%0DI would like to report a data glitch for%0D%0DSymbol: ${this.assetProfile?.symbol}%0DData Source: ${this.assetProfile?.dataSource}%0D%0DAdditional notes:%0D%0DCan you please take a look?%0D%0DKind regards`; + this.reportDataGlitchMailHref = `mailto:hi@ghostfol.io?subject=${reportDataGlitchSubject}&body=${[ + 'Hello', + '', + 'I would like to report a data glitch for', + '', + `Symbol: ${this.assetProfile?.symbol}`, + `Data Source: ${this.assetProfile?.dataSource}`, + '', + 'Additional notes:', + '', + 'Can you please take a look?', + '', + 'Kind regards' + ].join(E_MAIL_LINE_BREAK)}`; if (this.assetProfile?.assetClass) { this.assetClass = translate(this.assetProfile?.assetClass); diff --git a/apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html b/apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html index 04388bddb..f233df8b6 100644 --- a/apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html +++ b/apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html @@ -456,7 +456,10 @@ dataSource?.data.length > 0 && data.hasPermissionToReportDataGlitch === true ) { - Report Data Glitch... @if (!user?.subscription?.expiresAt) { - Try Premium } @else if (hasPermissionToRequestOwnUserDeletion) { - Close Account } diff --git a/libs/common/src/lib/config.ts b/libs/common/src/lib/config.ts index e071a72c5..f7bafb101 100644 --- a/libs/common/src/lib/config.ts +++ b/libs/common/src/lib/config.ts @@ -184,6 +184,8 @@ export const DERIVED_CURRENCIES = [ } ]; +export const E_MAIL_LINE_BREAK = '%0D%0A'; + export const GATHER_ASSET_PROFILE_PROCESS_JOB_NAME = 'GATHER_ASSET_PROFILE'; export const GATHER_ASSET_PROFILE_PROCESS_JOB_OPTIONS: JobOptions = { attempts: 12, From d35e76737b1c06055ead68f9dafe6f993a0378be Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Wed, 15 Jul 2026 13:30:22 +0200 Subject: [PATCH 05/17] Task/improve error handling in HTML template middleware (#7331) * Improve error handling * Update changelog --- CHANGELOG.md | 1 + .../middlewares/html-template.middleware.ts | 38 ++++++++++++------- 2 files changed, 25 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e218513b7..1e6176b26 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - Resolved a startup error in data gathering caused by uninitialized data provider mappings +- Improved the error handling in the `HtmlTemplateMiddleware` - Improved the error handling in the get quotes functionality of the _Financial Modeling Prep_ service ## 3.26.0 - 2026-07-14 diff --git a/apps/api/src/middlewares/html-template.middleware.ts b/apps/api/src/middlewares/html-template.middleware.ts index 325f64eaa..bdace3402 100644 --- a/apps/api/src/middlewares/html-template.middleware.ts +++ b/apps/api/src/middlewares/html-template.middleware.ts @@ -97,20 +97,29 @@ export class HtmlTemplateMiddleware implements NestMiddleware { private indexHtmlMap: { [languageCode: string]: string } = {}; public constructor(private readonly i18nService: I18nService) { - try { - this.indexHtmlMap = SUPPORTED_LANGUAGE_CODES.reduce( - (map, languageCode) => ({ - ...map, - [languageCode]: readFileSync( - join(__dirname, '..', 'client', languageCode, 'index.html'), - 'utf8' - ) - }), - {} - ); - } catch (error) { - this.logger.error('Failed to initialize index HTML map', error); + if (!environment.production) { + return; } + + this.indexHtmlMap = SUPPORTED_LANGUAGE_CODES.reduce((map, languageCode) => { + const indexHtmlPath = join( + __dirname, + '..', + 'client', + languageCode, + 'index.html' + ); + + try { + map[languageCode] = readFileSync(indexHtmlPath, 'utf8'); + } catch { + this.logger.warn( + `Skipping language '${languageCode}': ${indexHtmlPath} not found` + ); + } + + return map; + }, {}); } public use(request: Request, response: Response, next: NextFunction) { @@ -118,7 +127,8 @@ export class HtmlTemplateMiddleware implements NestMiddleware { let languageCode = path.substr(1, 2); if ( - !(SUPPORTED_LANGUAGE_CODES as readonly string[]).includes(languageCode) + !(SUPPORTED_LANGUAGE_CODES as readonly string[]).includes(languageCode) || + !this.indexHtmlMap[languageCode] ) { languageCode = DEFAULT_LANGUAGE_CODE; } From 05192164db58f018249f0881cd50fa949f2147ce Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Wed, 15 Jul 2026 13:31:54 +0200 Subject: [PATCH 06/17] Task/validate and encode URL in logo endpoint (#7330) * Harden URL validation in logo endpoint * Update changelog --- CHANGELOG.md | 1 + apps/api/src/app/logo/get-logo.dto.ts | 9 +++++++++ apps/api/src/app/logo/logo.controller.ts | 3 ++- apps/api/src/app/logo/logo.service.ts | 2 +- .../lib/entity-logo/entity-logo-image-source.service.ts | 2 +- 5 files changed, 14 insertions(+), 3 deletions(-) create mode 100644 apps/api/src/app/logo/get-logo.dto.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e6176b26..5109c8154 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Hardened the validation of the URL in the logo endpoint - Set the change detection strategy to `OnPush` in the about pages - Set the change detection strategy to `OnPush` in the accounts page - Set the change detection strategy to `OnPush` in the demo page diff --git a/apps/api/src/app/logo/get-logo.dto.ts b/apps/api/src/app/logo/get-logo.dto.ts new file mode 100644 index 000000000..e19753157 --- /dev/null +++ b/apps/api/src/app/logo/get-logo.dto.ts @@ -0,0 +1,9 @@ +import { IsUrl } from 'class-validator'; + +export class GetLogoDto { + @IsUrl({ + protocols: ['http', 'https'], + require_protocol: true + }) + url: string; +} diff --git a/apps/api/src/app/logo/logo.controller.ts b/apps/api/src/app/logo/logo.controller.ts index fdbe430c9..47bd1fc75 100644 --- a/apps/api/src/app/logo/logo.controller.ts +++ b/apps/api/src/app/logo/logo.controller.ts @@ -12,6 +12,7 @@ import { import { DataSource } from '@prisma/client'; import { Response } from 'express'; +import { GetLogoDto } from './get-logo.dto'; import { LogoService } from './logo.service'; @Controller('logo') @@ -41,7 +42,7 @@ export class LogoController { @Get() public async getLogoByUrl( - @Query('url') url: string, + @Query() { url }: GetLogoDto, @Res() response: Response ) { try { diff --git a/apps/api/src/app/logo/logo.service.ts b/apps/api/src/app/logo/logo.service.ts index 551d62438..e01e6cace 100644 --- a/apps/api/src/app/logo/logo.service.ts +++ b/apps/api/src/app/logo/logo.service.ts @@ -47,7 +47,7 @@ export class LogoService { private async getBuffer(aUrl: string) { const blob = await this.fetchService .fetch( - `https://t0.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${aUrl}&size=64`, + `https://t0.gstatic.com/faviconV2?client=SOCIAL&fallback_opts=TYPE,SIZE,URL&size=64&type=FAVICON&url=${encodeURIComponent(aUrl)}`, { headers: { 'User-Agent': 'request' }, signal: AbortSignal.timeout( diff --git a/libs/ui/src/lib/entity-logo/entity-logo-image-source.service.ts b/libs/ui/src/lib/entity-logo/entity-logo-image-source.service.ts index 9cbea529b..db916a34f 100644 --- a/libs/ui/src/lib/entity-logo/entity-logo-image-source.service.ts +++ b/libs/ui/src/lib/entity-logo/entity-logo-image-source.service.ts @@ -15,6 +15,6 @@ export class EntityLogoImageSourceService { } public getLogoUrlByUrl(url: string) { - return `../api/v1/logo?url=${url}`; + return `../api/v1/logo?url=${encodeURIComponent(url)}`; } } From 9e4269fa3706a8ad9d4495c4906626fa05958e36 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Wed, 15 Jul 2026 18:57:30 +0200 Subject: [PATCH 07/17] Bugfix/preserve asset profile when cloning activity (#7339) * Preserve asset profile when cloning activity * Update changelog --- CHANGELOG.md | 1 + .../app/pages/portfolio/activities/activities-page.component.ts | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5109c8154..426cbd8a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Fixed an issue where the symbol was not selected when cloning an activity - Resolved a startup error in data gathering caused by uninitialized data provider mappings - Improved the error handling in the `HtmlTemplateMiddleware` - Improved the error handling in the get quotes functionality of the _Financial Modeling Prep_ service diff --git a/apps/client/src/app/pages/portfolio/activities/activities-page.component.ts b/apps/client/src/app/pages/portfolio/activities/activities-page.component.ts index 3d1c0ceeb..45653ff80 100644 --- a/apps/client/src/app/pages/portfolio/activities/activities-page.component.ts +++ b/apps/client/src/app/pages/portfolio/activities/activities-page.component.ts @@ -409,7 +409,7 @@ export class GfActivitiesPageComponent implements OnInit { activity: { ...aActivity, accountId: aActivity?.accountId, - assetProfile: null, + assetProfile: aActivity?.assetProfile ?? null, date: new Date(), id: null, fee: 0, From 0fb079fb9c9fe668d13dc6e11048fa0c5507f268 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Wed, 15 Jul 2026 18:59:57 +0200 Subject: [PATCH 08/17] Release 3.27.0 (#7341) --- CHANGELOG.md | 2 +- package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 426cbd8a7..76727fefd 100644 --- a/CHANGELOG.md +++ b/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.27.0 - 2026-07-15 ### Changed diff --git a/package-lock.json b/package-lock.json index 00128128e..efc1f9d7c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "ghostfolio", - "version": "3.26.0", + "version": "3.27.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ghostfolio", - "version": "3.26.0", + "version": "3.27.0", "hasInstallScript": true, "license": "AGPL-3.0", "dependencies": { diff --git a/package.json b/package.json index 384ca8a8b..39df1afa5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ghostfolio", - "version": "3.26.0", + "version": "3.27.0", "homepage": "https://ghostfol.io", "license": "AGPL-3.0", "repository": "https://github.com/ghostfolio/ghostfolio", From def15e544a7bf73d9f46d8f13e2d25d311d0c605 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Thu, 16 Jul 2026 17:53:12 +0200 Subject: [PATCH 09/17] Bugfix/liabilities without market data (#7345) * Fix liabilities without market data * Update changelog --- CHANGELOG.md | 7 ++ .../roai/portfolio-calculator-fee.spec.ts | 2 +- .../calculator/roai/portfolio-calculator.ts | 76 +++++++++++-------- 3 files changed, 53 insertions(+), 32 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 76727fefd..894e914a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,13 @@ 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 + +### Fixed + +- Fixed an issue where the dividends, the interest and the liabilities of asset profiles without market data have been valued at zero in the portfolio calculation +- Fixed an issue where an error has been reported for asset profiles without market data which do not hold any units + ## 3.27.0 - 2026-07-15 ### Changed diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-fee.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-fee.spec.ts index e5798818f..4e413c0c5 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-fee.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-fee.spec.ts @@ -113,7 +113,7 @@ describe('PortfolioCalculator', () => { expect(portfolioSnapshot).toMatchObject({ currentValueInBaseCurrency: new Big('0'), errors: [], - hasErrors: true, + hasErrors: false, positions: [], totalFeesWithCurrencyEffect: new Big('49'), totalInterestWithCurrencyEffect: new Big('0'), diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.ts index f79e5bc79..747fca7a5 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.ts @@ -236,6 +236,36 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator { }; } + // The dividends, the interest and the liabilities are derived from the + // activities only. Accumulate them upfront so that they survive the bail + // out for symbols without a market price below. + for (const order of orders) { + const exchangeRateAtOrderDate = exchangeRates[order.date]; + + if (order.type === 'DIVIDEND') { + const dividend = order.quantity.mul(order.unitPrice); + + totalDividend = totalDividend.plus(dividend); + totalDividendInBaseCurrency = totalDividendInBaseCurrency.plus( + dividend.mul(exchangeRateAtOrderDate ?? 1) + ); + } else if (order.type === 'INTEREST') { + const interest = order.quantity.mul(order.unitPrice); + + totalInterest = totalInterest.plus(interest); + totalInterestInBaseCurrency = totalInterestInBaseCurrency.plus( + interest.mul(exchangeRateAtOrderDate ?? 1) + ); + } else if (order.type === 'LIABILITY') { + const liabilities = order.quantity.mul(order.unitPrice); + + totalLiabilities = totalLiabilities.plus(liabilities); + totalLiabilitiesInBaseCurrency = totalLiabilitiesInBaseCurrency.plus( + liabilities.mul(exchangeRateAtOrderDate ?? 1) + ); + } + } + const dateOfFirstTransaction = new Date(orders[0].date); const endDateString = format(end, DATE_FORMAT); @@ -263,7 +293,20 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator { !unitPriceAtEndDate || (!unitPriceAtStartDate && isBefore(dateOfFirstTransaction, start)) ) { + // A missing market price can only affect the units which are held. The + // dividends, the interest and the liabilities do not hold any units and + // are therefore not in error. + const hasActivitiesWithUnits = orders.some(({ type }) => { + return ['BUY', 'SELL'].includes(type); + }); + return { + totalDividend, + totalDividendInBaseCurrency, + totalInterest, + totalInterestInBaseCurrency, + totalLiabilities, + totalLiabilitiesInBaseCurrency, currentValues: {}, currentValuesWithCurrencyEffect: {}, feesWithCurrencyEffect: new Big(0), @@ -271,7 +314,7 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator { grossPerformancePercentage: new Big(0), grossPerformancePercentageWithCurrencyEffect: new Big(0), grossPerformanceWithCurrencyEffect: new Big(0), - hasErrors: true, + hasErrors: hasActivitiesWithUnits, initialValue: new Big(0), initialValueWithCurrencyEffect: new Big(0), investmentValuesAccumulated: {}, @@ -288,14 +331,8 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator { timeWeightedInvestmentValuesWithCurrencyEffect: {}, timeWeightedInvestmentWithCurrencyEffect: new Big(0), totalAccountBalanceInBaseCurrency: new Big(0), - totalDividend: new Big(0), - totalDividendInBaseCurrency: new Big(0), - totalInterest: new Big(0), - totalInterestInBaseCurrency: new Big(0), totalInvestment: new Big(0), - totalInvestmentWithCurrencyEffect: new Big(0), - totalLiabilities: new Big(0), - totalLiabilitiesInBaseCurrency: new Big(0) + totalInvestmentWithCurrencyEffect: new Big(0) }; } @@ -415,29 +452,6 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator { const exchangeRateAtOrderDate = exchangeRates[order.date]; - if (order.type === 'DIVIDEND') { - const dividend = order.quantity.mul(order.unitPrice); - - totalDividend = totalDividend.plus(dividend); - totalDividendInBaseCurrency = totalDividendInBaseCurrency.plus( - dividend.mul(exchangeRateAtOrderDate ?? 1) - ); - } else if (order.type === 'INTEREST') { - const interest = order.quantity.mul(order.unitPrice); - - totalInterest = totalInterest.plus(interest); - totalInterestInBaseCurrency = totalInterestInBaseCurrency.plus( - interest.mul(exchangeRateAtOrderDate ?? 1) - ); - } else if (order.type === 'LIABILITY') { - const liabilities = order.quantity.mul(order.unitPrice); - - totalLiabilities = totalLiabilities.plus(liabilities); - totalLiabilitiesInBaseCurrency = totalLiabilitiesInBaseCurrency.plus( - liabilities.mul(exchangeRateAtOrderDate ?? 1) - ); - } - if (order.itemType === 'start') { // Take the unit price of the order as the market price if there are no // orders of this symbol before the start date From 8caf48d5831f3cd175d297085b1cb8d56892f974 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Thu, 16 Jul 2026 18:14:26 +0200 Subject: [PATCH 10/17] Task/extend self-hosting section with FAQ link (#7052) Extend self-hosting section with FAQ link --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index ce89412a3..473d5c367 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,8 @@ The frontend is built with [Angular](https://angular.dev) and uses [Angular Mate We provide official container images hosted on [Docker Hub](https://hub.docker.com/r/ghostfolio/ghostfolio) for `linux/amd64`, `linux/arm/v7` and `linux/arm64`. +Find answers to commonly asked questions about self-hosting Ghostfolio in our [Frequently Asked Questions (FAQ)](https://ghostfol.io/en/faq/self-hosting) section. +
[Buy me a coffee button](https://www.buymeacoffee.com/ghostfolio) From 05b9cd2cc4e65bb5b7edf9f95b7a7686729bdd1e Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Thu, 16 Jul 2026 18:20:36 +0200 Subject: [PATCH 11/17] Bugfix/preserve tags when updating activity (#7347) * Validate tags when creating or updating an activity * Fix loss of activity tags when updating an activity fails * Update changelog --- CHANGELOG.md | 3 ++ .../account-balance/account-balance.module.ts | 3 +- apps/api/src/app/account/account.module.ts | 2 + apps/api/src/app/account/account.service.ts | 35 ++++------------ .../app/activities/activities.controller.ts | 1 + .../src/app/activities/activities.module.ts | 2 + .../src/app/activities/activities.service.ts | 42 ++++++++++++++----- apps/api/src/app/endpoints/ai/ai.module.ts | 2 + .../endpoints/benchmarks/benchmarks.module.ts | 2 + .../src/app/endpoints/public/public.module.ts | 2 + .../roai/portfolio-calculator-cash.spec.ts | 2 + .../api/src/app/portfolio/portfolio.module.ts | 2 + .../app/portfolio/portfolio.service.spec.ts | 2 + apps/api/src/services/tag/tag.service.ts | 38 ++++++++++++++++- libs/common/src/lib/dtos/create-order.dto.ts | 3 ++ libs/common/src/lib/dtos/update-order.dto.ts | 3 ++ 16 files changed, 103 insertions(+), 41 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 894e914a9..da9855a75 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Fixed the missing validation of the tags when creating or updating an activity +- Fixed the missing validation of the tags when updating the tags of a holding +- Fixed an issue where the tags of an activity were lost if updating the activity failed - Fixed an issue where the dividends, the interest and the liabilities of asset profiles without market data have been valued at zero in the portfolio calculation - Fixed an issue where an error has been reported for asset profiles without market data which do not hold any units diff --git a/apps/api/src/app/account-balance/account-balance.module.ts b/apps/api/src/app/account-balance/account-balance.module.ts index 02323acc9..f7b1efc51 100644 --- a/apps/api/src/app/account-balance/account-balance.module.ts +++ b/apps/api/src/app/account-balance/account-balance.module.ts @@ -1,6 +1,7 @@ import { AccountService } from '@ghostfolio/api/app/account/account.service'; import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module'; import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; +import { TagModule } from '@ghostfolio/api/services/tag/tag.module'; import { Module } from '@nestjs/common'; @@ -10,7 +11,7 @@ import { AccountBalanceService } from './account-balance.service'; @Module({ controllers: [AccountBalanceController], exports: [AccountBalanceService], - imports: [ExchangeRateDataModule, PrismaModule], + imports: [ExchangeRateDataModule, PrismaModule, TagModule], providers: [AccountBalanceService, AccountService] }) export class AccountBalanceModule {} diff --git a/apps/api/src/app/account/account.module.ts b/apps/api/src/app/account/account.module.ts index 253c7fb1d..47b859ba3 100644 --- a/apps/api/src/app/account/account.module.ts +++ b/apps/api/src/app/account/account.module.ts @@ -7,6 +7,7 @@ import { ConfigurationModule } from '@ghostfolio/api/services/configuration/conf import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module'; import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module'; import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; +import { TagModule } from '@ghostfolio/api/services/tag/tag.module'; import { Module } from '@nestjs/common'; @@ -25,6 +26,7 @@ import { AccountService } from './account.service'; PortfolioModule, PrismaModule, RedactValuesInResponseModule, + TagModule, UserModule ], providers: [AccountService] diff --git a/apps/api/src/app/account/account.service.ts b/apps/api/src/app/account/account.service.ts index d1f4009a9..f84f085a3 100644 --- a/apps/api/src/app/account/account.service.ts +++ b/apps/api/src/app/account/account.service.ts @@ -3,10 +3,11 @@ import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed. import { WHERE_ACCOUNT_NOT_EXCLUDED } from '@ghostfolio/api/helper/account.helper'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; +import { TagService } from '@ghostfolio/api/services/tag/tag.service'; import { DATE_FORMAT } from '@ghostfolio/common/helper'; import { Filter } from '@ghostfolio/common/interfaces'; -import { HttpException, Injectable } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { EventEmitter2 } from '@nestjs/event-emitter'; import { Account, @@ -19,7 +20,6 @@ import { } from '@prisma/client'; import { Big } from 'big.js'; import { format } from 'date-fns'; -import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { groupBy } from 'lodash'; import { CashDetails } from './interfaces/cash-details.interface'; @@ -30,7 +30,8 @@ export class AccountService { private readonly accountBalanceService: AccountBalanceService, private readonly eventEmitter: EventEmitter2, private readonly exchangeRateDataService: ExchangeRateDataService, - private readonly prismaService: PrismaService + private readonly prismaService: PrismaService, + private readonly tagService: TagService ) {} public async account({ @@ -127,7 +128,7 @@ export class AccountService { aUserId: string, tagIds?: string[] ): Promise { - await this.validateTagIds(tagIds, aUserId); + await this.tagService.validateTagIds({ tagIds, userId: aUserId }); const account = await this.prismaService.account.create({ data: { @@ -259,15 +260,15 @@ export class AccountService { public async updateAccount( params: { - where: Prisma.AccountWhereUniqueInput; data: Prisma.AccountUpdateInput; + where: Prisma.AccountWhereUniqueInput; }, aUserId: string, tagIds?: string[] ): Promise { const { data, where } = params; - await this.validateTagIds(tagIds, aUserId); + await this.tagService.validateTagIds({ tagIds, userId: aUserId }); const account = await this.prismaService.account.update({ data: { @@ -340,26 +341,4 @@ export class AccountService { }); } } - - private async validateTagIds(tagIds: string[], userId: string) { - if (!tagIds?.length) { - return; - } - - const uniqueTagIds = Array.from(new Set(tagIds)); - - const tagsCount = await this.prismaService.tag.count({ - where: { - id: { in: uniqueTagIds }, - OR: [{ userId }, { userId: null }] - } - }); - - if (tagsCount !== uniqueTagIds.length) { - throw new HttpException( - getReasonPhrase(StatusCodes.BAD_REQUEST), - StatusCodes.BAD_REQUEST - ); - } - } } diff --git a/apps/api/src/app/activities/activities.controller.ts b/apps/api/src/app/activities/activities.controller.ts index d00dd7292..f0f3a2acd 100644 --- a/apps/api/src/app/activities/activities.controller.ts +++ b/apps/api/src/app/activities/activities.controller.ts @@ -338,6 +338,7 @@ export class ActivitiesController { }), user: { connect: { id: this.request.user.id } } }, + userId: this.request.user.id, where: { id } diff --git a/apps/api/src/app/activities/activities.module.ts b/apps/api/src/app/activities/activities.module.ts index 661163ff1..34091ba5e 100644 --- a/apps/api/src/app/activities/activities.module.ts +++ b/apps/api/src/app/activities/activities.module.ts @@ -14,6 +14,7 @@ import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-da import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; import { DataGatheringQueueModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module'; import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module'; +import { TagModule } from '@ghostfolio/api/services/tag/tag.module'; import { Module } from '@nestjs/common'; @@ -36,6 +37,7 @@ import { ActivitiesService } from './activities.service'; RedactValuesInResponseModule, RedisCacheModule, SymbolProfileModule, + TagModule, TransformDataSourceInRequestModule, TransformDataSourceInResponseModule ], diff --git a/apps/api/src/app/activities/activities.service.ts b/apps/api/src/app/activities/activities.service.ts index 427cddf4c..539c05c66 100644 --- a/apps/api/src/app/activities/activities.service.ts +++ b/apps/api/src/app/activities/activities.service.ts @@ -12,6 +12,7 @@ import { MarketDataService } from '@ghostfolio/api/services/market-data/market-d import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; +import { TagService } from '@ghostfolio/api/services/tag/tag.service'; import { DATA_GATHERING_QUEUE_PRIORITY_HIGH, GATHER_ASSET_PROFILE_PROCESS_JOB_NAME, @@ -61,7 +62,8 @@ export class ActivitiesService { private readonly exchangeRateDataService: ExchangeRateDataService, private readonly marketDataService: MarketDataService, private readonly prismaService: PrismaService, - private readonly symbolProfileService: SymbolProfileService + private readonly symbolProfileService: SymbolProfileService, + private readonly tagService: TagService ) {} public areCashActivitiesExcludedByFilters(filters: Filter[] = []) { @@ -107,6 +109,13 @@ export class ActivitiesService { tags, userId }: { tags: Tag[]; userId: string } & AssetProfileIdentifier) { + await this.tagService.validateTagIds({ + userId, + tagIds: tags.map(({ id }) => { + return id; + }) + }); + const activities = await this.prismaService.order.findMany({ where: { userId, @@ -153,6 +162,15 @@ export class ActivitiesService { userId: string; } ): Promise { + const tags = data.tags ?? []; + + await this.tagService.validateTagIds({ + tagIds: tags.map(({ id }) => { + return id; + }), + userId: data.userId + }); + let account: Prisma.AccountCreateNestedOneWithoutActivitiesInput; if (data.accountId) { @@ -167,7 +185,6 @@ export class ActivitiesService { } const accountId = data.accountId; - const tags = data.tags ?? []; const updateAccountBalance = data.updateAccountBalance ?? false; const userId = data.userId; @@ -932,6 +949,7 @@ export class ActivitiesService { public async updateActivity({ data, + userId, where }: { data: Prisma.OrderUpdateInput & { @@ -942,14 +960,22 @@ export class ActivitiesService { tags?: { id: string }[]; type?: ActivityType; }; + userId: string; where: Prisma.OrderWhereUniqueInput; }): Promise { + const tags = data.tags ?? []; + + await this.tagService.validateTagIds({ + userId, + tagIds: tags.map(({ id }) => { + return id; + }) + }); + if (!data.comment) { data.comment = null; } - const tags = data.tags ?? []; - let isDraft = false; if ( @@ -989,19 +1015,13 @@ export class ActivitiesService { delete data.symbol; delete data.tags; - // Remove existing tags - await this.prismaService.order.update({ - where, - data: { tags: { set: [] } } - }); - const activity = await this.prismaService.order.update({ where, data: { ...data, isDraft, tags: { - connect: tags + set: tags } } }); diff --git a/apps/api/src/app/endpoints/ai/ai.module.ts b/apps/api/src/app/endpoints/ai/ai.module.ts index 5267f40c8..d5cf0e3e9 100644 --- a/apps/api/src/app/endpoints/ai/ai.module.ts +++ b/apps/api/src/app/endpoints/ai/ai.module.ts @@ -20,6 +20,7 @@ import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; import { PropertyModule } from '@ghostfolio/api/services/property/property.module'; import { PortfolioSnapshotQueueModule } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.module'; import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module'; +import { TagModule } from '@ghostfolio/api/services/tag/tag.module'; import { Module } from '@nestjs/common'; @@ -44,6 +45,7 @@ import { AiService } from './ai.service'; PropertyModule, RedisCacheModule, SymbolProfileModule, + TagModule, UserModule ], providers: [ diff --git a/apps/api/src/app/endpoints/benchmarks/benchmarks.module.ts b/apps/api/src/app/endpoints/benchmarks/benchmarks.module.ts index 2bcd6177d..3c540c337 100644 --- a/apps/api/src/app/endpoints/benchmarks/benchmarks.module.ts +++ b/apps/api/src/app/endpoints/benchmarks/benchmarks.module.ts @@ -23,6 +23,7 @@ import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; import { PropertyModule } from '@ghostfolio/api/services/property/property.module'; import { PortfolioSnapshotQueueModule } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.module'; import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module'; +import { TagModule } from '@ghostfolio/api/services/tag/tag.module'; import { Module } from '@nestjs/common'; @@ -46,6 +47,7 @@ import { BenchmarksService } from './benchmarks.service'; RedisCacheModule, SymbolModule, SymbolProfileModule, + TagModule, TransformDataSourceInRequestModule, TransformDataSourceInResponseModule, UserModule diff --git a/apps/api/src/app/endpoints/public/public.module.ts b/apps/api/src/app/endpoints/public/public.module.ts index e8395228f..b992694c5 100644 --- a/apps/api/src/app/endpoints/public/public.module.ts +++ b/apps/api/src/app/endpoints/public/public.module.ts @@ -18,6 +18,7 @@ import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-da import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; import { PortfolioSnapshotQueueModule } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.module'; import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module'; +import { TagModule } from '@ghostfolio/api/services/tag/tag.module'; import { Module } from '@nestjs/common'; @@ -38,6 +39,7 @@ import { PublicController } from './public.controller'; PrismaModule, RedisCacheModule, SymbolProfileModule, + TagModule, TransformDataSourceInRequestModule, UserModule ], diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-cash.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-cash.spec.ts index 589168989..31e451051 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-cash.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-cash.spec.ts @@ -91,6 +91,7 @@ describe('PortfolioCalculator', () => { accountBalanceService, null, exchangeRateDataService, + null, null ); @@ -122,6 +123,7 @@ describe('PortfolioCalculator', () => { exchangeRateDataService, null, null, + null, null ); diff --git a/apps/api/src/app/portfolio/portfolio.module.ts b/apps/api/src/app/portfolio/portfolio.module.ts index d818195ca..7f7a894df 100644 --- a/apps/api/src/app/portfolio/portfolio.module.ts +++ b/apps/api/src/app/portfolio/portfolio.module.ts @@ -20,6 +20,7 @@ import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.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 { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module'; +import { TagModule } from '@ghostfolio/api/services/tag/tag.module'; import { Module } from '@nestjs/common'; @@ -50,6 +51,7 @@ import { RulesService } from './rules.service'; RedactValuesInResponseModule, RedisCacheModule, SymbolProfileModule, + TagModule, TransformDataSourceInRequestModule, TransformDataSourceInResponseModule, UserModule diff --git a/apps/api/src/app/portfolio/portfolio.service.spec.ts b/apps/api/src/app/portfolio/portfolio.service.spec.ts index adddb4013..c16590cce 100644 --- a/apps/api/src/app/portfolio/portfolio.service.spec.ts +++ b/apps/api/src/app/portfolio/portfolio.service.spec.ts @@ -54,6 +54,7 @@ describe('PortfolioService', () => { null, null, exchangeRateDataService, + null, null ); @@ -67,6 +68,7 @@ describe('PortfolioService', () => { exchangeRateDataService, null, null, + null, null ); diff --git a/apps/api/src/services/tag/tag.service.ts b/apps/api/src/services/tag/tag.service.ts index d967ce854..38fb90a69 100644 --- a/apps/api/src/services/tag/tag.service.ts +++ b/apps/api/src/services/tag/tag.service.ts @@ -1,7 +1,8 @@ import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; -import { Injectable } from '@nestjs/common'; +import { HttpException, Injectable } from '@nestjs/common'; import { Prisma, Tag } from '@prisma/client'; +import { StatusCodes, getReasonPhrase } from 'http-status-codes'; @Injectable() export class TagService { @@ -124,4 +125,39 @@ export class TagService { where }); } + + public async validateTagIds({ + tagIds, + userId + }: { + tagIds: string[]; + userId: string; + }) { + if (!tagIds?.length) { + return; + } + + if (!userId) { + throw new HttpException( + getReasonPhrase(StatusCodes.BAD_REQUEST), + StatusCodes.BAD_REQUEST + ); + } + + const uniqueTagIds = Array.from(new Set(tagIds)); + + const tagsCount = await this.prismaService.tag.count({ + where: { + id: { in: uniqueTagIds }, + OR: [{ userId }, { userId: null }] + } + }); + + if (tagsCount !== uniqueTagIds.length) { + throw new HttpException( + getReasonPhrase(StatusCodes.BAD_REQUEST), + StatusCodes.BAD_REQUEST + ); + } + } } diff --git a/libs/common/src/lib/dtos/create-order.dto.ts b/libs/common/src/lib/dtos/create-order.dto.ts index 9236a12d8..36365b7be 100644 --- a/libs/common/src/lib/dtos/create-order.dto.ts +++ b/libs/common/src/lib/dtos/create-order.dto.ts @@ -4,6 +4,7 @@ import { IsCurrencyCode } from '@ghostfolio/common/validators/is-currency-code'; import { AssetClass, AssetSubClass, DataSource, Type } from '@prisma/client'; import { Transform, TransformFnParams } from 'class-transformer'; import { + ArrayUnique, IsArray, IsBoolean, IsEnum, @@ -62,8 +63,10 @@ export class CreateOrderDto { @IsString() symbol: string; + @ArrayUnique() @IsArray() @IsOptional() + @IsString({ each: true }) tags?: string[]; @IsEnum(Type) diff --git a/libs/common/src/lib/dtos/update-order.dto.ts b/libs/common/src/lib/dtos/update-order.dto.ts index 7843f142c..991479bc8 100644 --- a/libs/common/src/lib/dtos/update-order.dto.ts +++ b/libs/common/src/lib/dtos/update-order.dto.ts @@ -4,6 +4,7 @@ import { IsCurrencyCode } from '@ghostfolio/common/validators/is-currency-code'; import { AssetClass, AssetSubClass, DataSource, Type } from '@prisma/client'; import { Transform, TransformFnParams } from 'class-transformer'; import { + ArrayUnique, IsArray, IsEnum, IsISO8601, @@ -63,8 +64,10 @@ export class UpdateOrderDto { @IsString() symbol: string; + @ArrayUnique() @IsArray() @IsOptional() + @IsString({ each: true }) tags?: string[]; @IsString() From 22d68e7ac16ad043e3492cd5dbdce01e7a0ad1f7 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Thu, 16 Jul 2026 18:25:02 +0200 Subject: [PATCH 12/17] Bugfix/remove linked account from BUY / SELL / DIVIDEND activity (#7346) * Fix removing a linked account from a buy, sell or dividend activity * Update changelog --- CHANGELOG.md | 1 + apps/api/src/app/activities/activities.controller.ts | 12 +++++++----- apps/api/src/app/activities/activities.service.ts | 4 ---- 3 files changed, 8 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index da9855a75..b645fb0e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Fixed an issue where the tags of an activity were lost if updating the activity failed - Fixed an issue where the dividends, the interest and the liabilities of asset profiles without market data have been valued at zero in the portfolio calculation - Fixed an issue where an error has been reported for asset profiles without market data which do not hold any units +- Fixed an issue with removing a linked account from a buy, sell or dividend activity ## 3.27.0 - 2026-07-15 diff --git a/apps/api/src/app/activities/activities.controller.ts b/apps/api/src/app/activities/activities.controller.ts index f0f3a2acd..21dabc639 100644 --- a/apps/api/src/app/activities/activities.controller.ts +++ b/apps/api/src/app/activities/activities.controller.ts @@ -315,11 +315,13 @@ export class ActivitiesController { data: { ...data, date, - account: { - connect: { - id_userId: { id: accountId, userId: this.request.user.id } - } - }, + account: accountId + ? { + connect: { + id_userId: { id: accountId, userId: this.request.user.id } + } + } + : { disconnect: true }, SymbolProfile: { connect: { dataSource_symbol: { diff --git a/apps/api/src/app/activities/activities.service.ts b/apps/api/src/app/activities/activities.service.ts index 539c05c66..df616a5dd 100644 --- a/apps/api/src/app/activities/activities.service.ts +++ b/apps/api/src/app/activities/activities.service.ts @@ -983,10 +983,6 @@ export class ActivitiesService { (data.SymbolProfile.connect.dataSource_symbol.dataSource === 'MANUAL' && data.type === 'BUY') ) { - if (data.account?.connect?.id_userId?.id === null) { - data.account = { disconnect: true }; - } - delete data.SymbolProfile.connect; delete data.SymbolProfile.update.name; } else { From ece4ed3edee152887e8829ba2c320769d1c61769 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Thu, 16 Jul 2026 20:52:36 +0200 Subject: [PATCH 13/17] Task/migrate activity dialogs to dedicated routes (#7343) * Migrate clone, create and edit activity dialogs to dedicated routes * Update changelog --- CHANGELOG.md | 4 + apps/client/src/app/app.component.ts | 7 +- .../account-detail-dialog.component.ts | 47 +++-- .../account-detail-dialog.html | 2 - .../interfaces/interfaces.ts | 4 + .../holding-detail-dialog.component.ts | 49 +++-- .../holding-detail-dialog.html | 2 - .../interfaces/interfaces.ts | 4 + .../pages/accounts/accounts-page.component.ts | 14 +- .../activities/activities-page.component.ts | 141 +-------------- .../portfolio/activities/activities-page.html | 11 +- .../activities/activities-page.routes.ts | 29 +++ .../activity-dialog-host.component.ts | 169 ++++++++++++++++++ .../types/activity-dialog-mode.type.ts | 1 + .../activities/interfaces/interfaces.ts | 7 - .../allocations/allocations-page.component.ts | 14 +- .../interfaces/internal-route.interface.ts | 2 +- libs/common/src/lib/routes/routes.ts | 21 +++ .../activities-table.component.html | 14 +- .../activities-table.component.stories.ts | 33 +++- .../activities-table.component.ts | 35 ++-- .../src/lib/assistant/assistant.component.ts | 28 ++- libs/ui/src/lib/fab/fab.component.html | 2 +- libs/ui/src/lib/fab/fab.component.ts | 3 +- 24 files changed, 402 insertions(+), 241 deletions(-) create mode 100644 apps/client/src/app/pages/portfolio/activities/activity-dialog-host/activity-dialog-host.component.ts create mode 100644 apps/client/src/app/pages/portfolio/activities/activity-dialog-host/types/activity-dialog-mode.type.ts delete mode 100644 apps/client/src/app/pages/portfolio/activities/interfaces/interfaces.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index b645fb0e7..f83f4d8ce 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 +### Changed + +- Migrated the clone, create and edit activity dialogs to dedicated routes + ### Fixed - Fixed the missing validation of the tags when creating or updating an activity diff --git a/apps/client/src/app/app.component.ts b/apps/client/src/app/app.component.ts index 90ff2f1bc..ff67daf5e 100644 --- a/apps/client/src/app/app.component.ts +++ b/apps/client/src/app/app.component.ts @@ -40,6 +40,7 @@ import { filter } from 'rxjs/operators'; import { GfFooterComponent } from './components/footer/footer.component'; import { GfHeaderComponent } from './components/header/header.component'; import { GfHoldingDetailDialogComponent } from './components/holding-detail-dialog/holding-detail-dialog.component'; +import { HoldingDetailDialogResult } from './components/holding-detail-dialog/interfaces/interfaces'; import { GfAppQueryParams } from './interfaces/interfaces'; import { ImpersonationStorageService } from './services/impersonation-storage.service'; import { UserService } from './services/user/user.service'; @@ -319,7 +320,11 @@ export class GfAppComponent implements OnInit { dialogRef .afterClosed() .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe(() => { + .subscribe((result: HoldingDetailDialogResult) => { + if (result?.isNavigating) { + return; + } + void this.router.navigate([], { queryParams: { dataSource: null, diff --git a/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.component.ts b/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.component.ts index 5af8c9d00..62b87cfc0 100644 --- a/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.component.ts +++ b/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.component.ts @@ -15,7 +15,6 @@ import { User } from '@ghostfolio/common/interfaces'; import { hasPermission, permissions } from '@ghostfolio/common/permissions'; -import { internalRoutes } from '@ghostfolio/common/routes/routes'; import { GfAccountBalancesComponent } from '@ghostfolio/ui/account-balances'; import { GfActivitiesTableComponent } from '@ghostfolio/ui/activities-table'; import { GfDialogFooterComponent } from '@ghostfolio/ui/dialog-footer'; @@ -41,7 +40,7 @@ import { PageEvent } from '@angular/material/paginator'; import { Sort, SortDirection } from '@angular/material/sort'; import { MatTableDataSource } from '@angular/material/table'; import { MatTabsModule } from '@angular/material/tabs'; -import { Router } from '@angular/router'; +import { NavigationStart, Router } from '@angular/router'; import { IonIcon } from '@ionic/angular/standalone'; import { Big } from 'big.js'; import { format, parseISO } from 'date-fns'; @@ -53,9 +52,12 @@ import { } from 'ionicons/icons'; import { isNumber } from 'lodash'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; -import { forkJoin } from 'rxjs'; +import { filter, forkJoin } from 'rxjs'; -import { AccountDetailDialogParams } from './interfaces/interfaces'; +import { + AccountDetailDialogParams, + AccountDetailDialogResult +} from './interfaces/interfaces'; @Component({ changeDetection: ChangeDetectionStrategy.OnPush, @@ -113,11 +115,24 @@ export class GfAccountDetailDialogComponent implements OnInit { private readonly dataService = inject(DataService); private readonly destroyRef = inject(DestroyRef); private readonly dialogRef = - inject>(MatDialogRef); + inject< + MatDialogRef + >(MatDialogRef); private readonly router = inject(Router); private readonly userService = inject(UserService); public constructor() { + this.router.events + .pipe( + filter((event) => { + return event instanceof NavigationStart; + }), + takeUntilDestroyed(this.destroyRef) + ) + .subscribe(() => { + this.dialogRef.close({ isNavigating: true }); + }); + this.userService.stateChanged .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe((state) => { @@ -155,17 +170,6 @@ export class GfAccountDetailDialogComponent implements OnInit { this.fetchActivities(); } - protected onCloneActivity(aActivity: Activity) { - this.router.navigate( - internalRoutes.portfolio.subRoutes.activities.routerLink, - { - queryParams: { activityId: aActivity.id, createDialog: true } - } - ); - - this.dialogRef.close(); - } - protected onClose() { this.dialogRef.close(); } @@ -208,17 +212,6 @@ export class GfAccountDetailDialogComponent implements OnInit { this.fetchActivities(); } - protected onUpdateActivity(aActivity: Activity) { - this.router.navigate( - internalRoutes.portfolio.subRoutes.activities.routerLink, - { - queryParams: { activityId: aActivity.id, editDialog: true } - } - ); - - this.dialogRef.close(); - } - protected showValuesInPercentage() { return ( this.data.hasImpersonationId || this.user?.settings?.isRestrictedView diff --git a/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html b/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html index 4b652db96..5c5be6cb7 100644 --- a/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html +++ b/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html @@ -132,8 +132,6 @@ [sortColumn]="sortColumn" [sortDirection]="sortDirection" [totalItems]="totalItems" - (activityToClone)="onCloneActivity($event)" - (activityToUpdate)="onUpdateActivity($event)" (export)="onExport()" (pageChanged)="onChangePage($event)" (sortChanged)="onSortChanged($event)" diff --git a/apps/client/src/app/components/account-detail-dialog/interfaces/interfaces.ts b/apps/client/src/app/components/account-detail-dialog/interfaces/interfaces.ts index 01c84e956..2f80dac36 100644 --- a/apps/client/src/app/components/account-detail-dialog/interfaces/interfaces.ts +++ b/apps/client/src/app/components/account-detail-dialog/interfaces/interfaces.ts @@ -4,3 +4,7 @@ export interface AccountDetailDialogParams { hasImpersonationId: boolean; hasPermissionToCreateActivity: boolean; } + +export interface AccountDetailDialogResult { + isNavigating?: boolean; +} diff --git a/apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.component.ts b/apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.component.ts index 52e0a14d4..944b1cb1d 100644 --- a/apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.component.ts +++ b/apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.component.ts @@ -63,7 +63,7 @@ import { PageEvent } from '@angular/material/paginator'; import { SortDirection } from '@angular/material/sort'; import { MatTableDataSource } from '@angular/material/table'; import { MatTabsModule } from '@angular/material/tabs'; -import { Router, RouterModule } from '@angular/router'; +import { NavigationStart, Router, RouterModule } from '@angular/router'; import { IonIcon } from '@ionic/angular/standalone'; import { Account, MarketData, Tag } from '@prisma/client'; import { isUUID } from 'class-validator'; @@ -80,9 +80,12 @@ import { } from 'ionicons/icons'; import { isNumber, round, uniqBy } from 'lodash'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; -import { switchMap } from 'rxjs/operators'; +import { filter, switchMap } from 'rxjs/operators'; -import { HoldingDetailDialogParams } from './interfaces/interfaces'; +import { + HoldingDetailDialogParams, + HoldingDetailDialogResult +} from './interfaces/interfaces'; @Component({ changeDetection: ChangeDetectionStrategy.OnPush, @@ -186,9 +189,10 @@ export class GfHoldingDetailDialogComponent implements OnInit { protected value: number; protected readonly data = inject(MAT_DIALOG_DATA); - protected readonly dialogRef = inject( - MatDialogRef - ); + protected readonly dialogRef = + inject< + MatDialogRef + >(MatDialogRef); private tags: Tag[]; @@ -200,6 +204,17 @@ export class GfHoldingDetailDialogComponent implements OnInit { private readonly userService = inject(UserService); public constructor() { + this.router.events + .pipe( + filter((event) => { + return event instanceof NavigationStart; + }), + takeUntilDestroyed(this.destroyRef) + ) + .subscribe(() => { + this.dialogRef.close({ isNavigating: true }); + }); + addIcons({ arrowDownCircleOutline, createOutline, @@ -589,17 +604,6 @@ export class GfHoldingDetailDialogComponent implements OnInit { this.fetchActivities(); } - protected onCloneActivity(aActivity: Activity) { - this.router.navigate( - internalRoutes.portfolio.subRoutes.activities.routerLink, - { - queryParams: { activityId: aActivity.id, createDialog: true } - } - ); - - this.dialogRef.close(); - } - protected onClose() { this.dialogRef.close(); } @@ -661,17 +665,6 @@ export class GfHoldingDetailDialogComponent implements OnInit { } } - protected onUpdateActivity(aActivity: Activity) { - this.router.navigate( - internalRoutes.portfolio.subRoutes.activities.routerLink, - { - queryParams: { activityId: aActivity.id, editDialog: true } - } - ); - - this.dialogRef.close(); - } - private fetchActivities(filters: Filter[] = this.getActivityFilters()) { this.dataService .fetchActivities({ diff --git a/apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html b/apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html index f233df8b6..0a8ee6dce 100644 --- a/apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html +++ b/apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html @@ -374,8 +374,6 @@ [sortDirection]="sortDirection" [sortDisabled]="true" [totalItems]="activitiesCount" - (activityToClone)="onCloneActivity($event)" - (activityToUpdate)="onUpdateActivity($event)" (export)="onExport()" (pageChanged)="onChangePage($event)" /> diff --git a/apps/client/src/app/components/holding-detail-dialog/interfaces/interfaces.ts b/apps/client/src/app/components/holding-detail-dialog/interfaces/interfaces.ts index 527b13636..40c94ca60 100644 --- a/apps/client/src/app/components/holding-detail-dialog/interfaces/interfaces.ts +++ b/apps/client/src/app/components/holding-detail-dialog/interfaces/interfaces.ts @@ -15,3 +15,7 @@ export interface HoldingDetailDialogParams { locale: string; symbol: string; } + +export interface HoldingDetailDialogResult { + isNavigating?: boolean; +} diff --git a/apps/client/src/app/pages/accounts/accounts-page.component.ts b/apps/client/src/app/pages/accounts/accounts-page.component.ts index 7d5e2fff7..1cf0e44a7 100644 --- a/apps/client/src/app/pages/accounts/accounts-page.component.ts +++ b/apps/client/src/app/pages/accounts/accounts-page.component.ts @@ -1,5 +1,8 @@ import { GfAccountDetailDialogComponent } from '@ghostfolio/client/components/account-detail-dialog/account-detail-dialog.component'; -import { AccountDetailDialogParams } from '@ghostfolio/client/components/account-detail-dialog/interfaces/interfaces'; +import { + AccountDetailDialogParams, + AccountDetailDialogResult +} from '@ghostfolio/client/components/account-detail-dialog/interfaces/interfaces'; import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service'; import { UserService } from '@ghostfolio/client/services/user/user.service'; import { @@ -243,7 +246,8 @@ export class GfAccountsPageComponent implements OnInit { private openAccountDetailDialog(aAccountId: string) { const dialogRef = this.dialog.open< GfAccountDetailDialogComponent, - AccountDetailDialogParams + AccountDetailDialogParams, + AccountDetailDialogResult >(GfAccountDetailDialogComponent, { autoFocus: false, data: { @@ -262,7 +266,11 @@ export class GfAccountsPageComponent implements OnInit { dialogRef .afterClosed() .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe(() => { + .subscribe((result) => { + if (result?.isNavigating) { + return; + } + this.fetchAccounts(); this.router.navigate(['.'], { relativeTo: this.route }); diff --git a/apps/client/src/app/pages/portfolio/activities/activities-page.component.ts b/apps/client/src/app/pages/portfolio/activities/activities-page.component.ts index 45653ff80..3362dc5d5 100644 --- a/apps/client/src/app/pages/portfolio/activities/activities-page.component.ts +++ b/apps/client/src/app/pages/portfolio/activities/activities-page.component.ts @@ -2,7 +2,6 @@ import { IcsService } from '@ghostfolio/client/services/ics/ics.service'; import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service'; import { UserService } from '@ghostfolio/client/services/user/user.service'; import { DEFAULT_PAGE_SIZE } from '@ghostfolio/common/config'; -import { CreateOrderDto, UpdateOrderDto } from '@ghostfolio/common/dtos'; import { downloadAsFile } from '@ghostfolio/common/helper'; import { Activity, @@ -10,6 +9,7 @@ import { User } from '@ghostfolio/common/interfaces'; import { hasPermission, permissions } from '@ghostfolio/common/permissions'; +import { internalRoutes } from '@ghostfolio/common/routes/routes'; import { DateRange } from '@ghostfolio/common/types'; import { GfActivitiesTableComponent } from '@ghostfolio/ui/activities-table'; import { GfFabComponent } from '@ghostfolio/ui/fab'; @@ -29,17 +29,12 @@ import { PageEvent } from '@angular/material/paginator'; import { MatSnackBarModule } from '@angular/material/snack-bar'; import { Sort, SortDirection } from '@angular/material/sort'; import { MatTableDataSource } from '@angular/material/table'; -import { ActivatedRoute, Router, RouterModule } from '@angular/router'; +import { Router, RouterModule } from '@angular/router'; import { format, parseISO } from 'date-fns'; import { DeviceDetectorService } from 'ngx-device-detector'; -import { of } from 'rxjs'; -import { map, switchMap } from 'rxjs/operators'; -import { GfCreateOrUpdateActivityDialogComponent } from './create-or-update-activity-dialog/create-or-update-activity-dialog.component'; -import { CreateOrUpdateActivityDialogParams } from './create-or-update-activity-dialog/interfaces/interfaces'; import { GfImportActivitiesDialogComponent } from './import-activities-dialog/import-activities-dialog.component'; import { ImportActivitiesDialogParams } from './import-activities-dialog/interfaces/interfaces'; -import { ActivitiesPageParams } from './interfaces/interfaces'; @Component({ changeDetection: ChangeDetectionStrategy.OnPush, @@ -59,6 +54,7 @@ export class GfActivitiesPageComponent implements OnInit { protected hasImpersonationId: boolean; protected hasPermissionToCreateActivity: boolean; protected hasPermissionToDeleteActivity: boolean; + protected readonly internalRoutes = internalRoutes; protected pageIndex = 0; protected readonly pageSize = DEFAULT_PAGE_SIZE; protected sortColumn = 'date'; @@ -77,37 +73,9 @@ export class GfActivitiesPageComponent implements OnInit { private readonly impersonationStorageService = inject( ImpersonationStorageService ); - private readonly route = inject(ActivatedRoute); private readonly router = inject(Router); private readonly userService = inject(UserService); - public constructor() { - this.route.queryParams - .pipe( - takeUntilDestroyed(this.destroyRef), - switchMap((params: ActivitiesPageParams) => { - if (params.activityId && (params.createDialog || params.editDialog)) { - return this.dataService - .fetchActivity(params.activityId) - .pipe(map((activity) => ({ activity, params }))); - } - - return of({ params, activity: undefined }); - }) - ) - .subscribe(({ activity, params }) => { - if (params.createDialog) { - this.openCreateActivityDialog(activity); - } else if (params.editDialog) { - if (activity) { - this.openUpdateActivityDialog(activity); - } else { - this.router.navigate(['.'], { relativeTo: this.route }); - } - } - }); - } - public ngOnInit() { this.deviceType = this.deviceDetectorService.getDeviceInfo().deviceType; @@ -149,10 +117,6 @@ export class GfActivitiesPageComponent implements OnInit { }); } - protected onCloneActivity(aActivity: Activity) { - this.openCreateActivityDialog(aActivity); - } - protected onDeleteActivities() { this.dataService .deleteActivities({ @@ -308,12 +272,6 @@ export class GfActivitiesPageComponent implements OnInit { this.fetchActivities(); } - protected onUpdateActivity(aActivity: Activity) { - this.router.navigate([], { - queryParams: { activityId: aActivity.id, editDialog: true } - }); - } - private fetchActivities() { // Reset dataSource and totalItems to show loading state this.dataSource = undefined; @@ -343,48 +301,16 @@ export class GfActivitiesPageComponent implements OnInit { this.hasPermissionToCreateActivity && this.user?.activitiesCount === 0 ) { - this.router.navigate([], { queryParams: { createDialog: true } }); + void this.router.navigate( + internalRoutes.portfolio.subRoutes.activities.subRoutes.create + .routerLink + ); } this.changeDetectorRef.markForCheck(); }); } - private openUpdateActivityDialog(aActivity: Activity) { - const dialogRef = this.dialog.open< - GfCreateOrUpdateActivityDialogComponent, - CreateOrUpdateActivityDialogParams - >(GfCreateOrUpdateActivityDialogComponent, { - data: { - activity: aActivity, - accounts: this.user?.accounts, - user: this.user - }, - height: this.deviceType === 'mobile' ? '98vh' : '80vh', - width: this.deviceType === 'mobile' ? '100vw' : '50rem' - }); - - dialogRef - .afterClosed() - .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe((activity: UpdateOrderDto) => { - if (activity) { - this.dataService - .putActivity(activity) - .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe({ - next: () => { - this.fetchActivities(); - - this.changeDetectorRef.markForCheck(); - } - }); - } - - this.router.navigate(['.'], { relativeTo: this.route }); - }); - } - private isCalendarYear(dateRange?: DateRange) { if (!dateRange) { return false; @@ -393,59 +319,6 @@ export class GfActivitiesPageComponent implements OnInit { return /^\d{4}$/.test(dateRange); } - private openCreateActivityDialog(aActivity?: Activity) { - this.userService - .get() - .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe((user) => { - this.updateUser(user); - - const dialogRef = this.dialog.open< - GfCreateOrUpdateActivityDialogComponent, - CreateOrUpdateActivityDialogParams - >(GfCreateOrUpdateActivityDialogComponent, { - data: { - accounts: this.user?.accounts, - activity: { - ...aActivity, - accountId: aActivity?.accountId, - assetProfile: aActivity?.assetProfile ?? null, - date: new Date(), - id: null, - fee: 0, - type: aActivity?.type ?? 'BUY', - unitPrice: null - }, - user: this.user - } satisfies CreateOrUpdateActivityDialogParams, - height: this.deviceType === 'mobile' ? '98vh' : '80vh', - width: this.deviceType === 'mobile' ? '100vw' : '50rem' - }); - - dialogRef - .afterClosed() - .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe((transaction: CreateOrderDto | null) => { - if (transaction) { - this.dataService.postActivity(transaction).subscribe({ - next: () => { - this.userService - .get(true) - .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe(); - - this.fetchActivities(); - - this.changeDetectorRef.markForCheck(); - } - }); - } - - this.router.navigate(['.'], { relativeTo: this.route }); - }); - }); - } - private updateUser(aUser: User) { this.user = aUser; diff --git a/apps/client/src/app/pages/portfolio/activities/activities-page.html b/apps/client/src/app/pages/portfolio/activities/activities-page.html index 23e0cef02..c69437742 100644 --- a/apps/client/src/app/pages/portfolio/activities/activities-page.html +++ b/apps/client/src/app/pages/portfolio/activities/activities-page.html @@ -25,8 +25,6 @@ (activitiesDeleted)="onDeleteActivities()" (activityClicked)="onClickActivity($event)" (activityDeleted)="onDeleteActivity($event)" - (activityToClone)="onCloneActivity($event)" - (activityToUpdate)="onUpdateActivity($event)" (export)="onExport()" (exportDrafts)="onExportDrafts($event)" (import)="onImport()" @@ -43,6 +41,13 @@ hasPermissionToCreateActivity && !user.settings.isRestrictedView ) { - + }
+ + diff --git a/apps/client/src/app/pages/portfolio/activities/activities-page.routes.ts b/apps/client/src/app/pages/portfolio/activities/activities-page.routes.ts index c96c8a558..f21f23ba4 100644 --- a/apps/client/src/app/pages/portfolio/activities/activities-page.routes.ts +++ b/apps/client/src/app/pages/portfolio/activities/activities-page.routes.ts @@ -4,10 +4,39 @@ import { internalRoutes } from '@ghostfolio/common/routes/routes'; import { Routes } from '@angular/router'; import { GfActivitiesPageComponent } from './activities-page.component'; +import { GfActivityDialogHostComponent } from './activity-dialog-host/activity-dialog-host.component'; + +const { clone, create, update } = + internalRoutes.portfolio.subRoutes.activities.subRoutes; export const routes: Routes = [ { canActivate: [AuthGuard], + children: [ + { + component: GfActivityDialogHostComponent, + data: { mode: 'create' }, + path: create.path, + title: create.title + }, + { + children: [ + { + component: GfActivityDialogHostComponent, + data: { mode: 'clone' }, + path: clone.path, + title: clone.title + }, + { + component: GfActivityDialogHostComponent, + data: { mode: 'update' }, + path: update.path, + title: update.title + } + ], + path: ':activityId' + } + ], component: GfActivitiesPageComponent, path: '', title: internalRoutes.portfolio.subRoutes.activities.title diff --git a/apps/client/src/app/pages/portfolio/activities/activity-dialog-host/activity-dialog-host.component.ts b/apps/client/src/app/pages/portfolio/activities/activity-dialog-host/activity-dialog-host.component.ts new file mode 100644 index 000000000..e0e3fc950 --- /dev/null +++ b/apps/client/src/app/pages/portfolio/activities/activity-dialog-host/activity-dialog-host.component.ts @@ -0,0 +1,169 @@ +import { UserService } from '@ghostfolio/client/services/user/user.service'; +import { CreateOrderDto, UpdateOrderDto } from '@ghostfolio/common/dtos'; +import { Activity, User } from '@ghostfolio/common/interfaces'; +import { internalRoutes } from '@ghostfolio/common/routes/routes'; +import { DataService } from '@ghostfolio/ui/services'; + +import { + ChangeDetectionStrategy, + Component, + DestroyRef, + OnDestroy, + OnInit, + inject +} from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { MatDialog, MatDialogRef } from '@angular/material/dialog'; +import { ActivatedRoute, Router } from '@angular/router'; +import { DeviceDetectorService } from 'ngx-device-detector'; +import { Observable, of } from 'rxjs'; +import { map, switchMap } from 'rxjs/operators'; + +import { GfCreateOrUpdateActivityDialogComponent } from '../create-or-update-activity-dialog/create-or-update-activity-dialog.component'; +import { CreateOrUpdateActivityDialogParams } from '../create-or-update-activity-dialog/interfaces/interfaces'; +import { ActivityDialogMode } from './types/activity-dialog-mode.type'; + +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + selector: 'gf-activity-dialog-host', + template: '' +}) +export class GfActivityDialogHostComponent implements OnDestroy, OnInit { + private dialogRef: MatDialogRef; + + private readonly dataService = inject(DataService); + private readonly destroyRef = inject(DestroyRef); + private readonly deviceDetectorService = inject(DeviceDetectorService); + private readonly dialog = inject(MatDialog); + private readonly route = inject(ActivatedRoute); + private readonly router = inject(Router); + private readonly userService = inject(UserService); + + public ngOnInit() { + const mode = this.route.snapshot.data.mode as ActivityDialogMode; + const activityId = this.route.snapshot.paramMap.get('activityId'); + + const activity$: Observable = activityId + ? this.dataService.fetchActivity(activityId) + : of(undefined); + + this.userService + .get() + .pipe( + switchMap((user) => { + return activity$.pipe( + map((activity) => { + return { activity, user }; + }) + ); + }), + takeUntilDestroyed(this.destroyRef) + ) + .subscribe({ + error: () => { + this.navigateBack(); + }, + next: ({ activity, user }) => { + if (mode === 'update') { + if (!activity) { + this.navigateBack(); + + return; + } + + this.openDialog({ activity, user, isUpdate: true }); + + return; + } + + if (mode === 'clone' && !activity) { + this.navigateBack(); + + return; + } + + this.openDialog({ + user, + activity: { + ...activity, + accountId: activity?.accountId, + assetProfile: activity?.assetProfile ?? null, + date: new Date(), + fee: 0, + id: null, + type: activity?.type ?? 'BUY', + unitPrice: null + }, + isUpdate: false + }); + } + }); + } + + public ngOnDestroy() { + // The dialog lives in an overlay outside of this component, so it needs to + // be closed explicitly when leaving the route (for example via the browser + // navigation) + this.dialogRef?.close(); + } + + private navigateBack() { + void this.router.navigate( + internalRoutes.portfolio.subRoutes.activities.routerLink + ); + } + + private openDialog({ + activity, + isUpdate, + user + }: { + activity: CreateOrUpdateActivityDialogParams['activity']; + isUpdate: boolean; + user: User; + }) { + const deviceType = this.deviceDetectorService.getDeviceInfo().deviceType; + + this.dialogRef = this.dialog.open< + GfCreateOrUpdateActivityDialogComponent, + CreateOrUpdateActivityDialogParams + >(GfCreateOrUpdateActivityDialogComponent, { + data: { + activity, + user, + accounts: user?.accounts + }, + height: deviceType === 'mobile' ? '98vh' : '80vh', + width: deviceType === 'mobile' ? '100vw' : '50rem' + }); + + this.dialogRef + .afterClosed() + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((result: CreateOrderDto | UpdateOrderDto | null) => { + if (!result) { + this.navigateBack(); + + return; + } + + const request$: Observable = isUpdate + ? this.dataService.putActivity(result as UpdateOrderDto) + : this.dataService.postActivity(result as CreateOrderDto); + + request$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe({ + error: () => { + this.navigateBack(); + }, + next: () => { + // Deliberately not bound to the destroy reference: navigating back + // destroys this component and the refreshed user is what makes the + // activities page reload its data + this.userService.get(true).subscribe(); + + this.navigateBack(); + } + }); + }); + } +} diff --git a/apps/client/src/app/pages/portfolio/activities/activity-dialog-host/types/activity-dialog-mode.type.ts b/apps/client/src/app/pages/portfolio/activities/activity-dialog-host/types/activity-dialog-mode.type.ts new file mode 100644 index 000000000..03d6305a5 --- /dev/null +++ b/apps/client/src/app/pages/portfolio/activities/activity-dialog-host/types/activity-dialog-mode.type.ts @@ -0,0 +1 @@ +export type ActivityDialogMode = 'clone' | 'create' | 'update'; diff --git a/apps/client/src/app/pages/portfolio/activities/interfaces/interfaces.ts b/apps/client/src/app/pages/portfolio/activities/interfaces/interfaces.ts deleted file mode 100644 index 51f240cb5..000000000 --- a/apps/client/src/app/pages/portfolio/activities/interfaces/interfaces.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { Params } from '@angular/router'; - -export interface ActivitiesPageParams extends Params { - activityId?: string; - createDialog?: string; - editDialog?: string; -} diff --git a/apps/client/src/app/pages/portfolio/allocations/allocations-page.component.ts b/apps/client/src/app/pages/portfolio/allocations/allocations-page.component.ts index f1dfed942..52f051e77 100644 --- a/apps/client/src/app/pages/portfolio/allocations/allocations-page.component.ts +++ b/apps/client/src/app/pages/portfolio/allocations/allocations-page.component.ts @@ -1,5 +1,8 @@ import { GfAccountDetailDialogComponent } from '@ghostfolio/client/components/account-detail-dialog/account-detail-dialog.component'; -import { AccountDetailDialogParams } from '@ghostfolio/client/components/account-detail-dialog/interfaces/interfaces'; +import { + AccountDetailDialogParams, + AccountDetailDialogResult +} from '@ghostfolio/client/components/account-detail-dialog/interfaces/interfaces'; import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service'; import { UserService } from '@ghostfolio/client/services/user/user.service'; import { MAX_TOP_HOLDINGS, UNKNOWN_KEY } from '@ghostfolio/common/config'; @@ -604,7 +607,8 @@ export class GfAllocationsPageComponent implements OnInit { private openAccountDetailDialog(aAccountId: string) { const dialogRef = this.dialog.open< GfAccountDetailDialogComponent, - AccountDetailDialogParams + AccountDetailDialogParams, + AccountDetailDialogResult >(GfAccountDetailDialogComponent, { autoFocus: false, data: { @@ -623,7 +627,11 @@ export class GfAllocationsPageComponent implements OnInit { dialogRef .afterClosed() .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe(() => { + .subscribe((result) => { + if (result?.isNavigating) { + return; + } + void this.router.navigate(['.'], { relativeTo: this.route }); }); } diff --git a/libs/common/src/lib/routes/interfaces/internal-route.interface.ts b/libs/common/src/lib/routes/interfaces/internal-route.interface.ts index 8240db46a..14538bb8e 100644 --- a/libs/common/src/lib/routes/interfaces/internal-route.interface.ts +++ b/libs/common/src/lib/routes/interfaces/internal-route.interface.ts @@ -3,7 +3,7 @@ import { User } from '@ghostfolio/common/interfaces'; export interface InternalRoute { excludeFromAssistant?: boolean | ((aUser: User) => boolean); path?: string; - routerLink: string[]; + routerLink: string[] | ((...aParams: string[]) => string[]); subRoutes?: Record; title?: string; } diff --git a/libs/common/src/lib/routes/routes.ts b/libs/common/src/lib/routes/routes.ts index 8132520fc..86cb2480b 100644 --- a/libs/common/src/lib/routes/routes.ts +++ b/libs/common/src/lib/routes/routes.ts @@ -125,6 +125,27 @@ export const internalRoutes = { activities: { path: 'activities', routerLink: ['/portfolio', 'activities'], + subRoutes: { + clone: { + path: 'clone', + routerLink: (aActivityId: string) => { + return ['/portfolio', 'activities', aActivityId, 'clone']; + }, + title: $localize`Clone Activity` + }, + create: { + path: 'create', + routerLink: ['/portfolio', 'activities', 'create'], + title: $localize`Add Activity` + }, + update: { + path: 'update', + routerLink: (aActivityId: string) => { + return ['/portfolio', 'activities', aActivityId, 'update']; + }, + title: $localize`Update Activity` + } + }, title: $localize`Activities` }, allocations: { diff --git a/libs/ui/src/lib/activities-table/activities-table.component.html b/libs/ui/src/lib/activities-table/activities-table.component.html index 172059d1c..2266e758b 100644 --- a/libs/ui/src/lib/activities-table/activities-table.component.html +++ b/libs/ui/src/lib/activities-table/activities-table.component.html @@ -476,18 +476,24 @@ } - - +