Files
website/src/lib/money-tracker/charts.ts
Stan Runge 4cfe6bad6c app: lists on meta tables, schema-qualified SQL, typed columns
- lists read/write real lists.* tables via meta.tables/meta.columns and the
  SECURITY DEFINER meta functions; typed extra columns in the item forms
- old numeric list URLs 308-redirect to the slug
- include_recurring is boolean, osu accuracy/pp/rate numeric
- raw SQL qualified with the new schemas
2026-10-23 22:03:42 +00:00

309 lines
11 KiB
TypeScript

export type ChartPoint = { date: string; value: number };
export type ChartSeries = {
label: string;
color: string;
points: ChartPoint[];
emphasis?: boolean;
/** Points after this date are drawn dashed (projected). */
dashedFrom?: string;
};
const palette = ['#0070f3', '#e5484d', '#46a758', '#ad7f00', '#8e4ec6', '#0091ad', '#e56910'];
/** The account's chosen color, or a palette color based on its position in the list. */
export function accountColor(account: { color?: string | null }, index: number): string {
return account.color || palette[index % palette.length];
}
export function balanceSeries(
accounts: { id: number; name: string; openingBalanceCents: number; color?: string | null }[],
transactions: { accountId: number; date: string; amountCents: number }[]
): ChartSeries[] {
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
);
}
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: accountColor(account, index),
points: accountPoints.get(account.id)!
})),
{ label: 'All accounts', color: 'var(--site-muted)', points: combinedPoints }
];
}
export function accountBalances(
accounts: { id: number; openingBalanceCents: number }[],
transactions: { accountId: number; amountCents: number }[]
): Map<number, number> {
const balances = new Map(accounts.map((account) => [account.id, account.openingBalanceCents]));
for (const transaction of transactions)
balances.set(
transaction.accountId,
(balances.get(transaction.accountId) ?? 0) + transaction.amountCents
);
return balances;
}
export function monthlyCents(amountCents: number, cadence: string): number {
return cadence === 'weekly'
? (amountCents * 52) / 12
: cadence === 'yearly'
? amountCents / 12
: amountCents;
}
export function forecastSeries(
currentCents: number,
forecasts: { date: string; amountCents: number }[],
today: string
): ChartSeries[] {
let projected = currentCents;
const points: ChartPoint[] = [{ date: today, value: currentCents }];
for (const item of forecasts
.filter((entry) => entry.date >= today)
.sort((a, b) => a.date.localeCompare(b.date))) {
projected += item.amountCents;
points.push({ date: item.date, value: projected });
}
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: boolean;
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 !== false && 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 (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 type TimelineView = 'total' | 'split' | 'accounts';
/**
* Joins actual history (solid, through today) with the wealth projection (dashed, after
* today) so both read as one continuous line per measure.
*/
export function wealthTimeline(
accounts: {
id: number;
name: string;
kind: string;
openingBalanceCents: number;
color?: string | null;
}[],
actualTransactions: { accountId: number; date: string; amountCents: number }[],
projection: ChartSeries[],
today: string,
view: TimelineView
): ChartSeries[] {
const history = balanceSeries(accounts, actualTransactions);
const accountLines = history.slice(0, accounts.length);
const dates = history.at(-1)!.points.map((point) => point.date);
const sumKind = (investment: boolean) =>
dates.map((date, index) => ({
date,
value: accounts.reduce(
(sum, account, accountIndex) =>
(account.kind === 'investment') === investment
? sum + accountLines[accountIndex].points[index].value
: sum,
0
)
}));
const untilToday = (points: ChartPoint[]) => {
const last = points.at(-1);
return last && last.date < today ? [...points, { date: today, value: last.value }] : points;
};
const join = (actual: ChartPoint[], projected: ChartPoint[]) => {
const solid = untilToday(actual);
const cutoff = solid.at(-1)?.date ?? '';
return [...solid, ...projected.filter((point) => point.date > cutoff)];
};
const [totalProjection, spendableProjection, investedProjection] = projection;
const total: ChartSeries = {
label: 'Total',
color: 'var(--site-text)',
emphasis: true,
dashedFrom: today,
points: join(history.at(-1)!.points, totalProjection?.points ?? [])
};
if (view === 'accounts')
return [
...accountLines
.filter((line) => line.points.some((point) => point.value !== 0))
.map((line) => ({ ...line, points: untilToday(line.points) })),
total
];
if (view === 'split')
return [
total,
{
label: 'Spendable',
color: '#46a758',
dashedFrom: today,
points: join(sumKind(false), spendableProjection?.points ?? [])
},
{
label: 'Invested',
color: '#8e4ec6',
dashedFrom: today,
points: join(sumKind(true), investedProjection?.points ?? [])
}
];
return [total];
}
export function utilityEstimate(
readings: { date: string; reading: string; kind: string }[],
kind: string,
ratePerUnit: string,
monthlyAdvanceCents: number,
billingStart?: string | null
) {
const byKind = readings.filter(
(reading) => reading.kind === kind && (!billingStart || reading.date >= billingStart)
);
if (byKind.length < 2) return null;
const first = byKind[0];
const last = byKind.at(-1)!;
const usage = Number(last.reading) - Number(first.reading);
const months =
(Date.parse(`${last.date}T12:00:00Z`) - Date.parse(`${first.date}T12:00:00Z`)) /
((365.25 / 12) * 86_400_000);
if (months <= 0 || usage < 0) return null;
const estimatedCents = Math.round(usage * Number(ratePerUnit) * 100);
const advancesCents = Math.round(months * monthlyAdvanceCents);
return {
usage,
months,
estimatedCents,
advancesCents,
differenceCents: advancesCents - estimatedCents
};
}