diff --git a/src/lib/money-tracker/ScenarioDetails.svelte b/src/lib/money-tracker/ScenarioDetails.svelte new file mode 100644 index 0000000..0e72048 --- /dev/null +++ b/src/lib/money-tracker/ScenarioDetails.svelte @@ -0,0 +1,273 @@ + + +
+ {#each scenarioIds as id (id)} + + {/each} +
+ +
+ + {active.label} assumptions, yearly table and milestones{#if edited} · edited{/if} + +
+
+ {#each groups as group (group.title)} +
+ {group.title} + {#each group.fields as field (field.key)} + + {/each} + {#if group.title === 'Markets'} + + {/if} +
+ {/each} +
+
+

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

+ +
+
+ + {#each result.years as row (row.year)}{/each} +
YearIncomeSpendingSavedRateBox 3 paidInvestedNet worthToday’s €
{row.year}{cents(row.income)}{cents(row.spending)}{cents(row.saved)}{Math.round(row.savingsRate * 100)}%{cents(row.box3)}{cents(row.invested)}{cents(row.total)}{cents(row.real)}
+
+ +
+
+ + diff --git a/src/lib/money-tracker/scenarios.test.ts b/src/lib/money-tracker/scenarios.test.ts new file mode 100644 index 0000000..d9004a4 --- /dev/null +++ b/src/lib/money-tracker/scenarios.test.ts @@ -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); + }); +}); diff --git a/src/lib/money-tracker/scenarios.ts b/src/lib/money-tracker/scenarios.ts new file mode 100644 index 0000000..25e0d52 --- /dev/null +++ b/src/lib/money-tracker/scenarios.ts @@ -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 = { + 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; + 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(); + 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 = { + 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, + params: Record, + 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) })) + })); +} diff --git a/src/routes/trackers/money/+page.svelte b/src/routes/trackers/money/+page.svelte index d950576..a581fcc 100644 --- a/src/routes/trackers/money/+page.svelte +++ b/src/routes/trackers/money/+page.svelte @@ -18,6 +18,17 @@ import FileText from 'lucide-svelte/icons/file-text'; import MoneyChart from '#lib/money-tracker/MoneyChart.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 { Badge } from '#lib/components/ui/badge/index.js'; import { resolve } from '$app/paths'; @@ -140,6 +151,53 @@ 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( + 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 + ); + let selectedScenario = $derived( + projectionView === 'compare' || projectionView === 'simple' ? 'realistic' : projectionView + ); + let activeProjection = $derived( + projectionView === 'simple' ? projection : scenarioProjection(scenarioResults[selectedScenario]) + ); const timelineViews: { id: TimelineView; label: string }[] = [ { id: 'total', label: 'Total' }, { id: 'split', label: 'Spendable vs invested' }, @@ -162,11 +220,22 @@ } let timeline = $derived( 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) ) ); - 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( [5, 4, 3, 2, 1].map((tier) => ({ tier, @@ -313,7 +382,11 @@

- 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()}`}

{@render signed(projectionEnd[0])}

@@ -327,7 +400,7 @@

Wealth over time

-

Actual balances through today, then the projection.

+

Actual balances through today, then the selected projection scenario.

-

- Includes future dated transactions and estimates{data.projectionSettings - ?.includeRecurring !== false - ? ', plus recurring monthly equivalents from next month' - : ''}. {data.projectionSettings?.investmentAccountId - ? `The selected investment account and new contributions use a hypothetical ${data.projectionSettings.annualReturnPercent}% 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 - transactions, estimates, and recurring items to avoid counting it twice. -

+ {#if projectionView !== 'simple'} + (scenarioParams = structuredClone(defaultScenarios))} + /> +

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

+ {:else}

+ Includes future dated transactions and estimates{data.projectionSettings + ?.includeRecurring !== false + ? ', plus recurring monthly equivalents from next month' + : ''}. {data.projectionSettings?.investmentAccountId + ? `The selected investment account and new contributions use a hypothetical ${data.projectionSettings.annualReturnPercent}% 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 + transactions, estimates, and recurring items to avoid counting it twice. +

{/if}