First version mostly built

This commit is contained in:
Deven Thiel 2026-03-05 17:04:52 -05:00
parent 27bb45f7df
commit 99a3dbd73c
42 changed files with 9443 additions and 3338 deletions

256
src/__tests__/stats.test.ts Normal file
View file

@ -0,0 +1,256 @@
import { describe, it, expect } from 'vitest';
import { aggregate, buildHierarchy, buildChartSeries } from '@/lib/stats/aggregate';
import { workEntryValue } from '@/types';
import type { WorkEntry, Payment, Expense } from '@/types';
const mkWork = (date: string, amount?: number, hours?: number, rate?: number): WorkEntry => ({
id: `w-${date}-${amount ?? hours}`, date,
description: 'work', amount, hours, rate,
createdAt: 0, updatedAt: 0,
});
const mkPayment = (date: string, amount: number): Payment => ({
id: `p-${date}-${amount}`, date, amount, payer: 'X', createdAt: 0, updatedAt: 0,
});
const mkExpense = (date: string, amount: number, deductible = true): Expense => ({
id: `e-${date}-${amount}`, date, amount, description: 'exp', deductible,
createdAt: 0, updatedAt: 0,
});
describe('workEntryValue', () => {
it('returns amount when set', () => {
expect(workEntryValue(mkWork('2024-01-01', 150))).toBe(150);
});
it('returns hours * rate when amount not set', () => {
expect(workEntryValue(mkWork('2024-01-01', undefined, 4, 50))).toBe(200);
});
it('amount takes precedence over hours/rate', () => {
const e = mkWork('2024-01-01', 100, 10, 50);
expect(workEntryValue(e)).toBe(100);
});
it('returns 0 when neither set', () => {
expect(workEntryValue(mkWork('2024-01-01'))).toBe(0);
});
});
describe('aggregate', () => {
it('rolls up days → months → years', () => {
const agg = aggregate(
[],
[mkPayment('2024-01-15', 1000), mkPayment('2024-01-20', 500), mkPayment('2024-02-10', 300)],
[],
);
expect(agg.days.get('2024-01-15')?.payments).toBe(1000);
expect(agg.months.get('2024-01')?.payments).toBe(1500);
expect(agg.months.get('2024-02')?.payments).toBe(300);
expect(agg.years.find((y) => y.label === '2024')?.payments).toBe(1800);
});
it('computes net = payments expenses', () => {
const agg = aggregate(
[],
[mkPayment('2024-03-01', 5000)],
[mkExpense('2024-03-01', 1200)],
);
expect(agg.days.get('2024-03-01')?.net).toBe(3800);
expect(agg.months.get('2024-03')?.net).toBe(3800);
});
it('separates deductible from total expenses', () => {
const agg = aggregate(
[],
[],
[mkExpense('2024-03-01', 100, true), mkExpense('2024-03-01', 50, false)],
);
const d = agg.days.get('2024-03-01')!;
expect(d.expenses).toBe(150);
expect(d.deductibleExpenses).toBe(100);
});
it('computes monthly average per active day', () => {
const agg = aggregate(
[],
[mkPayment('2024-03-01', 100), mkPayment('2024-03-10', 200)],
[],
);
// 2 active days, 300 total → 150 avg
expect(agg.months.get('2024-03')?.avgPerChild).toBe(150);
expect(agg.months.get('2024-03')?.childCount).toBe(2);
});
it('computes yearly average per active month', () => {
const agg = aggregate(
[],
[mkPayment('2024-01-05', 1000), mkPayment('2024-03-05', 2000)],
[],
);
const y = agg.years.find((y) => y.label === '2024')!;
// 2 months active, 3000 net → 1500 avg/month
expect(y.childCount).toBe(2);
expect(y.avgPerChild).toBe(1500);
});
it('projects year-end from YTD (halfway through year)', () => {
const agg = aggregate(
[],
[mkPayment('2024-01-01', 50000)],
[],
new Date('2024-07-01'), // ~halfway
);
const y = agg.years.find((y) => y.label === '2024')!;
expect(y.projected).toBeGreaterThan(90000);
expect(y.projected).toBeLessThan(110000);
});
it('does not project completed years', () => {
const agg = aggregate([], [mkPayment('2023-06-01', 1000)], [], new Date('2024-06-01'));
const y = agg.years.find((y) => y.label === '2023')!;
expect(y.projected).toBeNull();
});
it('projects month-end from current day', () => {
const agg = aggregate(
[],
[mkPayment('2024-06-10', 1000)],
[],
new Date('2024-06-15'), // halfway through June
);
const m = agg.months.get('2024-06')!;
expect(m.projected).toBeGreaterThan(1500);
expect(m.projected).toBeLessThan(2500);
});
it('sorts years descending', () => {
const agg = aggregate(
[],
[mkPayment('2022-01-01', 1), mkPayment('2024-01-01', 1), mkPayment('2023-01-01', 1)],
[],
);
expect(agg.years.map((y) => y.label)).toEqual(['2024', '2023', '2022']);
});
});
describe('buildHierarchy', () => {
it('builds year→month→day→item tree', () => {
const work = [
mkWork('2024-03-15', 100),
mkWork('2024-03-15', 50),
mkWork('2024-04-01', 200),
];
const tree = buildHierarchy(work, (e) => workEntryValue(e as WorkEntry), (e) => (e as WorkEntry).description);
expect(tree).toHaveLength(1); // one year
expect(tree[0].level).toBe('year');
expect(tree[0].value).toBe(350);
expect(tree[0].children).toHaveLength(2); // two months
const march = tree[0].children.find((m) => m.key === '2024-03')!;
expect(march.value).toBe(150);
expect(march.children).toHaveLength(1); // one day
expect(march.children[0].children).toHaveLength(2); // two items
});
it('attaches entry to item leaves', () => {
const work = [mkWork('2024-01-01', 100)];
const tree = buildHierarchy(work, (e) => workEntryValue(e as WorkEntry), () => 'label');
const item = tree[0].children[0].children[0].children[0];
expect(item.level).toBe('item');
expect(item.entry).toBe(work[0]);
});
it('sorts years, months, days descending (newest first)', () => {
const work = [mkWork('2023-01-01', 1), mkWork('2024-01-01', 1)];
const tree = buildHierarchy(work, (e) => workEntryValue(e as WorkEntry), () => '');
expect(tree[0].key).toBe('2024');
expect(tree[1].key).toBe('2023');
});
});
describe('buildChartSeries', () => {
it('produces points at requested granularity', () => {
const series = buildChartSeries(
[],
[mkPayment('2024-01-15', 100), mkPayment('2024-02-10', 200)],
[],
['payments'],
'month',
null,
null,
);
expect(series).toHaveLength(2);
expect(series[0].payments).toBe(100);
expect(series[1].payments).toBe(200);
});
it('filters by date range', () => {
const series = buildChartSeries(
[],
[mkPayment('2024-01-01', 1), mkPayment('2024-06-01', 1), mkPayment('2024-12-01', 1)],
[],
['payments'],
'month',
'2024-03',
'2024-09',
);
expect(series).toHaveLength(1);
expect(series[0].label).toBe('2024-06');
});
it('computes cumulative metrics', () => {
const series = buildChartSeries(
[],
[mkPayment('2024-01-01', 100), mkPayment('2024-02-01', 200), mkPayment('2024-03-01', 300)],
[],
['cumulativePayments'],
'month',
null,
null,
);
expect(series[0].cumulativePayments).toBe(100);
expect(series[1].cumulativePayments).toBe(300);
expect(series[2].cumulativePayments).toBe(600);
});
it('sorts ascending for time-series display', () => {
const series = buildChartSeries(
[],
[mkPayment('2024-03-01', 1), mkPayment('2024-01-01', 1)],
[],
['payments'],
'month',
null,
null,
);
expect(series[0].label < series[1].label).toBe(true);
});
it('groups by week when requested', () => {
const series = buildChartSeries(
[],
// Monday and Tuesday of same week
[mkPayment('2024-06-03', 100), mkPayment('2024-06-04', 50)],
[],
['payments'],
'week',
null,
null,
);
expect(series).toHaveLength(1);
expect(series[0].payments).toBe(150);
});
it('computes netIncome metric', () => {
const series = buildChartSeries(
[],
[mkPayment('2024-01-01', 1000)],
[mkExpense('2024-01-01', 300)],
['netIncome'],
'month',
null,
null,
);
expect(series[0].netIncome).toBe(700);
});
});