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

@@ -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<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 }[] = [
{ 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 @@
</div>
<div class="stat">
<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 class="stat-value">{@render signed(projectionEnd[0])}</p>
</div>
@@ -327,7 +400,7 @@
<div class="panel-heading">
<div>
<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>
<Button
variant="outline"
@@ -343,6 +416,16 @@
>{/each}</Tabs.List
></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.List
>{#each historyRanges as item (item.id)}<Tabs.Trigger value={item.id}
@@ -374,16 +457,31 @@
>
</div>
</div>
<p class="muted projection-note">
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.
</p>
{#if projectionView !== 'simple'}
<ScenarioDetails
bind:params={scenarioParams}
results={scenarioResults}
selected={selectedScenario}
edited={scenarioEdited}
onselect={setProjectionView}
onreset={() => (scenarioParams = structuredClone(defaultScenarios))}
/>
<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
?.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.
</p>{/if}
</section>
<section class="panel span-4" id="accounts">