From 081ce27d78be4f380f15bcf5ca35193a153cdcde Mon Sep 17 00:00:00 2001 From: Kenrick Tandrian <60643640+KenTandrian@users.noreply.github.com> Date: Sat, 23 May 2026 23:24:28 +0700 Subject: [PATCH] Task/improve type safety in home overview component (#6927) * feat(client): implement default date range * feat(client): resolve errors * feat(client): replace constructor based DI with inject function * feat(client): enforce encapsulation * fix(client): remove dead code * feat(client): enforce immutability * feat(client): replace deprecated getDeviceInfo * feat(client): convert errors to signal * feat(client): convert hasImpersonationId to signal * feat(client): convert user to signal * feat(client): convert to computed signals * feat(client): convert historicalDataItems and isLoadingPerformance to signals * feat(client): convert performance and precision to signals * feat(client): implement OnPush change detection strategy * fix(common): revert DateRange type change * fix(client): format file --- .../benchmarks/benchmarks.controller.ts | 7 +- .../src/app/portfolio/portfolio.controller.ts | 11 +- .../src/app/portfolio/portfolio.service.ts | 5 +- apps/api/src/app/user/user.service.ts | 4 +- .../account-detail-dialog.component.ts | 3 +- .../home-overview/home-overview.component.ts | 151 +++++++++--------- .../home-overview/home-overview.html | 42 +++-- .../portfolio-performance.component.html | 4 - .../portfolio-performance.component.ts | 2 - .../import-activities-dialog.component.ts | 3 +- libs/common/src/lib/config.ts | 3 +- 11 files changed, 122 insertions(+), 113 deletions(-) diff --git a/apps/api/src/app/endpoints/benchmarks/benchmarks.controller.ts b/apps/api/src/app/endpoints/benchmarks/benchmarks.controller.ts index 970925777..74bb6b672 100644 --- a/apps/api/src/app/endpoints/benchmarks/benchmarks.controller.ts +++ b/apps/api/src/app/endpoints/benchmarks/benchmarks.controller.ts @@ -5,7 +5,10 @@ import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interc import { ApiService } from '@ghostfolio/api/services/api/api.service'; import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service'; import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper'; -import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config'; +import { + DEFAULT_DATE_RANGE, + HEADER_KEY_IMPERSONATION +} from '@ghostfolio/common/config'; import type { AssetProfileIdentifier, BenchmarkMarketDataDetailsResponse, @@ -118,7 +121,7 @@ export class BenchmarksController { @Param('dataSource') dataSource: DataSource, @Param('startDateString') startDateString: string, @Param('symbol') symbol: string, - @Query('range') dateRange: DateRange = 'max', + @Query('range') dateRange: DateRange = DEFAULT_DATE_RANGE, @Query('accounts') filterByAccounts?: string, @Query('assetClasses') filterByAssetClasses?: string, @Query('dataSource') filterByDataSource?: string, diff --git a/apps/api/src/app/portfolio/portfolio.controller.ts b/apps/api/src/app/portfolio/portfolio.controller.ts index 3b306c87b..8aa94ee92 100644 --- a/apps/api/src/app/portfolio/portfolio.controller.ts +++ b/apps/api/src/app/portfolio/portfolio.controller.ts @@ -14,6 +14,7 @@ import { ConfigurationService } from '@ghostfolio/api/services/configuration/con import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service'; import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper'; import { + DEFAULT_DATE_RANGE, HEADER_KEY_IMPERSONATION, UNKNOWN_KEY } from '@ghostfolio/common/config'; @@ -82,7 +83,7 @@ export class PortfolioController { @Query('accounts') filterByAccounts?: string, @Query('assetClasses') filterByAssetClasses?: string, @Query('dataSource') filterByDataSource?: string, - @Query('range') dateRange: DateRange = 'max', + @Query('range') dateRange: DateRange = DEFAULT_DATE_RANGE, @Query('symbol') filterBySymbol?: string, @Query('tags') filterByTags?: string, @Query('withMarkets') withMarketsParam = 'false' @@ -321,7 +322,7 @@ export class PortfolioController { @Query('assetClasses') filterByAssetClasses?: string, @Query('dataSource') filterByDataSource?: string, @Query('groupBy') groupBy?: GroupBy, - @Query('range') dateRange: DateRange = 'max', + @Query('range') dateRange: DateRange = DEFAULT_DATE_RANGE, @Query('symbol') filterBySymbol?: string, @Query('tags') filterByTags?: string ): Promise { @@ -422,7 +423,7 @@ export class PortfolioController { @Query('dataSource') filterByDataSource?: string, @Query('holdingType') filterByHoldingType?: string, @Query('query') filterBySearchQuery?: string, - @Query('range') dateRange: DateRange = 'max', + @Query('range') dateRange: DateRange = DEFAULT_DATE_RANGE, @Query('symbol') filterBySymbol?: string, @Query('tags') filterByTags?: string ): Promise { @@ -455,7 +456,7 @@ export class PortfolioController { @Query('assetClasses') filterByAssetClasses?: string, @Query('dataSource') filterByDataSource?: string, @Query('groupBy') groupBy?: GroupBy, - @Query('range') dateRange: DateRange = 'max', + @Query('range') dateRange: DateRange = DEFAULT_DATE_RANGE, @Query('symbol') filterBySymbol?: string, @Query('tags') filterByTags?: string ): Promise { @@ -527,7 +528,7 @@ export class PortfolioController { @Query('accounts') filterByAccounts?: string, @Query('assetClasses') filterByAssetClasses?: string, @Query('dataSource') filterByDataSource?: string, - @Query('range') dateRange: DateRange = 'max', + @Query('range') dateRange: DateRange = DEFAULT_DATE_RANGE, @Query('symbol') filterBySymbol?: string, @Query('tags') filterByTags?: string, @Query('withExcludedAccounts') withExcludedAccountsParam = 'false' diff --git a/apps/api/src/app/portfolio/portfolio.service.ts b/apps/api/src/app/portfolio/portfolio.service.ts index ade683d41..2a9639561 100644 --- a/apps/api/src/app/portfolio/portfolio.service.ts +++ b/apps/api/src/app/portfolio/portfolio.service.ts @@ -32,6 +32,7 @@ import { } from '@ghostfolio/common/calculation-helper'; import { DEFAULT_CURRENCY, + DEFAULT_DATE_RANGE, TAG_ID_EMERGENCY_FUND, TAG_ID_EXCLUDE_FROM_ANALYSIS, UNKNOWN_KEY @@ -470,7 +471,7 @@ export class PortfolioService { } public async getDetails({ - dateRange = 'max', + dateRange = DEFAULT_DATE_RANGE, filters, impersonationId, userId, @@ -1013,7 +1014,7 @@ export class PortfolioService { } public async getPerformance({ - dateRange = 'max', + dateRange = DEFAULT_DATE_RANGE, filters, impersonationId, userId diff --git a/apps/api/src/app/user/user.service.ts b/apps/api/src/app/user/user.service.ts index 4ad22a043..4a0e1598b 100644 --- a/apps/api/src/app/user/user.service.ts +++ b/apps/api/src/app/user/user.service.ts @@ -26,6 +26,7 @@ import { PropertyService } from '@ghostfolio/api/services/property/property.serv import { TagService } from '@ghostfolio/api/services/tag/tag.service'; import { DEFAULT_CURRENCY, + DEFAULT_DATE_RANGE, DEFAULT_LANGUAGE_CODE, PROPERTY_IS_READ_ONLY_MODE, PROPERTY_SYSTEM_MESSAGE, @@ -281,7 +282,8 @@ export class UserService { (user.settings.settings as UserSettings).dateRange = (user.settings.settings as UserSettings).viewMode === 'ZEN' ? 'max' - : ((user.settings.settings as UserSettings)?.dateRange ?? 'max'); + : ((user.settings.settings as UserSettings)?.dateRange ?? + DEFAULT_DATE_RANGE); // Set default value for performance calculation type if (!(user.settings.settings as UserSettings)?.performanceCalculationType) { 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 a429d9e64..d9b279040 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 @@ -1,6 +1,7 @@ import { GfInvestmentChartComponent } from '@ghostfolio/client/components/investment-chart/investment-chart.component'; import { UserService } from '@ghostfolio/client/services/user/user.service'; import { + DEFAULT_DATE_RANGE, DEFAULT_PAGE_SIZE, NUMERICAL_PRECISION_THRESHOLD_6_FIGURES } from '@ghostfolio/common/config'; @@ -330,7 +331,7 @@ export class GfAccountDetailDialogComponent implements OnInit { type: 'ACCOUNT' } ], - range: 'max', + range: DEFAULT_DATE_RANGE, withExcludedAccounts: true, withItems: true }) diff --git a/apps/client/src/app/components/home-overview/home-overview.component.ts b/apps/client/src/app/components/home-overview/home-overview.component.ts index 0336cf169..ad3556536 100644 --- a/apps/client/src/app/components/home-overview/home-overview.component.ts +++ b/apps/client/src/app/components/home-overview/home-overview.component.ts @@ -2,7 +2,11 @@ import { GfPortfolioPerformanceComponent } from '@ghostfolio/client/components/p import { LayoutService } from '@ghostfolio/client/core/layout.service'; import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service'; import { UserService } from '@ghostfolio/client/services/user/user.service'; -import { NUMERICAL_PRECISION_THRESHOLD_6_FIGURES } from '@ghostfolio/common/config'; +import { + DEFAULT_CURRENCY, + DEFAULT_DATE_RANGE, + NUMERICAL_PRECISION_THRESHOLD_6_FIGURES +} from '@ghostfolio/common/config'; import { AssetProfileIdentifier, LineChartItem, @@ -15,11 +19,13 @@ import { GfLineChartComponent } from '@ghostfolio/ui/line-chart'; import { DataService } from '@ghostfolio/ui/services'; import { - ChangeDetectorRef, + ChangeDetectionStrategy, Component, - CUSTOM_ELEMENTS_SCHEMA, + computed, DestroyRef, - OnInit + inject, + OnInit, + signal } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { MatButtonModule } from '@angular/material/button'; @@ -27,79 +33,80 @@ import { RouterModule } from '@angular/router'; import { DeviceDetectorService } from 'ngx-device-detector'; @Component({ + changeDetection: ChangeDetectionStrategy.OnPush, imports: [ GfLineChartComponent, GfPortfolioPerformanceComponent, MatButtonModule, RouterModule ], - schemas: [CUSTOM_ELEMENTS_SCHEMA], selector: 'gf-home-overview', styleUrls: ['./home-overview.scss'], templateUrl: './home-overview.html' }) export class GfHomeOverviewComponent implements OnInit { - public deviceType: string; - public errors: AssetProfileIdentifier[]; - public hasError: boolean; - public hasImpersonationId: boolean; - public hasPermissionToCreateActivity: boolean; - public historicalDataItems: LineChartItem[]; - public isAllTimeHigh: boolean; - public isAllTimeLow: boolean; - public isLoadingPerformance = true; - public performance: PortfolioPerformance; - public performanceLabel = $localize`Performance`; - public precision = 2; - public routerLinkAccounts = internalRoutes.accounts.routerLink; - public routerLinkPortfolio = internalRoutes.portfolio.routerLink; - public routerLinkPortfolioActivities = + protected readonly errors = signal([]); + protected readonly hasImpersonationId = signal(false); + protected readonly historicalDataItems = signal(null); + protected readonly isLoadingPerformance = signal(true); + protected readonly performance = signal(null); + protected readonly performanceLabel = $localize`Performance`; + protected readonly precision = signal(2); + protected readonly user = signal(null); + + protected readonly routerLinkAccounts = internalRoutes.accounts.routerLink; + protected readonly routerLinkPortfolio = internalRoutes.portfolio.routerLink; + protected readonly routerLinkPortfolioActivities = internalRoutes.portfolio.subRoutes.activities.routerLink; - public showDetails = false; - public unit: string; - public user: User; - - public constructor( - private changeDetectorRef: ChangeDetectorRef, - private dataService: DataService, - private destroyRef: DestroyRef, - private deviceDetectorService: DeviceDetectorService, - private impersonationStorageService: ImpersonationStorageService, - private layoutService: LayoutService, - private userService: UserService - ) { + + protected readonly deviceType = computed( + () => this.deviceDetectorService.deviceInfo().deviceType + ); + + protected readonly hasPermissionToCreateActivity = computed(() => { + return hasPermission(this.user()?.permissions, permissions.createActivity); + }); + + protected readonly showDetails = computed(() => { + const user = this.user(); + + return user + ? !user.settings.isRestrictedView && user.settings.viewMode !== 'ZEN' + : false; + }); + + protected readonly unit = computed(() => { + return this.showDetails() + ? (this.user()?.settings?.baseCurrency ?? DEFAULT_CURRENCY) + : '%'; + }); + + private readonly dataService = inject(DataService); + private readonly destroyRef = inject(DestroyRef); + private readonly deviceDetectorService = inject(DeviceDetectorService); + private readonly impersonationStorageService = inject( + ImpersonationStorageService + ); + private readonly layoutService = inject(LayoutService); + private readonly userService = inject(UserService); + + public constructor() { this.userService.stateChanged .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe((state) => { if (state?.user) { - this.user = state.user; - - this.hasPermissionToCreateActivity = hasPermission( - this.user.permissions, - permissions.createActivity - ); - + this.user.set(state.user); this.update(); } }); } public ngOnInit() { - this.deviceType = this.deviceDetectorService.getDeviceInfo().deviceType; - - this.showDetails = - !this.user.settings.isRestrictedView && - this.user.settings.viewMode !== 'ZEN'; - - this.unit = this.showDetails ? this.user.settings.baseCurrency : '%'; - this.impersonationStorageService .onChangeHasImpersonation() .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe((impersonationId) => { - this.hasImpersonationId = !!impersonationId; - - this.changeDetectorRef.markForCheck(); + this.hasImpersonationId.set(!!impersonationId); }); this.layoutService.shouldReloadContent$ @@ -110,40 +117,40 @@ export class GfHomeOverviewComponent implements OnInit { } private update() { - this.historicalDataItems = null; - this.isLoadingPerformance = true; + this.historicalDataItems.set(null); + this.isLoadingPerformance.set(true); this.dataService .fetchPortfolioPerformance({ - range: this.user?.settings?.dateRange + range: this.user()?.settings?.dateRange ?? DEFAULT_DATE_RANGE }) .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe(({ chart, errors, performance }) => { - this.errors = errors; - this.performance = performance; - - this.historicalDataItems = chart.map( - ({ date, netPerformanceInPercentageWithCurrencyEffect }) => { - return { - date, - value: netPerformanceInPercentageWithCurrencyEffect * 100 - }; - } + this.errors.set(errors ?? []); + this.performance.set(performance); + + this.historicalDataItems.set( + chart?.map( + ({ date, netPerformanceInPercentageWithCurrencyEffect }) => { + return { + date, + value: (netPerformanceInPercentageWithCurrencyEffect ?? 0) * 100 + }; + } + ) ?? null ); + this.precision.set(2); + if ( - this.deviceType === 'mobile' && - this.performance.currentValueInBaseCurrency >= + this.deviceType() === 'mobile' && + performance.currentValueInBaseCurrency >= NUMERICAL_PRECISION_THRESHOLD_6_FIGURES ) { - this.precision = 0; + this.precision.set(0); } - this.isLoadingPerformance = false; - - this.changeDetectorRef.markForCheck(); + this.isLoadingPerformance.set(false); }); - - this.changeDetectorRef.markForCheck(); } } diff --git a/apps/client/src/app/components/home-overview/home-overview.html b/apps/client/src/app/components/home-overview/home-overview.html index b3b957d72..8361c5b88 100644 --- a/apps/client/src/app/components/home-overview/home-overview.html +++ b/apps/client/src/app/components/home-overview/home-overview.html @@ -2,16 +2,16 @@ class="align-items-center container d-flex flex-column h-100 justify-content-center overview p-0 position-relative" > @if ( - !hasImpersonationId && - hasPermissionToCreateActivity && - user?.activitiesCount === 0 + !hasImpersonationId() && + hasPermissionToCreateActivity() && + user()?.activitiesCount === 0 ) {

Welcome to Ghostfolio

Ready to take control of your personal finances?

    -
  1. +
  2. Setup your accounts
- @if (user?.accounts?.length === 1) { + @if (user()?.accounts?.length === 1) { Setup accounts - } @else if (user?.accounts?.length > 1) { + } @else if (user()?.accounts?.length > 1) {
diff --git a/apps/client/src/app/components/portfolio-performance/portfolio-performance.component.html b/apps/client/src/app/components/portfolio-performance/portfolio-performance.component.html index 9db996fbf..e8bcfea0e 100644 --- a/apps/client/src/app/components/portfolio-performance/portfolio-performance.component.html +++ b/apps/client/src/app/components/portfolio-performance/portfolio-performance.component.html @@ -24,10 +24,6 @@ }
diff --git a/apps/client/src/app/components/portfolio-performance/portfolio-performance.component.ts b/apps/client/src/app/components/portfolio-performance/portfolio-performance.component.ts index dd3ce248e..56e75ec1e 100644 --- a/apps/client/src/app/components/portfolio-performance/portfolio-performance.component.ts +++ b/apps/client/src/app/components/portfolio-performance/portfolio-performance.component.ts @@ -35,8 +35,6 @@ import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; export class GfPortfolioPerformanceComponent implements OnChanges { @Input() deviceType: string; @Input() errors: ResponseError['errors']; - @Input() isAllTimeHigh: boolean; - @Input() isAllTimeLow: boolean; @Input() isLoading: boolean; @Input() locale = getLocale(); @Input() performance: PortfolioPerformance; diff --git a/apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.component.ts b/apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.component.ts index 7796939fd..42260d648 100644 --- a/apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.component.ts +++ b/apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.component.ts @@ -1,5 +1,6 @@ import { GfFileDropDirective } from '@ghostfolio/client/directives/file-drop/file-drop.directive'; import { ImportActivitiesService } from '@ghostfolio/client/services/import-activities.service'; +import { DEFAULT_DATE_RANGE } from '@ghostfolio/common/config'; import { CreateAccountWithBalancesDto, CreateAssetProfileWithMarketDataDto, @@ -145,7 +146,7 @@ export class GfImportActivitiesDialogComponent { type: 'ASSET_CLASS' } ], - range: 'max' + range: DEFAULT_DATE_RANGE }) .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe(({ holdings }) => { diff --git a/libs/common/src/lib/config.ts b/libs/common/src/lib/config.ts index 9d6122c6c..113dffe4a 100644 --- a/libs/common/src/lib/config.ts +++ b/libs/common/src/lib/config.ts @@ -2,7 +2,7 @@ import { AssetClass, AssetSubClass, DataSource, Type } from '@prisma/client'; import { JobOptions, JobStatus } from 'bull'; import ms from 'ms'; -import { ColorScheme } from './types'; +import { ColorScheme, DateRange } from './types'; export const ghostfolioPrefix = 'GF'; export const ghostfolioScraperApiSymbolPrefix = `_${ghostfolioPrefix}_`; @@ -82,6 +82,7 @@ export const STATISTICS_GATHERING_QUEUE = 'STATISTICS_GATHERING_QUEUE'; export const DEFAULT_COLOR_SCHEME: ColorScheme = 'LIGHT'; export const DEFAULT_CURRENCY = 'USD'; export const DEFAULT_DATE_FORMAT_MONTH_YEAR = 'MMM yyyy'; +export const DEFAULT_DATE_RANGE: DateRange = 'max'; export const DEFAULT_HOST = '0.0.0.0'; export const DEFAULT_LANGUAGE_CODE = 'en'; export const DEFAULT_PAGE_SIZE = 50;