import { getTooltipOptions, transformTickToAbbreviation } from '@ghostfolio/common/chart-helper'; import { primaryColorRgb } from '@ghostfolio/common/config'; import { getLocale } from '@ghostfolio/common/helper'; import { ColorScheme } from '@ghostfolio/common/types'; import { CommonModule } from '@angular/common'; import { ChangeDetectionStrategy, ChangeDetectorRef, Component, ElementRef, EventEmitter, Input, OnChanges, OnDestroy, Output, ViewChild } from '@angular/core'; import { FormBuilder, FormControl, FormsModule, ReactiveFormsModule } from '@angular/forms'; import { MatButtonModule } from '@angular/material/button'; import { MatDatepicker, MatDatepickerModule } from '@angular/material/datepicker'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; import { BarController, BarElement, CategoryScale, Chart, LinearScale, Tooltip } from 'chart.js'; import 'chartjs-adapter-date-fns'; import Color from 'color'; import { add, addYears, getMonth, setMonth, setYear, startOfMonth, sub } from 'date-fns'; import { isNumber } from 'lodash'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; import { Subject, debounceTime, takeUntil } from 'rxjs'; import { FireCalculatorService } from './fire-calculator.service'; @Component({ changeDetection: ChangeDetectionStrategy.OnPush, imports: [ CommonModule, FormsModule, MatButtonModule, MatDatepickerModule, MatFormFieldModule, MatInputModule, NgxSkeletonLoaderModule, ReactiveFormsModule ], providers: [FireCalculatorService], selector: 'gf-fire-calculator', styleUrls: ['./fire-calculator.component.scss'], templateUrl: './fire-calculator.component.html' }) export class GfFireCalculatorComponent implements OnChanges, OnDestroy { @Input() annualInterestRate: number; @Input() colorScheme: ColorScheme; @Input() currency: string; @Input() deviceType: string; @Input() fireWealth: number; @Input() hasPermissionToUpdateUserSettings: boolean; @Input() locale = getLocale(); @Input() projectedTotalAmount: number; @Input() retirementDate: Date; @Input() savingsRate: number; @Output() annualInterestRateChanged = new EventEmitter(); @Output() projectedTotalAmountChanged = new EventEmitter(); @Output() retirementDateChanged = new EventEmitter(); @Output() savingsRateChanged = new EventEmitter(); @ViewChild('chartCanvas') chartCanvas: ElementRef; public calculatorForm = this.formBuilder.group({ annualInterestRate: new FormControl(undefined), paymentPerPeriod: new FormControl(undefined), principalInvestmentAmount: new FormControl(undefined), projectedTotalAmount: new FormControl(undefined), retirementDate: new FormControl(undefined) }); public chart: Chart<'bar'>; public isLoading = true; public periodsToRetire = 0; private readonly CONTRIBUTION_PERIOD = 12; private readonly DEFAULT_RETIREMENT_DATE = startOfMonth( addYears(new Date(), 10) ); private unsubscribeSubject = new Subject(); public constructor( private changeDetectorRef: ChangeDetectorRef, private fireCalculatorService: FireCalculatorService, private formBuilder: FormBuilder ) { Chart.register( BarController, BarElement, CategoryScale, LinearScale, Tooltip ); this.calculatorForm.valueChanges .pipe(takeUntil(this.unsubscribeSubject)) .subscribe(() => { this.initialize(); }); this.calculatorForm .get('annualInterestRate') .valueChanges.pipe(debounceTime(500), takeUntil(this.unsubscribeSubject)) .subscribe((annualInterestRate) => { this.annualInterestRateChanged.emit(annualInterestRate); }); this.calculatorForm .get('paymentPerPeriod') .valueChanges.pipe(debounceTime(500), takeUntil(this.unsubscribeSubject)) .subscribe((savingsRate) => { this.savingsRateChanged.emit(savingsRate); }); this.calculatorForm .get('projectedTotalAmount') .valueChanges.pipe(debounceTime(500), takeUntil(this.unsubscribeSubject)) .subscribe((projectedTotalAmount) => { this.projectedTotalAmountChanged.emit(projectedTotalAmount); }); this.calculatorForm .get('retirementDate') .valueChanges.pipe(debounceTime(500), takeUntil(this.unsubscribeSubject)) .subscribe((retirementDate) => { this.retirementDateChanged.emit(retirementDate); }); } public ngOnChanges() { if (isNumber(this.fireWealth) && this.fireWealth >= 0) { this.calculatorForm.setValue( { annualInterestRate: this.annualInterestRate ?? 5, paymentPerPeriod: this.savingsRate ?? 0, principalInvestmentAmount: this.fireWealth, projectedTotalAmount: this.projectedTotalAmount ?? 0, retirementDate: this.retirementDate ?? this.DEFAULT_RETIREMENT_DATE }, { emitEvent: false } ); this.periodsToRetire = this.getPeriodsToRetire(); setTimeout(() => { // Wait for the chartCanvas this.calculatorForm.patchValue( { annualInterestRate: this.calculatorForm.get('annualInterestRate').value, paymentPerPeriod: this.getPMT(), principalInvestmentAmount: this.calculatorForm.get( 'principalInvestmentAmount' ).value, projectedTotalAmount: Number(this.getProjectedTotalAmount().toFixed(0)) ?? 0, retirementDate: this.getRetirementDate() ?? this.DEFAULT_RETIREMENT_DATE }, { emitEvent: false } ); this.calculatorForm.get('principalInvestmentAmount').disable(); this.changeDetectorRef.markForCheck(); }); } if (this.hasPermissionToUpdateUserSettings === true) { this.calculatorForm .get('annualInterestRate') .enable({ emitEvent: false }); this.calculatorForm.get('paymentPerPeriod').enable({ emitEvent: false }); this.calculatorForm .get('projectedTotalAmount') .enable({ emitEvent: false }); } else { this.calculatorForm .get('annualInterestRate') .disable({ emitEvent: false }); this.calculatorForm.get('paymentPerPeriod').disable({ emitEvent: false }); this.calculatorForm .get('projectedTotalAmount') .disable({ emitEvent: false }); } this.calculatorForm.get('retirementDate').disable({ emitEvent: false }); } public setMonthAndYear( normalizedMonthAndYear: Date, datepicker: MatDatepicker ) { const retirementDate = this.calculatorForm.get('retirementDate').value; const newRetirementDate = setMonth( setYear(retirementDate, normalizedMonthAndYear.getFullYear()), normalizedMonthAndYear.getMonth() ); this.calculatorForm.get('retirementDate').setValue(newRetirementDate); datepicker.close(); } public ngOnDestroy() { this.chart?.destroy(); this.unsubscribeSubject.next(); this.unsubscribeSubject.complete(); } private initialize() { this.isLoading = true; const chartData = this.getChartData(); if (this.chartCanvas) { if (this.chart) { this.chart.data.labels = chartData.labels; for (let index = 0; index < this.chart.data.datasets.length; index++) { this.chart.data.datasets[index].data = chartData.datasets[index].data; } this.chart.update(); } else { this.chart = new Chart(this.chartCanvas.nativeElement, { data: chartData, options: { plugins: { tooltip: { ...getTooltipOptions({ colorScheme: this.colorScheme }), mode: 'index', callbacks: { footer: (items) => { const totalAmount = items.reduce( (a, b) => a + b.parsed.y, 0 ); return `Total: ${new Intl.NumberFormat(this.locale, { currency: this.currency, // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore: Only supported from ES2020 or later currencyDisplay: 'code', style: 'currency' }).format(totalAmount)}`; }, label: (context) => { let label = context.dataset.label || ''; if (label) { label += ': '; } if (context.parsed.y !== null) { label += new Intl.NumberFormat(this.locale, { currency: this.currency, // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore: Only supported from ES2020 or later currencyDisplay: 'code', style: 'currency' }).format(context.parsed.y); } return label; } } } }, responsive: true, scales: { x: { grid: { display: false }, stacked: true }, y: { display: this.deviceType !== 'mobile', grid: { display: false }, position: 'right', stacked: true, ticks: { callback: (value: number) => { return transformTickToAbbreviation(value); } } } } }, type: 'bar' }); } } this.isLoading = false; } private getChartData() { const currentYear = new Date().getFullYear(); const labels = []; // Principal investment amount const P: number = this.getP(); // Payment per period const PMT = this.getPMT(); // Annual interest rate const r: number = this.getR(); // Calculate retirement date // 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 // +1 to take into account the current year const t = yearsToRetire + 1; for (let year = currentYear; year < currentYear + t; year++) { labels.push(year); } const datasetDeposit = { backgroundColor: `rgb(${primaryColorRgb.r}, ${primaryColorRgb.g}, ${primaryColorRgb.b})`, data: [], label: $localize`Deposit` }; const datasetInterest = { backgroundColor: Color( `rgb(${primaryColorRgb.r}, ${primaryColorRgb.g}, ${primaryColorRgb.b})` ) .lighten(0.5) .hex(), data: [], label: $localize`Interest` }; const datasetSavings = { backgroundColor: Color( `rgb(${primaryColorRgb.r}, ${primaryColorRgb.g}, ${primaryColorRgb.b})` ) .lighten(0.25) .hex(), data: [], label: $localize`Savings` }; const monthsPassedInCurrentYear = getMonth(new Date()); for (let period = 1; period <= t; period++) { const periodInMonths = period * this.CONTRIBUTION_PERIOD - monthsPassedInCurrentYear; const { interest, principal } = this.fireCalculatorService.calculateCompoundInterest({ P, periodInMonths, PMT, r }); datasetDeposit.data.push(this.fireWealth); datasetInterest.data.push(interest.toNumber()); datasetSavings.data.push(principal.minus(this.fireWealth).toNumber()); } return { labels, datasets: [datasetDeposit, datasetSavings, datasetInterest] }; } private getP() { return this.fireWealth || 0; } private getPeriodsToRetire(): number { if (this.calculatorForm.get('projectedTotalAmount').value) { let periods = this.fireCalculatorService.calculatePeriodsToRetire({ P: this.getP(), PMT: this.getPMT(), r: this.getR(), totalAmount: this.calculatorForm.get('projectedTotalAmount').value }); if (periods === Infinity) { periods = Number.MAX_SAFE_INTEGER; } return periods; } else { const today = new Date(); const retirementDate = this.retirementDate ?? this.DEFAULT_RETIREMENT_DATE; return ( 12 * (retirementDate.getFullYear() - today.getFullYear()) + retirementDate.getMonth() - today.getMonth() ); } } private getPMT() { return this.calculatorForm.get('paymentPerPeriod').value; } private getProjectedTotalAmount() { if (this.calculatorForm.get('projectedTotalAmount').value) { return this.calculatorForm.get('projectedTotalAmount').value; } const { totalAmount } = this.fireCalculatorService.calculateCompoundInterest({ P: this.getP(), periodInMonths: this.periodsToRetire, PMT: this.getPMT(), r: this.getR() }); return totalAmount.toNumber(); } private getR() { return this.calculatorForm.get('annualInterestRate').value / 100; } private getRetirementDate(): Date { if (this.periodsToRetire === Number.MAX_SAFE_INTEGER) { return undefined; } const monthsToRetire = this.periodsToRetire % 12; const yearsToRetire = Math.floor(this.periodsToRetire / 12); return startOfMonth( add(new Date(), { months: monthsToRetire, years: yearsToRetire }) ); } }