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

@@ -1,8 +1,15 @@
<script lang="ts">
import { timeGrid, yearLabels, type ChartPoint, type ChartSeries, type GridMode } from './charts';
import {
decimateSeries,
timeGrid,
yearLabels,
type ChartPoint,
type ChartSeries,
type GridMode
} from './charts';
let {
series,
series: input,
title,
unit = '€',
marker,
@@ -24,6 +31,8 @@
let compact = $derived(width < 520);
let height = $derived(compact ? 240 : 280);
let plot = $derived({ left: compact ? 52 : 72, right: 14, top: 18, bottom: 30 });
// Daily projections over decades: keep each pixel column's low and high so spikes stay.
let series = $derived(decimateSeries(input, width - plot.left - plot.right));
let values = $derived(series.flatMap((item) => item.points.map((point) => point.value)));
let times = $derived(series.flatMap((item) => item.points.map((point) => time(point.date))));
let scale = $derived(niceScale(Math.min(0, ...values), Math.max(0, ...values)));

View File

@@ -3,6 +3,7 @@ import {
accountBalances,
accountColor,
balanceSeries,
decimateSeries,
forecastSeries,
parseHistoryRange,
gridFor,
@@ -119,7 +120,18 @@ describe('money tracker calculations', () => {
settings,
'2026-09-23'
);
expect(series[0].points.map((point) => point.value)).toEqual([100000, 100000, 160000, 210244]);
const on = (index: number, date: string) =>
series[index].points.find((point) => point.date === date)?.value;
expect(series[0].points).toHaveLength(69);
expect(
['2026-09-23', '2026-09-30', '2026-10-31', '2026-11-30'].map((date) => on(0, date))
).toEqual([100000, 100000, 160000, 210244]);
// Day by day: the monthly item lands on the 1st and the estimate on the 15th.
expect(on(1, '2026-10-01')).toBe(140000);
expect(on(1, '2026-10-15')).toBe(160000);
// Excess above the reserve moves into investments at the end of the month without a payday.
expect(on(1, '2026-10-31')).toBe(100000);
expect(on(2, '2026-10-31')).toBe(60000);
expect(series[1].points.at(-1)?.value).toBe(100000);
expect(series[2].points.at(-1)?.value).toBe(110244);
});
@@ -151,9 +163,10 @@ describe('money tracker calculations', () => {
},
'2026-09-23'
);
expect(projection[0].points.map((point) => point.value)).toEqual([
40054, 40054, 167191, 294602
]);
const total = new Map(projection[0].points.map((point) => [point.date, point.value]));
expect(
['2026-09-23', '2026-09-30', '2026-10-31', '2026-11-30'].map((date) => total.get(date))
).toEqual([40054, 40054, 167191, 294602]);
expect(projection[1].points.at(-1)?.value).toBe(100000);
expect(projection[2].points.at(-1)?.value).toBe(194602);
});
@@ -373,4 +386,62 @@ describe('history range', () => {
'2120'
]);
});
it('draws a day-by-day sawtooth: salary on payday, costs on their days, invest the day after', () => {
const projection = wealthProjection(
[
{ id: 1, kind: 'spendable', openingBalanceCents: 0 },
{ id: 2, kind: 'investment', openingBalanceCents: 0 }
],
new Map([
[1, 150000],
[2, 0]
]),
[],
[],
[
{ name: 'Cargill salary', amountCents: 300000, cadence: 'monthly', accountId: 1 },
{ name: 'Spotify', amountCents: -1199, cadence: 'monthly', accountId: 1 }
],
{
investmentAccountId: 2,
reserveCents: 100000,
annualReturnPercent: '0.00',
contributionMode: 'excess',
fixedMonthlyCents: 0,
includeRecurring: true,
endDate: '2026-12-31'
},
'2026-10-26',
null,
[
{ date: '2026-10-21', amountCents: 298653, description: 'Salaris Cargill' },
{ date: '2026-10-24', amountCents: -1199, description: 'Spotify AB' }
]
);
const cash = new Map(projection[1].points.map((point) => [point.date, point.value]));
const invested = new Map(projection[2].points.map((point) => [point.date, point.value]));
expect(cash.get('2026-11-19')).toBe(150000);
// 21 Nov is a Saturday: paid on Friday the 20th, invested above the reserve on the 21st.
expect(cash.get('2026-11-20')).toBe(450000);
expect(cash.get('2026-11-21')).toBe(100000);
expect(invested.get('2026-11-21')).toBe(350000);
expect(cash.get('2026-11-24')).toBe(100000 - 1199);
expect(cash.get('2026-12-21')).toBe(100000 - 1199 + 300000);
});
it('thins long daily series to each bucket’s low and high, keeping both ends', () => {
const points = Array.from({ length: 3650 }, (_, index) => ({
date: new Date(Date.UTC(2026, 0, 1 + index)).toISOString().slice(0, 10),
value: index % 30 === 0 ? 1000 : index % 30 === 15 ? -1000 : 0
}));
const series = [{ label: 'Total', color: '#000', points }];
const [thin] = decimateSeries(series, 100);
expect(thin.points.length).toBeLessThanOrEqual(202);
expect(thin.points[0]).toEqual(points[0]);
expect(thin.points.at(-1)).toEqual(points.at(-1));
expect(Math.max(...thin.points.map((point) => point.value))).toBe(1000);
expect(Math.min(...thin.points.map((point) => point.value))).toBe(-1000);
expect(decimateSeries(series, 5000)).toBe(series);
});
});

View File

