money scenarios: forecast horizon 1–100y (?horizon=), today’s-euro view, scaling gridlines, career paths with job hops and plateaus, crash cycles per portfolio, retirement at 68 with withdrawals, salary path table
All checks were successful
Deploy production / deploy (push) Successful in 2m49s
All checks were successful
Deploy production / deploy (push) Successful in 2m49s
This commit is contained in:
@@ -134,7 +134,9 @@ export function wealthProjection(
|
||||
forecasts: ProjectionMovement[],
|
||||
recurring: ProjectionRecurring[],
|
||||
settings: ProjectionSettings | null,
|
||||
today: string
|
||||
today: string,
|
||||
/** Overrides the settings' end date, e.g. with the chart's forecast horizon. */
|
||||
endDate?: string | null
|
||||
): ChartSeries[] {
|
||||
const excluded = excludedIds(accounts);
|
||||
const counted = (accountId: number | null) => accountId === null || !excluded.has(accountId);
|
||||
@@ -164,9 +166,11 @@ export function wealthProjection(
|
||||
const fallback = new Date(`${today}T12:00:00Z`);
|
||||
fallback.setUTCFullYear(fallback.getUTCFullYear() + 1);
|
||||
const horizon =
|
||||
settings?.endDate && settings.endDate > today
|
||||
? settings.endDate
|
||||
: latestMovement || fallback.toISOString().slice(0, 10);
|
||||
endDate && endDate > today
|
||||
? endDate
|
||||
: settings?.endDate && settings.endDate > today
|
||||
? settings.endDate
|
||||
: latestMovement || fallback.toISOString().slice(0, 10);
|
||||
const monthEnd = (year: number, month: number) =>
|
||||
new Date(Date.UTC(year, month + 1, 0)).toISOString().slice(0, 10);
|
||||
const start = new Date(`${today}T12:00:00Z`);
|
||||
@@ -174,7 +178,7 @@ export function wealthProjection(
|
||||
let month = start.getUTCMonth();
|
||||
const yearly = Number(settings?.annualReturnPercent ?? 5) / 100;
|
||||
const monthlyGrowth = Math.pow(1 + yearly, 1 / 12) - 1;
|
||||
for (let index = 0; index < 240; index++) {
|
||||
for (let index = 0; index < 1212; index++) {
|
||||
const first = `${year}-${String(month + 1).padStart(2, '0')}-01`;
|
||||
const end = monthEnd(year, month);
|
||||
const date = end < horizon ? end : horizon;
|
||||
@@ -366,12 +370,42 @@ export function parseHistoryRange(value: string | null | undefined): HistoryRang
|
||||
return value === '3y' || value === 'all' ? value : '1y';
|
||||
}
|
||||
|
||||
export type GridMode = 'months' | 'years';
|
||||
export type ForecastHorizon = '1y' | '3y' | '5y' | '10y' | '20y' | '30y' | '50y' | '100y';
|
||||
export const forecastHorizons: ForecastHorizon[] = [
|
||||
'1y',
|
||||
'3y',
|
||||
'5y',
|
||||
'10y',
|
||||
'20y',
|
||||
'30y',
|
||||
'50y',
|
||||
'100y'
|
||||
];
|
||||
|
||||
/** Reads a `?horizon=` value, falling back to five years for anything unknown. */
|
||||
export function parseHorizon(value: string | null | undefined): ForecastHorizon {
|
||||
return forecastHorizons.find((item) => item === value) ?? '5y';
|
||||
}
|
||||
|
||||
/** Last projected date for a horizon: the same day N years from today. */
|
||||
export function horizonEnd(horizon: ForecastHorizon, today: string): string {
|
||||
return `${Number(today.slice(0, 4)) + Number.parseInt(horizon)}${today.slice(4)}`;
|
||||
}
|
||||
|
||||
/** Gridlines that stay readable for the whole chart span: months, years, or decades. */
|
||||
export function gridFor(from: string | null | undefined, until: string): GridMode {
|
||||
if (!from) return 'years';
|
||||
const years = (Date.parse(until) - Date.parse(from)) / (365.25 * 86_400_000);
|
||||
return years <= 4.5 ? 'months' : years <= 40 ? 'years' : 'decades';
|
||||
}
|
||||
|
||||
export type GridMode = 'months' | 'years' | 'decades';
|
||||
export type GridLine = { date: string; year: boolean };
|
||||
|
||||
/**
|
||||
* Vertical gridlines between two dates (inclusive): the first of every month (`months`),
|
||||
* or only 1 January (`years`). Lines on 1 January are flagged so they can be drawn stronger.
|
||||
* only 1 January (`years`), or 1 January of every tenth year (`decades`). Lines on 1 January
|
||||
* are flagged so they can be drawn stronger.
|
||||
*/
|
||||
export function timeGrid(from: string, until: string, mode: GridMode): GridLine[] {
|
||||
const lines: GridLine[] = [];
|
||||
@@ -379,12 +413,12 @@ export function timeGrid(from: string, until: string, mode: GridMode): GridLine[
|
||||
let year = Number(from.slice(0, 4));
|
||||
let month = Number(from.slice(5, 7)) - 1;
|
||||
if (from.slice(8) !== '01') month++;
|
||||
for (let guard = 0; guard < 1200; guard++) {
|
||||
for (let guard = 0; guard < 2400; guard++) {
|
||||
if (month > 11) {
|
||||
month = 0;
|
||||
year++;
|
||||
}
|
||||
if (mode === 'years' && month !== 0) {
|
||||
if ((mode === 'years' && month !== 0) || (mode === 'decades' && (month !== 0 || year % 10))) {
|
||||
month = 12;
|
||||
continue;
|
||||
}
|
||||
@@ -399,6 +433,9 @@ export function timeGrid(from: string, until: string, mode: GridMode): GridLine[
|
||||
/** Year labels for the `years` grid, thinned to every 2nd/5th/10th year when crowded. */
|
||||
export function yearLabels(lines: GridLine[], maxLabels: number): string[] {
|
||||
const years = lines.filter((line) => line.year).map((line) => line.date);
|
||||
const step = [1, 2, 5, 10, 20].find((size) => Math.ceil(years.length / size) <= maxLabels) ?? 50;
|
||||
const step =
|
||||
[1, 2, 5, 10, 20, 50].find(
|
||||
(size) => years.filter((date) => Number(date.slice(0, 4)) % size === 0).length <= maxLabels
|
||||
) ?? 100;
|
||||
return years.filter((date) => Number(date.slice(0, 4)) % step === 0);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user