From 61ae72e50bcbdc739700a85d2b88eb38d0858126 Mon Sep 17 00:00:00 2001 From: Stan Runge Date: Sat, 24 Oct 2026 15:13:20 +0000 Subject: [PATCH] =?UTF-8?q?money=20scenarios:=20forecast=20horizon=201?= =?UTF-8?q?=E2=80=93100y=20(=3Fhorizon=3D),=20today=E2=80=99s-euro=20view,?= =?UTF-8?q?=20scaling=20gridlines,=20career=20paths=20with=20job=20hops=20?= =?UTF-8?q?and=20plateaus,=20crash=20cycles=20per=20portfolio,=20retiremen?= =?UTF-8?q?t=20at=2068=20with=20withdrawals,=20salary=20path=20table?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/lib/money-tracker/MoneyChart.svelte | 6 +- src/lib/money-tracker/ScenarioDetails.svelte | 118 +++++- src/lib/money-tracker/charts.test.ts | 24 ++ src/lib/money-tracker/charts.ts | 57 ++- src/lib/money-tracker/scenarios.test.ts | 67 +++- src/lib/money-tracker/scenarios.ts | 395 ++++++++++++++----- src/routes/trackers/money/+page.svelte | 97 ++++- src/routes/trackers/money/money-tracker.css | 7 + 8 files changed, 642 insertions(+), 129 deletions(-) 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'} +
+

+ {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.

@@ -168,6 +225,32 @@ > +
+ {active.label} salary path +
+ + {#each career as row (row.year)}{/each} +
YearAgeRole (band)Gross/moBase/yr, today’s €Change
{row.year}{row.age}{row.role}{row.grossMonthly ? cents(row.grossMonthly) : '–'}{row.baseReal ? cents(row.baseReal * 100) : '–'}{row.events.join('; ')}
+
+

+ Roles follow the base salary in today’s euros: + {salaryBands.map((band) => `${band.role} from ${cents(band.fromEuros * 100)}`).join(', ')}. +

+