feat(budgets,categories,tags): in-place limits with pace, category shares, tag tree and clean-up

This commit is contained in:
hh committed 2026-10-16 03:38:31 +00:00
1 parent 4a693a83c9
commit 89bbf56062
54 files changed
+5135 -1

No files matched your search

+283
View File
@@ -0,0 +1,283 @@
import { api } from "#lib/api/client.js";
import { all } from "#lib/api/lists.js";
import type {
AvailableBudget,
Budget,
BudgetLimit,
Currency,
InsightTotalEntry,
Item,
Page,
TransactionGroup,
} from "#lib/api/types.js";
import {
add,
type Cur,
fromEntries,
list,
type Sum,
type Sums,
} from "#lib/components/categories/sums.js";
import { contains, daysBetween, type Range, today } from "#lib/dates.js";
import { toNumber } from "#lib/format.js";
export interface Line {
amount: number | null;
cur: Cur & { id: string };
key: string;
limit?: Item<BudgetLimit>;
range: Range;
spent: number;
}
export interface BudgetRow {
budget: Item<Budget>;
lines: Line[];
}
export interface Pool {
available: number | null;
availableRange: Range | null;
budgeted: number;
cur: Cur;
spent: number;
}
export interface Pace {
expected: number;
fraction: number;
over: number;
}
export interface BudgetPage {
available: Item<AvailableBudget>[];
budgets: Item<Budget>[];
limits: Item<BudgetLimit>[];
noBudget: Sum[];
noBudgetCount: number;
total: Sum[];
}
export const day = (value: string | undefined) => (value ?? "").slice(0, 10);
export const limitRange = (limit: Item<BudgetLimit>): Range => ({
end: day(limit.attributes.end),
start: day(limit.attributes.start),
});
export function curOf(
currencies: Item<Currency>[],
code: string | undefined
): Cur & { id: string } {
const found = currencies.find((c) => c.attributes.code === code);
return {
code: code ?? "",
decimals: found?.attributes.decimal_places ?? 2,
id: found?.id ?? "",
symbol: found?.attributes.symbol ?? code ?? "",
};
}
function insightSums(
entries: InsightTotalEntry[],
currencies: Item<Currency>[]
): Sum[] {
const sums: Sums = {};
for (const entry of entries) {
add(
sums,
curOf(currencies, entry.currency_code),
toNumber(entry.difference)
);
}
return list(sums);
}
export async function loadBudgetPage(
range: Range,
currencies: Item<Currency>[]
): Promise<BudgetPage> {
const query = { end: range.end, start: range.start };
const [budgets, limits, available, total, noBudget, noBudgetPage] =
await Promise.all([
all<Budget>("/v1/budgets", query),
all<BudgetLimit>("/v1/budget-limits", query),
all<AvailableBudget>("/v1/available-budgets", query),
api<InsightTotalEntry[]>("/v1/insight/expense/total", { query }),
api<InsightTotalEntry[]>("/v1/insight/expense/no-budget", { query }),
api<Page<TransactionGroup>>("/v1/budgets/transactions-without-budget", {
query: { ...query, limit: 1, type: "withdrawal" },
}),
]);
return {
available,
budgets,
limits,
noBudget: insightSums(noBudget, currencies),
noBudgetCount: noBudgetPage.meta.pagination?.total ?? 0,
total: insightSums(total, currencies),
};
}
const byOrder = (a: Item<Budget>, b: Item<Budget>) =>
(a.attributes.order ?? 0) - (b.attributes.order ?? 0) ||
a.attributes.name.localeCompare(b.attributes.name);
export function buildRows(
data: BudgetPage,
range: Range,
currencies: Item<Currency>[],
primary: string
): BudgetRow[] {
return data.budgets
.filter((budget) => budget.attributes.active)
.sort(byOrder)
.map((budget) => {
const spent = fromEntries(budget.attributes.spent);
const lines: Line[] = data.limits
.filter((limit) => limit.attributes.budget_id === budget.id)
.map((limit) => {
const code = limit.attributes.currency_code;
const own = fromEntries(limit.attributes.spent);
return {
amount: toNumber(limit.attributes.amount),
cur: curOf(currencies, code),
key: `${budget.id}:${limit.id}`,
limit,
range: limitRange(limit),
spent: own.find((s) => s.code === code)?.amount ?? 0,
};
});
for (const sum of spent) {
if (!lines.some((line) => line.cur.code === sum.code)) {
lines.push({
amount: null,
cur: curOf(currencies, sum.code),
key: `${budget.id}:${sum.code}`,
range,
spent: sum.amount,
});
}
}
if (lines.length === 0) {
lines.push({
amount: null,
cur: curOf(currencies, primary),
key: `${budget.id}:${primary}`,
range,
spent: 0,
});
}
return { budget, lines };
});
}
export function buildPools(
data: BudgetPage,
rows: BudgetRow[],
currencies: Item<Currency>[],
primary: string
): Pool[] {
const pools = new Map<string, Pool>();
const pool = (code: string) => {
let found = pools.get(code);
if (!found) {
found = {
available: null,
availableRange: null,
budgeted: 0,
cur: curOf(currencies, code),
spent: 0,
};
pools.set(code, found);
}
return found;
};
for (const item of data.available) {
const target = pool(item.attributes.currency_code ?? primary);
target.available =
(target.available ?? 0) + toNumber(item.attributes.amount);
target.availableRange = {
end: day(item.attributes.end),
start: day(item.attributes.start),
};
}
for (const row of rows) {
for (const line of row.lines) {
const target = pool(line.cur.code);
target.budgeted += line.amount ?? 0;
target.spent += line.spent;
}
}
const result = [...pools.values()].filter(
(p) => p.available !== null || p.budgeted !== 0 || p.spent !== 0
);
return result.length > 0 ? result : [pool(primary)];
}
export function pace(line: Line, now = today()): Pace | null {
if (!(line.amount && contains(line.range, now))) {
return null;
}
const total = daysBetween(line.range.start, line.range.end) + 1;
const elapsed = daysBetween(line.range.start, now) + 1;
const expected = (line.amount * elapsed) / total;
return { expected, fraction: elapsed / total, over: -line.spent - expected };
}
export function perDay(line: Line, now = today()) {
const total = daysBetween(line.range.start, line.range.end) + 1;
const current = contains(line.range, now);
const passed = current ? daysBetween(line.range.start, now) + 1 : total;
const left = current ? daysBetween(now, line.range.end) + 1 : total;
return {
left: line.amount === null ? null : (line.amount + line.spent) / left,
past: line.range.end < now,
spent: line.spent / passed,
};
}
const SPACES = /\s/g;
export const parseAmount = (raw: string): number | null => {
const clean = raw.replace(SPACES, "").replace(",", ".");
if (clean === "") {
return 0;
}
const value = Number(clean);
return Number.isFinite(value) && value >= 0 ? value : null;
};
export async function saveLimit(
budgetId: string,
line: Line,
amount: number,
range: Range
) {
const { limit } = line;
if (limit && amount === 0) {
await api(`/v1/budgets/${budgetId}/limits/${limit.id}`, {
method: "DELETE",
});
return;
}
if (limit) {
await api(`/v1/budgets/${budgetId}/limits/${limit.id}`, {
body: { amount: String(amount) },
method: "PUT",
});
return;
}
if (amount === 0) {
return;
}
await api(`/v1/budgets/${budgetId}/limits`, {
body: {
amount: String(amount),
currency_code: line.cur.code,
end: range.end,
start: range.start,
},
method: "POST",
});
}