All checks were successful
Deploy production / deploy (push) Successful in 1m13s
- 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.
448 lines
15 KiB
TypeScript
448 lines
15 KiB
TypeScript
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);
|
||
});
|
||
});
|