Browse Source

Feature/extend FIRE page with projection information at retirement date (#6034)

* Extend FIRE page with projection information at retirement date

* Update changelog

---------

Co-authored-by: Thomas Kaul <4159106+dtslvr@users.noreply.github.com>
pull/6049/head
Johnson Towoju 4 days ago
committed by GitHub
parent
commit
de3f0c4207
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 4
      CHANGELOG.md
  2. 15
      apps/api/src/app/user/user.service.ts
  3. 40
      apps/client/src/app/pages/portfolio/fire/fire-page.component.ts
  4. 56
      apps/client/src/app/pages/portfolio/fire/fire-page.html
  5. 4
      libs/common/src/lib/interfaces/fire-calculation-complete-event.interface.ts
  6. 2
      libs/common/src/lib/interfaces/index.ts
  7. 21
      libs/ui/src/lib/fire-calculator/fire-calculator.component.ts

4
CHANGELOG.md

@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## Unreleased ## Unreleased
### Added
- Included wealth projection data calculated for the retirement date in the _FIRE_ section (experimental)
### Changed ### Changed
- Moved the notification module to `@ghostfolio/ui` - Moved the notification module to `@ghostfolio/ui`

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

@ -248,6 +248,11 @@ export class UserService {
}; };
} }
// Set default value for annual interest rate
if (!(user.settings.settings as UserSettings)?.annualInterestRate) {
(user.settings.settings as UserSettings).annualInterestRate = 5;
}
// Set default value for base currency // Set default value for base currency
if (!(user.settings.settings as UserSettings)?.baseCurrency) { if (!(user.settings.settings as UserSettings)?.baseCurrency) {
(user.settings.settings as UserSettings).baseCurrency = DEFAULT_CURRENCY; (user.settings.settings as UserSettings).baseCurrency = DEFAULT_CURRENCY;
@ -265,11 +270,21 @@ export class UserService {
PerformanceCalculationType.ROAI; PerformanceCalculationType.ROAI;
} }
// Set default value for projected total amount
if (!(user.settings.settings as UserSettings)?.projectedTotalAmount) {
(user.settings.settings as UserSettings).projectedTotalAmount = 0;
}
// Set default value for safe withdrawal rate // Set default value for safe withdrawal rate
if (!(user.settings.settings as UserSettings)?.safeWithdrawalRate) { if (!(user.settings.settings as UserSettings)?.safeWithdrawalRate) {
(user.settings.settings as UserSettings).safeWithdrawalRate = 0.04; (user.settings.settings as UserSettings).safeWithdrawalRate = 0.04;
} }
// Set default value for savings rate
if (!(user.settings.settings as UserSettings)?.savingsRate) {
(user.settings.settings as UserSettings).savingsRate = 0;
}
// Set default value for view mode // Set default value for view mode
if (!(user.settings.settings as UserSettings).viewMode) { if (!(user.settings.settings as UserSettings).viewMode) {
(user.settings.settings as UserSettings).viewMode = 'DEFAULT'; (user.settings.settings as UserSettings).viewMode = 'DEFAULT';

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

@ -1,7 +1,11 @@
import { DataService } from '@ghostfolio/client/services/data.service'; import { DataService } from '@ghostfolio/client/services/data.service';
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service'; import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { FireWealth, User } from '@ghostfolio/common/interfaces'; import {
FireCalculationCompleteEvent,
FireWealth,
User
} from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { GfFireCalculatorComponent } from '@ghostfolio/ui/fire-calculator'; import { GfFireCalculatorComponent } from '@ghostfolio/ui/fire-calculator';
import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator'; import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator';
@ -38,11 +42,15 @@ export class GfFirePageComponent implements OnDestroy, OnInit {
public hasImpersonationId: boolean; public hasImpersonationId: boolean;
public hasPermissionToUpdateUserSettings: boolean; public hasPermissionToUpdateUserSettings: boolean;
public isLoading = false; public isLoading = false;
public projectedTotalAmount: number;
public retirementDate: Date;
public safeWithdrawalRateControl = new FormControl<number>(undefined); public safeWithdrawalRateControl = new FormControl<number>(undefined);
public safeWithdrawalRateOptions = [0.025, 0.03, 0.035, 0.04, 0.045]; public safeWithdrawalRateOptions = [0.025, 0.03, 0.035, 0.04, 0.045];
public user: User; public user: User;
public withdrawalRatePerMonth: Big; public withdrawalRatePerMonth: Big;
public withdrawalRatePerMonthProjected: Big;
public withdrawalRatePerYear: Big; public withdrawalRatePerYear: Big;
public withdrawalRatePerYearProjected: Big;
private unsubscribeSubject = new Subject<void>(); private unsubscribeSubject = new Subject<void>();
@ -79,8 +87,6 @@ export class GfFirePageComponent implements OnDestroy, OnInit {
this.calculateWithdrawalRates(); this.calculateWithdrawalRates();
this.isLoading = false;
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
}); });
@ -139,6 +145,18 @@ export class GfFirePageComponent implements OnDestroy, OnInit {
}); });
} }
public onCalculationComplete({
projectedTotalAmount,
retirementDate
}: FireCalculationCompleteEvent) {
this.projectedTotalAmount = projectedTotalAmount;
this.retirementDate = retirementDate;
this.calculateWithdrawalRatesProjected();
this.isLoading = false;
}
public onRetirementDateChange(retirementDate: Date) { public onRetirementDateChange(retirementDate: Date) {
this.dataService this.dataService
.putUserSetting({ .putUserSetting({
@ -170,6 +188,7 @@ export class GfFirePageComponent implements OnDestroy, OnInit {
this.user = user; this.user = user;
this.calculateWithdrawalRates(); this.calculateWithdrawalRates();
this.calculateWithdrawalRatesProjected();
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
}); });
@ -225,4 +244,19 @@ export class GfFirePageComponent implements OnDestroy, OnInit {
this.withdrawalRatePerMonth = this.withdrawalRatePerYear.div(12); this.withdrawalRatePerMonth = this.withdrawalRatePerYear.div(12);
} }
} }
private calculateWithdrawalRatesProjected() {
if (
this.fireWealth &&
this.projectedTotalAmount &&
this.user?.settings?.safeWithdrawalRate
) {
this.withdrawalRatePerYearProjected = new Big(
this.projectedTotalAmount
).mul(this.user.settings.safeWithdrawalRate);
this.withdrawalRatePerMonthProjected =
this.withdrawalRatePerYearProjected.div(12);
}
}
} }

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

