Files
website/src/lib/money-tracker/charts.test.ts
Stan Runge 78b69c7d8f
All checks were successful
Deploy production / deploy (push) Successful in 1m13s
Money forecast: calm optimistic start, rising plateau, thin solid projected lines
- Optimistic: no crash or correction through 2031 (5 years from today), earning the
  9.5% average; cycle resumes after (correction 2035, crash 2040). Salary plateau
  grows 2%/yr real so pay never stalls.
- Realistic keeps its single -30% crash in 2029; pessimistic unchanged.
- Projected lines are now thin, slightly lighter solid lines in every view; legend updated.
- Explanation text lists the crash/correction years and recovery time.
2026-10-26 08:25:52 +00:00

448 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { describe, expect, it } from 'vitest';
import {
accountBalances,
accountColor,
balanceSeries,
decimateSeries,
forecastSeries,
parseHistoryRange,
gridFor,
horizonEnd,
parseHorizon,
timeGrid,
yearLabels,
utilityEstimate,
wealthProjection,
wealthTimeline
} from './charts';
describe('money tracker calculations', () => {
it('keeps balances separate across accounts and preserves transaction order', () => {
const accounts = [
{ id: 1, name: 'Cash', openingBalanceCents: 10000 },
{ id: 2, name: 'Bank', openingBalanceCents: 20000 }
];
const transactions = [
{ accountId: 1, date: '2026-01-01', amountCents: -2500 },
{ accountId: 2, date: '2026-01-02', amountCents: 5000 },
{ accountId: 1, date: '2026-01-03', amountCents: 1000 }
];
expect([...accountBalances(accounts, transactions).values()]).toEqual([8500, 25000]);
expect(balanceSeries(accounts, transactions)[0].points.map((point) => point.value)).toEqual([
7500, 7500, 8500
]);
const combined = balanceSeries(accounts, transactions).at(-1)!;
expect(combined.label).toBe('All accounts');
expect(combined.emphasis).toBeFalsy();
expect(combined.points.map((point) => point.value)).toEqual([27500, 32500, 33500]);
expect(combined.points.at(-1)?.value).toBe(
[...accountBalances(accounts, transactions).values()].reduce((sum, value) => sum + value, 0)
);
});
it("uses an account's chosen color and falls back to the palette", () => {
const series = balanceSeries(
[
{ id: 1, name: 'Revolut', openingBalanceCents: 0, color: '#000000' },
{ id: 2, name: 'Knab', openingBalanceCents: 0, color: null }
],
[{ accountId: 1, date: '2026-01-01', amountCents: 100 }]
);
expect(series[0].color).toBe('#000000');
expect(series[1].color).toBe(accountColor({ color: null }, 1));
});
it('plots combined transactions in date order even if the input is unordered', () => {
const combined = balanceSeries(
[{ id: 1, name: 'Cash', openingBalanceCents: 1000 }],
[
{ accountId: 1, date: '2026-02-02', amountCents: 200 },
{ accountId: 1, date: '2026-01-01', amountCents: -100 }
]
).at(-1)!;
expect(combined.points).toEqual([
{ date: '2026-01-01', value: 900 },
{ date: '2026-02-02', value: 1100 }
]);
});
it('does not show a temporary loss when money moves between accounts on the same date', () => {
const combined = balanceSeries(
[
{ id: 1, name: 'Daily', openingBalanceCents: 1000 },
{ id: 2, name: 'Shared', openingBalanceCents: 0 }
],
[
{ accountId: 1, date: '2026-01-01', amountCents: -200 },
{ accountId: 2, date: '2026-01-01', amountCents: 200 }
]
).at(-1)!;
expect(combined.points).toEqual([{ date: '2026-01-01', value: 1000 }]);
});
it('projects only future estimates from the current balance', () => {
expect(
forecastSeries(
10000,
[
{ date: '2026-01-01', amountCents: -9999 },
{ date: '2026-10-01', amountCents: -2500 },
{ date: '2026-11-01', amountCents: 5000 }
],
'2026-09-23'
)[0].points.map((point) => point.value)
).toEqual([10000, 7500, 12500]);
});
it('keeps the reserve, transfers excess into investments, and adds growth only to invested money', () => {
const settings = {
investmentAccountId: 2,
reserveCents: 100000,
annualReturnPercent: '5.00',
contributionMode: 'excess',
fixedMonthlyCents: 0,
includeRecurring: true,
endDate: '2026-11-30'
};
const accounts = [
{ id: 1, kind: 'spendable', openingBalanceCents: 100000 },
{ id: 2, kind: 'investment', openingBalanceCents: 0 }
];
const series = wealthProjection(
accounts,
new Map([
[1, 100000],
[2, 0]
]),
[{ date: '2026-10-01', accountId: 1, amountCents: -10000 }],
[{ date: '2026-10-15', accountId: 1, amountCents: 20000 }],
[{ amountCents: 50000, cadence: 'monthly', accountId: 1 }],
settings,
'2026-09-23'
);
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);
});
it('does not add a future dated transaction to today’s starting balance', () => {
const accounts = [{ id: 1, kind: 'spendable', openingBalanceCents: 10000 }];
const future = [{ date: '2026-10-01', accountId: 1, amountCents: 5000 }];
const balances = accountBalances(accounts, []);
const series = wealthProjection(accounts, balances, future, [], [], null, '2026-09-23');
expect(series[0].points[0].value).toBe(10000);
expect(series[0].points.at(-1)?.value).toBe(15000);
});
it('invests excess each month without requiring an existing investment account', () => {
const projection = wealthProjection(
[{ id: 1, kind: 'spendable', openingBalanceCents: 40054 }],
new Map([[1, 40054]]),
[],
[],
[{ amountCents: 127137, cadence: 'monthly', accountId: 1 }],
{
investmentAccountId: null,
reserveCents: 100000,
annualReturnPercent: '5.00',
contributionMode: 'excess',
fixedMonthlyCents: 0,
includeRecurring: true,
endDate: '2026-11-30'
},
'2026-09-23'
);
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);
});
it('continues actual history into the projection as one dashed line', () => {
const accounts = [
{ id: 1, name: 'Bank', kind: 'spendable', openingBalanceCents: 0 },
{ id: 2, name: 'Broker', kind: 'investment', openingBalanceCents: 0 }
];
const actual = [
{ accountId: 1, date: '2026-09-01', amountCents: 50000 },
{ accountId: 2, date: '2026-09-10', amountCents: 20000 }
];
const projection = [
{
label: 'Total wealth',
color: '',
points: [
{ date: '2026-09-23', value: 70000 },
{ date: '2026-10-31', value: 90000 }
]
},
{
label: 'Spendable',
color: '',
points: [
{ date: '2026-09-23', value: 50000 },
{ date: '2026-10-31', value: 60000 }
]
},
{
label: 'Invested',
color: '',
points: [
{ date: '2026-09-23', value: 20000 },
{ date: '2026-10-31', value: 30000 }
]
}
];
const [total] = wealthTimeline(accounts, actual, projection, '2026-09-23', 'total');
expect(total.projectedFrom).toBe('2026-09-23');
expect(total.points).toEqual([
{ date: '2026-09-01', value: 50000 },
{ date: '2026-09-10', value: 70000 },
{ date: '2026-09-23', value: 70000 },
{ date: '2026-10-31', value: 90000 }
]);
const [, spendable, invested] = wealthTimeline(
accounts,
actual,
projection,
'2026-09-23',
'split'
);
expect(spendable.points.map((point) => point.value)).toEqual([50000, 50000, 50000, 60000]);
expect(invested.points.map((point) => point.value)).toEqual([0, 20000, 20000, 30000]);
const byAccount = wealthTimeline(accounts, actual, projection, '2026-09-23', 'accounts');
expect(byAccount.map((line) => line.label)).toEqual(['Bank', 'Broker', 'Total']);
expect(byAccount[0].points.at(-1)).toEqual({ date: '2026-09-23', value: 50000 });
});
it('starts the timeline at today when there is no history', () => {
const [total] = wealthTimeline(
[],
[],
[
{
label: 'Total wealth',
color: '',
points: [
{ date: '2026-09-23', value: 0 },
{ date: '2026-10-31', value: 100 }
]
}
],
'2026-09-23',
'total'
);
expect(total.points.map((point) => point.date)).toEqual(['2026-09-23', '2026-10-31']);
});
it('compares estimated usage cost with prorated advances', () => {
const result = utilityEstimate(
[
{ kind: 'gas', date: '2026-01-01', reading: '100' },
{ kind: 'gas', date: '2026-04-01', reading: '130' }
],
'gas',
'1.2500',
2000
);
expect(result?.usage).toBe(30);
expect(result?.estimatedCents).toBe(3750);
expect(result?.advancesCents).toBeGreaterThan(5800);
expect(
utilityEstimate(
[
{ kind: 'gas', date: '2025-01-01', reading: '0' },
{ kind: 'gas', date: '2026-01-01', reading: '100' },
{ kind: 'gas', date: '2026-04-01', reading: '130' }
],
'gas',
'1.2500',
2000,
'2026-01-01'
)?.usage
).toBe(30);
});
});
describe('history range', () => {
it('starts lines at the range start with the balance they had then', async () => {
const { clipSeries, historyStart } = await import('./charts');
expect(historyStart('1y', '2026-10-24')).toBe('2025-10-24');
expect(historyStart('all', '2026-10-24')).toBeNull();
const series = [
{
label: 'Knab',
color: 'x',
points: [
{ date: '2021-08-24', value: 100 },
{ date: '2025-01-01', value: 50 },
{ date: '2026-01-01', value: 70 }
]
}
];
expect(clipSeries(series, '2025-10-24')[0].points).toEqual([
{ date: '2025-10-24', value: 50 },
{ date: '2026-01-01', value: 70 }
]);
expect(clipSeries(series, null)).toBe(series);
});
it('leaves accounts with includeInTotal false out of the totals but keeps their own line', () => {
const accounts = [
{ id: 1, name: 'Bank', kind: 'spendable', openingBalanceCents: 1000 },
{ id: 2, name: 'Parents', kind: 'spendable', openingBalanceCents: 0, includeInTotal: false }
];
const actual = [
{ accountId: 2, date: '2019-03-01', amountCents: 1_000_000 },
{ accountId: 2, date: '2019-04-01', amountCents: -1_000_000 },
{ accountId: 1, date: '2019-04-01', amountCents: 500 }
];
const series = balanceSeries(accounts, actual);
expect(series[1].points.map((point) => point.value)).toEqual([1_000_000, 0]);
expect(series.at(-1)!.points.map((point) => point.value)).toEqual([1000, 1500]);
const balances = accountBalances(accounts, actual);
const future = [
{ accountId: 2, date: '2026-10-01', amountCents: 9999 },
{ accountId: 1, date: '2026-10-01', amountCents: 100 }
];
const projection = wealthProjection(accounts, balances, future, [], [], null, '2026-09-23');
expect(projection[0].points[0].value).toBe(1500);
expect(projection[0].points.at(-1)!.value).toBe(1600);
const [, spendable] = wealthTimeline(accounts, actual, projection, '2026-09-23', 'split');
expect(spendable.points.slice(0, 2).map((point) => point.value)).toEqual([1000, 1500]);
const byAccount = wealthTimeline(accounts, actual, projection, '2026-09-23', 'accounts');
expect(byAccount.map((line) => line.label)).toEqual(['Bank', 'Parents', 'Total']);
expect(byAccount.at(-1)!.points[0].value).toBe(1000);
});
it('reads the history range from the URL, defaulting to one year', () => {
expect(parseHistoryRange('3y')).toBe('3y');
expect(parseHistoryRange('all')).toBe('all');
expect(parseHistoryRange('1y')).toBe('1y');
expect(parseHistoryRange('5y')).toBe('1y');
expect(parseHistoryRange(null)).toBe('1y');
});
it('puts a gridline on every month start and marks 1 January as a year line', () => {
const lines = timeGrid('2025-10-24', '2026-03-01', 'months');
expect(lines).toEqual([
{ date: '2025-11-01', year: false },
{ date: '2025-12-01', year: false },
{ date: '2026-01-01', year: true },
{ date: '2026-02-01', year: false },
{ date: '2026-03-01', year: false }
]);
expect(timeGrid('2025-01-01', '2025-02-15', 'months').map((line) => line.date)).toEqual([
'2025-01-01',
'2025-02-01'
]);
});
it('puts only year lines on the all-time grid and thins crowded year labels', () => {
const lines = timeGrid('2007-02-01', '2027-10-24', 'years');
expect(lines).toHaveLength(20);
expect(lines.every((line) => line.year && line.date.endsWith('-01-01'))).toBe(true);
expect(lines[0].date).toBe('2008-01-01');
expect(yearLabels(lines, 30)).toHaveLength(20);
expect(yearLabels(lines, 6).map((date) => date.slice(0, 4))).toEqual([
'2010',
'2015',
'2020',
'2025'
]);
expect(timeGrid('2026-01-02', '2026-01-01', 'months')).toEqual([]);
});
it('reads the forecast horizon and picks gridlines that scale with the span', () => {
expect(parseHorizon('10y')).toBe('10y');
expect(parseHorizon('2y')).toBe('5y');
expect(parseHorizon(null)).toBe('5y');
expect(horizonEnd('100y', '2026-10-24')).toBe('2126-10-24');
expect(gridFor('2025-10-24', '2027-10-24')).toBe('months');
expect(gridFor('2025-10-24', '2056-10-24')).toBe('years');
expect(gridFor('2025-10-24', '2076-10-24')).toBe('decades');
const decades = timeGrid('2025-10-24', '2126-10-24', 'decades');
expect(decades.map((line) => line.date.slice(0, 4))).toEqual(
[2030, 2040, 2050, 2060, 2070, 2080, 2090, 2100, 2110, 2120].map(String)
);
expect(yearLabels(decades, 5).map((date) => date.slice(0, 4))).toEqual([
'2040',
'2060',
'2080',
'2100',
'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);
});
});