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"> <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 { let {
series, series: input,
title, title,
unit = '€', unit = '€',
marker, marker,
@@ -24,6 +31,8 @@
let compact = $derived(width < 520); let compact = $derived(width < 520);
let height = $derived(compact ? 240 : 280); let height = $derived(compact ? 240 : 280);
let plot = $derived({ left: compact ? 52 : 72, right: 14, top: 18, bottom: 30 }); 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 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 times = $derived(series.flatMap((item) => item.points.map((point) => time(point.date))));
let scale = $derived(niceScale(Math.min(0, ...values), Math.max(0, ...values))); let scale = $derived(niceScale(Math.min(0, ...values), Math.max(0, ...values)));

View File

@@ -3,6 +3,7 @@ import {
accountBalances, accountBalances,
accountColor, accountColor,
balanceSeries, balanceSeries,
decimateSeries,
forecastSeries, forecastSeries,
parseHistoryRange, parseHistoryRange,
gridFor, gridFor,
@@ -119,7 +120,18 @@ describe('money tracker calculations', () => {
settings, settings,
'2026-09-23' '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[1].points.at(-1)?.value).toBe(100000);
expect(series[2].points.at(-1)?.value).toBe(110244); expect(series[2].points.at(-1)?.value).toBe(110244);
}); });
@@ -151,9 +163,10 @@ describe('money tracker calculations', () => {
}, },
'2026-09-23' '2026-09-23'
); );
expect(projection[0].points.map((point) => point.value)).toEqual([ const total = new Map(projection[0].points.map((point) => [point.date, point.value]));
40054, 40054, 167191, 294602 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[1].points.at(-1)?.value).toBe(100000);
expect(projection[2].points.at(-1)?.value).toBe(194602); expect(projection[2].points.at(-1)?.value).toBe(194602);
}); });
@@ -373,4 +386,62 @@ describe('history range', () => {
'2120' '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 ChartPoint = { date: string; value: number };
export type ChartSeries = { export type ChartSeries = {
label: string; label: string;
@@ -111,6 +122,7 @@ type ProjectionAccount = {
}; };
type ProjectionMovement = { date: string; amountCents: number; accountId: number | null }; type ProjectionMovement = { date: string; amountCents: number; accountId: number | null };
type ProjectionRecurring = { type ProjectionRecurring = {
name?: string;
amountCents: number; amountCents: number;
cadence: string; cadence: string;
accountId: number | null; accountId: number | null;
@@ -127,6 +139,11 @@ type ProjectionSettings = {
endDate?: string | null; 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( export function wealthProjection(
accounts: ProjectionAccount[], accounts: ProjectionAccount[],
balances: Map<number, number>, balances: Map<number, number>,
@@ -136,11 +153,15 @@ export function wealthProjection(
settings: ProjectionSettings | null, settings: ProjectionSettings | null,
today: string, today: string,
/** Overrides the settings' end date, e.g. with the chart's forecast horizon. */ /** 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[] { ): ChartSeries[] {
const excluded = excludedIds(accounts); const excluded = excludedIds(accounts);
const counted = (accountId: number | null) => accountId === null || !excluded.has(accountId); const counted = (accountId: number | null) => accountId === null || !excluded.has(accountId);
const accountKind = new Map(accounts.map((account) => [account.id, account.kind])); 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 investmentId = settings?.investmentAccountId ?? null;
const validInvestmentId = const validInvestmentId =
investmentId !== null && accountKind.get(investmentId) === 'investment' ? investmentId : null; investmentId !== null && accountKind.get(investmentId) === 'investment' ? investmentId : null;
@@ -156,13 +177,10 @@ export function wealthProjection(
const totalPoints: ChartPoint[] = [{ date: today, value: spendable + invested }]; const totalPoints: ChartPoint[] = [{ date: today, value: spendable + invested }];
const spendablePoints: ChartPoint[] = [{ date: today, value: spendable }]; const spendablePoints: ChartPoint[] = [{ date: today, value: spendable }];
const investedPoints: ChartPoint[] = [{ date: today, value: invested }]; const investedPoints: ChartPoint[] = [{ date: today, value: invested }];
const movements = [...futureTransactions, ...forecasts].filter( const movements = [...futureTransactions, ...forecasts]
(entry) => entry.date > today && counted(entry.accountId) .filter((entry) => entry.date > today && counted(entry.accountId))
); .sort((a, b) => a.date.localeCompare(b.date));
const latestMovement = movements.reduce( const latestMovement = movements.at(-1)?.date ?? '';
(latest, entry) => (entry.date > latest ? entry.date : latest),
''
);
const fallback = new Date(`${today}T12:00:00Z`); const fallback = new Date(`${today}T12:00:00Z`);
fallback.setUTCFullYear(fallback.getUTCFullYear() + 1); fallback.setUTCFullYear(fallback.getUTCFullYear() + 1);
const horizon = const horizon =
@@ -171,43 +189,71 @@ export function wealthProjection(
: settings?.endDate && settings.endDate > today : settings?.endDate && settings.endDate > today
? settings.endDate ? settings.endDate
: latestMovement || fallback.toISOString().slice(0, 10); : latestMovement || fallback.toISOString().slice(0, 10);
const monthEnd = (year: number, month: number) => const todayNumber = dayNumber(today);
new Date(Date.UTC(year, month + 1, 0)).toISOString().slice(0, 10); const endNumber = dayNumber(horizon);
const start = new Date(`${today}T12:00:00Z`); const items =
let year = start.getUTCFullYear(); settings?.includeRecurring === false
let month = start.getUTCMonth(); ? []
: 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 yearly = Number(settings?.annualReturnPercent ?? 5) / 100;
const monthlyGrowth = Math.pow(1 + yearly, 1 / 12) - 1; const monthlyGrowth = Math.pow(1 + yearly, 1 / 12) - 1;
for (let index = 0; index < 1212; index++) { const reserve = settings?.reserveCents ?? 100000;
const first = `${year}-${String(month + 1).padStart(2, '0')}-01`; let year = Number(today.slice(0, 4));
const end = monthEnd(year, month); let month = Number(today.slice(5, 7));
const date = end < horizon ? end : horizon; let next = 0;
if (date > today) { for (let guard = 0; guard < 1213; guard++) {
for (const entry of movements.filter( const firstDay = dayOfMonth(year, month, 1);
(item) => (index === 0 ? item.date > today : item.date >= first) && item.date <= date if (firstDay > endNumber) break;
)) { const length = daysInMonth(year, month);
if (entry.accountId !== null && accountKind.get(entry.accountId) === 'investment') { const toSpendable = new Float64Array(length);
invested += entry.amountCents; const toInvested = new Float64Array(length);
if (entry.accountId === validInvestmentId) indexBalance += entry.amountCents; const toIndex = new Float64Array(length);
} else spendable += entry.amountCents; 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);
} }
if (settings?.includeRecurring !== false && index > 0) { for (const { item, schedule } of items) {
for (const item of recurring) { const flows = monthFlows(item, schedule, year, month, todayNumber);
if (!counted(item.accountId)) continue; for (const [day, cents] of flows.events) add(day, cents, item.accountId);
if ((item.startsOn && item.startsOn > date) || (item.endsOn && item.endsOn < first)) for (let day = flows.spreadFrom; day <= flows.spreadTo; day++)
continue; add(day, flows.spread, item.accountId);
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;
} }
} const paid =
if (index > 0) { salary?.schedule.kind === 'monthly'
const growth = Math.round(indexBalance * monthlyGrowth); ? 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; invested += growth;
indexBalance += growth; indexBalance += growth;
const excess = Math.max(0, spendable - (settings?.reserveCents ?? 100000)); if (day === sweepDay) {
const excess = Math.max(0, spendable - reserve);
const contribution = const contribution =
settings?.contributionMode === 'fixed' settings?.contributionMode === 'fixed'
? Math.min(excess, settings.fixedMonthlyCents) ? Math.min(excess, settings.fixedMonthlyCents)
@@ -216,14 +262,14 @@ export function wealthProjection(
invested += contribution; invested += contribution;
indexBalance += contribution; indexBalance += contribution;
} }
totalPoints.push({ date, value: spendable + invested }); const date = isoDay(day);
spendablePoints.push({ date, value: spendable }); totalPoints.push({ date, value: Math.round(spendable + invested) });
investedPoints.push({ date, value: invested }); spendablePoints.push({ date, value: Math.round(spendable) });
investedPoints.push({ date, value: Math.round(invested) });
} }
if (end >= horizon) break;
month++; month++;
if (month === 12) { if (month === 13) {
month = 0; month = 1;
year++; 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'; export type TimelineView = 'total' | 'split' | 'accounts';
/** /**

View File

@@ -76,29 +76,73 @@ describe('runScenario', () => {
moveCostEuros: 0, moveCostEuros: 0,
bonusPercentOfTarget: 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); const result = runScenario(inputs, flat);
expect(result.points[0]).toMatchObject({ date: '2026-10-24', total: 350000 }); 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.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]); 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', () => { it('pays salary on payday, sweeps the excess the day after and counts recurring income and costs', () => {
const result = runScenario(inputs, flat); const points = runScenario(inputs, flat).points;
const november = result.points[1]; // Without history the contract payday (the 25th) is used; €900 pre-move household is
expect(november.cash).toBe(500000); // spread at €30 a day in November.
// 300k salary + 10k benefit + 28k one-off + 13th month 3/12 − 3k gym − 90k pre-move household. expect(on(points, '2026-11-25').total - on(points, '2026-11-24').total).toBeCloseTo(
expect(november.total).toBeCloseTo(350000 + 300000 + 10000 + 28000 + 51250 - 3000 - 90000, 0); 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)', () => { it('pays contract extras on payday in May (holiday pay) and August (bonus)', () => {
const delta = (points: { total: number }[], index: number) =>
points[index].total - points[index - 1].total;
const flatRun = runScenario(inputs, flat).points; const flatRun = runScenario(inputs, flat).points;
// Index 7 is May 2027: holiday pay 8/12 of €1,950. const jump = (points: typeof flatRun, date: string, before: string) =>
expect(delta(flatRun, 7) - delta(flatRun, 6)).toBeCloseTo(130000, 0); 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; const bonus = runScenario(inputs, { ...flat, bonusPercentOfTarget: 100 }).points;
// Index 10 is August 2027: the first bonus, €850. expect(on(bonus, '2027-08-24').total - on(flatRun, '2027-08-24').total).toBeCloseTo(0, 0);
expect(delta(bonus, 10) - delta(flatRun, 10)).toBeCloseTo(85000, 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', () => { it('orders the default scenarios from pessimistic to optimistic', () => {
const [pessimistic, realistic, optimistic] = ( const [pessimistic, realistic, optimistic] = (
@@ -148,7 +192,7 @@ describe('runScenario', () => {
hopGapMonths: 4, hopGapMonths: 4,
salaryCapEuros: 60000 salaryCapEuros: 60000
}); });
expect(result.points).toHaveLength(1201); expect(result.points).toHaveLength(36525);
const byYear = new Map(result.years.map((row) => [row.year, row])); const byYear = new Map(result.years.map((row) => [row.year, row]));
expect(byYear.get(2029)!.events).toEqual(['Between jobs (4 mo)']); expect(byYear.get(2029)!.events).toEqual(['Between jobs (4 mo)']);
expect(byYear.get(2030)!.events).toEqual(['New job']); expect(byYear.get(2030)!.events).toEqual(['New job']);

View File

@@ -9,6 +9,17 @@
*/ */
import type { ChartSeries } from './charts'; 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 type ScenarioId = 'pessimistic' | 'realistic' | 'optimistic';
export const scenarioIds: ScenarioId[] = ['pessimistic', 'realistic', 'optimistic']; export const scenarioIds: ScenarioId[] = ['pessimistic', 'realistic', 'optimistic'];
@@ -246,6 +257,8 @@ export type ScenarioRecurring = {
endsOn?: string | null; endsOn?: string | null;
}; };
export type ScenarioMovement = { date: string; amountCents: number; description: string }; 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 = { export type ScenarioInputs = {
today: string; today: string;
@@ -254,10 +267,12 @@ export type ScenarioInputs = {
investedCents: number; investedCents: number;
/** Net salary per month from the recurring list. */ /** Net salary per month from the recurring list. */
netSalaryCents: number; 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. */ /** Recurring items other than salary and Joint income. */
personal: ScenarioRecurring[]; personal: ScheduledRecurring[];
/** Joint recurring costs, shared after the move. */ /** Joint recurring costs, shared after the move. */
household: ScenarioRecurring[]; household: ScheduledRecurring[];
/** Rent start: the move month. */ /** Rent start: the move month. */
moveDate: string | null; moveDate: string | null;
oneOffs: ScenarioMovement[]; oneOffs: ScenarioMovement[];
@@ -300,18 +315,6 @@ const salaryPattern = /salary|salaris/i;
/** Tracker estimates the scenarios model themselves (holiday pay, 13th month, bonus). */ /** Tracker estimates the scenarios model themselves (holiday pay, 13th month, bonus). */
const modelledPattern = /cargill|holiday|vakantiegeld|13th|dertiende|bonus/i; 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. */ /** Splits tracker data into the pieces the scenario model needs. */
export function scenarioInputs(input: { export function scenarioInputs(input: {
accounts: { id: number; kind: string; name: string; includeInTotal?: boolean }[]; accounts: { id: number; kind: string; name: string; includeInTotal?: boolean }[];
@@ -326,6 +329,8 @@ export function scenarioInputs(input: {
}[]; }[];
today: string; today: string;
endDate?: string | null; endDate?: string | null;
/** Bank transactions through today, to find the day each recurring item hits. */
history?: HistoryTransaction[];
}): ScenarioInputs { }): ScenarioInputs {
const counted = input.accounts.filter((account) => account.includeInTotal !== false); const counted = input.accounts.filter((account) => account.includeInTotal !== false);
const countedIds = new Set(counted.map((account) => account.id)); const countedIds = new Set(counted.map((account) => account.id));
@@ -354,15 +359,25 @@ export function scenarioInputs(input: {
!modelledPattern.test(item.description) !modelledPattern.test(item.description)
) )
.map(({ date, amountCents, description }) => ({ date, amountCents, 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 { return {
today: input.today, today: input.today,
endDate: input.endDate && input.endDate > input.today ? input.endDate : '2030-12-31', endDate: input.endDate && input.endDate > input.today ? input.endDate : '2030-12-31',
spendableCents: spendable, spendableCents: spendable,
investedCents: invested, investedCents: invested,
netSalaryCents: salary?.amountCents ?? 0, 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. // Joint income (your partner's salary) pays her share of the household, so it stays out.
personal: recurring.filter((item) => item !== salary && !isJoint(item)), personal: recurring.filter((item) => item !== salary && !isJoint(item)).map(scheduled),
household, household: household.map(scheduled),
moveDate: rent?.startsOn ?? null, moveDate: rent?.startsOn ?? null,
oneOffs 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 { export function runScenario(inputs: ScenarioInputs, params: ScenarioParams): ScenarioResult {
const facts = scenarioFacts; const facts = scenarioFacts;
const baseGross = facts.grossCents; const baseGross = facts.grossCents;
@@ -425,6 +451,9 @@ export function runScenario(inputs: ScenarioInputs, params: ScenarioParams): Sce
params.hopEveryYears > 0 && params.hopEveryYears > 0 &&
year >= params.firstHopYear && year >= params.firstHopYear &&
(year - params.firstHopYear) % Math.max(1, Math.round(params.hopEveryYears)) === 0; (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 cash = inputs.spendableCents;
let invested = inputs.investedCents; let invested = inputs.investedCents;
let basis = 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 years = new Map<number, ScenarioYear>();
const target = facts.targetMonthlyCents; const target = facts.targetMonthlyCents;
let buffer = facts.bufferCents;
const milestones: { label: string; date: string | null; test: () => boolean }[] = [ const milestones: { label: string; date: string | null; test: () => boolean }[] = [
{ {
label: 'Cash buffer full', label: 'Cash buffer full',
date: null, date: null,
test: () => cash >= facts.bufferCents * deflator - 100 test: () => cash >= buffer - 100
}, },
...[10_000, 100_000, 250_000, 1_000_000].map((euros) => ({ ...[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`, label: `€${euros >= 1_000_000 ? `${euros / 1_000_000}M` : `${euros / 1000}k`} invested`,
@@ -475,14 +505,9 @@ export function runScenario(inputs: ScenarioInputs, params: ScenarioParams): Sce
Number(firstMonth.slice(5, 7)) + Number(firstMonth.slice(5, 7)) +
1; 1;
const moveDate = inputs.moveDate ?? '9999-12-31'; 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 first = addMonths(firstMonth, index); const moveDay = inputs.moveDate ? dayNumber(inputs.moveDate) : 0;
const last = monthEnd(first); const yearRow = (year: number) =>
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.get(year) ??
years years
.set(year, { .set(year, {
@@ -502,12 +527,44 @@ export function runScenario(inputs: ScenarioInputs, params: ScenarioParams): Sce
events: [] events: []
}) })
.get(year)!; .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 year = Number(first.slice(0, 4));
const month = Number(first.slice(5, 7));
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) { if (month === 1) {
investedAtYearStart = invested; investedAtYearStart = invested;
contributedThisYear = 0; contributedThisYear = 0;
realisedThisYear = 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; const retired = first >= retireStart;
let income = 0;
let spending = 0;
let employed = false;
if (!partial) {
// Salary path: extension or next job after the contract (optional gap), raises, job hops // 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. // in October (optional gap before each), capped at the career plateau in today's euros.
const hopStart = isHopYear(year) ? `${year}-10-01` : null; const hopStart = isHopYear(year) ? `${year}-10-01` : null;
@@ -519,7 +576,7 @@ export function runScenario(inputs: ScenarioInputs, params: ScenarioParams): Sce
const inHopGap = hops.some((hop) => first >= hop.start && first < hop.end); const inHopGap = hops.some((hop) => first >= hop.start && first < hop.end);
const hopEnd = hops.find((hop) => hop.end === first)?.end ?? null; const hopEnd = hops.find((hop) => hop.end === first)?.end ?? null;
const inContractGap = params.gapMonths > 0 && first >= gapStart && first < gapEnd; const inContractGap = params.gapMonths > 0 && first >= gapStart && first < gapEnd;
const employed = !retired && !inHopGap && !inContractGap; employed = !retired && !inHopGap && !inContractGap;
if (first === gapStart) { if (first === gapStart) {
gross = baseGross * (1 + pct(params.nextJobPercent)); gross = baseGross * (1 + pct(params.nextJobPercent));
row.events.push( row.events.push(
@@ -548,83 +605,149 @@ export function runScenario(inputs: ScenarioInputs, params: ScenarioParams): Sce
first === retireStart || first === retireStart ||
(hopStart !== null && first === hopStart) || (hopStart !== null && first === hopStart) ||
(params.gapMonths > 0 && first === gapStart); (params.gapMonths > 0 && first === gapStart);
let income = 0; // Pay, benefit or pension, and the contract extras: all on payday.
if (employed) income += netFromGross(gross); let pay = 0;
else if (!retired) income += params.gapNetEuros * 100 * deflator; if (employed) pay += netFromGross(gross);
else if (!retired) pay += params.gapNetEuros * 100 * deflator;
if (employed) { if (employed) {
accruedHoliday += (facts.holidayNetYearCents / 12) * (gross / baseGross); accruedHoliday += (facts.holidayNetYearCents / 12) * (gross / baseGross);
accrued13 += (facts.thirteenthNetYearCents / 12) * (gross / baseGross); accrued13 += (facts.thirteenthNetYearCents / 12) * (gross / baseGross);
} }
if (month === 5 || jobChange) { if (month === 5 || jobChange) {
income += accruedHoliday; pay += accruedHoliday;
accruedHoliday = 0; accruedHoliday = 0;
} }
if (month === 11 || jobChange) { if (month === 11 || jobChange) {
income += accrued13; pay += accrued13;
accrued13 = 0; accrued13 = 0;
} }
if (month === 8 && year >= facts.firstBonusYear && employed) if (month === 8 && year >= facts.firstBonusYear && employed)
income += pay +=
pct(params.bonusPercentOfTarget) * pct(params.bonusPercentOfTarget) *
(year === facts.firstBonusYear (year === facts.firstBonusYear
? facts.firstBonusCents ? facts.firstBonusCents
: facts.bonusNetYearCents * (gross / baseGross)); : facts.bonusNetYearCents * (gross / baseGross));
if (retired) { if (retired) {
income += params.pensionNetEuros * 100 * deflator; pay += params.pensionNetEuros * 100 * deflator;
if (first === retireStart) row.events.push(`Retire at ${params.retireAge}`); if (first === retireStart) row.events.push(`Retire at ${params.retireAge}`);
} else { }
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 ramp = Math.min(1, Math.max(0, (index - 3) / 57));
const sideGross = const sideGross =
params.sideStartEuros + (params.sideEndEuros - params.sideStartEuros) * ramp; params.sideStartEuros + (params.sideEndEuros - params.sideStartEuros) * ramp;
income += sideGross * 100 * deflator * (1 - facts.sideTaxRate); const side = sideGross * 100 * deflator * (1 - facts.sideTaxRate);
income += side;
spreadMonth(side);
} }
// Recurring items from the tracker (positive ones such as benefits count as income).
if (index >= 3) if (index >= 3)
spendGrowth *= Math.pow( spendGrowth *= Math.pow(
1 + pct(index < 123 ? params.spendGrowthPercent : params.inflationPercent), 1 + pct(index < 123 ? params.spendGrowthPercent : params.inflationPercent),
1 / 12 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;
} }
spending += params.otherSpendEuros * 100 * spendGrowth; // Recurring items from the tracker (positive ones such as benefits count as income).
if (first >= '2027-01-01') spending += params.healthInsuranceEuros * 100 * spendGrowth; const addItem = (item: ScheduledRecurring, scale: number, incomeAllowed: boolean) => {
if (first < moveDate.slice(0, 7) + '-01') spending += facts.preMoveHouseholdCents; const flows = monthFlows(item, item.schedule, year, month, todayNumber);
else { for (const [day, cents] of flows.events) {
let household = params.householdExtraEuros * 100; if (cents >= 0) {
for (const item of inputs.household) if (!incomeAllowed) continue;
if (activeIn(item, first, last)) household -= monthly(item); income += cents;
spending += household * pct(params.householdSharePercent) * spendGrowth; at(day, cents);
} else {
spending -= cents * scale;
at(day, cents * scale);
} }
if (first === moveDate.slice(0, 7) + '-01') spending += params.moveCostEuros * 100; }
if (year >= facts.duoFrom && year <= facts.duoUntil) spending += facts.duoCents; if (flows.spread && flows.spreadTo >= flows.spreadFrom) {
for (const item of inputs.oneOffs) const cents = flows.spread * (flows.spreadTo - flows.spreadFrom + 1);
if ((index === 0 ? item.date > inputs.today : item.date >= first) && item.date <= last) { 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);
}
}
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 === 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; if (item.amountCents >= 0) income += item.amountCents;
else spending -= item.amountCents; else spending -= item.amountCents;
at(day, item.amountCents);
}
} }
let tax = 0; 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; tax = box3Due;
box3Due = 0; box3Due = 0;
at(firstDay, -tax);
} }
cash += income - spending - tax; // Returns: crash and correction years spread their drop over March–October; daily
// Returns: crash and correction years spread their drop over March–October. // compounding gives the same monthly return.
const market = markets.yearReturn(year); const market = markets.yearReturn(year);
const rate = const monthRate =
market.kind === 'normal' market.kind === 'normal'
? Math.pow(1 + market.rate, 1 / 12) - 1 ? Math.pow(1 + market.rate, 1 / 12) - 1
: month >= 3 && month <= 10 : month >= 3 && month <= 10
? Math.pow(1 + market.rate, 1 / 8) - 1 ? Math.pow(1 + market.rate, 1 / 8) - 1
: 0; : 0;
invested += invested * rate; const dayGrowth = Math.pow(1 + monthRate, 1 / length);
cash *= Math.pow(1 + pct(params.cashRatePercent), 1 / 12); const dayCash = Math.pow(1 + pct(params.cashRatePercent), 1 / 12 / length);
const buffer = facts.bufferCents * deflator; const dayInflation = Math.pow(monthlyInflation, 1 / length);
if (cash > buffer) { 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; const extra = cash - buffer;
invested += extra; invested += extra;
basis += extra; basis += extra;
@@ -640,6 +763,11 @@ export function runScenario(inputs: ScenarioInputs, params: ScenarioParams): Sce
contributedThisYear -= sold; contributedThisYear -= sold;
cash += 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) { if (month === 12) {
const gain = invested - investedAtYearStart - contributedThisYear; const gain = invested - investedAtYearStart - contributedThisYear;
if (year >= 2028) { if (year >= 2028) {
@@ -659,21 +787,28 @@ export function runScenario(inputs: ScenarioInputs, params: ScenarioParams): Sce
} }
} }
const total = cash + invested; const total = cash + invested;
const real = total / deflator; if (!partial) {
recentRealIncome.push(income / deflator); recentRealIncome.push(income / deflator);
if (recentRealIncome.length > 12) recentRealIncome.shift(); if (recentRealIncome.length > 12) recentRealIncome.shift();
points.push({ date, cash, invested, total, real }); }
row.income += income; row.income += income;
row.spending += spending + tax; row.spending += spending + tax;
row.box3 += tax; row.box3 += tax;
row.total = total; row.total = total;
row.invested = invested; row.invested = invested;
row.real = real; row.real = total / deflator;
if (!partial) {
row.grossMonthly = employed ? gross : 0; row.grossMonthly = employed ? gross : 0;
row.baseReal = retired ? 0 : (gross * 12) / deflator / 100; row.baseReal = retired ? 0 : (gross * 12) / deflator / 100;
row.role = retired ? 'Retired' : roleFor(row.baseReal); 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) for (const milestone of milestones)
if (milestone.date === null && milestone.test()) milestone.date = first.slice(0, 7); if (milestone.date === null && milestone.test()) milestone.date = first.slice(0, 7);
if (last >= inputs.endDate) break;
} }
for (const row of years.values()) { for (const row of years.values()) {
row.saved = row.income - row.spending; 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 };
}

View File

@@ -18,6 +18,7 @@
import FileText from 'lucide-svelte/icons/file-text'; import FileText from 'lucide-svelte/icons/file-text';
import MoneyChart from '#lib/money-tracker/MoneyChart.svelte'; import MoneyChart from '#lib/money-tracker/MoneyChart.svelte';
import QuickAdd from '#lib/money-tracker/QuickAdd.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 ScenarioDetails from '#lib/money-tracker/ScenarioDetails.svelte';
import { import {
defaultScenarios, defaultScenarios,
@@ -100,6 +101,16 @@
let actualTransactions = $derived(data.transactions.filter((item) => item.date <= data.today)); let actualTransactions = $derived(data.transactions.filter((item) => item.date <= data.today));
let futureTransactions = $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)); 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)); 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 // Archived accounts (closed banks) stay in history, net worth and the charts, but not in the
// quick balances or the account pickers. // quick balances or the account pickers.
@@ -192,7 +203,8 @@
data.recurring, data.recurring,
data.projectionSettings, data.projectionSettings,
data.today, data.today,
forecastEnd forecastEnd,
scheduleHistory
) )
); );
type ProjectionView = ScenarioId | 'compare' | 'simple'; type ProjectionView = ScenarioId | 'compare' | 'simple';
@@ -228,7 +240,8 @@
forecasts: data.forecasts, forecasts: data.forecasts,
futureTransactions, futureTransactions,
today: data.today, today: data.today,
endDate: forecastEnd endDate: forecastEnd,
history: scheduleHistory
}) })
); );
let scenarioResults = $derived( let scenarioResults = $derived(
@@ -596,17 +609,20 @@
onreset={() => (scenarioParams = structuredClone(defaultScenarios))} onreset={() => (scenarioParams = structuredClone(defaultScenarios))}
/> />
<p class="muted projection-note"> <p class="muted projection-note">
Scenarios start from today’s balances and run monthly on the recurring list, one-off Scenarios start from today’s balances and run day by day: salary and its extras on payday,
estimates (salary extras such as holiday pay, the 13th month and the bonus are modelled each recurring item on the day the bank history (or its start date) puts it, budgets such
per scenario instead), and the assumptions above. Compare shows all three totals; Simple as groceries spread over the month and vacation over July–August, investing the day after
is the setting-based projection. The history tabs set how far back the chart starts and payday. They use the recurring list, one-off estimates (salary extras such as holiday pay,
Forecast how far ahead it runs (1–100 years); Today’s € divides projected values by the the 13th month and the bonus are modelled per scenario instead), and the assumptions
scenario’s inflation so long horizons stay comparable. 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> </p>
{:else}<p class="muted projection-note"> {:else}<p class="muted projection-note">
Includes future dated transactions and estimates{data.projectionSettings Includes future dated transactions and estimates{data.projectionSettings
?.includeRecurring !== false ?.includeRecurring !== false
? ', plus recurring monthly equivalents from next month' ? ', plus recurring items on the days they hit'
: ''}. {data.projectionSettings?.investmentAccountId : ''}. {data.projectionSettings?.investmentAccountId
? `The selected investment account and new contributions use a hypothetical ${data.projectionSettings.annualReturnPercent}% annual return.` ? `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.`} : `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" <span class="item-main"
>{@render swatch(row.accountId)}<span >{@render swatch(row.accountId)}<span
><strong>{row.name}</strong><small ><strong>{row.name}</strong><small
><span class="capitalize">{row.cadence}</span> · {row.accountId ><span class="capitalize">{row.cadence}</span
? accountName(row.accountId) >{#if recurringTiming.get(row.id)}<span
: 'Any account'} · {row.startsOn ? dateLabel(row.startsOn) : 'Now'} – {row.endsOn title={recurringTiming.get(row.id)!.source}
? dateLabel(row.endsOn) >
: 'ongoing'}</small · {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
></span ></span
><strong class="item-amount">{@render signed(row.amountCents)}</strong ><strong class="item-amount">{@render signed(row.amountCents)}</strong
@@ -1254,8 +1273,8 @@
> >
{:else if mode === 'projectionSettings'} {:else if mode === 'projectionSettings'}
<p class="dialog-copy"> <p class="dialog-copy">
A planning scenario. Returns are hypothetical and calculated monthly. Contributions A planning scenario. Returns are hypothetical and compound daily. Contributions transfer
transfer money from spendable accounts into a projected investment balance. money from spendable accounts into a projected investment balance.
</p> </p>
<label class="field" <label class="field"
>Existing investment account (optional)<NativeSelect name="investmentAccountId" >Existing investment account (optional)<NativeSelect name="investmentAccountId"