diff --git a/src/lib/money-tracker/MoneyChart.svelte b/src/lib/money-tracker/MoneyChart.svelte
index 975de43..a8af5f5 100644
--- a/src/lib/money-tracker/MoneyChart.svelte
+++ b/src/lib/money-tracker/MoneyChart.svelte
@@ -12,7 +12,7 @@
title: string;
unit?: 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;
} = $props();
let clientWidth = $state(800);
@@ -33,7 +33,7 @@
grid && values.length ? timeGrid(isoDate(from), isoDate(until), grid) : []
);
let axisTicks = $derived(
- grid === 'years'
+ grid === 'years' || grid === 'decades'
? yearLabels(gridLines, Math.max(2, Math.floor((width - plot.left - plot.right) / 44)))
: ticks()
);
@@ -123,7 +123,7 @@
return result;
}
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) {
return new Date(time(date)).toLocaleDateString('en-NL', {
diff --git a/src/lib/money-tracker/ScenarioDetails.svelte b/src/lib/money-tracker/ScenarioDetails.svelte
index 5ed8cc6..51c9d73 100644
--- a/src/lib/money-tracker/ScenarioDetails.svelte
+++ b/src/lib/money-tracker/ScenarioDetails.svelte
@@ -3,6 +3,8 @@
import { Input } from '#lib/components/ui/input/index.js';
import { NativeSelect } from '#lib/components/ui/native-select/index.js';
import {
+ marketReturns,
+ salaryBands,
scenarioColor,
scenarioFacts,
scenarioIds,
@@ -33,34 +35,48 @@
}[keyof ScenarioParams];
const groups: { title: string; fields: { key: NumberField; label: string; unit: string }[] }[] = [
{
- title: 'Markets',
+ title: 'Portfolio and markets',
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: 'crashYear', label: 'Crash year', unit: '' },
- { key: 'recoveryPercent', label: 'Return after a crash', unit: '%' },
+ { key: 'crashEveryYears', label: 'Crash every', unit: 'yr' },
+ { key: 'firstCrashYear', label: 'First crash', unit: '' },
+ { key: 'correctionPercent', label: 'Mid-cycle correction', unit: '%' },
{ key: 'cashRatePercent', label: 'Interest on cash', unit: '%' },
{ key: 'inflationPercent', label: 'Inflation', unit: '%' }
]
},
{
- title: 'Work',
+ title: 'Career',
fields: [
{ 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: 'raisePercent', label: 'Yearly raise', unit: '%' },
{ key: 'raiseMonth', label: 'Raise month', 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: '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',
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: 'healthInsuranceEuros', label: 'Health insurance (assumed)', unit: '€/mo' },
{ key: 'householdSharePercent', label: 'Your share of household', unit: '%' },
@@ -85,6 +101,14 @@
: 'Not by the end';
let active = $derived(params[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)}%`;
@@ -121,7 +145,10 @@
/>
{/each}
- {#if group.title === 'Markets'}
+ {#if group.title === 'Portfolio and markets'}
+ Portfolio mix
Box 3 (36%, from 2028) Yearly gains
{/each}
+
+ {active.label} portfolio:
+ {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.
+
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
the recurring list, cash buffer {cents(scenarioFacts.bufferCents)} then everything is invested,
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 (Chase IT 2026 ,
+ The Search Company 2026 ); staff in Amsterdam about €100–139k base (Glassdoor ), Adyen/GitLab staff €120–188k total pay and Amsterdam senior median €122k total pay (levels.fyi ). 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.
Reset assumptions
@@ -168,6 +225,32 @@
>
+
+ {active.label} salary path
+
+
+ Year Age Role (band) Gross/mo Base/yr, today’s € Change {#each career as row (row.year)}{row.year} {row.age} {row.role} {row.grossMonthly ? cents(row.grossMonthly) : '–'} {row.baseReal ? cents(row.baseReal * 100) : '–'} {row.events.join('; ')} {/each}
+
+
+
+ Roles follow the base salary in today’s euros:
+ {salaryBands.map((band) => `${band.role} from ${cents(band.fromEuros * 100)}`).join(', ')}.
+
+
{#each result.milestones as milestone (milestone.label)}
@@ -236,6 +319,21 @@
font-weight: 600;
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 {
display: grid;
grid-template-columns: 1fr 8rem;
diff --git a/src/lib/money-tracker/charts.test.ts b/src/lib/money-tracker/charts.test.ts
index a2bbd8b..959bf82 100644
--- a/src/lib/money-tracker/charts.test.ts
+++ b/src/lib/money-tracker/charts.test.ts
@@ -5,6 +5,9 @@ import {
balanceSeries,
forecastSeries,
parseHistoryRange,
+ gridFor,
+ horizonEnd,
+ parseHorizon,
timeGrid,
yearLabels,
utilityEstimate,
@@ -349,4 +352,25 @@ describe('history range', () => {
]);
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'
+ ]);
+ });
});
diff --git a/src/lib/money-tracker/charts.ts b/src/lib/money-tracker/charts.ts
index 4fd281f..b490ce6 100644
--- a/src/lib/money-tracker/charts.ts
+++ b/src/lib/money-tracker/charts.ts
@@ -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);
}
diff --git a/src/lib/money-tracker/scenarios.test.ts b/src/lib/money-tracker/scenarios.test.ts
index 5c948e5..af94a44 100644
--- a/src/lib/money-tracker/scenarios.test.ts
+++ b/src/lib/money-tracker/scenarios.test.ts
@@ -1,5 +1,12 @@
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 = [
{ id: 1, name: 'Revolut', kind: 'checking' },
@@ -55,8 +62,11 @@ describe('runScenario', () => {
const flat: ScenarioParams = {
...defaultScenarios.realistic,
returnPercent: 0,
- recoveryPercent: 0,
+ crashPercent: 0,
+ correctionPercent: 0,
cashRatePercent: 0,
+ inflationPercent: 0,
+ hopEveryYears: 0,
sideStartEuros: 0,
sideEndEuros: 0,
spendGrowthPercent: 0,
@@ -97,17 +107,60 @@ describe('runScenario', () => {
expect(pessimistic).toBeLessThan(realistic);
expect(realistic).toBeLessThan(optimistic);
});
- it('applies a crash in its year and a job gap after the contract', () => {
- const calm = runScenario(inputs, { ...flat, returnPercent: 5, recoveryPercent: 5 });
+ it('applies crashes and job gaps', () => {
+ const calm = runScenario(inputs, { ...flat, returnPercent: 5 });
const crash = runScenario(inputs, {
...flat,
returnPercent: 5,
- recoveryPercent: 5,
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 });
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');
+ });
});
diff --git a/src/lib/money-tracker/scenarios.ts b/src/lib/money-tracker/scenarios.ts
index 869f10e..7a8d250 100644
--- a/src/lib/money-tracker/scenarios.ts
+++ b/src/lib/money-tracker/scenarios.ts
@@ -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
- * 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,
* 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 = {
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;
- /** 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;
- crashYear: number;
- /** Yearly return in the years after the crash, in percent. */
- recoveryPercent: number;
+ /** Years between crashes, counted from the first crash year. */
+ crashEveryYears: number;
+ firstCrashYear: number;
+ /** Smaller correction halfway between crashes, percent (0 = none). */
+ correctionPercent: number;
/** Interest on the cash buffer, percent per year. */
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;
- /** Net unemployment benefit per month during the gap, euros. */
+ /** Net unemployment benefit per month during a gap, today's euros. */
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;
/** Yearly raise in percent, applied each raiseMonth from raiseFromYear. */
raisePercent: number;
raiseMonth: 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. */
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;
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;
- /** Inflation used to show the end value in today's euros, percent. */
- inflationPercent: number;
/** Your share of Joint household costs after the move, percent. */
householdSharePercent: number;
/** Personal spending not in the recurring list (going out, transport, shopping), euros/month. */
@@ -59,22 +79,32 @@ export type ScenarioParams = {
export const defaultScenarios: Record = {
pessimistic: {
label: 'Pessimistic',
- returnPercent: 0,
- crashPercent: -28,
- crashYear: 2027,
- recoveryPercent: 15,
+ portfolio: '60% global equity ETF, 40% euro bonds',
+ returnPercent: 4,
+ volatilityPercent: 10,
+ crashPercent: -25,
+ crashEveryYears: 8,
+ firstCrashYear: 2027,
+ correctionPercent: -8,
cashRatePercent: 0,
+ inflationPercent: 2.5,
gapMonths: 3,
gapNetEuros: 2300,
nextJobPercent: 0,
raisePercent: 2,
raiseMonth: 1,
raiseFromYear: 2029,
+ firstHopYear: 2031,
+ hopEveryYears: 5,
+ hopRaisePercent: 15,
+ hopGapMonths: 3,
+ salaryCapEuros: 75000,
bonusPercentOfTarget: 0,
sideStartEuros: 100,
sideEndEuros: 100,
+ retireAge: 68,
+ pensionNetEuros: 1700,
spendGrowthPercent: 3.5,
- inflationPercent: 2.5,
householdSharePercent: 57,
otherSpendEuros: 400,
householdExtraEuros: 200,
@@ -84,22 +114,32 @@ export const defaultScenarios: Record = {
},
realistic: {
label: 'Realistic',
- returnPercent: 6.5,
- crashPercent: 0,
- crashYear: 2027,
- recoveryPercent: 6.5,
+ portfolio: '100% global equity ETF (all-world index)',
+ returnPercent: 7,
+ volatilityPercent: 15,
+ crashPercent: -30,
+ crashEveryYears: 10,
+ firstCrashYear: 2029,
+ correctionPercent: -10,
cashRatePercent: 1.5,
+ inflationPercent: 2.5,
gapMonths: 0,
gapNetEuros: 2300,
- nextJobPercent: 3,
- raisePercent: 3.5,
+ nextJobPercent: 6,
+ raisePercent: 4,
raiseMonth: 10,
raiseFromYear: 2028,
+ firstHopYear: 2029,
+ hopEveryYears: 3,
+ hopRaisePercent: 20,
+ hopGapMonths: 0,
+ salaryCapEuros: 95000,
bonusPercentOfTarget: 100,
sideStartEuros: 250,
sideEndEuros: 500,
- spendGrowthPercent: 2.5,
- inflationPercent: 2.5,
+ retireAge: 68,
+ pensionNetEuros: 2400,
+ spendGrowthPercent: 3,
householdSharePercent: 50,
otherSpendEuros: 330,
householdExtraEuros: 65,
@@ -109,22 +149,32 @@ export const defaultScenarios: Record = {
},
optimistic: {
label: 'Optimistic',
- returnPercent: 9,
- crashPercent: 0,
- crashYear: 2027,
- recoveryPercent: 9,
+ portfolio: '80% global equity ETF, 20% tech, small-cap and factor tilts',
+ returnPercent: 9.5,
+ volatilityPercent: 19,
+ crashPercent: -38,
+ crashEveryYears: 10,
+ firstCrashYear: 2030,
+ correctionPercent: -15,
cashRatePercent: 1.5,
+ inflationPercent: 2.5,
gapMonths: 0,
gapNetEuros: 2300,
- nextJobPercent: 6,
- raisePercent: 5,
+ nextJobPercent: 8,
+ raisePercent: 6,
raiseMonth: 10,
raiseFromYear: 2028,
+ firstHopYear: 2029,
+ hopEveryYears: 3,
+ hopRaisePercent: 22,
+ hopGapMonths: 0,
+ salaryCapEuros: 150000,
bonusPercentOfTarget: 100,
sideStartEuros: 300,
sideEndEuros: 2500,
- spendGrowthPercent: 2,
- inflationPercent: 2.5,
+ retireAge: 68,
+ pensionNetEuros: 3200,
+ spendGrowthPercent: 2.5,
householdSharePercent: 45,
otherSpendEuros: 280,
householdExtraEuros: 0,
@@ -139,6 +189,8 @@ export const scenarioFacts = {
/** Contract gross per month and the date the current contract ends. */
grossCents: 372493,
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. */
marginalNet: (1 - 0.0265) * 0.5,
/**
@@ -155,14 +207,36 @@ export const scenarioFacts = {
firstBonusYear: 2027,
bonusNetYearCents: 130000,
sideTaxRate: 0.4,
+ /** Cash buffer in today's euros (grows with inflation); everything above it is invested. */
bufferCents: 500000,
/** Joint household contribution before the move, per month (Apr–Oct 2026 average). */
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,
- 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 = {
name: string;
amountCents: number;
@@ -206,6 +280,13 @@ export type ScenarioYear = {
invested: number;
real: 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 = {
points: ScenarioPoint[];
@@ -299,33 +380,93 @@ function monthEnd(first: string) {
.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 {
const facts = scenarioFacts;
const baseGross = facts.grossCents;
+ const pct = (value: number) => value / 100;
const netFromGross = (gross: number) =>
inputs.netSalaryCents + (gross - baseGross) * facts.marginalNet;
const gapStart = addMonths(facts.contractEnd, 1);
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 invested = inputs.investedCents;
+ let basis = inputs.investedCents;
let gross = baseGross;
let accruedHoliday = (facts.holidayNetYearCents / 12) * facts.holidayMonthsAccrued;
let accrued13 = (facts.thirteenthNetYearCents / 12) * facts.thirteenthMonthsAccrued;
let investedAtYearStart = invested;
let contributedThisYear = 0;
+ let realisedThisYear = 0;
+ let lossCarried = 0;
let box3Due = 0;
+ let deflator = 1;
+ let spendGrowth = 1;
+ const recentRealIncome: number[] = [];
const points: ScenarioPoint[] = [
{ date: inputs.today, cash, invested, total: cash + invested, real: cash + invested }
];
const years = new Map();
+ const target = facts.targetMonthlyCents;
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: `€${euros / 1000}k invested`,
+ {
+ label: 'Cash buffer full',
+ 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,
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 totalMonths =
@@ -334,12 +475,13 @@ export function runScenario(inputs: ScenarioInputs, params: ScenarioParams): Sce
Number(firstMonth.slice(5, 7)) +
1;
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 last = monthEnd(first);
const date = last < inputs.endDate ? last : inputs.endDate;
const year = Number(first.slice(0, 4));
const month = Number(first.slice(5, 7));
+ deflator *= monthlyInflation;
const row =
years.get(year) ??
years
@@ -352,66 +494,114 @@ export function runScenario(inputs: ScenarioInputs, params: ScenarioParams): Sce
total: 0,
invested: 0,
real: 0,
- box3: 0
+ box3: 0,
+ age: year - facts.birthYear,
+ grossMonthly: 0,
+ baseReal: 0,
+ role: '',
+ events: []
})
.get(year)!;
if (month === 1) {
investedAtYearStart = invested;
contributedThisYear = 0;
+ realisedThisYear = 0;
}
- // Salary path: extension or next job after the contract, with an optional gap.
- const employed = !(params.gapMonths > 0 && first >= gapStart && first < gapEnd);
- if (first === gapStart) gross = baseGross * (1 + pct(params.nextJobPercent));
- if (first > gapStart && year >= params.raiseFromYear && month === params.raiseMonth)
+ const retired = first >= retireStart;
+ // Salary path: extension or next job after the contract (optional gap), raises, job hops
+ // in October (optional gap before each), capped at the career plateau in today's euros.
+ 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);
- 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) {
accruedHoliday += (facts.holidayNetYearCents / 12) * (gross / baseGross);
accrued13 += (facts.thirteenthNetYearCents / 12) * (gross / baseGross);
}
- if (month === 5) {
+ if (month === 5 || jobChange) {
income += accruedHoliday;
accruedHoliday = 0;
}
- if (month === 11) {
+ if (month === 11 || jobChange) {
income += accrued13;
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)
income +=
pct(params.bonusPercentOfTarget) *
(year === facts.firstBonusYear
? facts.firstBonusCents
: facts.bonusNetYearCents * (gross / baseGross));
- const ramp = Math.min(1, Math.max(0, (index - 3) / Math.max(1, totalMonths - 4)));
- const sideGross = params.sideStartEuros + (params.sideEndEuros - params.sideStartEuros) * ramp;
- income += sideGross * 100 * (1 - facts.sideTaxRate);
+ if (retired) {
+ income += params.pensionNetEuros * 100 * deflator;
+ 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).
- 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;
for (const item of inputs.personal) {
if (!activeIn(item, first, last)) continue;
const value = monthly(item);
- if (value >= 0) income += value;
- else spending -= value * growth;
+ if (value >= 0) income += retired ? 0 : value;
+ else spending -= value * spendGrowth;
}
- spending += params.otherSpendEuros * 100 * growth;
- if (first >= '2027-01-01') spending += params.healthInsuranceEuros * 100 * growth;
+ spending += params.otherSpendEuros * 100 * spendGrowth;
+ if (first >= '2027-01-01') spending += params.healthInsuranceEuros * 100 * spendGrowth;
if (first < moveDate.slice(0, 7) + '-01') spending += facts.preMoveHouseholdCents;
else {
let household = params.householdExtraEuros * 100;
for (const item of inputs.household)
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 (year >= facts.duoFrom) spending += facts.duoCents;
+ if (year >= facts.duoFrom && year <= facts.duoUntil) spending += facts.duoCents;
for (const item of inputs.oneOffs)
if ((index === 0 ? item.date > inputs.today : item.date >= first) && item.date <= last) {
if (item.amountCents >= 0) income += item.amountCents;
@@ -423,42 +613,55 @@ export function runScenario(inputs: ScenarioInputs, params: ScenarioParams): Sce
box3Due = 0;
}
cash += income - spending - tax;
- // Returns: a crash spreads over March–October of its year.
- const crash = params.crashPercent !== 0 && year === params.crashYear;
- const yearly =
- params.crashPercent !== 0 && year > params.crashYear
- ? pct(params.recoveryPercent)
- : pct(params.returnPercent);
- const rate = crash
- ? month >= 3 && month <= 10
- ? Math.pow(1 + pct(params.crashPercent), 1 / 8) - 1
- : 0
- : Math.pow(1 + yearly, 1 / 12) - 1;
+ // Returns: crash and correction years spread their drop over March–October.
+ const market = markets.yearReturn(year);
+ const rate =
+ market.kind === 'normal'
+ ? Math.pow(1 + market.rate, 1 / 12) - 1
+ : month >= 3 && month <= 10
+ ? Math.pow(1 + market.rate, 1 / 8) - 1
+ : 0;
invested += invested * rate;
cash *= Math.pow(1 + pct(params.cashRatePercent), 1 / 12);
- if (cash > facts.bufferCents) {
- const extra = cash - facts.bufferCents;
+ const buffer = facts.bufferCents * deflator;
+ if (cash > buffer) {
+ const extra = cash - buffer;
invested += extra;
+ basis += extra;
contributedThisYear += extra;
- cash = facts.bufferCents;
+ cash = buffer;
} else if (cash < 0 && invested > 0) {
+ // Withdrawals: sell investments to cover the shortfall.
const sold = Math.min(invested, -cash);
+ const soldBasis = invested > 0 ? basis * (sold / invested) : 0;
+ realisedThisYear += sold - soldBasis;
+ basis -= soldBasis;
invested -= sold;
contributedThisYear -= sold;
cash += sold;
}
if (month === 12) {
const gain = invested - investedAtYearStart - contributedThisYear;
- if (year >= 2028) box3Due = params.box3 === 'accrual' ? Math.max(0, gain - 100000) * 0.36 : 0;
- else {
+ if (year >= 2028) {
+ 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 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 elapsed = (index + 1) / 12;
- const real = total / Math.pow(1 + pct(params.inflationPercent), elapsed);
+ const real = total / deflator;
+ recentRealIncome.push(income / deflator);
+ if (recentRealIncome.length > 12) recentRealIncome.shift();
points.push({ date, cash, invested, total, real });
row.income += income;
row.spending += spending + tax;
@@ -466,6 +669,9 @@ export function runScenario(inputs: ScenarioInputs, params: ScenarioParams): Sce
row.total = total;
row.invested = invested;
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)
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. */
-export function scenarioProjection(result: ScenarioResult): ChartSeries[] {
+export function scenarioProjection(result: ScenarioResult, todaysEuros = false): ChartSeries[] {
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 [
{ label: 'Total wealth', color: '#0070f3', points: line('total'), emphasis: true },
{ label: 'Spendable', color: '#46a758', points: line('cash') },
@@ -506,7 +717,8 @@ export function scenarioProjection(result: ScenarioResult): ChartSeries[] {
export function scenarioCompareLines(
results: Record,
params: Record,
- today: string
+ today: string,
+ todaysEuros = false
): ChartSeries[] {
return scenarioIds.map((id) => ({
label: params[id].label,
@@ -514,6 +726,9 @@ export function scenarioCompareLines(
dashedFrom: today,
points: results[id].points
.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)
+ }))
}));
}
diff --git a/src/routes/trackers/money/+page.svelte b/src/routes/trackers/money/+page.svelte
index a581fcc..d17f02c 100644
--- a/src/routes/trackers/money/+page.svelte
+++ b/src/routes/trackers/money/+page.svelte
@@ -41,7 +41,12 @@
accountBalances,
accountColor,
clipSeries,
+ type ForecastHorizon,
+ forecastHorizons,
+ gridFor,
type HistoryRange,
+ horizonEnd,
+ parseHorizon,
historyStart,
parseHistoryRange,
monthlyCents,
@@ -140,6 +145,27 @@
)
.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(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(
wealthProjection(
data.accounts,
@@ -148,7 +174,8 @@
data.forecasts,
data.recurring,
data.projectionSettings,
- data.today
+ data.today,
+ forecastEnd
)
);
type ProjectionView = ScenarioId | 'compare' | 'simple';
@@ -184,7 +211,7 @@
forecasts: data.forecasts,
futureTransactions,
today: data.today,
- endDate: data.projectionSettings?.endDate
+ endDate: forecastEnd
})
);
let scenarioResults = $derived(
@@ -195,8 +222,30 @@
let selectedScenario = $derived(
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(
- projectionView === 'simple' ? projection : scenarioProjection(scenarioResults[selectedScenario])
+ projectionView === 'simple'
+ ? todaysEuros
+ ? deflated(projection)
+ : projection
+ : scenarioProjection(scenarioResults[selectedScenario], todaysEuros)
);
const timelineViews: { id: TimelineView; label: string }[] = [
{ id: 'total', label: 'Total' },
@@ -223,7 +272,7 @@
projectionView === 'compare' && timelineView === 'total'
? [
...wealthTimeline(data.accounts, actualTransactions, [], data.today, 'total'),
- ...scenarioCompareLines(scenarioResults, scenarioParams, data.today)
+ ...scenarioCompareLines(scenarioResults, scenarioParams, data.today, todaysEuros)
]
: wealthTimeline(
data.accounts,
@@ -235,6 +284,12 @@
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 wishTiers = $derived(
[5, 4, 3, 2, 1].map((tier) => ({
@@ -386,7 +441,9 @@
activeProjection[0].points.at(-1)?.date ?? data.today
)}{projectionView === 'simple'
? ''
- : ` · ${scenarioParams[selectedScenario].label.toLowerCase()}`}
+ : ` · ${scenarioParams[selectedScenario].label.toLowerCase()}`}{todaysEuros
+ ? ' · today’s €'
+ : ''}
{@render signed(projectionEnd[0])}
@@ -433,12 +490,32 @@
>{/each}
+ Forecast setHorizon(event.currentTarget.value)}
+ aria-label="Forecast horizon"
+ >{#each forecastHorizons as item (item)}{horizonLabel(item)} {/each}
+ Nominal € Today’s €
@@ -470,7 +547,9 @@
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
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.
{:else}
Includes future dated transactions and estimates{data.projectionSettings
@@ -1186,8 +1265,8 @@
>
Project throughOptional, up to 20 years ahead. By default, ends at the latest future entry, or one
- year from today. Not used by the chart, which projects as far as the Forecast picker above it (1–100
+ years).