Browse Source

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
pull/6932/head
Kenrick Tandrian 2 days ago
committed by GitHub
parent
commit
081ce27d78
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 7
      apps/api/src/app/endpoints/benchmarks/benchmarks.controller.ts
  2. 11
      apps/api/src/app/portfolio/portfolio.controller.ts
  3. 5
      apps/api/src/app/portfolio/portfolio.service.ts
  4. 4
      apps/api/src/app/user/user.service.ts
  5. 3
      apps/client/src/app/components/account-detail-dialog/account-detail-dialog.component.ts
  6. 139
      apps/client/src/app/components/home-overview/home-overview.component.ts
  7. 42
      apps/client/src/app/components/home-overview/home-overview.html
  8. 4
      apps/client/src/app/components/portfolio-performance/portfolio-performance.component.html
  9. 2
      apps/client/src/app/components/portfolio-performance/portfolio-performance.component.ts
  10. 3
      apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.component.ts
  11. 3
      libs/common/src/lib/config.ts

7
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,

11
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<PortfolioDividendsResponse> {
@ -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<PortfolioHoldingsResponse> {
@ -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<PortfolioInvestmentsResponse> {
@ -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'

5
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

4
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) {

3
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
})

139
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<AssetProfileIdentifier[]>([]);
protected readonly hasImpersonationId = signal(false);
protected readonly historicalDataItems = signal<LineChartItem[] | null>(null);
protected readonly isLoadingPerformance = signal(true);
protected readonly performance = signal<PortfolioPerformance | null>(null);
protected readonly performanceLabel = $localize`Performance`;
protected readonly precision = signal(2);
protected readonly user = signal<User | null>(null);
protected readonly routerLinkAccounts = internalRoutes.accounts.routerLink;
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.errors.set(errors ?? []);
this.performance.set(performance);
this.historicalDataItems = chart.map(
this.historicalDataItems.set(
chart?.map(
({ date, netPerformanceInPercentageWithCurrencyEffect }) => {
return {
date,
value: netPerformanceInPercentageWithCurrencyEffect * 100
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();
}
}

42
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
) {
<div class="justify-content-center row w-100">
<div class="col introduction">
<h4 i18n>Welcome to Ghostfolio</h4>
<p i18n>Ready to take control of your personal finances?</p>
<ol class="font-weight-bold">
<li class="mb-2" [class.text-muted]="user?.accounts?.length > 1">
<li class="mb-2" [class.text-muted]="user()?.accounts?.length > 1">
<a class="d-block" [routerLink]="routerLinkAccounts"
><span i18n>Setup your accounts</span><br />
<span class="font-weight-normal" i18n
@ -40,7 +40,7 @@
</li>
</ol>
<div class="d-flex justify-content-center">
@if (user?.accounts?.length === 1) {
@if (user()?.accounts?.length === 1) {
<a
color="primary"
mat-flat-button
@ -48,7 +48,7 @@
>
<ng-container i18n>Setup accounts</ng-container>
</a>
} @else if (user?.accounts?.length > 1) {
} @else if (user()?.accounts?.length > 1) {
<a
color="primary"
mat-flat-button
@ -67,13 +67,13 @@
<gf-line-chart
class="position-absolute"
unit="%"
[class.pr-3]="deviceType === 'mobile'"
[colorScheme]="user?.settings?.colorScheme"
[hidden]="historicalDataItems?.length === 0"
[historicalDataItems]="historicalDataItems"
[isAnimated]="user?.settings?.dateRange === '1d' ? false : true"
[class.pr-3]="deviceType() === 'mobile'"
[colorScheme]="user()?.settings?.colorScheme"
[hidden]="historicalDataItems()?.length === 0"
[historicalDataItems]="historicalDataItems()"
[isAnimated]="user()?.settings?.dateRange === '1d' ? false : true"
[label]="performanceLabel"
[locale]="user?.settings?.locale"
[locale]="user()?.settings?.locale"
[showGradient]="true"
[showLoader]="false"
[showXAxis]="false"
@ -86,16 +86,14 @@
<div class="col">
<gf-portfolio-performance
class="pb-4"
[deviceType]="deviceType"
[errors]="errors"
[isAllTimeHigh]="isAllTimeHigh"
[isAllTimeLow]="isAllTimeLow"
[isLoading]="isLoadingPerformance"
[locale]="user?.settings?.locale"
[performance]="performance"
[precision]="precision"
[showDetails]="showDetails"
[unit]="unit"
[deviceType]="deviceType()"
[errors]="errors()"
[isLoading]="isLoadingPerformance()"
[locale]="user()?.settings?.locale"
[performance]="performance()"
[precision]="precision()"
[showDetails]="showDetails()"
[unit]="unit()"
/>
</div>
</div>

4
apps/client/src/app/components/portfolio-performance/portfolio-performance.component.html

@ -24,10 +24,6 @@
}
<div
class="display-4 font-weight-bold m-0 text-center value-container"
[class]="{
'text-danger': isAllTimeLow,
'text-success': isAllTimeHigh
}"
[hidden]="isLoading"
>
<span #value id="value"></span>

2
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;

3
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 }) => {

3
libs/common/src/lib/config.ts

@ -2,7 +2,7 @@ import { AssetClass, AssetSubClass, DataSource, Type } from '@prisma/client';
import { JobOptions, JobStatus } from 'bull';
import ms from 'ms';
import { ColorScheme } 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;

Loading…
Cancel
Save