@@ -1,3 +1,14 @@
import {
businessDay,
dayNumber,
dayOfMonth,
daysInMonth,
isoDay,
monthFlows,
scheduleFor,
type HistoryTransaction
} from './schedule';
export type ChartPoint = { date: string; value: number };
export type ChartSeries = {
label: string;
@@ -111,6 +122,7 @@ type ProjectionAccount = {
};
type ProjectionMovement = { date: string; amountCents: number; accountId: number | null };
type ProjectionRecurring = {
name?: string;
amountCents: number;
cadence: string;
accountId: number | null;
@@ -127,6 +139,11 @@ type ProjectionSettings = {
endDate?: string | null;
};
/**
* The setting-based ("Simple") projection, day by day: dated transactions and estimates on their
* date, recurring items on the days they hit (see schedule.ts), returns compounding daily, and the
* excess above the reserve invested the day after payday (month end when there is no salary).
*/
export function wealthProjection(
accounts: ProjectionAccount[],
balances: Map<number, number>,
@@ -136,11 +153,15 @@ export function wealthProjection(
settings: ProjectionSettings | null,
today: string,
/** Overrides the settings' end date, e.g. with the chart's forecast horizon. */
endDate?: string | null
endDate?: string | null,
/** Bank transactions through today, to find the day each recurring item hits. */
history: HistoryTransaction[] = []
): ChartSeries[] {
const excluded = excludedIds(accounts);
const counted = (accountId: number | null) => accountId === null || !excluded.has(accountId);
const accountKind = new Map(accounts.map((account) => [account.id, account.kind]));
const isInvestment = (accountId: number | null) =>
accountId !== null && accountKind.get(accountId) === 'investment';
const investmentId = settings?.investmentAccountId ?? null;
const validInvestmentId =
investmentId !== null && accountKind.get(investmentId) === 'investment' ? investmentId : null;
@@ -156,13 +177,10 @@ export function wealthProjection(
const totalPoints: ChartPoint[] = [{ date: today, value: spendable + invested }];
const spendablePoints: ChartPoint[] = [{ date: today, value: spendable }];
const investedPoints: ChartPoint[] = [{ date: today, value: invested }];
const movements = [...futureTransactions, ...forecasts].filter(
(entry) => entry.date > today && counted(entry.accountId)
);
const latestMovement = movements.reduce(
(latest, entry) => (entry.date > latest ? entry.date : latest),
''
);
const movements = [...futureTransactions, ...forecasts]
.filter((entry) => entry.date > today && counted(entry.accountId))
.sort((a, b) => a.date.localeCompare(b.date));
const latestMovement = movements.at(-1)?.date ?? '';
const fallback = new Date(`${today}T12:00:00Z`);
fallback.setUTCFullYear(fallback.getUTCFullYear() + 1);
const horizon =
@@ -171,43 +189,71 @@ export function wealthProjection(
: settings?.endDate && settings.endDate > today
? settings.endDate
: latestMovement || fallback.toISOString().slice(0, 10);
const monthEnd = (year: number, month: number) =>
new Date(Date.UTC(year, month + 1, 0)).toISOString().slice(0, 10);
const start = new Date(`${today}T12:00:00Z`);
let year = start.getUTCFullYear();
let month = start.getUTCMonth();
const todayNumber = dayNumber(today);
const endNumber = dayNumber(horizon);
const items =
settings?.includeRecurring === false
? []
: recurring
.filter((item) => counted(item.accountId))
.map((item) => {
const named = { ...item, name: item.name ?? '' };
return { item: named, schedule: scheduleFor(named, history, today) };
});
const salary = items.find(
({ item, schedule }) =>
item.amountCents > 0 && /salary|salaris/i.test(item.name) && schedule.kind === 'monthly'
);
const yearly = Number(settings?.annualReturnPercent ?? 5) / 100;
const monthlyGrowth = Math.pow(1 + yearly, 1 / 12) - 1;
for (let index = 0; index < 1212; index++) {
const first = `${year}-${String(month + 1).padStart(2, '0')}-01`;
const end = monthEnd(year, month);
const date = end < horizon ? end : horizon;
if (date > today) {
for (const entry of movements.filter(
(item) => (index === 0 ? item.date > today : item.date >= first) && item.date <= date
)) {
if (entry.accountId !== null && accountKind.get(entry.accountId) === 'investment') {
invested += entry.amountCents;
if (entry.accountId === validInvestmentId) indexBalance += entry.amountCents;
} else spendable += entry.amountCents;
}
if (settings?.includeRecurring !== false && index > 0) {
for (const item of recurring) {
if (!counted(item.accountId)) continue;
if ((item.startsOn && item.startsOn > date) || (item.endsOn && item.endsOn < first))
continue;
const value = Math.round(monthlyCents(item.amountCents, item.cadence));
if (item.accountId !== null && accountKind.get(item.accountId) === 'investment') {
invested += value;
if (item.accountId === validInvestmentId) indexBalance += value;
} else spendable += value;
}
}
if (index > 0) {
const growth = Math.round(indexBalance * monthlyGrowth);
invested += growth;
indexBalance += growth;
const excess = Math.max(0, spendable - (settings?.reserveCents ?? 100000));
const reserve = settings?.reserveCents ?? 100000;
let year = Number(today.slice(0, 4));
let month = Number(today.slice(5, 7));
let next = 0;
for (let guard = 0; guard < 1213; guard++) {
const firstDay = dayOfMonth(year, month, 1);
if (firstDay > endNumber) break;
const length = daysInMonth(year, month);
const toSpendable = new Float64Array(length);
const toInvested = new Float64Array(length);
const toIndex = new Float64Array(length);
const add = (day: number, cents: number, accountId: number | null) => {
const offset = day - firstDay;
if (offset < 0 || offset >= length) return;
if (isInvestment(accountId)) {
toInvested[offset] += cents;
if (accountId === validInvestmentId) toIndex[offset] += cents;
} else toSpendable[offset] += cents;
};
while (next < movements.length && dayNumber(movements[next].date) < firstDay + length) {
const entry = movements[next++];
add(dayNumber(entry.date), entry.amountCents, entry.accountId);
}
for (const { item, schedule } of items) {
const flows = monthFlows(item, schedule, year, month, todayNumber);
for (const [day, cents] of flows.events) add(day, cents, item.accountId);
for (let day = flows.spreadFrom; day <= flows.spreadTo; day++)
add(day, flows.spread, item.accountId);
}
const paid =
salary?.schedule.kind === 'monthly'
? businessDay(dayOfMonth(year, month, salary.schedule.day), salary.schedule.shift)
: null;
const sweepDay =
paid === null ? firstDay + length - 1 : Math.min(paid + 1, firstDay + length - 1);
const dayGrowth = Math.pow(1 + monthlyGrowth, 1 / length);
for (let offset = 0; offset < length; offset++) {
const day = firstDay + offset;
if (day <= todayNumber) continue;
if (day > endNumber) break;
spendable += toSpendable[offset];
invested += toInvested[offset];
indexBalance += toIndex[offset];
const growth = indexBalance * (dayGrowth - 1);
invested += growth;
indexBalance += growth;
if (day === sweepDay) {
const excess = Math.max(0, spendable - reserve);
const contribution =
settings?.contributionMode === 'fixed'
? Math.min(excess, settings.fixedMonthlyCents)
@@ -216,14 +262,14 @@ export function wealthProjection(
invested += contribution;
indexBalance += contribution;
}
totalPoints.push({ date, value: spendable + invested });
spendablePoints.push({ date, value: spendable });
investedPoints.push({ date, value: invested });
const date = isoDay(day);
totalPoints.push({ date, value: Math.round(spendable + invested) });
spendablePoints.push({ date, value: Math.round(spendable) });
investedPoints.push({ date, value: Math.round(invested) });
}
if (end >= horizon) break;
month++;
if (month === 12) {
month = 0;
if (month === 13) {
month = 1;
year++;
}
}
@@ -234,6 +280,57 @@ export function wealthProjection(
];
}
/**
* Thins long series for drawing: per time bucket only the lowest and highest point stay (in
* date order), so a daily projection over decades keeps its spikes without plotting every day.
* The first and last points and the solid/dashed join are always kept.
*/
export function decimateSeries(series: ChartSeries[], buckets: number): ChartSeries[] {
const limit = Math.max(2, Math.floor(buckets)) * 2;
if (!series.some((line) => line.points.length > limit)) return series;
let from = Infinity;
let until = -Infinity;
for (const line of series)
if (line.points.length) {
from = Math.min(from, Date.parse(line.points[0].date));
until = Math.max(until, Date.parse(line.points.at(-1)!.date));
}
const span = until - from || 1;
return series.map((line) => {
if (line.points.length <= limit) return line;
const keep = new Uint8Array(line.points.length);
keep[0] = 1;
keep[line.points.length - 1] = 1;
if (line.dashedFrom) {
const join = line.points.findLastIndex((point) => point.date <= line.dashedFrom!);
if (join >= 0) keep[join] = 1;
}
let bucket = -1;
let low = -1;
let high = -1;
const flush = () => {
if (low >= 0) keep[low] = 1;
if (high >= 0) keep[high] = 1;
};
line.points.forEach((point, index) => {
const at = Math.min(
buckets - 1,
Math.floor(((Date.parse(point.date) - from) / span) * buckets)
);
if (at !== bucket) {
flush();
bucket = at;
low = high = index;
return;
}
if (point.value < line.points[low].value) low = index;
if (point.value > line.points[high].value) high = index;
});
flush();
return { ...line, points: line.points.filter((_, index) => keep[index]) };
});
}
export type TimelineView = 'total' | 'split' | 'accounts';
/**

View File

@@ -76,29 +76,73 @@ describe('runScenario', () => {
moveCostEuros: 0,
bonusPercentOfTarget: 0
};
it('runs monthly to the end date and starts from today', () => {
type Point = { date: string; cash: number; invested: number; total: number };
const on = (points: Point[], date: string) => points.find((point) => point.date === date)!;
it('runs day by day 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.points[1].date).toBe('2026-10-25');
expect(result.end.date).toBe('2030-12-31');
expect(result.points).toHaveLength(51);
expect(result.points).toHaveLength(1530);
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 + 13th month 3/12 − 3k gym − 90k pre-move household.
expect(november.total).toBeCloseTo(350000 + 300000 + 10000 + 28000 + 51250 - 3000 - 90000, 0);
it('pays salary on payday, sweeps the excess the day after and counts recurring income and costs', () => {
const points = runScenario(inputs, flat).points;
// Without history the contract payday (the 25th) is used; €900 pre-move household is
// spread at €30 a day in November.
expect(on(points, '2026-11-25').total - on(points, '2026-11-24').total).toBeCloseTo(
300000 + 51250 - 3000,
0
);
// The benefit (paid on the 1st, a Sunday) moves to Monday.
expect(on(points, '2026-11-02').total - on(points, '2026-11-01').total).toBeCloseTo(
10000 - 3000,
0
);
expect(on(points, '2026-11-25').cash).toBeGreaterThan(500000);
expect(on(points, '2026-11-26').cash).toBe(500000);
// Rest of October: 7/31 of the €900 pre-move household. November: 300k salary + 10k benefit
// + 28k one-off + 13th month 3/12 − 3k gym − 90k pre-move household.
expect(on(points, '2026-11-30').total).toBeCloseTo(
350000 - (90000 * 7) / 31 + 300000 + 10000 + 28000 + 51250 - 3000 - 90000,
0
);
});
it('pays contract extras in May (holiday pay) and August (bonus)', () => {
const delta = (points: { total: number }[], index: number) =>
points[index].total - points[index - 1].total;
it('pays contract extras on payday in May (holiday pay) and August (bonus)', () => {
const flatRun = runScenario(inputs, flat).points;
// Index 7 is May 2027: holiday pay 8/12 of €1,950.
expect(delta(flatRun, 7) - delta(flatRun, 6)).toBeCloseTo(130000, 0);
const jump = (points: typeof flatRun, date: string, before: string) =>
on(points, date).total - on(points, before).total;
// Holiday pay 8/12 of €1,950 on top of the salary.
expect(jump(flatRun, '2027-05-25', '2027-05-24')).toBeCloseTo(300000 + 130000, 0);
expect(jump(flatRun, '2027-05-24', '2027-05-23')).toBeCloseTo(0, 0);
const bonus = runScenario(inputs, { ...flat, bonusPercentOfTarget: 100 }).points;
// Index 10 is August 2027: the first bonus, €850.
expect(delta(bonus, 10) - delta(flatRun, 10)).toBeCloseTo(85000, 0);
expect(on(bonus, '2027-08-24').total - on(flatRun, '2027-08-24').total).toBeCloseTo(0, 0);
expect(on(bonus, '2027-08-25').total - on(flatRun, '2027-08-25').total).toBeCloseTo(85000, 0);
});
it('uses the payday from history and moves it before a weekend', () => {
const withHistory = scenarioInputs({
accounts,
balances: new Map([[1, 200000]]),
recurring: [
{ name: 'Cargill salary', amountCents: 300000, cadence: 'monthly', accountId: 1 }
],
forecasts: [],
futureTransactions: [],
history: [
{ date: '2026-09-21', amountCents: 298000, description: 'Salaris Cargill' },
{ date: '2026-10-21', amountCents: 298653, description: 'Salaris Cargill' }
],
today: '2026-10-24',
endDate: '2027-01-31'
});
expect(withHistory.payday?.day).toBe(21);
const points = runScenario(withHistory, flat).points;
// 21 November 2026 is a Saturday, so the salary lands on Friday the 20th.
expect(on(points, '2026-11-20').total - on(points, '2026-11-19').total).toBeGreaterThan(300000);
expect(on(points, '2026-12-21').total - on(points, '2026-12-20').total).toBeCloseTo(
300000 - 90000 / 31,
0
);
});
it('orders the default scenarios from pessimistic to optimistic', () => {
const [pessimistic, realistic, optimistic] = (
@@ -148,7 +192,7 @@ describe('runScenario', () => {
hopGapMonths: 4,
salaryCapEuros: 60000
});
expect(result.points).toHaveLength(1201);
expect(result.points).toHaveLength(36525);
const byYear = new Map(result.years.map((row) => [row.year, row]));
expect(byYear.get(2029)!.events).toEqual(['Between jobs (4 mo)']);
expect(byYear.get(2030)!.events).toEqual(['New job']);

View File

@@ -9,6 +9,17 @@
*/
import type { ChartSeries } from './charts';
import {
businessDay,
dayNumber,
dayOfMonth,
daysInMonth,
isoDay,
monthFlows,
scheduleFor,
type HistoryTransaction,
type ItemSchedule
} from './schedule';
export type ScenarioId = 'pessimistic' | 'realistic' | 'optimistic';
export const scenarioIds: ScenarioId[] = ['pessimistic', 'realistic', 'optimistic'];
@@ -246,6 +257,8 @@ export type ScenarioRecurring = {
endsOn?: string | null;
};
export type ScenarioMovement = { date: string; amountCents: number; description: string };
/** A recurring item with the days it hits (see schedule.ts). */
export type ScheduledRecurring = ScenarioRecurring & { schedule: ItemSchedule };
export type ScenarioInputs = {
today: string;
@@ -254,10 +267,12 @@ export type ScenarioInputs = {
investedCents: number;
/** Net salary per month from the recurring list. */
netSalaryCents: number;
/** Salary payday from the bank history (null: the contract's 25th). */
payday: { day: number; shift: 'none' | 'before' | 'after'; source: string } | null;
/** Recurring items other than salary and Joint income. */
personal: ScenarioRecurring[];
personal: ScheduledRecurring[];
/** Joint recurring costs, shared after the move. */
household: ScenarioRecurring[];
household: ScheduledRecurring[];
/** Rent start: the move month. */
moveDate: string | null;
oneOffs: ScenarioMovement[];
@@ -300,18 +315,6 @@ 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 }[];
@@ -326,6 +329,8 @@ export function scenarioInputs(input: {
}[];
today: string;
endDate?: string | null;
/** Bank transactions through today, to find the day each recurring item hits. */
history?: HistoryTransaction[];
}): ScenarioInputs {
const counted = input.accounts.filter((account) => account.includeInTotal !== false);
const countedIds = new Set(counted.map((account) => account.id));
@@ -354,15 +359,25 @@ export function scenarioInputs(input: {
!modelledPattern.test(item.description)
)
.map(({ date, amountCents, description }) => ({ date, amountCents, description }));
const history = input.history ?? [];
const scheduled = (item: ScenarioRecurring): ScheduledRecurring => ({
...item,
schedule: scheduleFor(item, history, input.today)
});
const salarySchedule = salary ? scheduleFor(salary, history, input.today) : null;
return {
today: input.today,
endDate: input.endDate && input.endDate > input.today ? input.endDate : '2030-12-31',
spendableCents: spendable,
investedCents: invested,
netSalaryCents: salary?.amountCents ?? 0,
payday:
salarySchedule?.kind === 'monthly' && salarySchedule.last
? { day: salarySchedule.day, shift: 'before', source: salarySchedule.source }
: null,
// 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,
personal: recurring.filter((item) => item !== salary && !isJoint(item)).map(scheduled),
household: household.map(scheduled),
moveDate: rent?.startsOn ?? null,
oneOffs
};
@@ -410,6 +425,17 @@ export function marketReturns(params: ScenarioParams) {
};
}
/** Contract payday (the 25th) when the bank history has no salary yet. */
const contractPayday = 25;
/**
* Runs one scenario day by day from tomorrow to the end date. Every flow lands on its own day:
* salary and its extras on payday, recurring items on the days the bank history (or their start
* date) puts them, variable budgets spread over the days of the month, one-offs on their date.
* Investing happens the day after payday (everything above the buffer), returns compound daily,
* and investments are sold on any day the cash would go below zero. Amounts per month are the
* same as a monthly model's; only the timing within the month differs.
*/
export function runScenario(inputs: ScenarioInputs, params: ScenarioParams): ScenarioResult {
const facts = scenarioFacts;
const baseGross = facts.grossCents;
@@ -425,6 +451,9 @@ export function runScenario(inputs: ScenarioInputs, params: ScenarioParams): Sce
params.hopEveryYears > 0 &&
year >= params.firstHopYear &&
(year - params.firstHopYear) % Math.max(1, Math.round(params.hopEveryYears)) === 0;
const payday = inputs.payday ?? { day: contractPayday, shift: 'before' as const };
const todayNumber = dayNumber(inputs.today);
const endNumber = dayNumber(inputs.endDate);
let cash = inputs.spendableCents;
let invested = inputs.investedCents;
let basis = inputs.investedCents;
@@ -444,11 +473,12 @@ export function runScenario(inputs: ScenarioInputs, params: ScenarioParams): Sce
];
const years = new Map<number, ScenarioYear>();
const target = facts.targetMonthlyCents;
let buffer = facts.bufferCents;
const milestones: { label: string; date: string | null; test: () => boolean }[] = [
{
label: 'Cash buffer full',
date: null,
test: () => cash >= facts.bufferCents * deflator - 100
test: () => cash >= buffer - 100
},
...[10_000, 100_000, 250_000, 1_000_000].map((euros) => ({
label: `€${euros >= 1_000_000 ? `${euros / 1_000_000}M` : `${euros / 1000}k`} invested`,
@@ -475,170 +505,268 @@ export function runScenario(inputs: ScenarioInputs, params: ScenarioParams): Sce
Number(firstMonth.slice(5, 7)) +
1;
const moveDate = inputs.moveDate ?? '9999-12-31';
for (let index = 0; index < Math.min(totalMonths, 1212); index++) {
const moveMonth = moveDate.slice(0, 7) + '-01';
const moveDay = inputs.moveDate ? dayNumber(inputs.moveDate) : 0;
const yearRow = (year: number) =>
years.get(year) ??
years
.set(year, {
year,
income: 0,
spending: 0,
saved: 0,
savingsRate: 0,
total: 0,
invested: 0,
real: 0,
box3: 0,
age: year - facts.birthYear,
grossMonthly: 0,
baseReal: 0,
role: '',
events: []
})
.get(year)!;
// Index -1 is the rest of the current month (only recurring items, budgets and one-offs; the
// salary for it has been paid), then one index per calendar month.
for (let index = -1; index < Math.min(totalMonths, 1212); 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));
deflator *= monthlyInflation;
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,
age: year - facts.birthYear,
grossMonthly: 0,
baseReal: 0,
role: '',
events: []
})
.get(year)!;
const firstDay = dayNumber(first);
const length = daysInMonth(year, month);
const partial = index < 0;
const row = yearRow(year);
const startDeflator = deflator;
// The current month only inflates over its remaining days.
const monthInflation = partial
? Math.pow(monthlyInflation, (firstDay + length - 1 - todayNumber) / length)
: monthlyInflation;
deflator *= monthInflation;
if (month === 1) {
investedAtYearStart = invested;
contributedThisYear = 0;
realisedThisYear = 0;
}
const flow = new Float64Array(length);
const at = (day: number, cents: number) => {
const offset = day - firstDay;
if (offset >= 0 && offset < length) flow[offset] += cents;
};
/** Spreads a month's amount over its days, from tomorrow in the current month. */
const spreadMonth = (cents: number, from = firstDay, to = firstDay + length - 1) => {
const perDay = cents / length;
for (let day = Math.max(from, todayNumber + 1); day <= to; day++) at(day, perDay);
};
const paid = businessDay(dayOfMonth(year, month, payday.day), payday.shift);
const retired = first >= retireStart;
// Salary path: extension or next job after the contract (optional gap), raises, job hops
// in October (optional gap before each), capped at the career plateau in today's euros.
const hopStart = isHopYear(year) ? `${year}-10-01` : null;
// A gap that starts in October can run into the next year.
const hops = [year - 1, year].filter(isHopYear).map((hopYear) => {
const start = `${hopYear}-10-01`;
return { start, end: addMonths(start, Math.max(0, params.hopGapMonths)) };
});
const inHopGap = hops.some((hop) => first >= hop.start && first < hop.end);
const hopEnd = hops.find((hop) => hop.end === first)?.end ?? null;
const inContractGap = params.gapMonths > 0 && first >= gapStart && first < gapEnd;
const employed = !retired && !inHopGap && !inContractGap;
if (first === gapStart) {
gross = baseGross * (1 + pct(params.nextJobPercent));
row.events.push(
params.gapMonths > 0 ? `Contract ends, ${params.gapMonths} mo gap` : 'Contract renewed'
);
}
if (
first > gapStart &&
year >= params.raiseFromYear &&
month === params.raiseMonth &&
!(hopStart && month === 10)
)
gross *= 1 + pct(params.raisePercent);
const cap = ((params.salaryCapEuros * 100) / 12) * deflator;
if (hopStart && first === hopStart && params.hopGapMonths > 0 && !retired)
row.events.push(`Between jobs (${params.hopGapMonths} mo)`);
if (hopEnd && first === hopEnd && !retired) {
const plateau = gross >= cap * 0.99;
gross *= 1 + pct(params.hopRaisePercent);
row.events.push(
(params.hopGapMonths > 0 ? 'New job' : 'Job hop') + (plateau ? ' (at plateau)' : '')
);
}
if (gross > cap) gross = cap;
const jobChange =
first === retireStart ||
(hopStart !== null && first === hopStart) ||
(params.gapMonths > 0 && first === gapStart);
let income = 0;
if (employed) income += netFromGross(gross);
else if (!retired) income += params.gapNetEuros * 100 * deflator;
if (employed) {
accruedHoliday += (facts.holidayNetYearCents / 12) * (gross / baseGross);
accrued13 += (facts.thirteenthNetYearCents / 12) * (gross / baseGross);
}
if (month === 5 || jobChange) {
income += accruedHoliday;
accruedHoliday = 0;
}
if (month === 11 || jobChange) {
income += accrued13;
accrued13 = 0;
}
if (month === 8 && year >= facts.firstBonusYear && employed)
income +=
pct(params.bonusPercentOfTarget) *
(year === facts.firstBonusYear
? facts.firstBonusCents
: facts.bonusNetYearCents * (gross / baseGross));
if (retired) {
income += params.pensionNetEuros * 100 * deflator;
if (first === retireStart) row.events.push(`Retire at ${params.retireAge}`);
} else {
const ramp = Math.min(1, Math.max(0, (index - 3) / 57));
const sideGross =
params.sideStartEuros + (params.sideEndEuros - params.sideStartEuros) * ramp;
income += sideGross * 100 * deflator * (1 - facts.sideTaxRate);
let spending = 0;
let employed = false;
if (!partial) {
// Salary path: extension or next job after the contract (optional gap), raises, job hops
// in October (optional gap before each), capped at the career plateau in today's euros.
const hopStart = isHopYear(year) ? `${year}-10-01` : null;
// A gap that starts in October can run into the next year.
const hops = [year - 1, year].filter(isHopYear).map((hopYear) => {
const start = `${hopYear}-10-01`;
return { start, end: addMonths(start, Math.max(0, params.hopGapMonths)) };
});
const inHopGap = hops.some((hop) => first >= hop.start && first < hop.end);
const hopEnd = hops.find((hop) => hop.end === first)?.end ?? null;
const inContractGap = params.gapMonths > 0 && first >= gapStart && first < gapEnd;
employed = !retired && !inHopGap && !inContractGap;
if (first === gapStart) {
gross = baseGross * (1 + pct(params.nextJobPercent));
row.events.push(
params.gapMonths > 0 ? `Contract ends, ${params.gapMonths} mo gap` : 'Contract renewed'
);
}
if (
first > gapStart &&
year >= params.raiseFromYear &&
month === params.raiseMonth &&
!(hopStart && month === 10)
)
gross *= 1 + pct(params.raisePercent);
const cap = ((params.salaryCapEuros * 100) / 12) * deflator;
if (hopStart && first === hopStart && params.hopGapMonths > 0 && !retired)
row.events.push(`Between jobs (${params.hopGapMonths} mo)`);
if (hopEnd && first === hopEnd && !retired) {
const plateau = gross >= cap * 0.99;
gross *= 1 + pct(params.hopRaisePercent);
row.events.push(
(params.hopGapMonths > 0 ? 'New job' : 'Job hop') + (plateau ? ' (at plateau)' : '')
);
}
if (gross > cap) gross = cap;
const jobChange =
first === retireStart ||
(hopStart !== null && first === hopStart) ||
(params.gapMonths > 0 && first === gapStart);
// Pay, benefit or pension, and the contract extras: all on payday.
let pay = 0;
if (employed) pay += netFromGross(gross);
else if (!retired) pay += params.gapNetEuros * 100 * deflator;
if (employed) {
accruedHoliday += (facts.holidayNetYearCents / 12) * (gross / baseGross);
accrued13 += (facts.thirteenthNetYearCents / 12) * (gross / baseGross);
}
if (month === 5 || jobChange) {
pay += accruedHoliday;
accruedHoliday = 0;
}
if (month === 11 || jobChange) {
pay += accrued13;
accrued13 = 0;
}
if (month === 8 && year >= facts.firstBonusYear && employed)
pay +=
pct(params.bonusPercentOfTarget) *
(year === facts.firstBonusYear
? facts.firstBonusCents
: facts.bonusNetYearCents * (gross / baseGross));
if (retired) {
pay += params.pensionNetEuros * 100 * deflator;
if (first === retireStart) row.events.push(`Retire at ${params.retireAge}`);
}
income += pay;
at(paid, pay);
if (!retired) {
// Side income: invoices come in through the month, so it is spread.
const ramp = Math.min(1, Math.max(0, (index - 3) / 57));
const sideGross =
params.sideStartEuros + (params.sideEndEuros - params.sideStartEuros) * ramp;
const side = sideGross * 100 * deflator * (1 - facts.sideTaxRate);
income += side;
spreadMonth(side);
}
if (index >= 3)
spendGrowth *= Math.pow(
1 + pct(index < 123 ? params.spendGrowthPercent : params.inflationPercent),
1 / 12
);
}
// Recurring items from the tracker (positive ones such as benefits count as income).
if (index >= 3)
spendGrowth *= Math.pow(
1 + pct(index < 123 ? params.spendGrowthPercent : params.inflationPercent),
1 / 12
);
let spending = 0;
for (const item of inputs.personal) {
if (!activeIn(item, first, last)) continue;
const value = monthly(item);
if (value >= 0) income += retired ? 0 : value;
else spending -= value * spendGrowth;
const addItem = (item: ScheduledRecurring, scale: number, incomeAllowed: boolean) => {
const flows = monthFlows(item, item.schedule, year, month, todayNumber);
for (const [day, cents] of flows.events) {
if (cents >= 0) {
if (!incomeAllowed) continue;
income += cents;
at(day, cents);
} else {
spending -= cents * scale;
at(day, cents * scale);
}
}
if (flows.spread && flows.spreadTo >= flows.spreadFrom) {
const cents = flows.spread * (flows.spreadTo - flows.spreadFrom + 1);
if (cents >= 0) {
if (!incomeAllowed) return;
income += cents;
for (let day = flows.spreadFrom; day <= flows.spreadTo; day++) at(day, flows.spread);
} else {
spending -= cents * scale;
for (let day = flows.spreadFrom; day <= flows.spreadTo; day++)
at(day, flows.spread * scale);
}
}
};
for (const item of inputs.personal) addItem(item, spendGrowth, !retired);
// Spending not in the recurring list: spread over the month.
const remaining = partial ? (firstDay + length - 1 - todayNumber) / length : 1;
const other = params.otherSpendEuros * 100 * spendGrowth;
spending += other * remaining;
spreadMonth(-other);
if (first >= '2027-01-01') {
// Health insurance premium: collected on the 1st.
const premium = params.healthInsuranceEuros * 100 * spendGrowth;
const day = dayOfMonth(year, month, 1);
if (day > todayNumber) {
spending += premium;
at(day, -premium);
}
}
spending += params.otherSpendEuros * 100 * spendGrowth;
if (first >= '2027-01-01') spending += params.healthInsuranceEuros * 100 * spendGrowth;
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) * spendGrowth;
if (first < moveMonth) {
spending += facts.preMoveHouseholdCents * remaining;
spreadMonth(-facts.preMoveHouseholdCents);
} else {
const share = pct(params.householdSharePercent) * spendGrowth;
const extra = params.householdExtraEuros * 100 * share;
spending += extra * remaining;
spreadMonth(-extra);
for (const item of inputs.household) addItem(item, share, false);
}
if (first === moveDate.slice(0, 7) + '-01') spending += params.moveCostEuros * 100;
if (year >= facts.duoFrom && year <= facts.duoUntil) spending += facts.duoCents;
for (const item of inputs.oneOffs)
if ((index === 0 ? item.date > inputs.today : item.date >= first) && item.date <= last) {
if (first === moveMonth && moveDay > todayNumber) {
spending += params.moveCostEuros * 100;
at(moveDay, -params.moveCostEuros * 100);
}
if (!partial && year >= facts.duoFrom && year <= facts.duoUntil) {
// DUO collects the repayment around the 24th.
spending += facts.duoCents;
at(dayOfMonth(year, month, 24), -facts.duoCents);
}
for (const item of inputs.oneOffs) {
const day = dayNumber(item.date);
if (day > todayNumber && day >= firstDay && day < firstDay + length) {
if (item.amountCents >= 0) income += item.amountCents;
else spending -= item.amountCents;
at(day, item.amountCents);
}
}
let tax = 0;
if (month === 5) {
if (month === 5 && !partial) {
// Box 3 assessment for the previous year, paid at the start of May.
tax = box3Due;
box3Due = 0;
at(firstDay, -tax);
}
cash += income - spending - tax;
// Returns: crash and correction years spread their drop over March–October.
// Returns: crash and correction years spread their drop over March–October; daily
// compounding gives the same monthly return.
const market = markets.yearReturn(year);
const rate =
const monthRate =
market.kind === 'normal'
? Math.pow(1 + market.rate, 1 / 12) - 1
: month >= 3 && month <= 10
? Math.pow(1 + market.rate, 1 / 8) - 1
: 0;
invested += invested * rate;
cash *= Math.pow(1 + pct(params.cashRatePercent), 1 / 12);
const buffer = facts.bufferCents * deflator;
if (cash > buffer) {
const extra = cash - buffer;
invested += extra;
basis += extra;
contributedThisYear += extra;
cash = buffer;
} else if (cash < 0 && invested > 0) {
// Withdrawals: sell investments to cover the shortfall.
const sold = Math.min(invested, -cash);
const soldBasis = invested > 0 ? basis * (sold / invested) : 0;
realisedThisYear += sold - soldBasis;
basis -= soldBasis;
invested -= sold;
contributedThisYear -= sold;
cash += sold;
const dayGrowth = Math.pow(1 + monthRate, 1 / length);
const dayCash = Math.pow(1 + pct(params.cashRatePercent), 1 / 12 / length);
const dayInflation = Math.pow(monthlyInflation, 1 / length);
const sweepDay = Math.min(paid + 1, firstDay + length - 1);
let dayDeflator = partial ? 1 : startDeflator;
buffer = facts.bufferCents * deflator;
for (let offset = 0; offset < length; offset++) {
const day = firstDay + offset;
if (day <= todayNumber) continue;
if (day > endNumber) break;
dayDeflator *= dayInflation;
cash += flow[offset];
invested *= dayGrowth;
cash *= dayCash;
if (day === sweepDay && cash > buffer) {
// Investing right after payday: everything above the buffer.
const extra = cash - buffer;
invested += extra;
basis += extra;
contributedThisYear += extra;
cash = buffer;
} else if (cash < 0 && invested > 0) {
// Withdrawals: sell investments to cover the shortfall.
const sold = Math.min(invested, -cash);
const soldBasis = invested > 0 ? basis * (sold / invested) : 0;
realisedThisYear += sold - soldBasis;
basis -= soldBasis;
invested -= sold;
contributedThisYear -= sold;
cash += sold;
}
const total = cash + invested;
points.push({ date: isoDay(day), cash, invested, total, real: total / dayDeflator });
for (const milestone of milestones)
if (milestone.date === null && milestone.test()) milestone.date = first.slice(0, 7);
}
if (month === 12) {
const gain = invested - investedAtYearStart - contributedThisYear;
@@ -659,21 +787,28 @@ export function runScenario(inputs: ScenarioInputs, params: ScenarioParams): Sce
}
}
const total = cash + invested;
const real = total / deflator;
recentRealIncome.push(income / deflator);
if (recentRealIncome.length > 12) recentRealIncome.shift();
points.push({ date, cash, invested, total, real });
if (!partial) {
recentRealIncome.push(income / deflator);
if (recentRealIncome.length > 12) recentRealIncome.shift();
}
row.income += income;
row.spending += spending + tax;
row.box3 += tax;
row.total = total;
row.invested = invested;
row.real = real;
row.grossMonthly = employed ? gross : 0;
row.baseReal = retired ? 0 : (gross * 12) / deflator / 100;
row.role = retired ? 'Retired' : roleFor(row.baseReal);
row.real = total / deflator;
if (!partial) {
row.grossMonthly = employed ? gross : 0;
row.baseReal = retired ? 0 : (gross * 12) / deflator / 100;
row.role = retired ? 'Retired' : roleFor(row.baseReal);
} else {
row.grossMonthly = gross;
row.baseReal = (gross * 12) / 100;
row.role = roleFor(row.baseReal);
}
for (const milestone of milestones)
if (milestone.date === null && milestone.test()) milestone.date = first.slice(0, 7);
if (last >= inputs.endDate) break;
}
for (const row of years.values()) {
row.saved = row.income - row.spending;

View File

@@ -0,0 +1,152 @@
import { describe, expect, it } from 'vitest';
import {
businessDay,
dayNumber,
dayOfMonth,
isoDay,
monthFlows,
parseInterval,
scheduleFor,
scheduleLabel,
type HistoryTransaction
} from './schedule';
const today = '2026-10-26';
const at = (date: string) => dayNumber(date);
const dates = (events: [number, number][]) => events.map(([day]) => isoDay(day));
describe('day helpers', () => {
it('clamps a day of the month to the month end', () => {
expect(isoDay(dayOfMonth(2027, 2, 31))).toBe('2027-02-28');
expect(isoDay(dayOfMonth(2028, 2, 30))).toBe('2028-02-29');
expect(isoDay(dayOfMonth(2027, 4, 31))).toBe('2027-04-30');
});
it('moves weekend days to the Friday before or the Monday after', () => {
// 21 Nov 2026 is a Saturday, 22 Nov a Sunday.
expect(isoDay(businessDay(at('2026-11-21'), 'before'))).toBe('2026-11-20');
expect(isoDay(businessDay(at('2026-11-22'), 'before'))).toBe('2026-11-20');
expect(isoDay(businessDay(at('2026-11-21'), 'after'))).toBe('2026-11-23');
expect(isoDay(businessDay(at('2026-11-21'), 'none'))).toBe('2026-11-21');
expect(isoDay(businessDay(at('2026-11-23'), 'before'))).toBe('2026-11-23');
});
it('reads "every N weeks" from a name', () => {
expect(parseInterval('Basic-Fit (€29.99 every 4 weeks)')).toEqual({
everyDays: 28,
perChargeCents: 2999
});
expect(parseInterval('Haircut every 6 weeks')).toEqual({ everyDays: 42, perChargeCents: null });
expect(parseInterval('Spotify')).toBeNull();
});
});
describe('scheduleFor', () => {
const history: HistoryTransaction[] = [
{ date: '2026-07-18', amountCents: -899, description: 'Amazon Prime' },
{ date: '2026-08-18', amountCents: -899, description: 'Amazon Prime' },
{ date: '2026-09-17', amountCents: -899, description: 'AMAZON PRIME NL' },
{ date: '2026-10-18', amountCents: -899, description: 'Amazon Prime' },
{ date: '2026-10-18', amountCents: -12000, description: 'Amazon order' },
{ date: '2026-10-19', amountCents: -2999, description: 'Basic-Fit Nederland' },
{ date: '2025-10-09', amountCents: -1849, description: 'Apple.com/bill Authenticator' },
{ date: '2026-10-09', amountCents: -1849, description: 'APPLE.COM/BILL' },
{ date: '2026-10-21', amountCents: 298653, description: 'Salaris Cargill BV' }
];
it('uses the most common recent day of the month for monthly charges', () => {
const schedule = scheduleFor(
{ name: 'Amazon Prime', amountCents: -899, cadence: 'monthly' },
history,
today
);
expect(schedule).toMatchObject({ kind: 'monthly', day: 18, shift: 'none', last: '2026-10-18' });
expect(scheduleLabel(schedule)).toBe('on the 18th');
});
it('moves salary before weekends', () => {
expect(
scheduleFor(
{ name: 'Cargill salary', amountCents: 298653, cadence: 'monthly' },
history,
today
)
).toMatchObject({ kind: 'monthly', day: 21, shift: 'before' });
});
it('runs N-weekly items on an exact cadence from the last charge', () => {
const item = {
name: 'Basic-Fit (€29.99 every 4 weeks)',
amountCents: -3249,
cadence: 'monthly'
};
const schedule = scheduleFor(item, history, today);
expect(schedule).toMatchObject({
kind: 'interval',
everyDays: 28,
anchor: '2026-10-19',
perChargeCents: -2999
});
expect(dates(monthFlows(item, schedule, 2026, 11, at(today)).events)).toEqual(['2026-11-16']);
expect(dates(monthFlows(item, schedule, 2026, 12, at(today)).events)).toEqual(['2026-12-14']);
// 13 charges a year.
let count = 0;
for (let month = 1; month <= 12; month++)
count += monthFlows(item, schedule, 2027, month, at(today)).events.length;
expect(count).toBe(13);
});
it('puts yearly items on their renewal date and skips the one just charged', () => {
const item = { name: 'Authenticator (Apple)', amountCents: -1849, cadence: 'yearly' };
const schedule = scheduleFor(item, history, today);
expect(schedule).toMatchObject({ kind: 'yearly', month: 10, day: 9 });
expect(monthFlows(item, schedule, 2026, 10, at('2026-10-01')).events).toEqual([]);
expect(dates(monthFlows(item, schedule, 2027, 10, at(today)).events)).toEqual(['2027-10-09']);
const future = {
name: 'Cloudflare vash.software',
amountCents: -2920,
cadence: 'yearly',
startsOn: '2028-01-30'
};
const futureSchedule = scheduleFor(future, history, today);
expect(futureSchedule).toMatchObject({ kind: 'yearly', month: 1, day: 30 });
expect(monthFlows(future, futureSchedule, 2027, 1, at(today)).events).toEqual([]);
expect(dates(monthFlows(future, futureSchedule, 2028, 1, at(today)).events)).toEqual([
'2028-01-30'
]);
});
it('uses a future start date and respects the end date', () => {
const item = {
name: 'Grok',
amountCents: -3000,
cadence: 'monthly',
startsOn: '2026-11-24',
endsOn: '2027-01-31'
};
const schedule = scheduleFor(item, [], today);
expect(schedule).toMatchObject({ kind: 'monthly', day: 24 });
expect(dates(monthFlows(item, schedule, 2026, 11, at(today)).events)).toEqual(['2026-11-24']);
expect(monthFlows(item, schedule, 2027, 2, at(today)).events).toEqual([]);
});
it('skips a monthly charge that already happened this month', () => {
const item = { name: 'Amazon Prime', amountCents: -899, cadence: 'monthly' };
const schedule = scheduleFor(item, history, today);
expect(monthFlows(item, schedule, 2026, 10, at('2026-10-10')).events).toEqual([]);
expect(dates(monthFlows(item, schedule, 2026, 11, at(today)).events)).toEqual(['2026-11-18']);
});
it('spreads budgets over the month and the vacation over July and August', () => {
const groceries = {
name: 'Groceries',
amountCents: -25000,
cadence: 'monthly',
startsOn: '2027-01-01'
};
const schedule = scheduleFor(groceries, [], today);
expect(schedule.kind).toBe('spread');
const january = monthFlows(groceries, schedule, 2027, 1, at(today));
expect(january.events).toEqual([]);
expect(january.spread * (january.spreadTo - january.spreadFrom + 1)).toBeCloseTo(-25000, 6);
const rest = monthFlows({ ...groceries, startsOn: null }, schedule, 2026, 10, at(today));
expect(rest.spread * (rest.spreadTo - rest.spreadFrom + 1)).toBeCloseTo((-25000 * 5) / 31, 6);
const vacation = { name: 'Vacation', amountCents: -500000, cadence: 'yearly' };
const summer = scheduleFor(vacation, [], today);
expect(scheduleLabel(summer)).toBe('Jul–Aug');
expect(monthFlows(vacation, summer, 2027, 6, at(today)).spread).toBe(0);
const july = monthFlows(vacation, summer, 2027, 7, at(today));
expect(july.spread * 31).toBeCloseTo(-250000, 6);
});
});

View File

@@ -0,0 +1,388 @@
/**
* Day-accurate timing for recurring items: on which days a recurring cash flow actually hits.
*
* Nothing here needs extra columns. Each item's timing is derived from what the tracker already
* has: the bank history (the most common day of the month among recent matching charges, or the
* last charge for N-weekly and yearly items), the item's start date, and its name ("€29.99 every
* 4 weeks"). Variable budgets (groceries, takeout, Club Animo, …) are spread evenly over the days
* of the month, and the vacation budget over July and August. Dates are ISO strings; internally
* days are counted from 1970-01-01 (UTC) so the daily simulations stay fast.
*/
export type ScheduleItem = {
name: string;
amountCents: number;
cadence: string;
startsOn?: string | null;
endsOn?: string | null;
};
export type HistoryTransaction = { date: string; amountCents: number; description: string };
export type ItemSchedule =
| {
kind: 'monthly';
day: number;
shift: 'none' | 'before' | 'after';
source: string;
last: string | null;
}
| {
kind: 'interval';
everyDays: number;
anchor: string;
perChargeCents: number;
source: string;
last: string | null;
}
| { kind: 'yearly'; month: number; day: number; source: string; last: string | null }
| { kind: 'spread'; source: string; last: null }
| { kind: 'summer'; source: string; last: null };
const DAY = 86_400_000;
export function dayNumber(date: string) {
return Math.round(Date.parse(`${date}T00:00:00Z`) / DAY);
}
export function isoDay(day: number) {
return new Date(day * DAY).toISOString().slice(0, 10);
}
export function daysInMonth(year: number, month: number) {
return new Date(Date.UTC(year, month, 0)).getUTCDate();
}
/** Day number of a day of the month, clamped to the month end (31st → 30 Apr, 28/29 Feb). */
export function dayOfMonth(year: number, month: number, day: number) {
return Math.round(Date.UTC(year, month - 1, Math.min(day, daysInMonth(year, month))) / DAY);
}
/** 0 = Sunday … 6 = Saturday. */
function weekday(day: number) {
return (day + 4) % 7;
}
/** Moves a weekend date to the Friday before or the Monday after. */
export function businessDay(day: number, shift: 'none' | 'before' | 'after') {
const wd = weekday(day);
if (shift === 'none' || (wd !== 0 && wd !== 6)) return day;
if (shift === 'before') return day - (wd === 6 ? 1 : 2);
return day + (wd === 6 ? 2 : 1);
}
/** Variable budgets: spread over the month instead of a single charge. */
const budgetPattern =
/grocer|boodschap|uber ?eats|club animo|takeout|take-?out|thuisbezorgd|eating out|restaurant|going out|fuel|petrol/i;
const vacationPattern = /vacation|vakantie(?!geld)|holiday trip|travel budget/i;
/** Income paid on a fixed payday: moved to the Friday before when it falls on a weekend. */
const salaryPattern = /salary|salaris|wage|loon\b/i;
/** Benefits from the Belastingdienst are paid on the 20th, or the next working day. */
const benefitPattern = /toeslag|allowance|benefit|uitkering/i;
const stopwords = new Set([
'estimate',
'estimates',
'subscription',
'insurance',
'server',
'domains',
'domain',
'app',
'plus',
'the',
'and',
'for',
'revolut',
'living',
'with',
'parents',
'monthly',
'yearly',
'every',
'weeks'
]);
/** Other names a merchant shows up under on the bank statement. */
const aliases: Record<string, string[]> = {
icloud: ['icloud', 'apple'],
authenticator: ['authenticator', 'apple'],
chatgpt: ['chatgpt', 'openai'],
anthropic: ['anthropic', 'claude'],
claude: ['anthropic', 'claude'],
haircut: ['haircut', 'haarvisie', 'kapsalon', 'kapper'],
grok: ['grok', 'xai'],
salary: ['salary', 'salaris', 'cargill'],
zorgtoeslag: ['zorgtoeslag', 'belastingdienst']
};
function normalise(text: string) {
return ` ${text.toLowerCase().replace(/[^a-z0-9]+/g, ' ')} `;
}
/** Word groups an item's bank description must contain (each group: any of its words). */
export function matchTokens(name: string): string[][] {
const words = normalise(name.replace(/\([^)]*\)/g, ' '))
.split(' ')
.filter((word) => word.length >= 3 && !stopwords.has(word) && !/^\d+$/.test(word));
return [...new Set(words)].map((word) => aliases[word] ?? [word]);
}
/** "€29.99 every 4 weeks" → 28 days at €29.99 a charge. */
export function parseInterval(
name: string
): { everyDays: number; perChargeCents: number | null } | null {
const match = /(?:€\s*([\d.,]+)\s*)?every\s+(\d+)\s*(?:weeks?|wk)/i.exec(name);
if (!match) return null;
const euros = match[1] ? Number(match[1].replace(',', '.')) : NaN;
return {
everyDays: Number(match[2]) * 7,
perChargeCents: Number.isFinite(euros) ? Math.round(euros * 100) : null
};
}
/** Bank transactions that look like charges of this item: same merchant words, similar amount. */
export function matchingCharges(
item: ScheduleItem,
history: HistoryTransaction[],
perChargeCents: number
): HistoryTransaction[] {
const tokens = matchTokens(item.name);
if (!tokens.length || !perChargeCents) return [];
const expected = Math.abs(perChargeCents);
return history.filter((row) => {
if (Math.sign(row.amountCents) !== Math.sign(perChargeCents)) return false;
const ratio = Math.abs(row.amountCents) / expected;
if (ratio < 0.6 || ratio > 1.6) return false;
const text = normalise(row.description);
return tokens.every((group) => group.some((word) => text.includes(word)));
});
}
const ordinal = (day: number) =>
`${day}${day % 10 === 1 && day !== 11 ? 'st' : day % 10 === 2 && day !== 12 ? 'nd' : day % 10 === 3 && day !== 13 ? 'rd' : 'th'}`;
const monthNames = [
'Jan',
'Feb',
'Mar',
'Apr',
'May',
'Jun',
'Jul',
'Aug',
'Sep',
'Oct',
'Nov',
'Dec'
];
/**
* When an item hits. Monthly: the most common day among the charges of the last four months
* (ties → the latest), else the start date's day, else the 1st. N-weekly: every N×7 days from
* the last charge. Yearly: a future start date, else the last charge's date, else the start date.
*/
export function scheduleFor(
item: ScheduleItem,
history: HistoryTransaction[],
today: string
): ItemSchedule {
if (vacationPattern.test(item.name))
return { kind: 'summer', source: 'budget: spread over Jul–Aug', last: null };
if (budgetPattern.test(item.name))
return { kind: 'spread', source: 'budget: spread over the month', last: null };
const interval =
parseInterval(item.name) ??
(item.cadence === 'weekly' ? { everyDays: 7, perChargeCents: null } : null);
const perCharge =
interval?.perChargeCents != null
? Math.sign(item.amountCents || -1) * interval.perChargeCents
: interval
? Math.round((item.amountCents * 12 * interval.everyDays) / 365.25)
: item.amountCents;
const charges = matchingCharges(
item,
history,
item.cadence === 'weekly' ? item.amountCents : perCharge
)
.filter((row) => row.date <= today)
.sort((a, b) => a.date.localeCompare(b.date));
const last = charges.at(-1)?.date ?? null;
const futureStart = item.startsOn && item.startsOn > today ? item.startsOn : null;
if (interval) {
const anchor = futureStart ?? last ?? item.startsOn ?? today;
const source = futureStart
? `every ${interval.everyDays / 7} weeks from the start date`
: last
? `every ${interval.everyDays / 7} weeks from the last charge (${last})`
: `every ${interval.everyDays / 7} weeks from ${item.startsOn ? 'the start date' : 'today'} (no charge found)`;
return {
kind: 'interval',
everyDays: interval.everyDays,
anchor,
perChargeCents: item.cadence === 'weekly' ? item.amountCents : perCharge,
source,
last
};
}
if (item.cadence === 'yearly') {
const from = futureStart ?? last ?? item.startsOn ?? null;
if (!from)
return { kind: 'yearly', month: 1, day: 1, source: 'yearly on 1 Jan (no date known)', last };
const day = Number(from.slice(8, 10));
const month = Number(from.slice(5, 7));
return {
kind: 'yearly',
month,
day,
source: `yearly on ${day} ${monthNames[month - 1]} (${futureStart ? 'start date' : last ? 'last charge' : 'start date'})`,
last
};
}
const shift =
salaryPattern.test(item.name) && item.amountCents > 0
? 'before'
: benefitPattern.test(item.name) && item.amountCents > 0
? 'after'
: 'none';
if (futureStart) {
const day = Number(futureStart.slice(8, 10));
return { kind: 'monthly', day, shift, source: `the ${ordinal(day)} (start date)`, last };
}
const cutoff = isoDay(dayNumber(today) - 122);
const recent = charges.filter((row) => row.date >= cutoff);
const pool = recent.length ? recent : charges.slice(-4);
if (pool.length) {
const counts = new Map<number, number>();
for (const row of pool) {
const day = Number(row.date.slice(8, 10));
counts.set(day, (counts.get(day) ?? 0) + 1);
}
let best = 0;
let bestCount = 0;
// Latest first, so a tie goes to the most recent charge day.
for (const row of [...pool].reverse()) {
const day = Number(row.date.slice(8, 10));
if (counts.get(day)! > bestCount) {
best = day;
bestCount = counts.get(day)!;
}
}
// A weekend-shifted payment (e.g. paid Monday the 22nd for the 20th) shouldn't move the day.
return {
kind: 'monthly',
day: best,
shift,
source: `the ${ordinal(best)} (${pool.length} recent charge${pool.length === 1 ? '' : 's'})`,
last
};
}
if (item.startsOn) {
const day = Number(item.startsOn.slice(8, 10));
return {
kind: 'monthly',
day,
shift,
source: `the ${ordinal(day)} (start date, no charge found)`,
last
};
}
return {
kind: 'monthly',
day: 1,
shift,
source: 'the 1st (no charge or start date found)',
last
};
}
/** Short label for the recurring list, e.g. "18th" or "every 4 wk". */
export function scheduleLabel(schedule: ItemSchedule) {
switch (schedule.kind) {
case 'monthly':
return `on the ${ordinal(schedule.day)}`;
case 'interval':
return `every ${schedule.everyDays / 7} wk`;
case 'yearly':
return `${schedule.day} ${monthNames[schedule.month - 1]}`;
case 'spread':
return 'spread over the month';
case 'summer':
return 'Jul–Aug';
}
}
export type MonthFlows = {
/** Dated amounts in this month: [day number, cents]. */
events: [number, number][];
/** Cents per day, spread evenly over the days spreadFrom…spreadTo (day numbers). */
spread: number;
spreadFrom: number;
spreadTo: number;
};
function monthlyOf(item: ScheduleItem) {
return item.cadence === 'weekly'
? (item.amountCents * 52) / 12
: item.cadence === 'yearly'
? item.amountCents / 12
: item.amountCents;
}
function active(item: ScheduleItem, day: number) {
const date = isoDay(day);
return !(item.startsOn && item.startsOn > date) && !(item.endsOn && item.endsOn < date);
}
/**
* The item's cash flows within one calendar month (year, 1–12), only on days after `after` (day
* number, usually today). Amounts are unscaled cents; callers scale for spending growth.
*/
export function monthFlows(
item: ScheduleItem,
schedule: ItemSchedule,
year: number,
month: number,
after: number
): MonthFlows {
const first = dayOfMonth(year, month, 1);
const length = daysInMonth(year, month);
const lastDay = first + length - 1;
const events: [number, number][] = [];
let spread = 0;
let spreadFrom = 0;
let spreadTo = -1;
const lastCharge = schedule.last ? dayNumber(schedule.last) : -Infinity;
switch (schedule.kind) {
case 'monthly': {
const day = businessDay(dayOfMonth(year, month, schedule.day), schedule.shift);
// Skip a charge that already happened this month (e.g. taken a few days early).
const chargedThisMonth = lastCharge >= first && lastCharge <= lastDay;
if (day > after && active(item, day) && !chargedThisMonth)
events.push([day, item.amountCents]);
break;
}
case 'interval': {
const anchor = dayNumber(schedule.anchor);
const startsInFuture = item.startsOn && dayNumber(item.startsOn) === anchor && anchor > after;
let k = Math.ceil((first - anchor) / schedule.everyDays);
if (k < (startsInFuture ? 0 : 1)) k = startsInFuture ? 0 : 1;
for (let day = anchor + k * schedule.everyDays; day <= lastDay; day += schedule.everyDays)
if (day >= first && day > after && active(item, day))
events.push([day, schedule.perChargeCents]);
break;
}
case 'yearly': {
if (month !== schedule.month) break;
const day = dayOfMonth(year, month, schedule.day);
if (day > after && active(item, day) && day - lastCharge > 300)
events.push([day, item.amountCents]);
break;
}
case 'spread':
case 'summer': {
if (schedule.kind === 'summer' && month !== 7 && month !== 8) break;
const monthly =
schedule.kind === 'summer'
? (item.cadence === 'yearly' ? item.amountCents : monthlyOf(item) * 12) / 2 // a year's budget over two months
: monthlyOf(item);
spreadFrom = Math.max(first, after + 1, item.startsOn ? dayNumber(item.startsOn) : first);
spreadTo = Math.min(lastDay, item.endsOn ? dayNumber(item.endsOn) : lastDay);
// The month's budget, per calendar day; a part month (today, start or end) gets its share.
spread = spreadTo >= spreadFrom ? monthly / length : 0;
break;
}
}
return { events, spread, spreadFrom, spreadTo };
}