|
@ -13,6 +13,7 @@ import { |
|
|
ViewChild |
|
|
ViewChild |
|
|
} from '@angular/core'; |
|
|
} from '@angular/core'; |
|
|
import { FormBuilder, FormControl } from '@angular/forms'; |
|
|
import { FormBuilder, FormControl } from '@angular/forms'; |
|
|
|
|
|
import { MatDatepicker } from '@angular/material/datepicker'; |
|
|
import { |
|
|
import { |
|
|
getTooltipOptions, |
|
|
getTooltipOptions, |
|
|
transformTickToAbbreviation |
|
|
transformTickToAbbreviation |
|
@ -28,18 +29,62 @@ import { |
|
|
Tooltip |
|
|
Tooltip |
|
|
} from 'chart.js'; |
|
|
} from 'chart.js'; |
|
|
import * as Color from 'color'; |
|
|
import * as Color from 'color'; |
|
|
import { add, format, getMonth } from 'date-fns'; |
|
|
import { add, getMonth, sub } from 'date-fns'; |
|
|
import { isNumber } from 'lodash'; |
|
|
import { isNumber } from 'lodash'; |
|
|
import { Subject, takeUntil } from 'rxjs'; |
|
|
import { Subject, takeUntil } from 'rxjs'; |
|
|
|
|
|
|
|
|
import { FireCalculatorService } from './fire-calculator.service'; |
|
|
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({ |
|
|
@Component({ |
|
|
selector: 'gf-fire-calculator', |
|
|
selector: 'gf-fire-calculator', |
|
|
changeDetection: ChangeDetectionStrategy.OnPush, |
|
|
changeDetection: ChangeDetectionStrategy.OnPush, |
|
|
templateUrl: './fire-calculator.component.html', |
|
|
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 |
|
|
export class FireCalculatorComponent |
|
|
implements AfterViewInit, OnChanges, OnDestroy |
|
|
implements AfterViewInit, OnChanges, OnDestroy |
|
@ -50,11 +95,14 @@ export class FireCalculatorComponent |
|
|
@Input() fireWealth: number; |
|
|
@Input() fireWealth: number; |
|
|
@Input() hasPermissionToUpdateUserSettings: boolean; |
|
|
@Input() hasPermissionToUpdateUserSettings: boolean; |
|
|
@Input() locale: string; |
|
|
@Input() locale: string; |
|
|
|
|
|
@Input() retirementDate: string; |
|
|
@Input() savingsRate = 0; |
|
|
@Input() savingsRate = 0; |
|
|
@Input() targetNetWorth = 0; |
|
|
@Input() projectedTotalAmount = 0; |
|
|
|
|
|
@Input() projectedTotalAmountSet: boolean; |
|
|
|
|
|
|
|
|
|
|
|
@Output() retirementDateChanged = new EventEmitter<Date>(); |
|
|
@Output() savingsRateChanged = new EventEmitter<number>(); |
|
|
@Output() savingsRateChanged = new EventEmitter<number>(); |
|
|
@Output() targetNetWorthChanged = new EventEmitter<number>(); |
|
|
@Output() projectedTotalAmountChanged = new EventEmitter<number>(); |
|
|
|
|
|
|
|
|
@ViewChild('chartCanvas') chartCanvas; |
|
|
@ViewChild('chartCanvas') chartCanvas; |
|
|
|
|
|
|
|
@ -62,14 +110,12 @@ export class FireCalculatorComponent |
|
|
annualInterestRate: new FormControl<number>(undefined), |
|
|
annualInterestRate: new FormControl<number>(undefined), |
|
|
paymentPerPeriod: new FormControl<number>(undefined), |
|
|
paymentPerPeriod: new FormControl<number>(undefined), |
|
|
principalInvestmentAmount: new FormControl<number>(undefined), |
|
|
principalInvestmentAmount: new FormControl<number>(undefined), |
|
|
retirementNetWorth: new FormControl<number>(undefined), |
|
|
projectedTotalAmount: new FormControl<number>(undefined), |
|
|
time: new FormControl<number>(undefined) |
|
|
retirementDate: new FormControl(moment()) |
|
|
}); |
|
|
}); |
|
|
public chart: Chart<'bar'>; |
|
|
public chart: Chart<'bar'>; |
|
|
public isLoading = true; |
|
|
public isLoading = true; |
|
|
public projectedTotalAmount: number; |
|
|
public periodsToRetire = 0; |
|
|
public retirementDate: string; |
|
|
|
|
|
|
|
|
|
|
|
private readonly CONTRIBUTION_PERIOD = 12; |
|
|
private readonly CONTRIBUTION_PERIOD = 12; |
|
|
private unsubscribeSubject = new Subject<void>(); |
|
|
private unsubscribeSubject = new Subject<void>(); |
|
|
|
|
|
|
|
@ -91,8 +137,8 @@ export class FireCalculatorComponent |
|
|
annualInterestRate: 5, |
|
|
annualInterestRate: 5, |
|
|
paymentPerPeriod: this.savingsRate, |
|
|
paymentPerPeriod: this.savingsRate, |
|
|
principalInvestmentAmount: 0, |
|
|
principalInvestmentAmount: 0, |
|
|
retirementNetWorth: this.targetNetWorth, |
|
|
projectedTotalAmount: this.projectedTotalAmount, |
|
|
time: 10 |
|
|
retirementDate: moment(this.retirementDate) |
|
|
}, |
|
|
}, |
|
|
{ |
|
|
{ |
|
|
emitEvent: false |
|
|
emitEvent: false |
|
@ -112,10 +158,16 @@ export class FireCalculatorComponent |
|
|
this.savingsRateChanged.emit(savingsRate); |
|
|
this.savingsRateChanged.emit(savingsRate); |
|
|
}); |
|
|
}); |
|
|
this.calculatorForm |
|
|
this.calculatorForm |
|
|
.get('retirementNetWorth') |
|
|
.get('retirementDate') |
|
|
.valueChanges.pipe(takeUntil(this.unsubscribeSubject)) |
|
|
.valueChanges.pipe(takeUntil(this.unsubscribeSubject)) |
|
|
.subscribe((targetNetWorth) => { |
|
|
.subscribe((retirementDate) => { |
|
|
this.targetNetWorthChanged.emit(targetNetWorth); |
|
|
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
|
|
|
// Wait for the chartCanvas
|
|
|
this.calculatorForm.patchValue( |
|
|
this.calculatorForm.patchValue( |
|
|
{ |
|
|
{ |
|
|
principalInvestmentAmount: this.fireWealth, |
|
|
principalInvestmentAmount: this.getP(), |
|
|
paymentPerPeriod: this.savingsRate ?? 0, |
|
|
paymentPerPeriod: this.getPMT(), |
|
|
retirementNetWorth: this.targetNetWorth ?? 0 |
|
|
retirementDate: moment( |
|
|
|
|
|
this.getRetirementDate() || DEFAULT_RETIREMENT_DATE |
|
|
|
|
|
), |
|
|
|
|
|
projectedTotalAmount: |
|
|
|
|
|
Number(this.getProjectedTotalAmount().toFixed(2)) ?? 0 |
|
|
}, |
|
|
}, |
|
|
{ |
|
|
{ |
|
|
emitEvent: false |
|
|
emitEvent: false |
|
@ -141,18 +197,22 @@ export class FireCalculatorComponent |
|
|
|
|
|
|
|
|
if (this.hasPermissionToUpdateUserSettings === true) { |
|
|
if (this.hasPermissionToUpdateUserSettings === true) { |
|
|
this.calculatorForm.get('paymentPerPeriod').enable({ emitEvent: false }); |
|
|
this.calculatorForm.get('paymentPerPeriod').enable({ emitEvent: false }); |
|
|
|
|
|
this.calculatorForm.get('retirementDate').enable({ emitEvent: false }); |
|
|
|
|
|
|
|
|
this.calculatorForm |
|
|
this.calculatorForm |
|
|
.get('retirementNetWorth') |
|
|
.get('projectedTotalAmount') |
|
|
.enable({ emitEvent: false }); |
|
|
.enable({ emitEvent: false }); |
|
|
} else { |
|
|
} else { |
|
|
this.calculatorForm.get('paymentPerPeriod').disable({ emitEvent: false }); |
|
|
this.calculatorForm.get('paymentPerPeriod').disable({ emitEvent: false }); |
|
|
|
|
|
this.calculatorForm.get('retirementDate').disable({ emitEvent: false }); |
|
|
this.calculatorForm |
|
|
this.calculatorForm |
|
|
.get('retirementNetWorth') |
|
|
.get('projectedTotalAmount') |
|
|
.disable({ emitEvent: false }); |
|
|
.disable({ emitEvent: false }); |
|
|
} |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
public ngOnChanges() { |
|
|
public ngOnChanges() { |
|
|
|
|
|
this.periodsToRetire = this.getPeriodsToRetire(); |
|
|
if (isNumber(this.fireWealth) && this.fireWealth >= 0) { |
|
|
if (isNumber(this.fireWealth) && this.fireWealth >= 0) { |
|
|
setTimeout(() => { |
|
|
setTimeout(() => { |
|
|
// Wait for the chartCanvas
|
|
|
// Wait for the chartCanvas
|
|
@ -160,7 +220,11 @@ export class FireCalculatorComponent |
|
|
{ |
|
|
{ |
|
|
principalInvestmentAmount: this.fireWealth, |
|
|
principalInvestmentAmount: this.fireWealth, |
|
|
paymentPerPeriod: this.savingsRate ?? 0, |
|
|
paymentPerPeriod: this.savingsRate ?? 0, |
|
|
retirementNetWorth: this.targetNetWorth ?? 0 |
|
|
retirementDate: moment( |
|
|
|
|
|
this.getRetirementDate() || DEFAULT_RETIREMENT_DATE |
|
|
|
|
|
), |
|
|
|
|
|
projectedTotalAmount: |
|
|
|
|
|
Number(this.getProjectedTotalAmount().toFixed(2)) ?? 0 |
|
|
}, |
|
|
}, |
|
|
{ |
|
|
{ |
|
|
emitEvent: false |
|
|
emitEvent: false |
|
@ -174,13 +238,15 @@ export class FireCalculatorComponent |
|
|
|
|
|
|
|
|
if (this.hasPermissionToUpdateUserSettings === true) { |
|
|
if (this.hasPermissionToUpdateUserSettings === true) { |
|
|
this.calculatorForm.get('paymentPerPeriod').enable({ emitEvent: false }); |
|
|
this.calculatorForm.get('paymentPerPeriod').enable({ emitEvent: false }); |
|
|
|
|
|
this.calculatorForm.get('retirementDate').enable({ emitEvent: false }); |
|
|
this.calculatorForm |
|
|
this.calculatorForm |
|
|
.get('retirementNetWorth') |
|
|
.get('projectedTotalAmount') |
|
|
.enable({ emitEvent: false }); |
|
|
.enable({ emitEvent: false }); |
|
|
} else { |
|
|
} else { |
|
|
this.calculatorForm.get('paymentPerPeriod').disable({ emitEvent: false }); |
|
|
this.calculatorForm.get('paymentPerPeriod').disable({ emitEvent: false }); |
|
|
|
|
|
this.calculatorForm.get('retirementDate').disable({ emitEvent: false }); |
|
|
this.calculatorForm |
|
|
this.calculatorForm |
|
|
.get('retirementNetWorth') |
|
|
.get('projectedTotalAmount') |
|
|
.disable({ emitEvent: false }); |
|
|
.disable({ emitEvent: false }); |
|
|
} |
|
|
} |
|
|
} |
|
|
} |
|
@ -282,43 +348,89 @@ export class FireCalculatorComponent |
|
|
this.isLoading = false; |
|
|
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() { |
|
|
private getChartData() { |
|
|
const currentYear = new Date().getFullYear(); |
|
|
const currentYear = new Date().getFullYear(); |
|
|
const labels = []; |
|
|
const labels = []; |
|
|
let t: number; |
|
|
|
|
|
|
|
|
|
|
|
// Principal investment amount
|
|
|
// Principal investment amount
|
|
|
const P: number = |
|
|
const P: number = this.getP(); |
|
|
this.calculatorForm.get('principalInvestmentAmount').value || 0; |
|
|
|
|
|
|
|
|
|
|
|
// Payment per period
|
|
|
// Payment per period
|
|
|
const PMT = this.calculatorForm.get('paymentPerPeriod').value; |
|
|
const PMT = this.getPMT(); |
|
|
|
|
|
|
|
|
// Annual interest rate
|
|
|
// Annual interest rate
|
|
|
const r: number = this.calculatorForm.get('annualInterestRate').value / 100; |
|
|
const r: number = this.getR(); |
|
|
|
|
|
|
|
|
// Target net worth
|
|
|
|
|
|
const targetNetWorth = this.calculatorForm.get('retirementNetWorth').value; |
|
|
|
|
|
|
|
|
|
|
|
// Calculate retirement date
|
|
|
// Calculate retirement date
|
|
|
const periodsToRetire = this.fireCalculatorService.calculatePeriodsToRetire( |
|
|
// if we want to retire at month x, we need the projectedTotalAmount at month x-1
|
|
|
{ |
|
|
const lastPeriodDate = sub(this.getRetirementDate(), { months: 1 }); |
|
|
P, |
|
|
const yearsToRetire = lastPeriodDate.getFullYear() - currentYear; |
|
|
totalAmount: targetNetWorth, |
|
|
|
|
|
PMT, |
|
|
|
|
|
r |
|
|
|
|
|
} |
|
|
|
|
|
); |
|
|
|
|
|
const yearsToRetire = Math.floor(periodsToRetire / 12); |
|
|
|
|
|
const monthsToRetire = periodsToRetire % 12; |
|
|
|
|
|
|
|
|
|
|
|
// Time
|
|
|
// Time
|
|
|
if (targetNetWorth) { |
|
|
// +1 to take into account the current year
|
|
|
// +1 to take into account the current year
|
|
|
const t = yearsToRetire + 1; |
|
|
t = yearsToRetire + 1; |
|
|
|
|
|
} else { |
|
|
|
|
|
t = this.calculatorForm.get('time').value; |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
for (let year = currentYear; year < currentYear + t; year++) { |
|
|
for (let year = currentYear; year < currentYear + t; year++) { |
|
|
labels.push(year); |
|
|
labels.push(year); |
|
@ -366,21 +478,22 @@ export class FireCalculatorComponent |
|
|
datasetDeposit.data.push(this.fireWealth); |
|
|
datasetDeposit.data.push(this.fireWealth); |
|
|
datasetInterest.data.push(interest.toNumber()); |
|
|
datasetInterest.data.push(interest.toNumber()); |
|
|
datasetSavings.data.push(principal.minus(this.fireWealth).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 { |
|
|
return { |
|
|
labels, |
|
|
labels, |
|
|
datasets: [datasetDeposit, datasetSavings, datasetInterest] |
|
|
datasets: [datasetDeposit, datasetSavings, datasetInterest] |
|
|
}; |
|
|
}; |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
setMonthAndYear( |
|
|
|
|
|
normalizedMonthAndYear: Moment, |
|
|
|
|
|
datepicker: MatDatepicker<Moment> |
|
|
|
|
|
) { |
|
|
|
|
|
const ctrlValue = this.calculatorForm.get('retirementDate').value!; |
|
|
|
|
|
ctrlValue.month(normalizedMonthAndYear.month()); |
|
|
|
|
|
ctrlValue.year(normalizedMonthAndYear.year()); |
|
|
|
|
|
this.calculatorForm.get('retirementDate').setValue(ctrlValue); |
|
|
|
|
|
datepicker.close(); |
|
|
|
|
|
} |
|
|
} |
|
|
} |
|
|