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

@@ -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<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(
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<ScenarioId>(
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 €'
: ''}
</p>
<p class="stat-value">{@render signed(projectionEnd[0])}</p>
</div>
@@ -433,12 +490,32 @@
>{/each}</Tabs.List
></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>
<MoneyChart
series={timeline}
title="Actual and projected wealth over time"
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>
@@ -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.
</p>
{:else}<p class="muted projection-note">
Includes future dated transactions and estimates{data.projectionSettings
@@ -1186,8 +1265,8 @@
>
<label class="field"
>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
year from today.</small
>Not used by the chart, which projects as far as the Forecast picker above it (1–100
years).</small
></label
>
<label class="field check-field"

View File

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