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

This commit is contained in:
2026-10-24 15:13:20 +00:00
parent c4689adda4
commit 61ae72e50b
8 changed files with 642 additions and 129 deletions

View File

@@ -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);
}