money: day-by-day forecast with real payment dates
The wealth forecast now runs per day instead of per month, so it shows the same sawtooth as the actual history: - recurring items hit on the day the bank history shows (most common day of the last four months, clamped to month end), else their start date - "every N weeks" items (Basic-Fit, haircut) run on an exact N-day cadence from the last charge; yearly items on their renewal date - salary and its extras (holiday pay in May, 13th month in November, bonus, settlement) land on the payday from history (contract 25th as fallback), moved to the Friday before a weekend; toeslagen move to the Monday after - variable budgets are spread per day, the vacation budget over Jul-Aug - excess above the buffer is invested the day after payday; returns compound daily - long horizons stay fast (~35 ms for 100 years): the chart thins each pixel bucket to its low and high
This commit is contained in:
@@ -18,6 +18,7 @@
|
||||
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 { scheduleFor, scheduleLabel } from '#lib/money-tracker/schedule.js';
|
||||
import ScenarioDetails from '#lib/money-tracker/ScenarioDetails.svelte';
|
||||
import {
|
||||
defaultScenarios,
|
||||
@@ -100,6 +101,16 @@
|
||||
let actualTransactions = $derived(data.transactions.filter((item) => item.date <= data.today));
|
||||
let futureTransactions = $derived(data.transactions.filter((item) => item.date > data.today));
|
||||
let balances = $derived(accountBalances(data.accounts, actualTransactions));
|
||||
// The last ~13 months of bank history: enough to find the day each recurring item hits.
|
||||
let scheduleHistory = $derived.by(() => {
|
||||
const cutoff = new Date(Date.parse(`${data.today}T12:00:00Z`) - 400 * 86_400_000)
|
||||
.toISOString()
|
||||
.slice(0, 10);
|
||||
return actualTransactions.filter((item) => item.date >= cutoff);
|
||||
});
|
||||
let recurringTiming = $derived(
|
||||
new Map(data.recurring.map((row) => [row.id, scheduleFor(row, scheduleHistory, data.today)]))
|
||||
);
|
||||
let totalAccounts = $derived(data.accounts.filter((account) => account.includeInTotal !== false));
|
||||
// Archived accounts (closed banks) stay in history, net worth and the charts, but not in the
|
||||
// quick balances or the account pickers.
|
||||
@@ -192,7 +203,8 @@
|
||||
data.recurring,
|
||||
data.projectionSettings,
|
||||
data.today,
|
||||
forecastEnd
|
||||
forecastEnd,
|
||||
scheduleHistory
|
||||
)
|
||||
);
|
||||
type ProjectionView = ScenarioId | 'compare' | 'simple';
|
||||
@@ -228,7 +240,8 @@
|
||||
forecasts: data.forecasts,
|
||||
futureTransactions,
|
||||
today: data.today,
|
||||
endDate: forecastEnd
|
||||
endDate: forecastEnd,
|
||||
history: scheduleHistory
|
||||
})
|
||||
);
|
||||
let scenarioResults = $derived(
|
||||
@@ -596,17 +609,20 @@
|
||||
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. The history tabs set how far back the chart starts and
|
||||
Forecast how far ahead it runs (1–100 years); Today’s € divides projected values by the
|
||||
scenario’s inflation so long horizons stay comparable.
|
||||
Scenarios start from today’s balances and run day by day: salary and its extras on payday,
|
||||
each recurring item on the day the bank history (or its start date) puts it, budgets such
|
||||
as groceries spread over the month and vacation over July–August, investing the day after
|
||||
payday. They use 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. The history
|
||||
tabs set how far back the chart starts and Forecast how far ahead it runs (1–100 years);
|
||||
Today’s € divides projected values by the scenario’s inflation so long horizons stay
|
||||
comparable.
|
||||
</p>
|
||||
{:else}<p class="muted projection-note">
|
||||
Includes future dated transactions and estimates{data.projectionSettings
|
||||
?.includeRecurring !== false
|
||||
? ', plus recurring monthly equivalents from next month'
|
||||
? ', plus recurring items on the days they hit'
|
||||
: ''}. {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.`}
|
||||
@@ -778,11 +794,14 @@
|
||||
<span class="item-main"
|
||||
>{@render swatch(row.accountId)}<span
|
||||
><strong>{row.name}</strong><small
|
||||
><span class="capitalize">{row.cadence}</span> · {row.accountId
|
||||
? accountName(row.accountId)
|
||||
: 'Any account'} · {row.startsOn ? dateLabel(row.startsOn) : 'Now'} – {row.endsOn
|
||||
? dateLabel(row.endsOn)
|
||||
: 'ongoing'}</small
|
||||
><span class="capitalize">{row.cadence}</span
|
||||
>{#if recurringTiming.get(row.id)}<span
|
||||
title={recurringTiming.get(row.id)!.source}
|
||||
>
|
||||
· {scheduleLabel(recurringTiming.get(row.id)!)}</span
|
||||
>{/if} · {row.accountId ? accountName(row.accountId) : 'Any account'} · {row.startsOn
|
||||
? dateLabel(row.startsOn)
|
||||
: 'Now'} – {row.endsOn ? dateLabel(row.endsOn) : 'ongoing'}</small
|
||||
></span
|
||||
></span
|
||||
><strong class="item-amount">{@render signed(row.amountCents)}</strong
|
||||
@@ -1254,8 +1273,8 @@
|
||||
>
|
||||
{:else if mode === 'projectionSettings'}
|
||||
<p class="dialog-copy">
|
||||
A planning scenario. Returns are hypothetical and calculated monthly. Contributions
|
||||
transfer money from spendable accounts into a projected investment balance.
|
||||
A planning scenario. Returns are hypothetical and compound daily. Contributions transfer
|
||||
money from spendable accounts into a projected investment balance.
|
||||
</p>
|
||||
<label class="field"
|
||||
>Existing investment account (optional)<NativeSelect name="investmentAccountId"
|
||||
|
||||
Reference in New Issue
Block a user