money: pessimistic/realistic/optimistic scenarios on the wealth chart (realistic by default), editable assumptions, yearly table and milestones
All checks were successful
Deploy production / deploy (push) Successful in 2m30s

This commit is contained in:
2026-10-24 14:39:25 +00:00
parent 35f78ec829
commit 8a29d5ad59
4 changed files with 994 additions and 14 deletions

View File

@@ -0,0 +1,273 @@
<script lang="ts">
import { Button } from '#lib/components/ui/button/index.js';
import { Input } from '#lib/components/ui/input/index.js';
import { NativeSelect } from '#lib/components/ui/native-select/index.js';
import {
scenarioColor,
scenarioFacts,
scenarioIds,
type ScenarioId,
type ScenarioParams,
type ScenarioResult
} from './scenarios';
let {
params = $bindable(),
results,
selected,
edited,
onselect,
onreset
}: {
/** Editable assumptions; bound inputs write straight into this object. */
params: Record<ScenarioId, ScenarioParams>;
results: Record<ScenarioId, ScenarioResult>;
selected: ScenarioId;
edited: boolean;
onselect: (id: ScenarioId) => void;
onreset: () => void;
} = $props();
type NumberField = {
[K in keyof ScenarioParams]: ScenarioParams[K] extends number ? K : never;
}[keyof ScenarioParams];
const groups: { title: string; fields: { key: NumberField; label: string; unit: string }[] }[] = [
{
title: 'Markets',
fields: [
{ key: 'returnPercent', label: 'Yearly return', unit: '%' },
{ key: 'crashPercent', label: 'Crash (0 = none)', unit: '%' },
{ key: 'crashYear', label: 'Crash year', unit: '' },
{ key: 'recoveryPercent', label: 'Return after a crash', unit: '%' },
{ key: 'cashRatePercent', label: 'Interest on cash', unit: '%' },
{ key: 'inflationPercent', label: 'Inflation', unit: '%' }
]
},
{
title: 'Work',
fields: [
{ key: 'gapMonths', label: 'Gap after Sep 2027', unit: 'mo' },
{ key: 'gapNetEuros', label: 'Benefit during 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: 'bonusPercentOfTarget', label: 'Bonus paid of target', unit: '%' },
{ key: 'sideStartEuros', label: 'Side income now (gross)', unit: '€/mo' },
{ key: 'sideEndEuros', label: 'Side income at end (gross)', unit: '€/mo' }
]
},
{
title: 'Spending',
fields: [
{ key: 'spendGrowthPercent', label: 'Spending growth', 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: '%' },
{ key: 'householdExtraEuros', label: 'Household extras', unit: '€/mo' },
{ key: 'moveCostEuros', label: 'Moving costs (once)', unit: '€' }
]
}
];
const euros = new Intl.NumberFormat('en-NL', {
style: 'currency',
currency: 'EUR',
maximumFractionDigits: 0
});
const cents = (value: number) => euros.format(value / 100);
const monthLabel = (value: string | null) =>
value
? new Date(`${value}-01T12:00:00Z`).toLocaleDateString('en-NL', {
month: 'short',
year: 'numeric',
timeZone: 'UTC'
})
: 'Not by the end';
let active = $derived(params[selected]);
let result = $derived(results[selected]);
</script>
<div class="scenario-cards" role="group" aria-label="Scenario end values">
{#each scenarioIds as id (id)}
<button
type="button"
class={['scenario-card', id === selected && 'active']}
style:--scenario={scenarioColor(id)}
aria-pressed={id === selected}
onclick={() => onselect(id)}
>
<span class="eyebrow">{params[id].label}</span>
<strong>{cents(results[id].end.total)}</strong>
<small>{cents(results[id].end.real)} in today’s euros</small>
</button>
{/each}
</div>
<details class="scenario-details">
<summary>
{active.label} assumptions, yearly table and milestones{#if edited}&nbsp;· edited{/if}
</summary>
<div class="scenario-body">
<div class="scenario-grid">
{#each groups as group (group.title)}
<fieldset>
<legend>{group.title}</legend>
{#each group.fields as field (field.key)}
<label
><span>{field.label}{field.unit ? ` (${field.unit})` : ''}</span><Input
type="number"
step="any"
bind:value={params[selected][field.key]}
/></label
>
{/each}
{#if group.title === 'Markets'}
<label
><span>Box 3 (36%, from 2028)</span><NativeSelect bind:value={params[selected].box3}
><option value="accrual">Yearly gains</option><option value="realisation"
>When sold</option
></NativeSelect
></label
>
{/if}
</fieldset>
{/each}
</div>
<div class="scenario-actions">
<p class="muted">
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}.
</p>
<Button variant="outline" disabled={!edited} onclick={onreset}>Reset assumptions</Button>
</div>
<div class="table-wrap">
<table>
<thead
><tr
><th>Year</th><th class="numeric">Income</th><th class="numeric">Spending</th><th
class="numeric">Saved</th
><th class="numeric">Rate</th><th class="numeric">Box 3 paid</th><th class="numeric"
>Invested</th
><th class="numeric">Net worth</th><th class="numeric">Today’s €</th></tr
></thead
><tbody
>{#each result.years as row (row.year)}<tr
><td>{row.year}</td><td class="numeric">{cents(row.income)}</td><td class="numeric"
>{cents(row.spending)}</td
><td class="numeric">{cents(row.saved)}</td><td class="numeric"
>{Math.round(row.savingsRate * 100)}%</td
><td class="numeric">{cents(row.box3)}</td><td class="numeric"
>{cents(row.invested)}</td
><td class="numeric strong">{cents(row.total)}</td><td class="numeric"
>{cents(row.real)}</td
></tr
>{/each}</tbody
>
</table>
</div>
<ul class="scenario-milestones" aria-label="Milestones">
{#each result.milestones as milestone (milestone.label)}
<li>
<span class="eyebrow">{milestone.label}</span><strong>{monthLabel(milestone.date)}</strong
>
</li>
{/each}
</ul>
</div>
</details>
<style>
.scenario-cards {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.5rem;
margin-top: 1rem;
}
.scenario-card {
display: flex;
flex-direction: column;
gap: 0.15rem;
padding: 0.6rem 0.75rem;
text-align: left;
border: 1px solid var(--site-border, #e5e5e5);
border-left: 4px solid var(--scenario);
border-radius: 0.5rem;
background: transparent;
cursor: pointer;
}
.scenario-card.active {
background: color-mix(in srgb, var(--scenario) 8%, transparent);
}
.scenario-card strong {
font-size: 1.1rem;
font-variant-numeric: tabular-nums;
}
.scenario-card small {
color: var(--site-muted, #666);
}
.scenario-details {
margin-top: 1rem;
}
.scenario-details summary {
cursor: pointer;
font-weight: 600;
}
.scenario-body {
display: flex;
flex-direction: column;
gap: 1rem;
margin-top: 0.75rem;
}
.scenario-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr));
gap: 1rem;
}
fieldset {
display: flex;
flex-direction: column;
gap: 0.4rem;
min-width: 0;
}
legend {
font-weight: 600;
margin-bottom: 0.25rem;
}
label {
display: grid;
grid-template-columns: 1fr 8rem;
align-items: center;
gap: 0.5rem;
font-size: 0.85rem;
}
.scenario-actions {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
justify-content: space-between;
gap: 0.75rem;
}
.scenario-actions p {
flex: 1 1 20rem;
font-size: 0.85rem;
}
.scenario-milestones {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(8rem, 1fr));
gap: 0.5rem;
list-style: none;
padding: 0;
}
.scenario-milestones li {
display: flex;
flex-direction: column;
}
@media (max-width: 520px) {
.scenario-cards {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -0,0 +1,103 @@
import { describe, expect, it } from 'vitest';
import { defaultScenarios, runScenario, scenarioInputs, type ScenarioParams } from './scenarios';
const accounts = [
{ id: 1, name: 'Revolut', kind: 'checking' },
{ id: 4, name: 'Joint', kind: 'checking' },
{ id: 5, name: 'Broker', kind: 'investment' },
{ id: 9, name: 'Savings', kind: 'savings', includeInTotal: false }
];
const inputs = scenarioInputs({
accounts,
balances: new Map([
[1, 200000],
[4, 100000],
[5, 50000],
[9, 999999]
]),
recurring: [
{ name: 'Cargill salary', amountCents: 300000, cadence: 'monthly', accountId: 1 },
{ name: 'Gym', amountCents: -3000, cadence: 'monthly', accountId: 1 },
{ name: 'Benefit', amountCents: 10000, cadence: 'monthly', accountId: 1, endsOn: '2026-12-31' },
{
name: 'Rent + utilities (estimate)',
amountCents: -180000,
cadence: 'monthly',
accountId: 4,
startsOn: '2027-03-01'
},
{ name: 'Partner income', amountCents: 250000, cadence: 'monthly', accountId: 4 },
{ name: 'Hidden', amountCents: -5000, cadence: 'monthly', accountId: 9 }
],
forecasts: [
{ date: '2026-11-15', amountCents: 28000, description: 'Side job', accountId: 1 },
{ date: '2027-09-30', amountCents: 250000, description: 'Bonus Cargill', accountId: 1 }
],
futureTransactions: [],
today: '2026-10-24',
endDate: '2030-12-31'
});
describe('scenarioInputs', () => {
it('splits tracker data into balances, salary, personal and household items', () => {
expect(inputs.spendableCents).toBe(300000);
expect(inputs.investedCents).toBe(50000);
expect(inputs.netSalaryCents).toBe(300000);
expect(inputs.personal.map((item) => item.name)).toEqual(['Gym', 'Benefit']);
expect(inputs.household.map((item) => item.name)).toEqual(['Rent + utilities (estimate)']);
expect(inputs.moveDate).toBe('2027-03-01');
// Salary extras are modelled by the scenarios, other one-offs pass through.
expect(inputs.oneOffs.map((item) => item.description)).toEqual(['Side job']);
});
});
describe('runScenario', () => {
const flat: ScenarioParams = {
...defaultScenarios.realistic,
returnPercent: 0,
recoveryPercent: 0,
cashRatePercent: 0,
sideStartEuros: 0,
sideEndEuros: 0,
spendGrowthPercent: 0,
otherSpendEuros: 0,
householdExtraEuros: 0,
healthInsuranceEuros: 0,
moveCostEuros: 0,
bonusPercentOfTarget: 0
};
it('runs monthly to the end date and starts from today', () => {
const result = runScenario(inputs, flat);
expect(result.points[0]).toMatchObject({ date: '2026-10-24', total: 350000 });
expect(result.end.date).toBe('2030-12-31');
expect(result.points).toHaveLength(51);
expect(result.years.map((row) => row.year)).toEqual([2026, 2027, 2028, 2029, 2030]);
});
it('fills the buffer before investing and counts recurring income and costs', () => {
const result = runScenario(inputs, flat);
const november = result.points[1];
expect(november.cash).toBe(500000);
// 300k salary + 10k benefit + 28k one-off − 3k gym − 90k pre-move household.
expect(november.total).toBeCloseTo(350000 + 300000 + 10000 + 28000 - 3000 - 90000, 0);
});
it('orders the default scenarios from pessimistic to optimistic', () => {
const [pessimistic, realistic, optimistic] = (
['pessimistic', 'realistic', 'optimistic'] as const
).map((id) => runScenario(inputs, defaultScenarios[id]).end.total);
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 });
const crash = runScenario(inputs, {
...flat,
returnPercent: 5,
recoveryPercent: 5,
crashPercent: -30,
crashYear: 2027
});
expect(crash.end.total).toBeLessThan(calm.end.total);
const gap = runScenario(inputs, { ...flat, gapMonths: 3, gapNetEuros: 1000 });
expect(gap.end.total).toBeLessThan(runScenario(inputs, flat).end.total);
});
});

View File

@@ -0,0 +1,506 @@
/**
* Pessimistic / realistic / optimistic wealth scenarios to the projection end date.
*
* 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)
* 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.
*/
import type { ChartSeries } from './charts';
export type ScenarioId = 'pessimistic' | 'realistic' | 'optimistic';
export const scenarioIds: ScenarioId[] = ['pessimistic', 'realistic', 'optimistic'];
export type ScenarioParams = {
label: string;
/** Yearly investment return before any crash, in percent. */
returnPercent: number;
/** Market drop in the crash year (percent, e.g. -28); 0 means no crash. */
crashPercent: number;
crashYear: number;
/** Yearly return in the years after the crash, in percent. */
recoveryPercent: number;
/** Interest on the cash buffer, percent per year. */
cashRatePercent: number;
/** Months without a job after the contract ends (unemployment benefit instead). */
gapMonths: number;
/** Net unemployment benefit per month during the gap, euros. */
gapNetEuros: number;
/** Gross salary change at the extension or next job, percent. */
nextJobPercent: number;
/** Yearly raise in percent, applied each raiseMonth from raiseFromYear. */
raisePercent: number;
raiseMonth: number;
raiseFromYear: number;
/** Share of the 5% target bonus actually paid each September, 0–100. */
bonusPercentOfTarget: number;
/** Side income, gross euros per month, ramping linearly to the end value. */
sideStartEuros: number;
sideEndEuros: number;
/** Spending growth per year (lifestyle creep plus prices), percent. */
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. */
otherSpendEuros: number;
/** Household costs on top of the Joint recurring items after the move, euros/month. */
householdExtraEuros: number;
/** Health insurance (premium unknown yet), euros/month from January 2027. */
healthInsuranceEuros: number;
/** One-off moving costs in the move month, euros. */
moveCostEuros: number;
/** Box 3: tax on yearly gains from 2028 ('accrual'), or only when sold ('realisation'). */
box3: 'accrual' | 'realisation';
};
export const defaultScenarios: Record<ScenarioId, ScenarioParams> = {
pessimistic: {
label: 'Pessimistic',
returnPercent: 0,
crashPercent: -28,
crashYear: 2027,
recoveryPercent: 15,
cashRatePercent: 0,
gapMonths: 3,
gapNetEuros: 2300,
nextJobPercent: 0,
raisePercent: 2,
raiseMonth: 1,
raiseFromYear: 2029,
bonusPercentOfTarget: 0,
sideStartEuros: 100,
sideEndEuros: 100,
spendGrowthPercent: 3.5,
inflationPercent: 2.5,
householdSharePercent: 57,
otherSpendEuros: 400,
householdExtraEuros: 200,
healthInsuranceEuros: 165,
moveCostEuros: 2000,
box3: 'accrual'
},
realistic: {
label: 'Realistic',
returnPercent: 6.5,
crashPercent: 0,
crashYear: 2027,
recoveryPercent: 6.5,
cashRatePercent: 1.5,
gapMonths: 0,
gapNetEuros: 2300,
nextJobPercent: 3,
raisePercent: 3.5,
raiseMonth: 10,
raiseFromYear: 2028,
bonusPercentOfTarget: 50,
sideStartEuros: 250,
sideEndEuros: 500,
spendGrowthPercent: 2.5,
inflationPercent: 2.5,
householdSharePercent: 50,
otherSpendEuros: 330,
householdExtraEuros: 65,
healthInsuranceEuros: 165,
moveCostEuros: 1000,
box3: 'accrual'
},
optimistic: {
label: 'Optimistic',
returnPercent: 9,
crashPercent: 0,
crashYear: 2027,
recoveryPercent: 9,
cashRatePercent: 1.5,
gapMonths: 0,
gapNetEuros: 2300,
nextJobPercent: 6,
raisePercent: 5,
raiseMonth: 10,
raiseFromYear: 2028,
bonusPercentOfTarget: 100,
sideStartEuros: 300,
sideEndEuros: 2500,
spendGrowthPercent: 2,
inflationPercent: 2.5,
householdSharePercent: 45,
otherSpendEuros: 280,
householdExtraEuros: 0,
healthInsuranceEuros: 165,
moveCostEuros: 500,
box3: 'realisation'
}
};
/** Fixed facts shared by every scenario (contract, tax rules, buffer). */
export const scenarioFacts = {
/** Contract gross per month and the date the current contract ends. */
grossCents: 372493,
contractEnd: '2027-09-30',
/** Extra net per extra gross euro: about 50% marginal tax after 2.65% pension. */
marginalNet: (1 - 0.0265) * 0.5,
/** Holiday pay (8%) and 13th month accrue monthly; paid half net in May and December. */
holidayPayRate: 0.08,
/** Target bonus as a share of yearly gross (12.96 months), paid half net in September. */
bonusTarget: 0.05,
sideTaxRate: 0.4,
bufferCents: 500000,
/** Joint household contribution before the move, per month (Apr–Oct 2026 average). */
preMoveHouseholdCents: 90000,
/** DUO study-debt repayment from 2029, per month. */
duoFrom: 2029,
duoCents: 10000
};
export type ScenarioRecurring = {
name: string;
amountCents: number;
cadence: string;
accountId: number | null;
startsOn?: string | null;
endsOn?: string | null;
};
export type ScenarioMovement = { date: string; amountCents: number; description: string };
export type ScenarioInputs = {
today: string;
endDate: string;
spendableCents: number;
investedCents: number;
/** Net salary per month from the recurring list. */
netSalaryCents: number;
/** Recurring items other than salary and Joint income. */
personal: ScenarioRecurring[];
/** Joint recurring costs, shared after the move. */
household: ScenarioRecurring[];
/** Rent start: the move month. */
moveDate: string | null;
oneOffs: ScenarioMovement[];
};
export type ScenarioPoint = {
date: string;
cash: number;
invested: number;
total: number;
real: number;
};
export type ScenarioYear = {
year: number;
income: number;
spending: number;
saved: number;
savingsRate: number;
total: number;
invested: number;
real: number;
box3: number;
};
export type ScenarioResult = {
points: ScenarioPoint[];
years: ScenarioYear[];
milestones: { label: string; date: string | null }[];
end: ScenarioPoint;
grossEndCents: number;
};
const salaryPattern = /salary|salaris/i;
/** Tracker estimates the scenarios model themselves (holiday pay, 13th month, bonus). */
const modelledPattern = /cargill|holiday|vakantiegeld|13th|dertiende|bonus/i;
function monthly(item: ScenarioRecurring) {
return item.cadence === 'weekly'
? (item.amountCents * 52) / 12
: item.cadence === 'yearly'
? item.amountCents / 12
: item.amountCents;
}
function activeIn(item: ScenarioRecurring, first: string, last: string) {
return !(item.startsOn && item.startsOn > last) && !(item.endsOn && item.endsOn < first);
}
/** Splits tracker data into the pieces the scenario model needs. */
export function scenarioInputs(input: {
accounts: { id: number; kind: string; name: string; includeInTotal?: boolean }[];
balances: Map<number, number>;
recurring: ScenarioRecurring[];
forecasts: { date: string; amountCents: number; description: string; accountId: number | null }[];
futureTransactions: {
date: string;
amountCents: number;
description: string;
accountId: number | null;
}[];
today: string;
endDate?: string | null;
}): ScenarioInputs {
const counted = input.accounts.filter((account) => account.includeInTotal !== false);
const countedIds = new Set(counted.map((account) => account.id));
const jointIds = new Set(
input.accounts.filter((account) => /joint/i.test(account.name)).map((account) => account.id)
);
let spendable = 0;
let invested = 0;
for (const account of counted) {
const value = input.balances.get(account.id) ?? 0;
if (account.kind === 'investment') invested += value;
else spendable += value;
}
const inTotal = (accountId: number | null) => accountId === null || countedIds.has(accountId);
const recurring = input.recurring.filter((item) => inTotal(item.accountId));
const salary = recurring.find((item) => item.amountCents > 0 && salaryPattern.test(item.name));
const isJoint = (item: ScenarioRecurring) =>
item.accountId !== null && jointIds.has(item.accountId);
const household = recurring.filter((item) => isJoint(item) && item.amountCents < 0);
const rent = household.find((item) => /rent|huur/i.test(item.name));
const oneOffs = [...input.forecasts, ...input.futureTransactions]
.filter(
(item) =>
item.date > input.today &&
inTotal(item.accountId) &&
!modelledPattern.test(item.description)
)
.map(({ date, amountCents, description }) => ({ date, amountCents, description }));
return {
today: input.today,
endDate: input.endDate && input.endDate > input.today ? input.endDate : '2030-12-31',
spendableCents: spendable,
investedCents: invested,
netSalaryCents: salary?.amountCents ?? 0,
// Joint income (your partner's salary) pays her share of the household, so it stays out.
personal: recurring.filter((item) => item !== salary && !isJoint(item)),
household,
moveDate: rent?.startsOn ?? null,
oneOffs
};
}
function addMonths(date: string, months: number) {
const value = new Date(`${date.slice(0, 7)}-01T12:00:00Z`);
value.setUTCMonth(value.getUTCMonth() + months);
return value.toISOString().slice(0, 10);
}
function monthEnd(first: string) {
const value = new Date(`${first}T12:00:00Z`);
return new Date(Date.UTC(value.getUTCFullYear(), value.getUTCMonth() + 1, 0))
.toISOString()
.slice(0, 10);
}
export function runScenario(inputs: ScenarioInputs, params: ScenarioParams): ScenarioResult {
const facts = scenarioFacts;
const baseGross = facts.grossCents;
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;
let cash = inputs.spendableCents;
let invested = inputs.investedCents;
let gross = baseGross;
let accruedHoliday = baseGross * facts.holidayPayRate;
let accrued13 = baseGross / 12;
let investedAtYearStart = invested;
let contributedThisYear = 0;
let box3Due = 0;
const points: ScenarioPoint[] = [
{ date: inputs.today, cash, invested, total: cash + invested, real: cash + invested }
];
const years = new Map<number, ScenarioYear>();
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`,
date: null,
test: () => invested >= euros * 100
}))
];
const firstMonth = addMonths(inputs.today, 1);
const totalMonths =
(Number(inputs.endDate.slice(0, 4)) - Number(firstMonth.slice(0, 4))) * 12 +
Number(inputs.endDate.slice(5, 7)) -
Number(firstMonth.slice(5, 7)) +
1;
const moveDate = inputs.moveDate ?? '9999-12-31';
for (let index = 0; index < Math.min(totalMonths, 600); 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));
const row =
years.get(year) ??
years
.set(year, {
year,
income: 0,
spending: 0,
saved: 0,
savingsRate: 0,
total: 0,
invested: 0,
real: 0,
box3: 0
})
.get(year)!;
if (month === 1) {
investedAtYearStart = invested;
contributedThisYear = 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)
gross *= 1 + pct(params.raisePercent);
let income = employed ? netFromGross(gross) : params.gapNetEuros * 100;
if (employed) {
accruedHoliday += gross * facts.holidayPayRate;
accrued13 += gross / 12;
}
if (month === 5) {
income += accruedHoliday * 0.5;
accruedHoliday = 0;
}
if (month === 12) {
income += accrued13 * 0.5;
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) * 0.5;
accruedHoliday = 0;
accrued13 = 0;
}
if (month === 9 && first >= addMonths(facts.contractEnd, 0) && employed)
income += pct(params.bonusPercentOfTarget) * facts.bonusTarget * gross * 12.96 * 0.5;
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);
// 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));
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;
}
spending += params.otherSpendEuros * 100 * growth;
if (first >= '2027-01-01') spending += params.healthInsuranceEuros * 100 * growth;
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;
}
if (first === moveDate.slice(0, 7) + '-01') spending += params.moveCostEuros * 100;
if (year >= facts.duoFrom) 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;
else spending -= item.amountCents;
}
let tax = 0;
if (month === 5) {
tax = box3Due;
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;
invested += invested * rate;
cash *= Math.pow(1 + pct(params.cashRatePercent), 1 / 12);
if (cash > facts.bufferCents) {
const extra = cash - facts.bufferCents;
invested += extra;
contributedThisYear += extra;
cash = facts.bufferCents;
} else if (cash < 0 && invested > 0) {
const sold = Math.min(invested, -cash);
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 {
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;
}
}
const total = cash + invested;
const elapsed = (index + 1) / 12;
const real = total / Math.pow(1 + pct(params.inflationPercent), elapsed);
points.push({ date, cash, invested, total, real });
row.income += income;
row.spending += spending + tax;
row.box3 += tax;
row.total = total;
row.invested = invested;
row.real = real;
for (const milestone of milestones)
if (milestone.date === null && milestone.test()) milestone.date = first.slice(0, 7);
}
for (const row of years.values()) {
row.saved = row.income - row.spending;
row.savingsRate = row.income ? row.saved / row.income : 0;
}
return {
points,
years: [...years.values()],
milestones: milestones.map(({ label, date }) => ({ label, date })),
end: points.at(-1)!,
grossEndCents: gross
};
}
const scenarioColors: Record<ScenarioId, string> = {
pessimistic: '#e5484d',
realistic: '#0070f3',
optimistic: '#46a758'
};
export function scenarioColor(id: ScenarioId) {
return scenarioColors[id];
}
/** Total, spendable and invested lines in the shape `wealthTimeline` expects for a projection. */
export function scenarioProjection(result: ScenarioResult): ChartSeries[] {
const line = (key: 'total' | 'cash' | 'invested') =>
result.points.map((point) => ({ date: point.date, value: Math.round(point[key]) }));
return [
{ label: 'Total wealth', color: '#0070f3', points: line('total'), emphasis: true },
{ label: 'Spendable', color: '#46a758', points: line('cash') },
{ label: 'Invested', color: '#8e4ec6', points: line('invested') }
];
}
/** One dashed total line per scenario, for comparing them on the wealth chart. */
export function scenarioCompareLines(
results: Record<ScenarioId, ScenarioResult>,
params: Record<ScenarioId, ScenarioParams>,
today: string
): ChartSeries[] {
return scenarioIds.map((id) => ({
label: params[id].label,
color: scenarioColors[id],
dashedFrom: today,
points: results[id].points
.filter((point) => point.date > today)
.map((point) => ({ date: point.date, value: Math.round(point.total) }))
}));
}

View File

@@ -18,6 +18,17 @@
import FileText from 'lucide-svelte/icons/file-text'; import FileText from 'lucide-svelte/icons/file-text';
import MoneyChart from '#lib/money-tracker/MoneyChart.svelte'; import MoneyChart from '#lib/money-tracker/MoneyChart.svelte';
import QuickAdd from '#lib/money-tracker/QuickAdd.svelte'; import QuickAdd from '#lib/money-tracker/QuickAdd.svelte';
import ScenarioDetails from '#lib/money-tracker/ScenarioDetails.svelte';
import {
defaultScenarios,
runScenario,
scenarioCompareLines,
scenarioIds,
scenarioInputs,
scenarioProjection,
type ScenarioId,
type ScenarioResult
} from '#lib/money-tracker/scenarios.js';
import { compactAmount, sortWishlist, tierClass } from '#lib/money-tracker/wishlist.js'; import { compactAmount, sortWishlist, tierClass } from '#lib/money-tracker/wishlist.js';
import { Badge } from '#lib/components/ui/badge/index.js'; import { Badge } from '#lib/components/ui/badge/index.js';
import { resolve } from '$app/paths'; import { resolve } from '$app/paths';
@@ -140,6 +151,53 @@
data.today data.today
) )
); );
type ProjectionView = ScenarioId | 'compare' | 'simple';
const projectionViews: { id: ProjectionView; label: string }[] = [
{ id: 'pessimistic', label: 'Pessimistic' },
{ id: 'realistic', label: 'Realistic' },
{ id: 'optimistic', label: 'Optimistic' },
{ id: 'compare', label: 'Compare' },
{ id: 'simple', label: 'Simple' }
];
function parseProjectionView(value: string | null): ProjectionView {
return projectionViews.find((item) => item.id === value)?.id ?? 'realistic';
}
// The scenario lives in ?scenario= like the range; realistic is the default.
let projectionView = $state<ProjectionView>(
parseProjectionView(page.url.searchParams.get('scenario'))
);
function setProjectionView(value: string) {
projectionView = parseProjectionView(value);
const url = new URL(window.location.href);
url.searchParams.set('scenario', projectionView);
replaceState(url, page.state);
}
let scenarioParams = $state(structuredClone(defaultScenarios));
let scenarioEdited = $derived(
JSON.stringify(scenarioParams) !== JSON.stringify(defaultScenarios)
);
let scenarioData = $derived(
scenarioInputs({
accounts: data.accounts,
balances,
recurring: data.recurring,
forecasts: data.forecasts,
futureTransactions,
today: data.today,
endDate: data.projectionSettings?.endDate
})
);
let scenarioResults = $derived(
Object.fromEntries(
scenarioIds.map((id) => [id, runScenario(scenarioData, scenarioParams[id])])
) as Record<ScenarioId, ScenarioResult>
);
let selectedScenario = $derived<ScenarioId>(
projectionView === 'compare' || projectionView === 'simple' ? 'realistic' : projectionView
);
let activeProjection = $derived(
projectionView === 'simple' ? projection : scenarioProjection(scenarioResults[selectedScenario])
);
const timelineViews: { id: TimelineView; label: string }[] = [ const timelineViews: { id: TimelineView; label: string }[] = [
{ id: 'total', label: 'Total' }, { id: 'total', label: 'Total' },
{ id: 'split', label: 'Spendable vs invested' }, { id: 'split', label: 'Spendable vs invested' },
@@ -162,11 +220,22 @@
} }
let timeline = $derived( let timeline = $derived(
clipSeries( clipSeries(
wealthTimeline(data.accounts, actualTransactions, projection, data.today, timelineView), projectionView === 'compare' && timelineView === 'total'
? [
...wealthTimeline(data.accounts, actualTransactions, [], data.today, 'total'),
...scenarioCompareLines(scenarioResults, scenarioParams, data.today)
]
: wealthTimeline(
data.accounts,
actualTransactions,
activeProjection,
data.today,
timelineView
),
historyStart(historyRange, data.today) historyStart(historyRange, data.today)
) )
); );
let projectionEnd = $derived(projection.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) => ({
tier, tier,
@@ -313,7 +382,11 @@
</div> </div>
<div class="stat"> <div class="stat">
<p class="eyebrow"> <p class="eyebrow">
Projected {dateLabel(projection[0].points.at(-1)?.date ?? data.today)} Projected {dateLabel(
activeProjection[0].points.at(-1)?.date ?? data.today
)}{projectionView === 'simple'
? ''
: ` · ${scenarioParams[selectedScenario].label.toLowerCase()}`}
</p> </p>
<p class="stat-value">{@render signed(projectionEnd[0])}</p> <p class="stat-value">{@render signed(projectionEnd[0])}</p>
</div> </div>
@@ -327,7 +400,7 @@
<div class="panel-heading"> <div class="panel-heading">
<div> <div>
<h2>Wealth over time</h2> <h2>Wealth over time</h2>
<p>Actual balances through today, then the projection.</p> <p>Actual balances through today, then the selected projection scenario.</p>
</div> </div>
<Button <Button
variant="outline" variant="outline"
@@ -343,6 +416,16 @@
>{/each}</Tabs.List >{/each}</Tabs.List
></Tabs.Root ></Tabs.Root
> >
<Tabs.Root
value={projectionView}
onValueChange={setProjectionView}
aria-label="Projection scenario"
><Tabs.List
>{#each projectionViews as item (item.id)}<Tabs.Trigger value={item.id}
>{item.label}</Tabs.Trigger
>{/each}</Tabs.List
></Tabs.Root
>
<Tabs.Root value={historyRange} onValueChange={setHistoryRange} aria-label="History range" <Tabs.Root value={historyRange} onValueChange={setHistoryRange} aria-label="History range"
><Tabs.List ><Tabs.List
>{#each historyRanges as item (item.id)}<Tabs.Trigger value={item.id} >{#each historyRanges as item (item.id)}<Tabs.Trigger value={item.id}
@@ -374,7 +457,22 @@
> >
</div> </div>
</div> </div>
{#if projectionView !== 'simple'}
<ScenarioDetails
bind:params={scenarioParams}
results={scenarioResults}
selected={selectedScenario}
edited={scenarioEdited}
onselect={setProjectionView}
onreset={() => (scenarioParams = structuredClone(defaultScenarios))}
/>
<p class="muted projection-note"> <p class="muted projection-note">
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.
</p>
{:else}<p class="muted projection-note">
Includes future dated transactions and estimates{data.projectionSettings Includes future dated transactions and estimates{data.projectionSettings
?.includeRecurring !== false ?.includeRecurring !== false
? ', plus recurring monthly equivalents from next month' ? ', plus recurring monthly equivalents from next month'
@@ -383,7 +481,7 @@
: `Excess above ${amount(data.projectionSettings?.reserveCents ?? 100000)} moves into a projected investment balance with a hypothetical ${data.projectionSettings?.annualReturnPercent ?? '5.00'}% annual return.`} : `Excess above ${amount(data.projectionSettings?.reserveCents ?? 100000)} moves into a projected investment balance with a hypothetical ${data.projectionSettings?.annualReturnPercent ?? '5.00'}% annual return.`}
Contributions move money between spendable and invested balances. Enter an item only once across Contributions move money between spendable and invested balances. Enter an item only once across
transactions, estimates, and recurring items to avoid counting it twice. transactions, estimates, and recurring items to avoid counting it twice.
</p> </p>{/if}
</section> </section>
<section class="panel span-4" id="accounts"> <section class="panel span-4" id="accounts">