@ -28,6 +28,7 @@
[retirementDate]="user?.settings?.retirementDate" [retirementDate]="user?.settings?.retirementDate"
[savingsRate]="user?.settings?.savingsRate" [savingsRate]="user?.settings?.savingsRate"
(annualInterestRateChanged)="onAnnualInterestRateChange($event)" (annualInterestRateChanged)="onAnnualInterestRateChange($event)"
(calculationCompleted)="onCalculationComplete($event)"
(projectedTotalAmountChanged)="onProjectedTotalAmountChange($event)" (projectedTotalAmountChanged)="onProjectedTotalAmountChange($event)"
(retirementDateChanged)="onRetirementDateChange($event)" (retirementDateChanged)="onRetirementDateChange($event)"
(savingsRateChanged)="onSavingsRateChange($event)" (savingsRateChanged)="onSavingsRateChange($event)"
@ -62,6 +63,7 @@
</div> </div>
} @else { } @else {
<div [ngClass]="{ 'text-muted': user?.subscription?.type === 'Basic' }"> <div [ngClass]="{ 'text-muted': user?.subscription?.type === 'Basic' }">
<div class="mb-2">
<ng-container i18n <ng-container i18n
>If you retire today, you would be able to withdraw</ng-container >If you retire today, you would be able to withdraw</ng-container
> >
@ -132,6 +134,60 @@
>. >.
} }
</div> </div>
@if (user?.settings?.isExperimentalFeatures) {
<div>
<ng-container i18n>By</ng-container>
<ng-container>&nbsp;</ng-container>
<span class="font-weight-bold">{{
user?.settings?.retirementDate ?? retirementDate
| date: 'MMMM yyyy'
}}</span>
<ng-container i18n>,</ng-container>
<ng-container>&nbsp;</ng-container>
<ng-container i18n>this is projected to increase to</ng-container>
<ng-container>&nbsp;</ng-container>
<span class="font-weight-bold"
><gf-value
class="d-inline-block"
[isCurrency]="true"
[locale]="user?.settings?.locale"
[unit]="user?.settings?.baseCurrency"
[value]="withdrawalRatePerYearProjected?.toNumber()"
/>
<ng-container>&nbsp;</ng-container>
<ng-container i18n>per year</ng-container></span
>
<ng-container>&nbsp;</ng-container>
<ng-container i18n>or</ng-container>
<ng-container>&nbsp;</ng-container>
<span class="font-weight-bold"
><gf-value
class="d-inline-block"
[isCurrency]="true"
[locale]="user?.settings?.locale"
[unit]="user?.settings?.baseCurrency"
[value]="withdrawalRatePerMonthProjected?.toNumber()"
/>
<ng-container>&nbsp;</ng-container>
<ng-container i18n>per month</ng-container></span
><ng-container i18n>,</ng-container>
<ng-container>&nbsp;</ng-container>
<ng-container i18n>assuming a</ng-container>
<ng-container>&nbsp;</ng-container>
<span class="font-weight-bold"
><gf-value
class="d-inline-block"
[isPercent]="true"
[locale]="user?.settings?.locale"
[precision]="2"
[value]="user?.settings?.annualInterestRate / 100"
/></span>
<ng-container>&nbsp;</ng-container>
<ng-container i18n>annual interest rate</ng-container>.
</div>
}
</div>
} }
</div> </div>
</div> </div>

