update money tracker
All checks were successful
Test and deploy staging / staging (push) Successful in 30s

This commit is contained in:
2026-09-23 18:06:20 +00:00
parent 2466db74cd
commit 499dd94cbb
9 changed files with 641 additions and 77 deletions

View File

@@ -48,8 +48,7 @@
<figure class="chart">
{#if values.length}
<!-- svelte-ignore a11y_no_noninteractive_tabindex: the chart viewport scrolls horizontally on narrow screens -->
<div class="chart-scroll" role="region" tabindex="0" aria-label={title}>
<div class="chart-scroll" role="region" aria-label={title}>
<svg
viewBox={`0 0 ${width} ${height}`}
role="img"
@@ -80,11 +79,19 @@
d={path(item.points)}
fill="none"
stroke={item.color}
stroke-width="2"
stroke-width={item.emphasis ? 3.5 : 2}
stroke-linecap="round"
stroke-linejoin="round"
vector-effect="non-scaling-stroke"
/>
{#if item.points.length === 1}
<circle
cx={x(item.points[0].date)}
cy={y(item.points[0].value)}
r={item.emphasis ? 4 : 3}
fill={item.color}
/>
{/if}
{/if}
{/each}
<text x={plot.left} y={height - 8} fill="var(--site-muted)" font-size="11"
@@ -102,7 +109,9 @@
{#if series.length > 1}
<figcaption class="legend">
{#each series as item (item.label)}
<span><span class="swatch" style={`background:${item.color}`}></span>{item.label}</span>
<span class:emphasis={item.emphasis}
><span class="swatch" style={`background:${item.color}`}></span>{item.label}</span
>
{/each}
</figcaption>
{/if}
@@ -137,6 +146,12 @@
align-items: center;
gap: 7px;
}
.legend > span.emphasis {
order: -1;
flex-basis: 100%;
color: var(--site-text);
font-weight: 600;
}
.swatch {
display: inline-block;
width: 8px;

View File

@@ -1,5 +1,11 @@
import { describe, expect, it } from 'vitest';
import { accountBalances, balanceSeries, forecastSeries, utilityEstimate } from './charts';
import {
accountBalances,
balanceSeries,
forecastSeries,
utilityEstimate,
wealthProjection
} from './charts';
describe('money tracker calculations', () => {
it('keeps balances separate across accounts and preserves transaction order', () => {
@@ -14,8 +20,43 @@ describe('money tracker calculations', () => {
];
expect([...accountBalances(accounts, transactions).values()]).toEqual([8500, 25000]);
expect(balanceSeries(accounts, transactions)[0].points.map((point) => point.value)).toEqual([
10000, 7500, 8500
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('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', () => {
@@ -32,6 +73,46 @@ describe('money tracker calculations', () => {
).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: 1,
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'
);
expect(series[0].points.map((point) => point.value)).toEqual([100000, 100000, 160000, 210244]);
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('compares estimated usage cost with prorated advances', () => {
const result = utilityEstimate(
[

View File

@@ -1,25 +1,44 @@
export type ChartPoint = { date: string; value: number };
export type ChartSeries = { label: string; color: string; points: ChartPoint[] };
export type ChartSeries = {
label: string;
color: string;
points: ChartPoint[];
emphasis?: boolean;
};
export function balanceSeries(
accounts: { id: number; name: string; openingBalanceCents: number }[],
transactions: { accountId: number; date: string; amountCents: number }[]
): ChartSeries[] {
const palette = ['#0070f3', '#e5484d', '#46a758', '#ad7f00', '#8e4ec6', '#0091ad', '#e56910'];
const lastDate = transactions.at(-1)?.date;
return accounts.map((account, index) => {
let balance = account.openingBalanceCents;
const entries = transactions.filter((transaction) => transaction.accountId === account.id);
const points: ChartPoint[] = [];
if (entries.length) points.push({ date: entries[0].date, value: balance });
for (const entry of entries) {
balance += entry.amountCents;
points.push({ date: entry.date, value: balance });
const ordered = [...transactions].sort((a, b) => a.date.localeCompare(b.date));
const balances = new Map(accounts.map((account) => [account.id, account.openingBalanceCents]));
const accountPoints = new Map(accounts.map((account) => [account.id, [] as ChartPoint[]]));
const combinedPoints: ChartPoint[] = [];
for (let index = 0; index < ordered.length;) {
const date = ordered[index].date;
while (index < ordered.length && ordered[index].date === date) {
const transaction = ordered[index++];
balances.set(
transaction.accountId,
(balances.get(transaction.accountId) ?? 0) + transaction.amountCents
);
}
if (lastDate && entries.length && lastDate > entries.at(-1)!.date)
points.push({ date: lastDate, value: balance });
return { label: account.name, color: palette[index % palette.length], points };
});
for (const account of accounts)
accountPoints.get(account.id)!.push({ date, value: balances.get(account.id)! });
combinedPoints.push({
date,
value: [...balances.values()].reduce((sum, balance) => sum + balance, 0)
});
}
return [
...accounts.map((account, index) => ({
label: account.name,
color: palette[index % palette.length],
points: accountPoints.get(account.id)!
})),
{ label: 'All accounts', color: 'var(--site-muted)', points: combinedPoints }
];
}
export function accountBalances(
@@ -59,6 +78,122 @@ export function forecastSeries(
return [{ label: 'Projected total', color: '#0070f3', points }];
}
type ProjectionAccount = { id: number; kind: string; openingBalanceCents: number };
type ProjectionMovement = { date: string; amountCents: number; accountId: number | null };
type ProjectionRecurring = {
amountCents: number;
cadence: string;
accountId: number | null;
startsOn?: string | null;
endsOn?: string | null;
};
type ProjectionSettings = {
investmentAccountId?: number | null;
reserveCents: number;
annualReturnPercent: string | number;
contributionMode: string;
fixedMonthlyCents: number;
includeRecurring: number;
endDate?: string | null;
};
export function wealthProjection(
accounts: ProjectionAccount[],
balances: Map<number, number>,
futureTransactions: ProjectionMovement[],
forecasts: ProjectionMovement[],
recurring: ProjectionRecurring[],
settings: ProjectionSettings | null,
today: string
): ChartSeries[] {
const accountKind = new Map(accounts.map((account) => [account.id, account.kind]));
const investmentId = settings?.investmentAccountId ?? null;
const validInvestmentId =
investmentId !== null && accountKind.get(investmentId) === 'investment' ? investmentId : null;
let spendable = 0;
let invested = 0;
for (const account of accounts) {
const value = balances.get(account.id) ?? 0;
if (account.kind === 'investment') invested += value;
else spendable += value;
}
let indexBalance = validInvestmentId === null ? 0 : (balances.get(validInvestmentId) ?? 0);
const totalPoints: ChartPoint[] = [{ date: today, value: spendable + invested }];
const spendablePoints: ChartPoint[] = [{ date: today, value: spendable }];
const investedPoints: ChartPoint[] = [{ date: today, value: invested }];
const movements = [...futureTransactions, ...forecasts].filter((entry) => entry.date > today);
const latestMovement = movements.reduce(
(latest, entry) => (entry.date > latest ? entry.date : latest),
''
);
const fallback = new Date(`${today}T12:00:00Z`);
fallback.setUTCFullYear(fallback.getUTCFullYear() + 1);
const horizon =
settings?.endDate && settings.endDate > today
? settings.endDate
: latestMovement || fallback.toISOString().slice(0, 10);
const monthEnd = (year: number, month: number) =>
new Date(Date.UTC(year, month + 1, 0)).toISOString().slice(0, 10);
const start = new Date(`${today}T12:00:00Z`);
let year = start.getUTCFullYear();
let month = start.getUTCMonth();
const yearly = Number(settings?.annualReturnPercent ?? 5) / 100;
const monthlyGrowth = Math.pow(1 + yearly, 1 / 12) - 1;
for (let index = 0; index < 240; index++) {
const first = `${year}-${String(month + 1).padStart(2, '0')}-01`;
const end = monthEnd(year, month);
const date = end < horizon ? end : horizon;
if (date > today) {
for (const entry of movements.filter(
(item) => (index === 0 ? item.date > today : item.date >= first) && item.date <= date
)) {
if (entry.accountId !== null && accountKind.get(entry.accountId) === 'investment') {
invested += entry.amountCents;
if (entry.accountId === validInvestmentId) indexBalance += entry.amountCents;
} else spendable += entry.amountCents;
}
if (settings?.includeRecurring !== 0 && index > 0) {
for (const item of recurring) {
if ((item.startsOn && item.startsOn > date) || (item.endsOn && item.endsOn < first))
continue;
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;
}
}
if (validInvestmentId !== null && index > 0) {
const growth = Math.round(indexBalance * monthlyGrowth);
invested += growth;
indexBalance += growth;
const excess = Math.max(0, spendable - (settings?.reserveCents ?? 100000));
const contribution =
settings?.contributionMode === 'fixed'
? Math.min(excess, settings.fixedMonthlyCents)
: excess;
spendable -= contribution;
invested += contribution;
indexBalance += contribution;
}
totalPoints.push({ date, value: spendable + invested });
spendablePoints.push({ date, value: spendable });
investedPoints.push({ date, value: invested });
}
if (end >= horizon) break;
month++;
if (month === 12) {
month = 0;
year++;
}
}
return [
{ label: 'Total wealth', color: '#0070f3', points: totalPoints, emphasis: true },
{ label: 'Spendable', color: '#46a758', points: spendablePoints },
{ label: 'Invested', color: '#8e4ec6', points: investedPoints }
];
}
export function utilityEstimate(
readings: { date: string; reading: string; kind: string }[],
kind: string,

View File

@@ -11,6 +11,7 @@ export const moneyAccounts = pgTable(
.references(() => user.id, { onDelete: 'cascade' }),
name: text('name').notNull(),
openingBalanceCents: integer('opening_balance_cents').notNull().default(0),
kind: text('kind').notNull().default('spendable'),
...timestamps
},
(table) => [unique().on(table.userId, table.name), index().on(table.userId)]
@@ -44,6 +45,8 @@ export const moneyRecurring = pgTable(
name: text('name').notNull(),
amountCents: integer('amount_cents').notNull(),
cadence: text('cadence').notNull(),
startsOn: date('starts_on', { mode: 'string' }),
endsOn: date('ends_on', { mode: 'string' }),
accountId: integer('account_id').references(() => moneyAccounts.id, { onDelete: 'set null' }),
...timestamps
},
@@ -106,12 +109,36 @@ export const moneyWishlist = pgTable(
.references(() => user.id, { onDelete: 'cascade' }),
name: text('name').notNull(),
estimatedCents: integer('estimated_cents'),
priority: integer('priority').notNull().default(3),
notes: text('notes'),
...timestamps
},
(table) => [index().on(table.userId)]
);
export const moneyProjectionSettings = pgTable(
'money_projection_settings',
{
id: id(),
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
investmentAccountId: integer('investment_account_id').references(() => moneyAccounts.id, {
onDelete: 'set null'
}),
reserveCents: integer('reserve_cents').notNull().default(100000),
annualReturnPercent: numeric('annual_return_percent', { precision: 5, scale: 2 })
.notNull()
.default('5.00'),
contributionMode: text('contribution_mode').notNull().default('excess'),
includeRecurring: integer('include_recurring').notNull().default(1),
fixedMonthlyCents: integer('fixed_monthly_cents').notNull().default(0),
endDate: date('end_date', { mode: 'string' }),
...timestamps
},
(table) => [unique().on(table.userId)]
);
export const moneyInsurances = pgTable(
'money_insurances',
{