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

@@ -12,7 +12,7 @@
title: string; title: string;
unit?: string; unit?: string;
marker?: { date: string; label: string }; marker?: { date: string; label: string };
/** Vertical gridlines: every month (1 January drawn stronger) or every year. */ /** Vertical gridlines: every month (1 January drawn stronger), every year, or every decade. */
grid?: GridMode; grid?: GridMode;
} = $props(); } = $props();
let clientWidth = $state(800); let clientWidth = $state(800);
@@ -33,7 +33,7 @@
grid && values.length ? timeGrid(isoDate(from), isoDate(until), grid) : [] grid && values.length ? timeGrid(isoDate(from), isoDate(until), grid) : []
); );
let axisTicks = $derived( let axisTicks = $derived(
grid === 'years' grid === 'years' || grid === 'decades'
? yearLabels(gridLines, Math.max(2, Math.floor((width - plot.left - plot.right) / 44))) ? yearLabels(gridLines, Math.max(2, Math.floor((width - plot.left - plot.right) / 44)))
: ticks() : ticks()
); );
@@ -123,7 +123,7 @@
return result; return result;
} }
function tickLabel(date: string) { function tickLabel(date: string) {
return grid === 'years' ? date.slice(0, 4) : dateLabel(date); return grid === 'years' || grid === 'decades' ? date.slice(0, 4) : dateLabel(date);
} }
function dateLabel(date: string, withDay = false) { function dateLabel(date: string, withDay = false) {
return new Date(time(date)).toLocaleDateString('en-NL', { return new Date(time(date)).toLocaleDateString('en-NL', {

View File

@@ -3,6 +3,8 @@
import { Input } from '#lib/components/ui/input/index.js'; import { Input } from '#lib/components/ui/input/index.js';
import { NativeSelect } from '#lib/components/ui/native-select/index.js'; import { NativeSelect } from '#lib/components/ui/native-select/index.js';
import { import {
marketReturns,
salaryBands,
scenarioColor, scenarioColor,
scenarioFacts, scenarioFacts,
scenarioIds, scenarioIds,
@@ -33,34 +35,48 @@
}[keyof ScenarioParams]; }[keyof ScenarioParams];
const groups: { title: string; fields: { key: NumberField; label: string; unit: string }[] }[] = [ const groups: { title: string; fields: { key: NumberField; label: string; unit: string }[] }[] = [
{ {
title: 'Markets', title: 'Portfolio and markets',
fields: [ fields: [
{ key: 'returnPercent', label: 'Yearly return', unit: '%' }, { key: 'returnPercent', label: 'Average return incl. crashes', unit: '%/yr' },
{ key: 'volatilityPercent', label: 'Volatility (context)', unit: '%' },
{ key: 'crashPercent', label: 'Crash (0 = none)', unit: '%' }, { key: 'crashPercent', label: 'Crash (0 = none)', unit: '%' },
{ key: 'crashYear', label: 'Crash year', unit: '' }, { key: 'crashEveryYears', label: 'Crash every', unit: 'yr' },
{ key: 'recoveryPercent', label: 'Return after a crash', unit: '%' }, { key: 'firstCrashYear', label: 'First crash', unit: '' },
{ key: 'correctionPercent', label: 'Mid-cycle correction', unit: '%' },
{ key: 'cashRatePercent', label: 'Interest on cash', unit: '%' }, { key: 'cashRatePercent', label: 'Interest on cash', unit: '%' },
{ key: 'inflationPercent', label: 'Inflation', unit: '%' } { key: 'inflationPercent', label: 'Inflation', unit: '%' }
] ]
}, },
{ {
title: 'Work', title: 'Career',
fields: [ fields: [
{ key: 'gapMonths', label: 'Gap after Sep 2027', unit: 'mo' }, { key: 'gapMonths', label: 'Gap after Sep 2027', unit: 'mo' },
{ key: 'gapNetEuros', label: 'Benefit during gap', unit: '€/mo' }, { key: 'gapNetEuros', label: 'Benefit during a gap', unit: '€/mo' },
{ key: 'nextJobPercent', label: 'Pay change Oct 2027', unit: '%' }, { key: 'nextJobPercent', label: 'Pay change Oct 2027', unit: '%' },
{ key: 'raisePercent', label: 'Yearly raise', unit: '%' }, { key: 'raisePercent', label: 'Yearly raise', unit: '%' },
{ key: 'raiseMonth', label: 'Raise month', unit: '' }, { key: 'raiseMonth', label: 'Raise month', unit: '' },
{ key: 'raiseFromYear', label: 'Raises from', unit: '' }, { key: 'raiseFromYear', label: 'Raises from', unit: '' },
{ key: 'firstHopYear', label: 'First job hop (Oct)', unit: '' },
{ key: 'hopEveryYears', label: 'Hop every (0 = never)', unit: 'yr' },
{ key: 'hopRaisePercent', label: 'Raise per hop', unit: '%' },
{ key: 'hopGapMonths', label: 'Gap before each hop', unit: 'mo' },
{ key: 'salaryCapEuros', label: 'Plateau base salary', unit: '€/yr' },
{ key: 'bonusPercentOfTarget', label: 'Bonus paid of estimate', unit: '%' }, { key: 'bonusPercentOfTarget', label: 'Bonus paid of estimate', unit: '%' },
{ key: 'sideStartEuros', label: 'Side income now (gross)', unit: '€/mo' }, { key: 'sideStartEuros', label: 'Side income now (gross)', unit: '€/mo' },
{ key: 'sideEndEuros', label: 'Side income at end (gross)', unit: '€/mo' } { key: 'sideEndEuros', label: 'Side income from year 5', unit: '€/mo' }
]
},
{
title: 'Retirement',
fields: [
{ key: 'retireAge', label: 'Retire at age', unit: '' },
{ key: 'pensionNetEuros', label: 'AOW + pension (net)', unit: '€/mo' }
] ]
}, },
{ {
title: 'Spending', title: 'Spending',
fields: [ fields: [
{ key: 'spendGrowthPercent', label: 'Spending growth', unit: '%/yr' }, { key: 'spendGrowthPercent', label: 'Spending growth, first 10 yr', unit: '%/yr' },
{ key: 'otherSpendEuros', label: 'Other personal spending', unit: '€/mo' }, { key: 'otherSpendEuros', label: 'Other personal spending', unit: '€/mo' },
{ key: 'healthInsuranceEuros', label: 'Health insurance (assumed)', unit: '€/mo' }, { key: 'healthInsuranceEuros', label: 'Health insurance (assumed)', unit: '€/mo' },
{ key: 'householdSharePercent', label: 'Your share of household', unit: '%' }, { key: 'householdSharePercent', label: 'Your share of household', unit: '%' },
@@ -85,6 +101,14 @@
: 'Not by the end'; : 'Not by the end';
let active = $derived(params[selected]); let active = $derived(params[selected]);
let result = $derived(results[selected]); let result = $derived(results[selected]);
let normalReturn = $derived(marketReturns(active).normal);
/** Salary path rows through the retirement year. */
let career = $derived(
result.years.filter(
(row, index, rows) => row.role !== 'Retired' || rows[index - 1]?.role !== 'Retired'
)
);
const percent = (value: number) => `${(value * 100).toFixed(1)}%`;
</script> </script>
<div class="scenario-cards" role="group" aria-label="Scenario end values"> <div class="scenario-cards" role="group" aria-label="Scenario end values">
@@ -121,7 +145,10 @@
/></label /></label
> >
{/each} {/each}
{#if group.title === 'Markets'} {#if group.title === 'Portfolio and markets'}
<label class="wide"
><span>Portfolio mix</span><Input bind:value={params[selected].portfolio} /></label
>
<label <label
><span>Box 3 (36%, from 2028)</span><NativeSelect bind:value={params[selected].box3} ><span>Box 3 (36%, from 2028)</span><NativeSelect bind:value={params[selected].box3}
><option value="accrual">Yearly gains</option><option value="realisation" ><option value="accrual">Yearly gains</option><option value="realisation"
@@ -133,13 +160,43 @@
</fieldset> </fieldset>
{/each} {/each}
</div> </div>
<p class="muted scenario-portfolio">
<strong>{active.label} portfolio:</strong>
{active.portfolio}. Average {active.returnPercent}%/yr nominal, volatility about {active.volatilityPercent}%.
{#if active.crashPercent !== 0}A {active.crashPercent}% crash every {active.crashEveryYears} years
from {active.firstCrashYear}{active.correctionPercent !== 0
? `, a ${active.correctionPercent}% correction halfway between`
: ''}, and {percent(normalReturn)} in the other years so it averages out.
{:else}No crashes modelled.{/if}
Retirement in {scenarioFacts.birthYear + active.retireAge} at {active.retireAge}: salary and
side income stop, {cents(active.pensionNetEuros * 100)}/mo net pension (today’s €) starts, and
the rest of the spending is sold from investments.
</p>
<div class="scenario-actions"> <div class="scenario-actions">
<p class="muted"> <p class="muted">
Edits recalculate the chart right away and are not saved. Fixed for every scenario: contract Edits recalculate the chart right away and are not saved. Fixed for every scenario: contract
gross {cents(scenarioFacts.grossCents)}/mo until 30 Sep 2027, net salary and all costs from gross {cents(scenarioFacts.grossCents)}/mo until 30 Sep 2027, net salary and all costs from
the recurring list, cash buffer {cents(scenarioFacts.bufferCents)} then everything is invested, the recurring list, cash buffer {cents(scenarioFacts.bufferCents)} then everything is invested,
Joint contribution {cents(scenarioFacts.preMoveHouseholdCents)}/mo until the rent starts, Joint contribution {cents(scenarioFacts.preMoveHouseholdCents)}/mo until the rent starts,
your partner’s income pays her share, DUO {cents(scenarioFacts.duoCents)}/mo from {scenarioFacts.duoFrom}. your partner’s income pays her share, DUO {cents(scenarioFacts.duoCents)}/mo from {scenarioFacts.duoFrom}
to {scenarioFacts.duoUntil}. Prices, benefits, pension, the buffer and the salary plateau
grow with inflation; spending grows at the spending rate for ten years, then with inflation.
Box 3 keeps taxing gains (losses carried forward). Salary bands (base per year, excl. 8%
holiday pay): medior €52–68k, senior €65–91k, lead €79–110k (<a
href="https://chaseitrecruitment.nl/tools/salaris/software-engineer/"
rel="external noreferrer">Chase IT 2026</a
>,
<a
href="https://searchcompany.nl/werving-en-selectie-blogartikel/it-salaris-nederland-2026/"
rel="external noreferrer">The Search Company 2026</a
>); staff in Amsterdam about €100–139k base (<a
href="https://www.glassdoor.com.hk/Salaries/amsterdam-netherlands-staff-engineer-salary-SRCH_IL.0,21_IM1112_KO22,36.htm"
rel="external noreferrer">Glassdoor</a
>), Adyen/GitLab staff €120–188k total pay and Amsterdam senior median €122k total pay (<a
href="https://www.levels.fyi/t/software-engineer/locations/amsterdam-nld"
rel="external noreferrer">levels.fyi</a
>). A Cargill US move (L-1) is upside that is not modelled: US pay, tax and costs differ too
much to fold into these euros.
</p> </p>
<Button variant="outline" disabled={!edited} onclick={onreset}>Reset assumptions</Button> <Button variant="outline" disabled={!edited} onclick={onreset}>Reset assumptions</Button>
</div> </div>
@@ -168,6 +225,32 @@
> >
</table> </table>
</div> </div>
<details class="salary-path">
<summary>{active.label} salary path</summary>
<div class="table-wrap">
<table>
<thead
><tr
><th>Year</th><th class="numeric">Age</th><th>Role (band)</th><th class="numeric"
>Gross/mo</th
><th class="numeric">Base/yr, today’s €</th><th>Change</th></tr
></thead
><tbody
>{#each career as row (row.year)}<tr
><td>{row.year}</td><td class="numeric">{row.age}</td><td>{row.role}</td><td
class="numeric">{row.grossMonthly ? cents(row.grossMonthly) : '–'}</td
><td class="numeric">{row.baseReal ? cents(row.baseReal * 100) : '–'}</td><td
>{row.events.join('; ')}</td
></tr
>{/each}</tbody
>
</table>
</div>
<p class="muted">
Roles follow the base salary in today’s euros:
{salaryBands.map((band) => `${band.role} from ${cents(band.fromEuros * 100)}`).join(', ')}.
</p>
</details>
<ul class="scenario-milestones" aria-label="Milestones"> <ul class="scenario-milestones" aria-label="Milestones">
{#each result.milestones as milestone (milestone.label)} {#each result.milestones as milestone (milestone.label)}
<li> <li>
@@ -236,6 +319,21 @@
font-weight: 600; font-weight: 600;
margin-bottom: 0.25rem; margin-bottom: 0.25rem;
} }
.scenario-portfolio {
font-size: 0.85rem;
line-height: 1.5;
}
.salary-path summary {
cursor: pointer;
font-weight: 600;
}
.table-wrap {
max-height: 28rem;
overflow: auto;
}
label.wide {
grid-template-columns: 1fr;
}
label { label {
display: grid; display: grid;
grid-template-columns: 1fr 8rem; grid-template-columns: 1fr 8rem;

View File

@@ -5,6 +5,9 @@ import {
balanceSeries, balanceSeries,
forecastSeries, forecastSeries,
parseHistoryRange, parseHistoryRange,
gridFor,
horizonEnd,
parseHorizon,
timeGrid, timeGrid,
yearLabels, yearLabels,
utilityEstimate, utilityEstimate,
@@ -349,4 +352,25 @@ describe('history range', () => {
]); ]);
expect(timeGrid('2026-01-02', '2026-01-01', 'months')).toEqual([]); expect(timeGrid('2026-01-02', '2026-01-01', 'months')).toEqual([]);
}); });
it('reads the forecast horizon and picks gridlines that scale with the span', () => {
expect(parseHorizon('10y')).toBe('10y');
expect(parseHorizon('2y')).toBe('5y');
expect(parseHorizon(null)).toBe('5y');
expect(horizonEnd('100y', '2026-10-24')).toBe('2126-10-24');
expect(gridFor('2025-10-24', '2027-10-24')).toBe('months');
expect(gridFor('2025-10-24', '2056-10-24')).toBe('years');
expect(gridFor('2025-10-24', '2076-10-24')).toBe('decades');
const decades = timeGrid('2025-10-24', '2126-10-24', 'decades');
expect(decades.map((line) => line.date.slice(0, 4))).toEqual(
[2030, 2040, 2050, 2060, 2070, 2080, 2090, 2100, 2110, 2120].map(String)
);
expect(yearLabels(decades, 5).map((date) => date.slice(0, 4))).toEqual([
'2040',
'2060',
'2080',
'2100',
'2120'
]);
});
}); });

View File

@@ -134,7 +134,9 @@ export function wealthProjection(
forecasts: ProjectionMovement[], forecasts: ProjectionMovement[],
recurring: ProjectionRecurring[], recurring: ProjectionRecurring[],
settings: ProjectionSettings | null, settings: ProjectionSettings | null,
today: string today: string,
/** Overrides the settings' end date, e.g. with the chart's forecast horizon. */
endDate?: string | null
): ChartSeries[] { ): ChartSeries[] {
const excluded = excludedIds(accounts); const excluded = excludedIds(accounts);
const counted = (accountId: number | null) => accountId === null || !excluded.has(accountId); const counted = (accountId: number | null) => accountId === null || !excluded.has(accountId);
@@ -164,7 +166,9 @@ export function wealthProjection(
const fallback = new Date(`${today}T12:00:00Z`); const fallback = new Date(`${today}T12:00:00Z`);
fallback.setUTCFullYear(fallback.getUTCFullYear() + 1); fallback.setUTCFullYear(fallback.getUTCFullYear() + 1);
const horizon = const horizon =
settings?.endDate && settings.endDate > today endDate && endDate > today
? endDate
: settings?.endDate && settings.endDate > today
? settings.endDate ? settings.endDate
: latestMovement || fallback.toISOString().slice(0, 10); : latestMovement || fallback.toISOString().slice(0, 10);
const monthEnd = (year: number, month: number) => const monthEnd = (year: number, month: number) =>
@@ -174,7 +178,7 @@ export function wealthProjection(
let month = start.getUTCMonth(); let month = start.getUTCMonth();
const yearly = Number(settings?.annualReturnPercent ?? 5) / 100; const yearly = Number(settings?.annualReturnPercent ?? 5) / 100;
const monthlyGrowth = Math.pow(1 + yearly, 1 / 12) - 1; 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 first = `${year}-${String(month + 1).padStart(2, '0')}-01`;
const end = monthEnd(year, month); const end = monthEnd(year, month);
const date = end < horizon ? end : horizon; 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'; 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 }; export type GridLine = { date: string; year: boolean };
/** /**
* Vertical gridlines between two dates (inclusive): the first of every month (`months`), * 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[] { export function timeGrid(from: string, until: string, mode: GridMode): GridLine[] {
const lines: 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 year = Number(from.slice(0, 4));
let month = Number(from.slice(5, 7)) - 1; let month = Number(from.slice(5, 7)) - 1;
if (from.slice(8) !== '01') month++; if (from.slice(8) !== '01') month++;
for (let guard = 0; guard < 1200; guard++) { for (let guard = 0; guard < 2400; guard++) {
if (month > 11) { if (month > 11) {
month = 0; month = 0;
year++; year++;
} }
if (mode === 'years' && month !== 0) { if ((mode === 'years' && month !== 0) || (mode === 'decades' && (month !== 0 || year % 10))) {
month = 12; month = 12;
continue; 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. */ /** Year labels for the `years` grid, thinned to every 2nd/5th/10th year when crowded. */
export function yearLabels(lines: GridLine[], maxLabels: number): string[] { export function yearLabels(lines: GridLine[], maxLabels: number): string[] {
const years = lines.filter((line) => line.year).map((line) => line.date); 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); return years.filter((date) => Number(date.slice(0, 4)) % step === 0);
} }

View File

@@ -1,5 +1,12 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { defaultScenarios, runScenario, scenarioInputs, type ScenarioParams } from './scenarios'; import {
defaultScenarios,
marketReturns,
roleFor,
runScenario,
scenarioInputs,
type ScenarioParams
} from './scenarios';
const accounts = [ const accounts = [
{ id: 1, name: 'Revolut', kind: 'checking' }, { id: 1, name: 'Revolut', kind: 'checking' },
@@ -55,8 +62,11 @@ describe('runScenario', () => {
const flat: ScenarioParams = { const flat: ScenarioParams = {
...defaultScenarios.realistic, ...defaultScenarios.realistic,
returnPercent: 0, returnPercent: 0,
recoveryPercent: 0, crashPercent: 0,
correctionPercent: 0,
cashRatePercent: 0, cashRatePercent: 0,
inflationPercent: 0,
hopEveryYears: 0,
sideStartEuros: 0, sideStartEuros: 0,
sideEndEuros: 0, sideEndEuros: 0,
spendGrowthPercent: 0, spendGrowthPercent: 0,
@@ -97,17 +107,60 @@ describe('runScenario', () => {
expect(pessimistic).toBeLessThan(realistic); expect(pessimistic).toBeLessThan(realistic);
expect(realistic).toBeLessThan(optimistic); expect(realistic).toBeLessThan(optimistic);
}); });
it('applies a crash in its year and a job gap after the contract', () => { it('applies crashes and job gaps', () => {
const calm = runScenario(inputs, { ...flat, returnPercent: 5, recoveryPercent: 5 }); const calm = runScenario(inputs, { ...flat, returnPercent: 5 });
const crash = runScenario(inputs, { const crash = runScenario(inputs, {
...flat, ...flat,
returnPercent: 5, returnPercent: 5,
recoveryPercent: 5,
crashPercent: -30, crashPercent: -30,
crashYear: 2027 crashEveryYears: 10,
firstCrashYear: 2027
}); });
expect(crash.end.total).toBeLessThan(calm.end.total); // The crash year itself ends lower; later years catch up to the same long-run average.
const at2027 = (result: typeof calm) => result.years.find((row) => row.year === 2027)!.total;
expect(at2027(crash)).toBeLessThan(at2027(calm));
const gap = runScenario(inputs, { ...flat, gapMonths: 3, gapNetEuros: 1000 }); const gap = runScenario(inputs, { ...flat, gapMonths: 3, gapNetEuros: 1000 });
expect(gap.end.total).toBeLessThan(runScenario(inputs, flat).end.total); expect(gap.end.total).toBeLessThan(runScenario(inputs, flat).end.total);
}); });
it('averages the crash cycle out at the expected return', () => {
const markets = marketReturns({
...flat,
returnPercent: 7,
crashPercent: -30,
crashEveryYears: 10,
firstCrashYear: 2029,
correctionPercent: -10
});
let growth = 1;
for (let year = 2029; year < 2039; year++) growth *= 1 + markets.yearReturn(year).rate;
expect(growth).toBeCloseTo(Math.pow(1.07, 10), 6);
expect(markets.yearReturn(2029).kind).toBe('crash');
expect(markets.yearReturn(2034).kind).toBe('correction');
expect(markets.yearReturn(2028).kind).toBe('normal');
});
it('hops jobs, caps the salary and retires at the chosen age', () => {
const long = { ...inputs, endDate: '2126-10-24' };
const result = runScenario(long, {
...flat,
firstHopYear: 2029,
hopEveryYears: 3,
hopRaisePercent: 20,
hopGapMonths: 4,
salaryCapEuros: 60000
});
expect(result.points).toHaveLength(1201);
const byYear = new Map(result.years.map((row) => [row.year, row]));
expect(byYear.get(2029)!.events).toEqual(['Between jobs (4 mo)']);
expect(byYear.get(2030)!.events).toEqual(['New job']);
// The gap runs into 2030, when the raise lands, then the plateau caps it.
expect(byYear.get(2030)!.grossMonthly).toBeCloseTo(500000, -1);
expect(byYear.get(2071)!.role).toBe('Retired');
expect(byYear.get(2071)!.events).toContain('Retire at 68');
expect(byYear.get(2070)!.age).toBe(67);
});
it('labels roles by base salary in today’s euros', () => {
expect(roleFor(44700)).toBe('Junior');
expect(roleFor(70000)).toBe('Senior');
expect(roleFor(150000)).toBe('Principal / big-tech staff');
});
}); });

View File

@@ -1,8 +1,9 @@
/** /**
* Pessimistic / realistic / optimistic wealth scenarios to the projection end date. * Pessimistic / realistic / optimistic wealth scenarios to the chosen horizon (up to 100 years).
* *
* Money in the tracker drives the start balances, recurring costs and one-off estimates; each * Money in the tracker drives the start balances, recurring costs and one-off estimates; each
* scenario adds labelled assumptions (returns, salary path, spending growth, Box 3, inflation) * scenario adds labelled assumptions (portfolio and crashes, career path, retirement, spending
* growth, Box 3, inflation)
* that the page lists and lets you tweak. Amounts are in cents, except the editable assumptions, * that the page lists and lets you tweak. Amounts are in cents, except the editable assumptions,
* which are whole euros or percentages so they read like the inputs on the page. * which are whole euros or percentages so they read like the inputs on the page.
*/ */
@@ -14,34 +15,53 @@ export const scenarioIds: ScenarioId[] = ['pessimistic', 'realistic', 'optimisti
export type ScenarioParams = { export type ScenarioParams = {
label: string; label: string;
/** Yearly investment return before any crash, in percent. */ /** Portfolio mix, shown with the assumptions. */
portfolio: string;
/** Long-run average yearly return of the portfolio including crashes, in percent. */
returnPercent: number; returnPercent: number;
/** Market drop in the crash year (percent, e.g. -28); 0 means no crash. */ /** Typical yearly swing (standard deviation), percent; shown for context. */
volatilityPercent: number;
/** Market drop in a crash year (percent, e.g. -30); 0 means no crashes. */
crashPercent: number; crashPercent: number;
crashYear: number; /** Years between crashes, counted from the first crash year. */
/** Yearly return in the years after the crash, in percent. */ crashEveryYears: number;
recoveryPercent: number; firstCrashYear: number;
/** Smaller correction halfway between crashes, percent (0 = none). */
correctionPercent: number;
/** Interest on the cash buffer, percent per year. */ /** Interest on the cash buffer, percent per year. */
cashRatePercent: number; cashRatePercent: number;
/** Months without a job after the contract ends (unemployment benefit instead). */ /** Inflation: prices, the salary cap, pension and benefits grow with it; percent. */
inflationPercent: number;
/** Months without a job after the contract ends on 30 Sep 2027 (benefit instead). */
gapMonths: number; gapMonths: number;
/** Net unemployment benefit per month during the gap, euros. */ /** Net unemployment benefit per month during a gap, today's euros. */
gapNetEuros: number; gapNetEuros: number;
/** Gross salary change at the extension or next job, percent. */ /** Gross salary change at the extension or next job in October 2027, percent. */
nextJobPercent: number; nextJobPercent: number;
/** Yearly raise in percent, applied each raiseMonth from raiseFromYear. */ /** Yearly raise in percent, applied each raiseMonth from raiseFromYear. */
raisePercent: number; raisePercent: number;
raiseMonth: number; raiseMonth: number;
raiseFromYear: number; raiseFromYear: number;
/** Job hops: in October of the first year, then every N years (0 = never). */
firstHopYear: number;
hopEveryYears: number;
/** Gross raise at each hop, percent. */
hopRaisePercent: number;
/** Months without a job before each hop starts (unemployment spells). */
hopGapMonths: number;
/** Career plateau: base salary cap per year in today's euros (excl. holiday pay). */
salaryCapEuros: number;
/** Share of the contract's bonus estimate actually paid each August, 0–100. */ /** Share of the contract's bonus estimate actually paid each August, 0–100. */
bonusPercentOfTarget: number; bonusPercentOfTarget: number;
/** Side income, gross euros per month, ramping linearly to the end value. */ /** Side income, gross today's euros per month, ramping over five years to the end value. */
sideStartEuros: number; sideStartEuros: number;
sideEndEuros: number; sideEndEuros: number;
/** Spending growth per year (lifestyle creep plus prices), percent. */ /** Retirement age: salary and side income stop, pension starts, spending comes out of savings. */
retireAge: number;
/** Net pension per month (AOW plus workplace pension), today's euros. */
pensionNetEuros: number;
/** Spending growth per year for the first ten years (prices plus lifestyle), then inflation. */
spendGrowthPercent: number; spendGrowthPercent: number;
/** Inflation used to show the end value in today's euros, percent. */
inflationPercent: number;
/** Your share of Joint household costs after the move, percent. */ /** Your share of Joint household costs after the move, percent. */
householdSharePercent: number; householdSharePercent: number;
/** Personal spending not in the recurring list (going out, transport, shopping), euros/month. */ /** Personal spending not in the recurring list (going out, transport, shopping), euros/month. */
@@ -59,22 +79,32 @@ export type ScenarioParams = {
export const defaultScenarios: Record<ScenarioId, ScenarioParams> = { export const defaultScenarios: Record<ScenarioId, ScenarioParams> = {
pessimistic: { pessimistic: {
label: 'Pessimistic', label: 'Pessimistic',
returnPercent: 0, portfolio: '60% global equity ETF, 40% euro bonds',
crashPercent: -28, returnPercent: 4,
crashYear: 2027, volatilityPercent: 10,
recoveryPercent: 15, crashPercent: -25,
crashEveryYears: 8,
firstCrashYear: 2027,
correctionPercent: -8,
cashRatePercent: 0, cashRatePercent: 0,
inflationPercent: 2.5,
gapMonths: 3, gapMonths: 3,
gapNetEuros: 2300, gapNetEuros: 2300,
nextJobPercent: 0, nextJobPercent: 0,
raisePercent: 2, raisePercent: 2,
raiseMonth: 1, raiseMonth: 1,
raiseFromYear: 2029, raiseFromYear: 2029,
firstHopYear: 2031,
hopEveryYears: 5,
hopRaisePercent: 15,
hopGapMonths: 3,
salaryCapEuros: 75000,
bonusPercentOfTarget: 0, bonusPercentOfTarget: 0,
sideStartEuros: 100, sideStartEuros: 100,
sideEndEuros: 100, sideEndEuros: 100,
retireAge: 68,
pensionNetEuros: 1700,
spendGrowthPercent: 3.5, spendGrowthPercent: 3.5,
inflationPercent: 2.5,
householdSharePercent: 57, householdSharePercent: 57,
otherSpendEuros: 400, otherSpendEuros: 400,
householdExtraEuros: 200, householdExtraEuros: 200,
@@ -84,22 +114,32 @@ export const defaultScenarios: Record<ScenarioId, ScenarioParams> = {
}, },
realistic: { realistic: {
label: 'Realistic', label: 'Realistic',
returnPercent: 6.5, portfolio: '100% global equity ETF (all-world index)',
crashPercent: 0, returnPercent: 7,
crashYear: 2027, volatilityPercent: 15,
recoveryPercent: 6.5, crashPercent: -30,
crashEveryYears: 10,
firstCrashYear: 2029,
correctionPercent: -10,
cashRatePercent: 1.5, cashRatePercent: 1.5,
inflationPercent: 2.5,
gapMonths: 0, gapMonths: 0,
gapNetEuros: 2300, gapNetEuros: 2300,
nextJobPercent: 3, nextJobPercent: 6,
raisePercent: 3.5, raisePercent: 4,
raiseMonth: 10, raiseMonth: 10,
raiseFromYear: 2028, raiseFromYear: 2028,
firstHopYear: 2029,
hopEveryYears: 3,
hopRaisePercent: 20,
hopGapMonths: 0,
salaryCapEuros: 95000,
bonusPercentOfTarget: 100, bonusPercentOfTarget: 100,
sideStartEuros: 250, sideStartEuros: 250,
sideEndEuros: 500, sideEndEuros: 500,
spendGrowthPercent: 2.5, retireAge: 68,
inflationPercent: 2.5, pensionNetEuros: 2400,
spendGrowthPercent: 3,
householdSharePercent: 50, householdSharePercent: 50,
otherSpendEuros: 330, otherSpendEuros: 330,
householdExtraEuros: 65, householdExtraEuros: 65,
@@ -109,22 +149,32 @@ export const defaultScenarios: Record<ScenarioId, ScenarioParams> = {
}, },
optimistic: { optimistic: {
label: 'Optimistic', label: 'Optimistic',
returnPercent: 9, portfolio: '80% global equity ETF, 20% tech, small-cap and factor tilts',
crashPercent: 0, returnPercent: 9.5,
crashYear: 2027, volatilityPercent: 19,
recoveryPercent: 9, crashPercent: -38,
crashEveryYears: 10,
firstCrashYear: 2030,
correctionPercent: -15,
cashRatePercent: 1.5, cashRatePercent: 1.5,
inflationPercent: 2.5,
gapMonths: 0, gapMonths: 0,
gapNetEuros: 2300, gapNetEuros: 2300,
nextJobPercent: 6, nextJobPercent: 8,
raisePercent: 5, raisePercent: 6,
raiseMonth: 10, raiseMonth: 10,
raiseFromYear: 2028, raiseFromYear: 2028,
firstHopYear: 2029,
hopEveryYears: 3,
hopRaisePercent: 22,
hopGapMonths: 0,
salaryCapEuros: 150000,
bonusPercentOfTarget: 100, bonusPercentOfTarget: 100,
sideStartEuros: 300, sideStartEuros: 300,
sideEndEuros: 2500, sideEndEuros: 2500,
spendGrowthPercent: 2, retireAge: 68,
inflationPercent: 2.5, pensionNetEuros: 3200,
spendGrowthPercent: 2.5,
householdSharePercent: 45, householdSharePercent: 45,
otherSpendEuros: 280, otherSpendEuros: 280,
householdExtraEuros: 0, householdExtraEuros: 0,
@@ -139,6 +189,8 @@ export const scenarioFacts = {
/** Contract gross per month and the date the current contract ends. */ /** Contract gross per month and the date the current contract ends. */
grossCents: 372493, grossCents: 372493,
contractEnd: '2027-09-30', contractEnd: '2027-09-30',
/** Born 2003: 23 now, so 68 is 2071 (the expected AOW age for that year is about 68–70). */
birthYear: 2003,
/** Extra net per extra gross euro: about 50% marginal tax after 2.65% pension. */ /** Extra net per extra gross euro: about 50% marginal tax after 2.65% pension. */
marginalNet: (1 - 0.0265) * 0.5, marginalNet: (1 - 0.0265) * 0.5,
/** /**
@@ -155,14 +207,36 @@ export const scenarioFacts = {
firstBonusYear: 2027, firstBonusYear: 2027,
bonusNetYearCents: 130000, bonusNetYearCents: 130000,
sideTaxRate: 0.4, sideTaxRate: 0.4,
/** Cash buffer in today's euros (grows with inflation); everything above it is invested. */
bufferCents: 500000, bufferCents: 500000,
/** Joint household contribution before the move, per month (Apr–Oct 2026 average). */ /** Joint household contribution before the move, per month (Apr–Oct 2026 average). */
preMoveHouseholdCents: 90000, preMoveHouseholdCents: 90000,
/** DUO study-debt repayment from 2029, per month. */ /** DUO study-debt repayment, per month, for at most 35 years from 2029. */
duoFrom: 2029, duoFrom: 2029,
duoCents: 10000 duoUntil: 2063,
duoCents: 10000,
/** Box 3 from 2028: 36% on gains above €1,000 a year, losses carried forward. */
box3Rate: 0.36,
box3FreeCents: 100000,
/** Withdrawal rate for the "passive income" milestone. */
withdrawalRate: 0.035,
/** Monthly amount for the income milestones, today's euros. */
targetMonthlyCents: 1_000_000
}; };
/** Base salary bands per year in today's euros (lower ends), for the role column. */
export const salaryBands: { fromEuros: number; role: string }[] = [
{ fromEuros: 0, role: 'Junior' },
{ fromEuros: 52000, role: 'Medior' },
{ fromEuros: 65000, role: 'Senior' },
{ fromEuros: 85000, role: 'Lead / senior II' },
{ fromEuros: 105000, role: 'Staff / eng. manager' },
{ fromEuros: 135000, role: 'Principal / big-tech staff' }
];
export function roleFor(baseRealEuros: number) {
return salaryBands.findLast((band) => baseRealEuros >= band.fromEuros)?.role ?? 'Junior';
}
export type ScenarioRecurring = { export type ScenarioRecurring = {
name: string; name: string;
amountCents: number; amountCents: number;
@@ -206,6 +280,13 @@ export type ScenarioYear = {
invested: number; invested: number;
real: number; real: number;
box3: number; box3: number;
age: number;
/** Gross per month at year end (nominal, 0 when retired). */
grossMonthly: number;
/** Base salary per year in today's euros, for the role. */
baseReal: number;
role: string;
events: string[];
}; };
export type ScenarioResult = { export type ScenarioResult = {
points: ScenarioPoint[]; points: ScenarioPoint[];
@@ -299,33 +380,93 @@ function monthEnd(first: string) {
.slice(0, 10); .slice(0, 10);
} }
/**
* Years of a crash cycle: a crash in the first year, a correction halfway, and normal years whose
* return makes the whole cycle average out at the expected return.
*/
export function marketReturns(params: ScenarioParams) {
const pct = (value: number) => value / 100;
const every = Math.max(2, Math.round(params.crashEveryYears));
const crashes = params.crashPercent !== 0;
const corrections = crashes && params.correctionPercent !== 0 && every >= 3;
const shocks =
(1 + (crashes ? pct(params.crashPercent) : 0)) *
(1 + (corrections ? pct(params.correctionPercent) : 0));
const normalYears = every - (crashes ? 1 : 0) - (corrections ? 1 : 0);
const normal = crashes
? Math.pow(Math.pow(1 + pct(params.returnPercent), every) / shocks, 1 / normalYears) - 1
: pct(params.returnPercent);
return {
normal,
yearReturn(year: number) {
if (!crashes || year < params.firstCrashYear)
return { kind: 'normal' as const, rate: normal };
const offset = (year - params.firstCrashYear) % every;
if (offset === 0) return { kind: 'crash' as const, rate: pct(params.crashPercent) };
if (corrections && offset === Math.floor(every / 2))
return { kind: 'correction' as const, rate: pct(params.correctionPercent) };
return { kind: 'normal' as const, rate: normal };
}
};
}
export function runScenario(inputs: ScenarioInputs, params: ScenarioParams): ScenarioResult { export function runScenario(inputs: ScenarioInputs, params: ScenarioParams): ScenarioResult {
const facts = scenarioFacts; const facts = scenarioFacts;
const baseGross = facts.grossCents; const baseGross = facts.grossCents;
const pct = (value: number) => value / 100;
const netFromGross = (gross: number) => const netFromGross = (gross: number) =>
inputs.netSalaryCents + (gross - baseGross) * facts.marginalNet; inputs.netSalaryCents + (gross - baseGross) * facts.marginalNet;
const gapStart = addMonths(facts.contractEnd, 1); const gapStart = addMonths(facts.contractEnd, 1);
const gapEnd = params.gapMonths > 0 ? addMonths(gapStart, params.gapMonths) : gapStart; const gapEnd = params.gapMonths > 0 ? addMonths(gapStart, params.gapMonths) : gapStart;
const pct = (value: number) => value / 100; const retireStart = `${facts.birthYear + Math.round(params.retireAge)}-01-01`;
const markets = marketReturns(params);
const monthlyInflation = Math.pow(1 + pct(params.inflationPercent), 1 / 12);
const isHopYear = (year: number) =>
params.hopEveryYears > 0 &&
year >= params.firstHopYear &&
(year - params.firstHopYear) % Math.max(1, Math.round(params.hopEveryYears)) === 0;
let cash = inputs.spendableCents; let cash = inputs.spendableCents;
let invested = inputs.investedCents; let invested = inputs.investedCents;
let basis = inputs.investedCents;
let gross = baseGross; let gross = baseGross;
let accruedHoliday = (facts.holidayNetYearCents / 12) * facts.holidayMonthsAccrued; let accruedHoliday = (facts.holidayNetYearCents / 12) * facts.holidayMonthsAccrued;
let accrued13 = (facts.thirteenthNetYearCents / 12) * facts.thirteenthMonthsAccrued; let accrued13 = (facts.thirteenthNetYearCents / 12) * facts.thirteenthMonthsAccrued;
let investedAtYearStart = invested; let investedAtYearStart = invested;
let contributedThisYear = 0; let contributedThisYear = 0;
let realisedThisYear = 0;
let lossCarried = 0;
let box3Due = 0; let box3Due = 0;
let deflator = 1;
let spendGrowth = 1;
const recentRealIncome: number[] = [];
const points: ScenarioPoint[] = [ const points: ScenarioPoint[] = [
{ date: inputs.today, cash, invested, total: cash + invested, real: cash + invested } { date: inputs.today, cash, invested, total: cash + invested, real: cash + invested }
]; ];
const years = new Map<number, ScenarioYear>(); const years = new Map<number, ScenarioYear>();
const target = facts.targetMonthlyCents;
const milestones: { label: string; date: string | null; test: () => boolean }[] = [ const milestones: { label: string; date: string | null; test: () => boolean }[] = [
{ label: 'Cash buffer full', date: null, test: () => cash >= facts.bufferCents - 100 }, {
...[10_000, 25_000, 50_000, 100_000].map((euros) => ({ label: 'Cash buffer full',
label: `€${euros / 1000}k invested`, date: null,
test: () => cash >= facts.bufferCents * deflator - 100
},
...[10_000, 100_000, 250_000, 1_000_000].map((euros) => ({
label: `€${euros >= 1_000_000 ? `${euros / 1_000_000}M` : `${euros / 1000}k`} invested`,
date: null, date: null,
test: () => invested >= euros * 100 test: () => invested >= euros * 100
})) })),
{
label: '€10k/mo net income (today’s €)',
date: null,
test: () =>
recentRealIncome.length === 12 &&
recentRealIncome.reduce((sum, value) => sum + value, 0) / 12 >= target
},
{
label: '€10k/mo passive at 3.5% (today’s €)',
date: null,
test: () => ((invested / deflator) * facts.withdrawalRate) / 12 >= target
}
]; ];
const firstMonth = addMonths(inputs.today, 1); const firstMonth = addMonths(inputs.today, 1);
const totalMonths = const totalMonths =
@@ -334,12 +475,13 @@ export function runScenario(inputs: ScenarioInputs, params: ScenarioParams): Sce
Number(firstMonth.slice(5, 7)) + Number(firstMonth.slice(5, 7)) +
1; 1;
const moveDate = inputs.moveDate ?? '9999-12-31'; const moveDate = inputs.moveDate ?? '9999-12-31';
for (let index = 0; index < Math.min(totalMonths, 600); index++) { for (let index = 0; index < Math.min(totalMonths, 1212); index++) {
const first = addMonths(firstMonth, index); const first = addMonths(firstMonth, index);
const last = monthEnd(first); const last = monthEnd(first);
const date = last < inputs.endDate ? last : inputs.endDate; const date = last < inputs.endDate ? last : inputs.endDate;
const year = Number(first.slice(0, 4)); const year = Number(first.slice(0, 4));
const month = Number(first.slice(5, 7)); const month = Number(first.slice(5, 7));
deflator *= monthlyInflation;
const row = const row =
years.get(year) ?? years.get(year) ??
years years
@@ -352,66 +494,114 @@ export function runScenario(inputs: ScenarioInputs, params: ScenarioParams): Sce
total: 0, total: 0,
invested: 0, invested: 0,
real: 0, real: 0,
box3: 0 box3: 0,
age: year - facts.birthYear,
grossMonthly: 0,
baseReal: 0,
role: '',
events: []
}) })
.get(year)!; .get(year)!;
if (month === 1) { if (month === 1) {
investedAtYearStart = invested; investedAtYearStart = invested;
contributedThisYear = 0; contributedThisYear = 0;
realisedThisYear = 0;
} }
// Salary path: extension or next job after the contract, with an optional gap. const retired = first >= retireStart;
const employed = !(params.gapMonths > 0 && first >= gapStart && first < gapEnd); // Salary path: extension or next job after the contract (optional gap), raises, job hops
if (first === gapStart) gross = baseGross * (1 + pct(params.nextJobPercent)); // in October (optional gap before each), capped at the career plateau in today's euros.
if (first > gapStart && year >= params.raiseFromYear && month === params.raiseMonth) const hopStart = isHopYear(year) ? `${year}-10-01` : null;
// A gap that starts in October can run into the next year.
const hops = [year - 1, year].filter(isHopYear).map((hopYear) => {
const start = `${hopYear}-10-01`;
return { start, end: addMonths(start, Math.max(0, params.hopGapMonths)) };
});
const inHopGap = hops.some((hop) => first >= hop.start && first < hop.end);
const hopEnd = hops.find((hop) => hop.end === first)?.end ?? null;
const inContractGap = params.gapMonths > 0 && first >= gapStart && first < gapEnd;
const employed = !retired && !inHopGap && !inContractGap;
if (first === gapStart) {
gross = baseGross * (1 + pct(params.nextJobPercent));
row.events.push(
params.gapMonths > 0 ? `Contract ends, ${params.gapMonths} mo gap` : 'Contract renewed'
);
}
if (
first > gapStart &&
year >= params.raiseFromYear &&
month === params.raiseMonth &&
!(hopStart && month === 10)
)
gross *= 1 + pct(params.raisePercent); gross *= 1 + pct(params.raisePercent);
let income = employed ? netFromGross(gross) : params.gapNetEuros * 100; const cap = ((params.salaryCapEuros * 100) / 12) * deflator;
if (hopStart && first === hopStart && params.hopGapMonths > 0 && !retired)
row.events.push(`Between jobs (${params.hopGapMonths} mo)`);
if (hopEnd && first === hopEnd && !retired) {
const plateau = gross >= cap * 0.99;
gross *= 1 + pct(params.hopRaisePercent);
row.events.push(
(params.hopGapMonths > 0 ? 'New job' : 'Job hop') + (plateau ? ' (at plateau)' : '')
);
}
if (gross > cap) gross = cap;
const jobChange =
first === retireStart ||
(hopStart !== null && first === hopStart) ||
(params.gapMonths > 0 && first === gapStart);
let income = 0;
if (employed) income += netFromGross(gross);
else if (!retired) income += params.gapNetEuros * 100 * deflator;
if (employed) { if (employed) {
accruedHoliday += (facts.holidayNetYearCents / 12) * (gross / baseGross); accruedHoliday += (facts.holidayNetYearCents / 12) * (gross / baseGross);
accrued13 += (facts.thirteenthNetYearCents / 12) * (gross / baseGross); accrued13 += (facts.thirteenthNetYearCents / 12) * (gross / baseGross);
} }
if (month === 5) { if (month === 5 || jobChange) {
income += accruedHoliday; income += accruedHoliday;
accruedHoliday = 0; accruedHoliday = 0;
} }
if (month === 11) { if (month === 11 || jobChange) {
income += accrued13; income += accrued13;
accrued13 = 0; accrued13 = 0;
} }
// A job change pays out what is still accrued when the contract ends.
if (params.gapMonths > 0 && first === addMonths(facts.contractEnd, 0)) {
income += accruedHoliday + accrued13;
accruedHoliday = 0;
accrued13 = 0;
}
if (month === 8 && year >= facts.firstBonusYear && employed) if (month === 8 && year >= facts.firstBonusYear && employed)
income += income +=
pct(params.bonusPercentOfTarget) * pct(params.bonusPercentOfTarget) *
(year === facts.firstBonusYear (year === facts.firstBonusYear
? facts.firstBonusCents ? facts.firstBonusCents
: facts.bonusNetYearCents * (gross / baseGross)); : facts.bonusNetYearCents * (gross / baseGross));
const ramp = Math.min(1, Math.max(0, (index - 3) / Math.max(1, totalMonths - 4))); if (retired) {
const sideGross = params.sideStartEuros + (params.sideEndEuros - params.sideStartEuros) * ramp; income += params.pensionNetEuros * 100 * deflator;
income += sideGross * 100 * (1 - facts.sideTaxRate); if (first === retireStart) row.events.push(`Retire at ${params.retireAge}`);
} else {
const ramp = Math.min(1, Math.max(0, (index - 3) / 57));
const sideGross =
params.sideStartEuros + (params.sideEndEuros - params.sideStartEuros) * ramp;
income += sideGross * 100 * deflator * (1 - facts.sideTaxRate);
}
// Recurring items from the tracker (positive ones such as benefits count as income). // Recurring items from the tracker (positive ones such as benefits count as income).
const growth = Math.pow(1 + pct(params.spendGrowthPercent), Math.max(0, (index - 3) / 12)); if (index >= 3)
spendGrowth *= Math.pow(
1 + pct(index < 123 ? params.spendGrowthPercent : params.inflationPercent),
1 / 12
);
let spending = 0; let spending = 0;
for (const item of inputs.personal) { for (const item of inputs.personal) {
if (!activeIn(item, first, last)) continue; if (!activeIn(item, first, last)) continue;
const value = monthly(item); const value = monthly(item);
if (value >= 0) income += value; if (value >= 0) income += retired ? 0 : value;
else spending -= value * growth; else spending -= value * spendGrowth;
} }
spending += params.otherSpendEuros * 100 * growth; spending += params.otherSpendEuros * 100 * spendGrowth;
if (first >= '2027-01-01') spending += params.healthInsuranceEuros * 100 * growth; if (first >= '2027-01-01') spending += params.healthInsuranceEuros * 100 * spendGrowth;
if (first < moveDate.slice(0, 7) + '-01') spending += facts.preMoveHouseholdCents; if (first < moveDate.slice(0, 7) + '-01') spending += facts.preMoveHouseholdCents;
else { else {
let household = params.householdExtraEuros * 100; let household = params.householdExtraEuros * 100;
for (const item of inputs.household) for (const item of inputs.household)
if (activeIn(item, first, last)) household -= monthly(item); if (activeIn(item, first, last)) household -= monthly(item);
spending += household * pct(params.householdSharePercent) * growth; spending += household * pct(params.householdSharePercent) * spendGrowth;
} }
if (first === moveDate.slice(0, 7) + '-01') spending += params.moveCostEuros * 100; if (first === moveDate.slice(0, 7) + '-01') spending += params.moveCostEuros * 100;
if (year >= facts.duoFrom) spending += facts.duoCents; if (year >= facts.duoFrom && year <= facts.duoUntil) spending += facts.duoCents;
for (const item of inputs.oneOffs) for (const item of inputs.oneOffs)
if ((index === 0 ? item.date > inputs.today : item.date >= first) && item.date <= last) { if ((index === 0 ? item.date > inputs.today : item.date >= first) && item.date <= last) {
if (item.amountCents >= 0) income += item.amountCents; if (item.amountCents >= 0) income += item.amountCents;
@@ -423,42 +613,55 @@ export function runScenario(inputs: ScenarioInputs, params: ScenarioParams): Sce
box3Due = 0; box3Due = 0;
} }
cash += income - spending - tax; cash += income - spending - tax;
// Returns: a crash spreads over March–October of its year. // Returns: crash and correction years spread their drop over March–October.
const crash = params.crashPercent !== 0 && year === params.crashYear; const market = markets.yearReturn(year);
const yearly = const rate =
params.crashPercent !== 0 && year > params.crashYear market.kind === 'normal'
? pct(params.recoveryPercent) ? Math.pow(1 + market.rate, 1 / 12) - 1
: pct(params.returnPercent); : month >= 3 && month <= 10
const rate = crash ? Math.pow(1 + market.rate, 1 / 8) - 1
? month >= 3 && month <= 10 : 0;
? Math.pow(1 + pct(params.crashPercent), 1 / 8) - 1
: 0
: Math.pow(1 + yearly, 1 / 12) - 1;
invested += invested * rate; invested += invested * rate;
cash *= Math.pow(1 + pct(params.cashRatePercent), 1 / 12); cash *= Math.pow(1 + pct(params.cashRatePercent), 1 / 12);
if (cash > facts.bufferCents) { const buffer = facts.bufferCents * deflator;
const extra = cash - facts.bufferCents; if (cash > buffer) {
const extra = cash - buffer;
invested += extra; invested += extra;
basis += extra;
contributedThisYear += extra; contributedThisYear += extra;
cash = facts.bufferCents; cash = buffer;
} else if (cash < 0 && invested > 0) { } else if (cash < 0 && invested > 0) {
// Withdrawals: sell investments to cover the shortfall.
const sold = Math.min(invested, -cash); const sold = Math.min(invested, -cash);
const soldBasis = invested > 0 ? basis * (sold / invested) : 0;
realisedThisYear += sold - soldBasis;
basis -= soldBasis;
invested -= sold; invested -= sold;
contributedThisYear -= sold; contributedThisYear -= sold;
cash += sold; cash += sold;
} }
if (month === 12) { if (month === 12) {
const gain = invested - investedAtYearStart - contributedThisYear; const gain = invested - investedAtYearStart - contributedThisYear;
if (year >= 2028) box3Due = params.box3 === 'accrual' ? Math.max(0, gain - 100000) * 0.36 : 0; if (year >= 2028) {
else { const taxable = params.box3 === 'accrual' ? gain : realisedThisYear;
const net = taxable - lossCarried;
if (net < 0) {
lossCarried = -net;
box3Due = 0;
} else {
lossCarried = 0;
box3Due = Math.max(0, net - facts.box3FreeCents) * facts.box3Rate;
}
} else {
const exempt = year === 2026 ? 5_935_700 : 3_084_600; const exempt = year === 2026 ? 5_935_700 : 3_084_600;
const deemed = year === 2026 ? 0.06 : 0.0787; const deemed = year === 2026 ? 0.06 : 0.0787;
box3Due = Math.max(0, invested - Math.max(0, exempt - cash)) * deemed * 0.36; box3Due = Math.max(0, invested - Math.max(0, exempt - cash)) * deemed * facts.box3Rate;
} }
} }
const total = cash + invested; const total = cash + invested;
const elapsed = (index + 1) / 12; const real = total / deflator;
const real = total / Math.pow(1 + pct(params.inflationPercent), elapsed); recentRealIncome.push(income / deflator);
if (recentRealIncome.length > 12) recentRealIncome.shift();
points.push({ date, cash, invested, total, real }); points.push({ date, cash, invested, total, real });
row.income += income; row.income += income;
row.spending += spending + tax; row.spending += spending + tax;
@@ -466,6 +669,9 @@ export function runScenario(inputs: ScenarioInputs, params: ScenarioParams): Sce
row.total = total; row.total = total;
row.invested = invested; row.invested = invested;
row.real = real; row.real = real;
row.grossMonthly = employed ? gross : 0;
row.baseReal = retired ? 0 : (gross * 12) / deflator / 100;
row.role = retired ? 'Retired' : roleFor(row.baseReal);
for (const milestone of milestones) for (const milestone of milestones)
if (milestone.date === null && milestone.test()) milestone.date = first.slice(0, 7); if (milestone.date === null && milestone.test()) milestone.date = first.slice(0, 7);
} }
@@ -492,9 +698,14 @@ export function scenarioColor(id: ScenarioId) {
} }
/** Total, spendable and invested lines in the shape `wealthTimeline` expects for a projection. */ /** Total, spendable and invested lines in the shape `wealthTimeline` expects for a projection. */
export function scenarioProjection(result: ScenarioResult): ChartSeries[] { export function scenarioProjection(result: ScenarioResult, todaysEuros = false): ChartSeries[] {
const line = (key: 'total' | 'cash' | 'invested') => const line = (key: 'total' | 'cash' | 'invested') =>
result.points.map((point) => ({ date: point.date, value: Math.round(point[key]) })); result.points.map((point) => ({
date: point.date,
value: Math.round(
todaysEuros && point.total ? point[key] * (point.real / point.total) : point[key]
)
}));
return [ return [
{ label: 'Total wealth', color: '#0070f3', points: line('total'), emphasis: true }, { label: 'Total wealth', color: '#0070f3', points: line('total'), emphasis: true },
{ label: 'Spendable', color: '#46a758', points: line('cash') }, { label: 'Spendable', color: '#46a758', points: line('cash') },
@@ -506,7 +717,8 @@ export function scenarioProjection(result: ScenarioResult): ChartSeries[] {
export function scenarioCompareLines( export function scenarioCompareLines(
results: Record<ScenarioId, ScenarioResult>, results: Record<ScenarioId, ScenarioResult>,
params: Record<ScenarioId, ScenarioParams>, params: Record<ScenarioId, ScenarioParams>,
today: string today: string,
todaysEuros = false
): ChartSeries[] { ): ChartSeries[] {
return scenarioIds.map((id) => ({ return scenarioIds.map((id) => ({
label: params[id].label, label: params[id].label,
@@ -514,6 +726,9 @@ export function scenarioCompareLines(
dashedFrom: today, dashedFrom: today,
points: results[id].points points: results[id].points
.filter((point) => point.date > today) .filter((point) => point.date > today)
.map((point) => ({ date: point.date, value: Math.round(point.total) })) .map((point) => ({
date: point.date,
value: Math.round(todaysEuros ? point.real : point.total)
}))
})); }));
} }

View File

@@ -41,7 +41,12 @@
accountBalances, accountBalances,
accountColor, accountColor,
clipSeries, clipSeries,
type ForecastHorizon,
forecastHorizons,
gridFor,
type HistoryRange, type HistoryRange,
horizonEnd,
parseHorizon,
historyStart, historyStart,
parseHistoryRange, parseHistoryRange,
monthlyCents, monthlyCents,
@@ -140,6 +145,27 @@
) )
.reduce((sum, item) => sum + monthlyCents(item.amountCents, item.cadence), 0) .reduce((sum, item) => sum + monthlyCents(item.amountCents, item.cadence), 0)
); );
// History range sets how far back the chart starts, the forecast horizon how far ahead it
// projects; both live in the URL (?range=, ?horizon=).
let horizon = $state<ForecastHorizon>(parseHorizon(page.url.searchParams.get('horizon')));
let forecastEnd = $derived(horizonEnd(horizon, data.today));
function setHorizon(value: string) {
horizon = parseHorizon(value);
const url = new URL(window.location.href);
url.searchParams.set('horizon', horizon);
replaceState(url, page.state);
}
// Nominal or today's euros (?euros=today): far-out nominal numbers mostly measure inflation.
let todaysEuros = $state(page.url.searchParams.get('euros') === 'today');
function setEuros(value: string) {
todaysEuros = value === 'today';
const url = new URL(window.location.href);
if (todaysEuros) url.searchParams.set('euros', 'today');
else url.searchParams.delete('euros');
replaceState(url, page.state);
}
const horizonLabel = (value: ForecastHorizon) =>
value === '1y' ? '1 year' : `${Number.parseInt(value)} years`;
let projection = $derived( let projection = $derived(
wealthProjection( wealthProjection(
data.accounts, data.accounts,
@@ -148,7 +174,8 @@
data.forecasts, data.forecasts,
data.recurring, data.recurring,
data.projectionSettings, data.projectionSettings,
data.today data.today,
forecastEnd
) )
); );
type ProjectionView = ScenarioId | 'compare' | 'simple'; type ProjectionView = ScenarioId | 'compare' | 'simple';
@@ -184,7 +211,7 @@
forecasts: data.forecasts, forecasts: data.forecasts,
futureTransactions, futureTransactions,
today: data.today, today: data.today,
endDate: data.projectionSettings?.endDate endDate: forecastEnd
}) })
); );
let scenarioResults = $derived( let scenarioResults = $derived(
@@ -195,8 +222,30 @@
let selectedScenario = $derived<ScenarioId>( let selectedScenario = $derived<ScenarioId>(
projectionView === 'compare' || projectionView === 'simple' ? 'realistic' : projectionView projectionView === 'compare' || projectionView === 'simple' ? 'realistic' : projectionView
); );
/** The simple projection in today's euros, using the realistic scenario's inflation. */
function deflated(series: typeof projection) {
const yearly = 1 + scenarioParams.realistic.inflationPercent / 100;
const start = Date.parse(`${data.today}T12:00:00Z`);
return series.map((line) => ({
...line,
points: line.points.map((point) => ({
...point,
value: Math.round(
point.value /
Math.pow(
yearly,
(Date.parse(`${point.date}T12:00:00Z`) - start) / (365.25 * 86_400_000)
)
)
}))
}));
}
let activeProjection = $derived( let activeProjection = $derived(
projectionView === 'simple' ? projection : scenarioProjection(scenarioResults[selectedScenario]) projectionView === 'simple'
? todaysEuros
? deflated(projection)
: projection
: scenarioProjection(scenarioResults[selectedScenario], todaysEuros)
); );
const timelineViews: { id: TimelineView; label: string }[] = [ const timelineViews: { id: TimelineView; label: string }[] = [
{ id: 'total', label: 'Total' }, { id: 'total', label: 'Total' },
@@ -223,7 +272,7 @@
projectionView === 'compare' && timelineView === 'total' projectionView === 'compare' && timelineView === 'total'
? [ ? [
...wealthTimeline(data.accounts, actualTransactions, [], data.today, 'total'), ...wealthTimeline(data.accounts, actualTransactions, [], data.today, 'total'),
...scenarioCompareLines(scenarioResults, scenarioParams, data.today) ...scenarioCompareLines(scenarioResults, scenarioParams, data.today, todaysEuros)
] ]
: wealthTimeline( : wealthTimeline(
data.accounts, data.accounts,
@@ -235,6 +284,12 @@
historyStart(historyRange, data.today) historyStart(historyRange, data.today)
) )
); );
let chartGrid = $derived(
gridFor(
timeline.flatMap((line) => line.points.map((point) => point.date)).sort()[0],
forecastEnd
)
);
let projectionEnd = $derived(activeProjection.map((line) => line.points.at(-1)?.value ?? 0)); let projectionEnd = $derived(activeProjection.map((line) => line.points.at(-1)?.value ?? 0));
let wishTiers = $derived( let wishTiers = $derived(
[5, 4, 3, 2, 1].map((tier) => ({ [5, 4, 3, 2, 1].map((tier) => ({
@@ -386,7 +441,9 @@
activeProjection[0].points.at(-1)?.date ?? data.today activeProjection[0].points.at(-1)?.date ?? data.today
)}{projectionView === 'simple' )}{projectionView === 'simple'
? '' ? ''
: ` · ${scenarioParams[selectedScenario].label.toLowerCase()}`} : ` · ${scenarioParams[selectedScenario].label.toLowerCase()}`}{todaysEuros
? ' · today’s €'
: ''}
</p> </p>
<p class="stat-value">{@render signed(projectionEnd[0])}</p> <p class="stat-value">{@render signed(projectionEnd[0])}</p>
</div> </div>
@@ -433,12 +490,32 @@
>{/each}</Tabs.List >{/each}</Tabs.List
></Tabs.Root ></Tabs.Root
> >
<label class="horizon-picker"
><span>Forecast</span><NativeSelect
value={horizon}
onchange={(event) => setHorizon(event.currentTarget.value)}
aria-label="Forecast horizon"
>{#each forecastHorizons as item (item)}<option value={item}
>{horizonLabel(item)}</option
>{/each}</NativeSelect
></label
>
<Tabs.Root
value={todaysEuros ? 'today' : 'nominal'}
onValueChange={setEuros}
aria-label="Euros shown"
><Tabs.List
><Tabs.Trigger value="nominal">Nominal €</Tabs.Trigger><Tabs.Trigger value="today"
>Today’s €</Tabs.Trigger
></Tabs.List
></Tabs.Root
>
</div> </div>
<MoneyChart <MoneyChart
series={timeline} series={timeline}
title="Actual and projected wealth over time" title="Actual and projected wealth over time"
marker={{ date: data.today, label: 'Today' }} marker={{ date: data.today, label: 'Today' }}
grid={historyRange === 'all' ? 'years' : 'months'} grid={chartGrid}
/> />
<div class="projection-totals" aria-label="Projected balances at end of period"> <div class="projection-totals" aria-label="Projected balances at end of period">
<div> <div>
@@ -470,7 +547,9 @@
Scenarios start from today’s balances and run monthly on the recurring list, one-off Scenarios start from today’s balances and run monthly on the recurring list, one-off
estimates (salary extras such as holiday pay, the 13th month and the bonus are modelled estimates (salary extras such as holiday pay, the 13th month and the bonus are modelled
per scenario instead), and the assumptions above. Compare shows all three totals; Simple per scenario instead), and the assumptions above. Compare shows all three totals; Simple
is the setting-based projection. is the setting-based projection. The history tabs set how far back the chart starts and
Forecast how far ahead it runs (1–100 years); Today’s € divides projected values by the
scenario’s inflation so long horizons stay comparable.
</p> </p>
{:else}<p class="muted projection-note"> {:else}<p class="muted projection-note">
Includes future dated transactions and estimates{data.projectionSettings Includes future dated transactions and estimates{data.projectionSettings
@@ -1186,8 +1265,8 @@
> >
<label class="field" <label class="field"
>Project through<Input name="endDate" type="date" value={active.endDate ?? ''} /><small >Project through<Input name="endDate" type="date" value={active.endDate ?? ''} /><small
>Optional, up to 20 years ahead. By default, ends at the latest future entry, or one >Not used by the chart, which projects as far as the Forecast picker above it (1–100
year from today.</small years).</small
></label ></label
> >
<label class="field check-field" <label class="field check-field"

View File

@@ -634,3 +634,10 @@
width: 100%; width: 100%;
} }
} }
.money-app .horizon-picker {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 14px;
color: var(--site-muted);
}