4
libs/common/src/lib/interfaces/fire-calculation-complete-event.interface.ts

@ -0,0 +1,4 @@
export interface FireCalculationCompleteEvent {
projectedTotalAmount: number;
retirementDate: Date;
}

2
libs/common/src/lib/interfaces/index.ts

@ -18,6 +18,7 @@ import type { DataProviderInfo } from './data-provider-info.interface';
import type { EnhancedSymbolProfile } from './enhanced-symbol-profile.interface'; import type { EnhancedSymbolProfile } from './enhanced-symbol-profile.interface';
import type { FilterGroup } from './filter-group.interface'; import type { FilterGroup } from './filter-group.interface';
import type { Filter } from './filter.interface'; import type { Filter } from './filter.interface';
import type { FireCalculationCompleteEvent } from './fire-calculation-complete-event.interface';
import type { FireWealth } from './fire-wealth.interface'; import type { FireWealth } from './fire-wealth.interface';
import type { HistoricalDataItem } from './historical-data-item.interface'; import type { HistoricalDataItem } from './historical-data-item.interface';
import type { HoldingWithParents } from './holding-with-parents.interface'; import type { HoldingWithParents } from './holding-with-parents.interface';
@ -140,6 +141,7 @@ export {
ExportResponse, ExportResponse,
Filter, Filter,
FilterGroup, FilterGroup,
FireCalculationCompleteEvent,
FireWealth, FireWealth,
HistoricalDataItem, HistoricalDataItem,
HistoricalResponse, HistoricalResponse,

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

@ -4,6 +4,7 @@ import {
} from '@ghostfolio/common/chart-helper'; } from '@ghostfolio/common/chart-helper';
import { primaryColorRgb } from '@ghostfolio/common/config'; import { primaryColorRgb } from '@ghostfolio/common/config';
import { getLocale } from '@ghostfolio/common/helper'; import { getLocale } from '@ghostfolio/common/helper';
import { FireCalculationCompleteEvent } from '@ghostfolio/common/interfaces';
import { ColorScheme } from '@ghostfolio/common/types'; import { ColorScheme } from '@ghostfolio/common/types';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
@ -88,6 +89,8 @@ export class GfFireCalculatorComponent implements OnChanges, OnDestroy {
@Input() savingsRate: number; @Input() savingsRate: number;
@Output() annualInterestRateChanged = new EventEmitter<number>(); @Output() annualInterestRateChanged = new EventEmitter<number>();
@Output() calculationCompleted =
new EventEmitter<FireCalculationCompleteEvent>();
@Output() projectedTotalAmountChanged = new EventEmitter<number>(); @Output() projectedTotalAmountChanged = new EventEmitter<number>();
@Output() retirementDateChanged = new EventEmitter<Date>(); @Output() retirementDateChanged = new EventEmitter<Date>();
@Output() savingsRateChanged = new EventEmitter<number>(); @Output() savingsRateChanged = new EventEmitter<number>();
@ -131,6 +134,18 @@ export class GfFireCalculatorComponent implements OnChanges, OnDestroy {
this.initialize(); this.initialize();
}); });
this.calculatorForm.valueChanges
.pipe(debounceTime(500), takeUntil(this.unsubscribeSubject))
.subscribe(() => {
const { projectedTotalAmount, retirementDate } =
this.calculatorForm.getRawValue();
this.calculationCompleted.emit({
projectedTotalAmount,
retirementDate
});
});
this.calculatorForm this.calculatorForm
.get('annualInterestRate') .get('annualInterestRate')
.valueChanges.pipe(debounceTime(500), takeUntil(this.unsubscribeSubject)) .valueChanges.pipe(debounceTime(500), takeUntil(this.unsubscribeSubject))
@ -161,10 +176,10 @@ export class GfFireCalculatorComponent implements OnChanges, OnDestroy {
if (isNumber(this.fireWealth) && this.fireWealth >= 0) { if (isNumber(this.fireWealth) && this.fireWealth >= 0) {
this.calculatorForm.setValue( this.calculatorForm.setValue(
{ {
annualInterestRate: this.annualInterestRate ?? 5, annualInterestRate: this.annualInterestRate,
paymentPerPeriod: this.savingsRate ?? 0, paymentPerPeriod: this.savingsRate,
principalInvestmentAmount: this.fireWealth, principalInvestmentAmount: this.fireWealth,
projectedTotalAmount: this.projectedTotalAmount ?? 0, projectedTotalAmount: this.projectedTotalAmount,
retirementDate: this.retirementDate ?? this.DEFAULT_RETIREMENT_DATE retirementDate: this.retirementDate ?? this.DEFAULT_RETIREMENT_DATE
}, },
{ {

Loading…
Cancel
Save