Files
website/src/lib/money-tracker/charts.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

539 lines
18 KiB
TypeScript

import {
businessDay,
dayNumber,
dayOfMonth,
daysInMonth,
isoDay,
monthFlows,
scheduleFor,
type HistoryTransaction
} from './schedule';
export type ChartPoint = { date: string; value: number };
export type ChartSeries = {
label: string;
color: string;
points: ChartPoint[];
emphasis?: boolean;
/** Points after this date are projected: drawn as a thinner, lighter solid line. */
projectedFrom?: 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];
}
/** Accounts kept out of net worth and total wealth (`includeInTotal: false`). */
export function excludedIds(accounts: { id: number; includeInTotal?: boolean }[]): Set<number> {
return new Set(
accounts.filter((account) => account.includeInTotal === false).map((account) => account.id)
);
}
export function balanceSeries(
accounts: {
id: number;
name: string;
openingBalanceCents: number;
color?: string | null;
includeInTotal?: boolean;
}[],
transactions: { accountId: number; date: string; amountCents: number }[]
): ChartSeries[] {
const excluded = excludedIds(accounts);
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].reduce(
(sum, [id, balance]) => (excluded.has(id) ? sum : 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;
includeInTotal?: boolean;
};
type ProjectionMovement = { date: string; amountCents: number; accountId: number | null };
type ProjectionRecurring = {
name?: string;
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;
};
/**
* The setting-based ("Simple") projection, day by day: dated transactions and estimates on their
* date, recurring items on the days they hit (see schedule.ts), returns compounding daily, and the
* excess above the reserve invested the day after payday (month end when there is no salary).
*/
export function wealthProjection(
accounts: ProjectionAccount[],
balances: Map<number, number>,
futureTransactions: ProjectionMovement[],
forecasts: ProjectionMovement[],
recurring: ProjectionRecurring[],
settings: ProjectionSettings | null,
today: string,
/** Overrides the settings' end date, e.g. with the chart's forecast horizon. */
endDate?: string | null,
/** Bank transactions through today, to find the day each recurring item hits. */
history: HistoryTransaction[] = []
): ChartSeries[] {
const excluded = excludedIds(accounts);
const counted = (accountId: number | null) => accountId === null || !excluded.has(accountId);
const accountKind = new Map(accounts.map((account) => [account.id, account.kind]));
const isInvestment = (accountId: number | null) =>
accountId !== null && accountKind.get(accountId) === 'investment';
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) {
if (excluded.has(account.id)) continue;
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 && counted(entry.accountId))
.sort((a, b) => a.date.localeCompare(b.date));
const latestMovement = movements.at(-1)?.date ?? '';
const fallback = new Date(`${today}T12:00:00Z`);
fallback.setUTCFullYear(fallback.getUTCFullYear() + 1);
const horizon =
endDate && endDate > today
? endDate
: settings?.endDate && settings.endDate > today
? settings.endDate
: latestMovement || fallback.toISOString().slice(0, 10);
const todayNumber = dayNumber(today);
const endNumber = dayNumber(horizon);
const items =
settings?.includeRecurring === false
? []
: recurring
.filter((item) => counted(item.accountId))
.map((item) => {
const named = { ...item, name: item.name ?? '' };
return { item: named, schedule: scheduleFor(named, history, today) };
});
const salary = items.find(
({ item, schedule }) =>
item.amountCents > 0 && /salary|salaris/i.test(item.name) && schedule.kind === 'monthly'
);
const yearly = Number(settings?.annualReturnPercent ?? 5) / 100;
const monthlyGrowth = Math.pow(1 + yearly, 1 / 12) - 1;
const reserve = settings?.reserveCents ?? 100000;
let year = Number(today.slice(0, 4));
let month = Number(today.slice(5, 7));
let next = 0;
for (let guard = 0; guard < 1213; guard++) {
const firstDay = dayOfMonth(year, month, 1);
if (firstDay > endNumber) break;
const length = daysInMonth(year, month);
const toSpendable = new Float64Array(length);
const toInvested = new Float64Array(length);
const toIndex = new Float64Array(length);
const add = (day: number, cents: number, accountId: number | null) => {
const offset = day - firstDay;
if (offset < 0 || offset >= length) return;
if (isInvestment(accountId)) {
toInvested[offset] += cents;
if (accountId === validInvestmentId) toIndex[offset] += cents;
} else toSpendable[offset] += cents;
};
while (next < movements.length && dayNumber(movements[next].date) < firstDay + length) {
const entry = movements[next++];
add(dayNumber(entry.date), entry.amountCents, entry.accountId);
}
for (const { item, schedule } of items) {
const flows = monthFlows(item, schedule, year, month, todayNumber);
for (const [day, cents] of flows.events) add(day, cents, item.accountId);
for (let day = flows.spreadFrom; day <= flows.spreadTo; day++)
add(day, flows.spread, item.accountId);
}
const paid =
salary?.schedule.kind === 'monthly'
? businessDay(dayOfMonth(year, month, salary.schedule.day), salary.schedule.shift)
: null;
const sweepDay =
paid === null ? firstDay + length - 1 : Math.min(paid + 1, firstDay + length - 1);
const dayGrowth = Math.pow(1 + monthlyGrowth, 1 / length);
for (let offset = 0; offset < length; offset++) {
const day = firstDay + offset;
if (day <= todayNumber) continue;
if (day > endNumber) break;
spendable += toSpendable[offset];
invested += toInvested[offset];
indexBalance += toIndex[offset];
const growth = indexBalance * (dayGrowth - 1);
invested += growth;
indexBalance += growth;
if (day === sweepDay) {
const excess = Math.max(0, spendable - reserve);
const contribution =
settings?.contributionMode === 'fixed'
? Math.min(excess, settings.fixedMonthlyCents)
: excess;
spendable -= contribution;
invested += contribution;
indexBalance += contribution;
}
const date = isoDay(day);
totalPoints.push({ date, value: Math.round(spendable + invested) });
spendablePoints.push({ date, value: Math.round(spendable) });
investedPoints.push({ date, value: Math.round(invested) });
}
month++;
if (month === 13) {
month = 1;
year++;
}
}
return [
{ label: 'Total wealth', color: '#0070f3', points: totalPoints, emphasis: true },
{ label: 'Spendable', color: '#46a758', points: spendablePoints },
{ label: 'Invested', color: '#8e4ec6', points: investedPoints }
];
}
/**
* Thins long series for drawing: per time bucket only the lowest and highest point stay (in
* date order), so a daily projection over decades keeps its spikes without plotting every day.
* The first and last points and the actual/projected join are always kept.
*/
export function decimateSeries(series: ChartSeries[], buckets: number): ChartSeries[] {
const limit = Math.max(2, Math.floor(buckets)) * 2;
if (!series.some((line) => line.points.length > limit)) return series;
let from = Infinity;
let until = -Infinity;
for (const line of series)
if (line.points.length) {
from = Math.min(from, Date.parse(line.points[0].date));
until = Math.max(until, Date.parse(line.points.at(-1)!.date));
}
const span = until - from || 1;
return series.map((line) => {
if (line.points.length <= limit) return line;
const keep = new Uint8Array(line.points.length);
keep[0] = 1;
keep[line.points.length - 1] = 1;
if (line.projectedFrom) {
const join = line.points.findLastIndex((point) => point.date <= line.projectedFrom!);
if (join >= 0) keep[join] = 1;
}
let bucket = -1;
let low = -1;
let high = -1;
const flush = () => {
if (low >= 0) keep[low] = 1;
if (high >= 0) keep[high] = 1;
};
line.points.forEach((point, index) => {
const at = Math.min(
buckets - 1,
Math.floor(((Date.parse(point.date) - from) / span) * buckets)
);
if (at !== bucket) {
flush();
bucket = at;
low = high = index;
return;
}
if (point.value < line.points[low].value) low = index;
if (point.value > line.points[high].value) high = index;
});
flush();
return { ...line, points: line.points.filter((_, index) => keep[index]) };
});
}
export type TimelineView = 'total' | 'split' | 'accounts';
/**
* Joins actual history (solid, through today) with the wealth projection (thin solid line, 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;
includeInTotal?: boolean;
}[],
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.includeInTotal !== false && (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,
projectedFrom: 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',
projectedFrom: today,
points: join(sumKind(false), spendableProjection?.points ?? [])
},
{
label: 'Invested',
color: '#8e4ec6',
projectedFrom: 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
};
}
export type HistoryRange = '1y' | '3y' | 'all';
/** First date shown for a history range (null = everything). */
export function historyStart(range: HistoryRange, today: string): string | null {
if (range === 'all') return null;
const years = range === '1y' ? 1 : 3;
return `${Number(today.slice(0, 4)) - years}${today.slice(4)}`;
}
/**
* Hides history before `from`, starting each line at `from` with the balance it had then,
* so a long bank history doesn't squash the recent months.
*/
export function clipSeries(series: ChartSeries[], from: string | null): ChartSeries[] {
if (!from) return series;
return series.map((line) => {
const before = line.points.filter((point) => point.date < from);
const after = line.points.filter((point) => point.date >= from);
const carried = before.at(-1);
const points =
carried && after[0]?.date !== from ? [{ date: from, value: carried.value }, ...after] : after;
return { ...line, points };
});
}
/** Reads a `?range=` value, falling back to one year for anything unknown. */
export function parseHistoryRange(value: string | null | undefined): HistoryRange {
return value === '3y' || value === 'all' ? value : '1y';
}
export type ForecastHorizon = '1y' | '3y' | '5y' | '10y' | '20y' | '30y' | '50y' | '100y';
export const forecastHorizons: ForecastHorizon[] = [
'1y',
'3y',
'5y',
'10y',
'20y',
'30y',
'50y',
'100y'
];
/** Reads a `?horizon=` value, falling back to five years for anything unknown. */
export function parseHorizon(value: string | null | undefined): ForecastHorizon {
return forecastHorizons.find((item) => item === value) ?? '5y';
}
/** Last projected date for a horizon: the same day N years from today. */
export function horizonEnd(horizon: ForecastHorizon, today: string): string {
return `${Number(today.slice(0, 4)) + Number.parseInt(horizon)}${today.slice(4)}`;
}
/** Gridlines that stay readable for the whole chart span: months, years, or decades. */
export function gridFor(from: string | null | undefined, until: string): GridMode {
if (!from) return 'years';
const years = (Date.parse(until) - Date.parse(from)) / (365.25 * 86_400_000);
return years <= 4.5 ? 'months' : years <= 40 ? 'years' : 'decades';
}
export type GridMode = 'months' | 'years' | 'decades';
export type GridLine = { date: string; year: boolean };
/**
* Vertical gridlines between two dates (inclusive): the first of every month (`months`),
* only 1 January (`years`), or 1 January of every tenth year (`decades`). Lines on 1 January
* are flagged so they can be drawn stronger.
*/
export function timeGrid(from: string, until: string, mode: GridMode): GridLine[] {
const lines: GridLine[] = [];
if (!from || !until || from > until) return lines;
let year = Number(from.slice(0, 4));
let month = Number(from.slice(5, 7)) - 1;
if (from.slice(8) !== '01') month++;
for (let guard = 0; guard < 2400; guard++) {
if (month > 11) {
month = 0;
year++;
}
if ((mode === 'years' && month !== 0) || (mode === 'decades' && (month !== 0 || year % 10))) {
month = 12;
continue;
}
const date = `${year}-${String(month + 1).padStart(2, '0')}-01`;
if (date > until) break;
lines.push({ date, year: month === 0 });
month++;
}
return lines;
}
/** Year labels for the `years` grid, thinned to every 2nd/5th/10th year when crowded. */
export function yearLabels(lines: GridLine[], maxLabels: number): string[] {
const years = lines.filter((line) => line.year).map((line) => line.date);
const step =
[1, 2, 5, 10, 20, 50].find(
(size) => years.filter((date) => Number(date.slice(0, 4)) % size === 0).length <= maxLabels
) ?? 100;
return years.filter((date) => Number(date.slice(0, 4)) % step === 0);
}