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
All checks were successful
Deploy production / deploy (push) Successful in 2m49s
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user