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:
2026-10-26 01:13:43 +00:00
parent 5d732fc750
commit 807a6b3104
8 changed files with 1163 additions and 248 deletions

View File

@@ -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"