You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

477 lines
14 KiB

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<number>();
@Output() projectedTotalAmountChanged = new EventEmitter<number>();
@Output() retirementDateChanged = new EventEmitter<Date>();
@Output() savingsRateChanged = new EventEmitter<number>();
@ViewChild('chartCanvas') chartCanvas: ElementRef<HTMLCanvasElement>;
public calculatorForm = this.formBuilder.group({
annualInterestRate: new FormControl<number>(undefined),
paymentPerPeriod: new FormControl<number>(undefined),
principalInvestmentAmount: new FormControl<number>(undefined),
projectedTotalAmount: new FormControl<number>(undefined),
retirementDate: new FormControl<Date>(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<void>();
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<Date>
) {
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
})
);
}
}