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
All checks were successful
Deploy production / deploy (push) Successful in 2m30s
This commit is contained in:
273
src/lib/money-tracker/ScenarioDetails.svelte
Normal file
273
src/lib/money-tracker/ScenarioDetails.svelte
Normal 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} · 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>
|
||||||
103
src/lib/money-tracker/scenarios.test.ts
Normal file
103
src/lib/money-tracker/scenarios.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
506
src/lib/money-tracker/scenarios.ts
Normal file
506
src/lib/money-tracker/scenarios.ts
Normal 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) }))
|
||||||
|
}));
|
||||||
|
}
|
||||||
@@ -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">
|
||||||
|
|||||||
Reference in New Issue
Block a user