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 { 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, 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 }; } 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 }; }); }