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:
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user