update money tracker
All checks were successful
Test and deploy staging / staging (push) Successful in 30s
All checks were successful
Test and deploy staging / staging (push) Successful in 30s
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user