feat(budgets,categories,tags): in-place limits with pace, category shares, tag tree and clean-up
This commit is contained in:
1 parent
4a693a83c9
commit
89bbf56062
54 files changed
+5135
-1
No files matched your search
@@ -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",
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user