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

@@ -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);
});
});