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]; } /** Accounts kept out of net worth and total wealth (`includeInTotal: false`). */ export function excludedIds(accounts: { id: number; includeInTotal?: boolean }[]): Set { 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 { 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 = { 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, futureTransactions: ProjectionMovement[], forecasts: ProjectionMovement[], recurring: ProjectionRecurring[], settings: ProjectionSettings | null, today: string ): 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 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) ); 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 (!counted(item.accountId)) continue; 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; 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, 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 }; } 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 GridMode = 'months' | 'years'; export type GridLine = { date: string; year: boolean }; /** * Vertical gridlines between two dates (inclusive): the first of every month (`months`), * or only 1 January (`years`). 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 < 1200; guard++) { if (month > 11) { month = 0; year++; } if (mode === 'years' && month !== 0) { 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].find((size) => Math.ceil(years.length / size) <= maxLabels) ?? 50; return years.filter((date) => Number(date.slice(0, 4)) % step === 0); }