diff --git a/apps/api/src/app/user/update-user-setting.dto.ts b/apps/api/src/app/user/update-user-setting.dto.ts index f925e400c..a4c844e37 100644 --- a/apps/api/src/app/user/update-user-setting.dto.ts +++ b/apps/api/src/app/user/update-user-setting.dto.ts @@ -48,13 +48,21 @@ export class UpdateUserSettingDto { @IsOptional() locale?: string; + @IsString() + @IsOptional() + retirementDate?: string; + @IsNumber() @IsOptional() savingsRate?: number; @IsNumber() @IsOptional() - targetNetWorth?: number; + projectedTotalAmount?: number; + + @IsBoolean() + @IsOptional() + projectedTotalAmountSet?: boolean; @IsIn(['DEFAULT', 'ZEN']) @IsOptional() diff --git a/apps/client/src/app/pages/portfolio/fire/fire-page.component.ts b/apps/client/src/app/pages/portfolio/fire/fire-page.component.ts index 55f6c072f..aa6439d83 100644 --- a/apps/client/src/app/pages/portfolio/fire/fire-page.component.ts +++ b/apps/client/src/app/pages/portfolio/fire/fire-page.component.ts @@ -109,9 +109,35 @@ export class FirePageComponent implements OnDestroy, OnInit { }); } - public onTargetNetWorthChanged(targetNetWorth: number) { + public onRetirementDateChange(retirementDate: Date) { this.dataService - .putUserSetting({ targetNetWorth }) + .putUserSetting({ + projectedTotalAmount: null, + projectedTotalAmountSet: false, + retirementDate: retirementDate.toISOString() + }) + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe(() => { + this.userService.remove(); + + this.userService + .get() + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe((user) => { + this.user = user; + + this.changeDetectorRef.markForCheck(); + }); + }); + } + + public onProjectedTotalAmountChanged(projectedTotalAmount: number) { + this.dataService + .putUserSetting({ + projectedTotalAmount, + projectedTotalAmountSet: true, + retirementDate: null + }) .pipe(takeUntil(this.unsubscribeSubject)) .subscribe(() => { this.userService.remove(); diff --git a/apps/client/src/app/pages/portfolio/fire/fire-page.html b/apps/client/src/app/pages/portfolio/fire/fire-page.html index dee64b405..e9a6b02f9 100644 --- a/apps/client/src/app/pages/portfolio/fire/fire-page.html +++ b/apps/client/src/app/pages/portfolio/fire/fire-page.html @@ -17,10 +17,13 @@ [fireWealth]="fireWealth?.toNumber()" [hasPermissionToUpdateUserSettings]="hasPermissionToUpdateUserSettings" [locale]="user?.settings?.locale" + [projectedTotalAmount]="user?.settings?.projectedTotalAmount" + [projectedTotalAmountSet]="user?.settings?.projectedTotalAmountSet" + [retirementDate]="user?.settings?.retirementDate" [savingsRate]="user?.settings?.savingsRate" - [targetNetWorth]="user?.settings?.targetNetWorth" + (projectedTotalAmountChanged)="onProjectedTotalAmountChanged($event)" + (retirementDateChanged)="onRetirementDateChange($event)" (savingsRateChanged)="onSavingsRateChange($event)" - (targetNetWorthChanged)="onTargetNetWorthChanged($event)" > diff --git a/apps/client/src/locales/messages.de.xlf b/apps/client/src/locales/messages.de.xlf index 2029f0021..67cf2eed3 100644 --- a/apps/client/src/locales/messages.de.xlf +++ b/apps/client/src/locales/messages.de.xlf @@ -3515,10 +3515,10 @@ Retirement Date - Pensionierungsdatum + Pensionierungsdatum libs/ui/src/lib/fire-calculator/fire-calculator.component.html - 65 + 32 diff --git a/apps/client/src/locales/messages.nl.xlf b/apps/client/src/locales/messages.nl.xlf index 47f216f83..b3dbcff38 100644 --- a/apps/client/src/locales/messages.nl.xlf +++ b/apps/client/src/locales/messages.nl.xlf @@ -2434,7 +2434,7 @@ Verwacht totaalbedrag libs/ui/src/lib/fire-calculator/fire-calculator.component.html - 44 + 52 @@ -3515,10 +3515,10 @@ Retirement Date - Pensioen Datum + Pensioen Datum libs/ui/src/lib/fire-calculator/fire-calculator.component.html - 65 + 32 diff --git a/apps/client/src/locales/messages.xlf b/apps/client/src/locales/messages.xlf index e2537f330..22764d45a 100644 --- a/apps/client/src/locales/messages.xlf +++ b/apps/client/src/locales/messages.xlf @@ -2193,7 +2193,7 @@ Projected Total Amount libs/ui/src/lib/fire-calculator/fire-calculator.component.html - 44 + 52 @@ -3157,7 +3157,7 @@ Retirement Date libs/ui/src/lib/fire-calculator/fire-calculator.component.html - 65 + 32 diff --git a/libs/common/src/lib/interfaces/user-settings.interface.ts b/libs/common/src/lib/interfaces/user-settings.interface.ts index 5ece060a6..44d90271d 100644 --- a/libs/common/src/lib/interfaces/user-settings.interface.ts +++ b/libs/common/src/lib/interfaces/user-settings.interface.ts @@ -11,6 +11,8 @@ export interface UserSettings { language?: string; locale?: string; savingsRate?: number; - targetNetWorth?: number; + retirementDate?: string; + projectedTotalAmount?: number; + projectedTotalAmountSet?: boolean; viewMode?: ViewMode; } diff --git a/libs/ui/src/lib/fire-calculator/fire-calculator.component.html b/libs/ui/src/lib/fire-calculator/fire-calculator.component.html index 0e9d44291..f360f212e 100644 --- a/libs/ui/src/lib/fire-calculator/fire-calculator.component.html +++ b/libs/ui/src/lib/fire-calculator/fire-calculator.component.html @@ -29,41 +29,35 @@ - Investment Horizon - - years + Retirement Date + + + + - Target Net Worth + Projected Total Amount {{ currency }} - - Projected Total Amount - Retirement Date
diff --git a/libs/ui/src/lib/fire-calculator/fire-calculator.component.ts b/libs/ui/src/lib/fire-calculator/fire-calculator.component.ts index 9a4baabce..3919072b8 100644 --- a/libs/ui/src/lib/fire-calculator/fire-calculator.component.ts +++ b/libs/ui/src/lib/fire-calculator/fire-calculator.component.ts @@ -13,6 +13,7 @@ import { ViewChild } from '@angular/core'; import { FormBuilder, FormControl } from '@angular/forms'; +import { MatDatepicker } from '@angular/material/datepicker'; import { getTooltipOptions, transformTickToAbbreviation @@ -28,18 +29,62 @@ import { Tooltip } from 'chart.js'; import * as Color from 'color'; -import { add, format, getMonth } from 'date-fns'; +import { add, getMonth, sub } from 'date-fns'; import { isNumber } from 'lodash'; import { Subject, takeUntil } from 'rxjs'; import { FireCalculatorService } from './fire-calculator.service'; -import { getDateFormatString } from '@ghostfolio/common/helper'; +import { + DateAdapter, + MAT_DATE_FORMATS, + MAT_DATE_LOCALE +} from '@angular/material/core'; +import { + MomentDateAdapter, + MAT_MOMENT_DATE_ADAPTER_OPTIONS +} from '@angular/material-moment-adapter'; +// Depending on whether rollup is used, moment needs to be imported differently. +// Since Moment.js doesn't have a default export, we normally need to import using the `* as` +// syntax. However, rollup creates a synthetic default module and we thus need to import it using +// the `default as` syntax. +import * as _moment from 'moment'; +// tslint:disable-next-line:no-duplicate-imports +import { default as _rollupMoment, Moment } from 'moment'; + +const moment = _rollupMoment || _moment; + +// See the Moment.js docs for the meaning of these formats: +// https://momentjs.com/docs/#/displaying/format/ +export const MY_FORMATS = { + parse: { + dateInput: 'MM/YYYY' + }, + display: { + dateInput: 'MM/YYYY', + monthYearLabel: 'MMM YYYY', + dateA11yLabel: 'LL', + monthYearA11yLabel: 'MMMM YYYY' + } +}; + +export const DEFAULT_RETIREMENT_DATE = add(new Date(), { years: 10 }); @Component({ selector: 'gf-fire-calculator', changeDetection: ChangeDetectionStrategy.OnPush, templateUrl: './fire-calculator.component.html', - styleUrls: ['./fire-calculator.component.scss'] + styleUrls: ['./fire-calculator.component.scss'], + providers: [ + // `MomentDateAdapter` can be automatically provided by importing `MomentDateModule` in your + // application's root module. We provide it at the component level here, due to limitations of + // our example generation script. + { + provide: DateAdapter, + useClass: MomentDateAdapter, + deps: [MAT_DATE_LOCALE, MAT_MOMENT_DATE_ADAPTER_OPTIONS] + }, + { provide: MAT_DATE_FORMATS, useValue: MY_FORMATS } + ] }) export class FireCalculatorComponent implements AfterViewInit, OnChanges, OnDestroy @@ -50,11 +95,14 @@ export class FireCalculatorComponent @Input() fireWealth: number; @Input() hasPermissionToUpdateUserSettings: boolean; @Input() locale: string; + @Input() retirementDate: string; @Input() savingsRate = 0; - @Input() targetNetWorth = 0; + @Input() projectedTotalAmount = 0; + @Input() projectedTotalAmountSet: boolean; + @Output() retirementDateChanged = new EventEmitter(); @Output() savingsRateChanged = new EventEmitter(); - @Output() targetNetWorthChanged = new EventEmitter(); + @Output() projectedTotalAmountChanged = new EventEmitter(); @ViewChild('chartCanvas') chartCanvas; @@ -62,14 +110,12 @@ export class FireCalculatorComponent annualInterestRate: new FormControl(undefined), paymentPerPeriod: new FormControl(undefined), principalInvestmentAmount: new FormControl(undefined), - retirementNetWorth: new FormControl(undefined), - time: new FormControl(undefined) + projectedTotalAmount: new FormControl(undefined), + retirementDate: new FormControl(moment()) }); public chart: Chart<'bar'>; public isLoading = true; - public projectedTotalAmount: number; - public retirementDate: string; - + public periodsToRetire = 0; private readonly CONTRIBUTION_PERIOD = 12; private unsubscribeSubject = new Subject(); @@ -91,8 +137,8 @@ export class FireCalculatorComponent annualInterestRate: 5, paymentPerPeriod: this.savingsRate, principalInvestmentAmount: 0, - retirementNetWorth: this.targetNetWorth, - time: 10 + projectedTotalAmount: this.projectedTotalAmount, + retirementDate: moment(this.retirementDate) }, { emitEvent: false @@ -112,10 +158,16 @@ export class FireCalculatorComponent this.savingsRateChanged.emit(savingsRate); }); this.calculatorForm - .get('retirementNetWorth') + .get('retirementDate') .valueChanges.pipe(takeUntil(this.unsubscribeSubject)) - .subscribe((targetNetWorth) => { - this.targetNetWorthChanged.emit(targetNetWorth); + .subscribe((retirementDate) => { + this.retirementDateChanged.emit(retirementDate.toDate()); + }); + this.calculatorForm + .get('projectedTotalAmount') + .valueChanges.pipe(takeUntil(this.unsubscribeSubject)) + .subscribe((projectedTotalAmount) => { + this.projectedTotalAmountChanged.emit(projectedTotalAmount); }); } @@ -125,9 +177,13 @@ export class FireCalculatorComponent // Wait for the chartCanvas this.calculatorForm.patchValue( { - principalInvestmentAmount: this.fireWealth, - paymentPerPeriod: this.savingsRate ?? 0, - retirementNetWorth: this.targetNetWorth ?? 0 + principalInvestmentAmount: this.getP(), + paymentPerPeriod: this.getPMT(), + retirementDate: moment( + this.getRetirementDate() || DEFAULT_RETIREMENT_DATE + ), + projectedTotalAmount: + Number(this.getProjectedTotalAmount().toFixed(2)) ?? 0 }, { emitEvent: false @@ -141,18 +197,22 @@ export class FireCalculatorComponent if (this.hasPermissionToUpdateUserSettings === true) { this.calculatorForm.get('paymentPerPeriod').enable({ emitEvent: false }); + this.calculatorForm.get('retirementDate').enable({ emitEvent: false }); + this.calculatorForm - .get('retirementNetWorth') + .get('projectedTotalAmount') .enable({ emitEvent: false }); } else { this.calculatorForm.get('paymentPerPeriod').disable({ emitEvent: false }); + this.calculatorForm.get('retirementDate').disable({ emitEvent: false }); this.calculatorForm - .get('retirementNetWorth') + .get('projectedTotalAmount') .disable({ emitEvent: false }); } } public ngOnChanges() { + this.periodsToRetire = this.getPeriodsToRetire(); if (isNumber(this.fireWealth) && this.fireWealth >= 0) { setTimeout(() => { // Wait for the chartCanvas @@ -160,7 +220,11 @@ export class FireCalculatorComponent { principalInvestmentAmount: this.fireWealth, paymentPerPeriod: this.savingsRate ?? 0, - retirementNetWorth: this.targetNetWorth ?? 0 + retirementDate: moment( + this.getRetirementDate() || DEFAULT_RETIREMENT_DATE + ), + projectedTotalAmount: + Number(this.getProjectedTotalAmount().toFixed(2)) ?? 0 }, { emitEvent: false @@ -174,13 +238,15 @@ export class FireCalculatorComponent if (this.hasPermissionToUpdateUserSettings === true) { this.calculatorForm.get('paymentPerPeriod').enable({ emitEvent: false }); + this.calculatorForm.get('retirementDate').enable({ emitEvent: false }); this.calculatorForm - .get('retirementNetWorth') + .get('projectedTotalAmount') .enable({ emitEvent: false }); } else { this.calculatorForm.get('paymentPerPeriod').disable({ emitEvent: false }); + this.calculatorForm.get('retirementDate').disable({ emitEvent: false }); this.calculatorForm - .get('retirementNetWorth') + .get('projectedTotalAmount') .disable({ emitEvent: false }); } } @@ -282,43 +348,89 @@ export class FireCalculatorComponent this.isLoading = false; } + private getP() { + return this.fireWealth || 0; + } + + private getPMT() { + return this.savingsRate ?? 0; + } + + private getR() { + return this.calculatorForm.get('annualInterestRate').value / 100; + } + + private getProjectedTotalAmount() { + if (this.projectedTotalAmountSet) { + return this.projectedTotalAmount || 0; + } else { + const { totalAmount } = + this.fireCalculatorService.calculateCompoundInterest({ + P: this.getP(), + periodInMonths: this.periodsToRetire, + PMT: this.getPMT(), + r: this.getR() + }); + + return totalAmount.toNumber(); + } + } + + private getPeriodsToRetire(): number { + if (this.projectedTotalAmountSet) { + const periods = this.fireCalculatorService.calculatePeriodsToRetire({ + P: this.getP(), + totalAmount: this.projectedTotalAmount, + PMT: this.getPMT(), + r: this.getR() + }); + + return periods; + } else { + const today = new Date(); + const retirementDate = this.retirementDate + ? new Date(this.retirementDate) + : DEFAULT_RETIREMENT_DATE; + + return ( + 12 * (retirementDate.getFullYear() - today.getFullYear()) + + retirementDate.getMonth() - + today.getMonth() + ); + } + } + + private getRetirementDate(): Date { + const yearsToRetire = Math.floor(this.periodsToRetire / 12); + const monthsToRetire = this.periodsToRetire % 12; + + return add(new Date(), { + years: yearsToRetire, + months: monthsToRetire + }); + } + private getChartData() { const currentYear = new Date().getFullYear(); const labels = []; - let t: number; // Principal investment amount - const P: number = - this.calculatorForm.get('principalInvestmentAmount').value || 0; + const P: number = this.getP(); // Payment per period - const PMT = this.calculatorForm.get('paymentPerPeriod').value; + const PMT = this.getPMT(); // Annual interest rate - const r: number = this.calculatorForm.get('annualInterestRate').value / 100; - - // Target net worth - const targetNetWorth = this.calculatorForm.get('retirementNetWorth').value; + const r: number = this.getR(); // Calculate retirement date - const periodsToRetire = this.fireCalculatorService.calculatePeriodsToRetire( - { - P, - totalAmount: targetNetWorth, - PMT, - r - } - ); - const yearsToRetire = Math.floor(periodsToRetire / 12); - const monthsToRetire = periodsToRetire % 12; + // if we want to retire at month x, we need the projectedTotalAmount at month x-1 + const lastPeriodDate = sub(this.getRetirementDate(), { months: 1 }); + const yearsToRetire = lastPeriodDate.getFullYear() - currentYear; // Time - if (targetNetWorth) { - // +1 to take into account the current year - t = yearsToRetire + 1; - } else { - t = this.calculatorForm.get('time').value; - } + // +1 to take into account the current year + const t = yearsToRetire + 1; for (let year = currentYear; year < currentYear + t; year++) { labels.push(year); @@ -366,21 +478,22 @@ export class FireCalculatorComponent datasetDeposit.data.push(this.fireWealth); datasetInterest.data.push(interest.toNumber()); datasetSavings.data.push(principal.minus(this.fireWealth).toNumber()); - - if (period === t) { - this.projectedTotalAmount = totalAmount.toNumber(); - } } - const retirementDate = add(new Date(), { - years: yearsToRetire, - months: monthsToRetire - }); - this.retirementDate = format(retirementDate, 'MMMM yyyy'); - return { labels, datasets: [datasetDeposit, datasetSavings, datasetInterest] }; } + + setMonthAndYear( + normalizedMonthAndYear: Moment, + datepicker: MatDatepicker + ) { + const ctrlValue = this.calculatorForm.get('retirementDate').value!; + ctrlValue.month(normalizedMonthAndYear.month()); + ctrlValue.year(normalizedMonthAndYear.year()); + this.calculatorForm.get('retirementDate').setValue(ctrlValue); + datepicker.close(); + } } diff --git a/libs/ui/src/lib/fire-calculator/fire-calculator.module.ts b/libs/ui/src/lib/fire-calculator/fire-calculator.module.ts index 44fd48c2e..688684019 100644 --- a/libs/ui/src/lib/fire-calculator/fire-calculator.module.ts +++ b/libs/ui/src/lib/fire-calculator/fire-calculator.module.ts @@ -2,6 +2,7 @@ import { CommonModule } from '@angular/common'; import { NgModule } from '@angular/core'; import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { MatButtonModule } from '@angular/material/button'; +import { MatDatepickerModule } from '@angular/material/datepicker'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; @@ -18,6 +19,7 @@ import { FireCalculatorService } from './fire-calculator.service'; FormsModule, GfValueModule, MatButtonModule, + MatDatepickerModule, MatFormFieldModule, MatInputModule, NgxSkeletonLoaderModule, diff --git a/libs/ui/src/lib/fire-calculator/fire-calculator.service.ts b/libs/ui/src/lib/fire-calculator/fire-calculator.service.ts index ca57a5380..44ea1eb98 100644 --- a/libs/ui/src/lib/fire-calculator/fire-calculator.service.ts +++ b/libs/ui/src/lib/fire-calculator/fire-calculator.service.ts @@ -55,7 +55,7 @@ export class FireCalculatorService { if (r == 0) { // No compound interest return (totalAmount - P) / PMT; - } else if (totalAmount <= 0) { + } else if (totalAmount <= P) { return 0; } diff --git a/package.json b/package.json index d644664f7..3b2f138d7 100644 --- a/package.json +++ b/package.json @@ -59,6 +59,7 @@ "@angular/core": "15.1.5", "@angular/forms": "15.1.5", "@angular/material": "15.1.5", + "@angular/material-moment-adapter": "15.2.1", "@angular/platform-browser": "15.1.5", "@angular/platform-browser-dynamic": "15.1.5", "@angular/router": "15.1.5", diff --git a/tsconfig.base.json b/tsconfig.base.json index b67024e12..e250c5542 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -1,6 +1,7 @@ { "compileOnSave": false, "compilerOptions": { + "allowSyntheticDefaultImports": true, "rootDir": ".", "sourceMap": true, "declaration": false,