feat(budgets): top line per currency, sorting places into budgets, projections

Budgets show budgeted, left, spent and spent without a budget per
currency, safe per day and where the month ends. t sorts expenses
without a budget by place, 1 to 9 move a whole place into a budget, u
undoes, and the editor and quick entry remember the place. A suggests
empty amounts from three months. Categories get the same sorting.
This commit is contained in:
hh committed 2026-10-16 15:13:35 +00:00
1 parent 68f64893f7
commit cea51281cd
12 files changed
+1535 -214

No files matched your search

+117 -25
View File
@@ -7,18 +7,27 @@ import type {
Currency,
InsightTotalEntry,
Item,
Page,
TransactionGroup,
Split,
} from "#lib/api/types.js";
import { placesOf, sumsOf, withoutBudget } from "#lib/budgets/triage.js";
import {
add,
amountIn,
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 {
contains,
daysBetween,
isDynamic,
type Range,
shiftPeriod,
today,
type ViewRange,
} from "#lib/dates.js";
import { toNumber } from "#lib/format.js";
export interface Line {
@@ -43,6 +52,15 @@ export interface Pool {
spent: number;
}
export interface Top {
available: number | null;
budgeted: number;
cur: Cur;
inBudgets: number;
outside: number;
spent: number;
}
export interface Pace {
expected: number;
fraction: number;
@@ -58,6 +76,9 @@ export interface BudgetPage {
total: Sum[];
}
export const isOpen = (split: Split) =>
split.type === "withdrawal" && !split.budget_id;
export const day = (value: string | undefined) => (value ?? "").slice(0, 10);
export const limitRange = (limit: Item<BudgetLimit>): Range => ({
@@ -98,23 +119,20 @@ export async function loadBudgetPage(
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" },
}),
]);
const [budgets, limits, available, total, groups] = 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 }),
withoutBudget(range),
]);
const entries = placesOf(groups, isOpen).flatMap((place) => place.entries);
return {
available,
budgets,
limits,
noBudget: insightSums(noBudget, currencies),
noBudgetCount: noBudgetPage.meta.pagination?.total ?? 0,
noBudget: sumsOf(entries),
noBudgetCount: entries.length,
total: insightSums(total, currencies),
};
}
@@ -215,6 +233,36 @@ export function buildPools(
return result.length > 0 ? result : [pool(primary)];
}
export function topLines(
pools: Pool[],
total: Sum[],
noBudget: Sum[],
primary: string
): Top[] {
const curs = new Map<string, Cur>();
for (const item of [...pools.map((pool) => pool.cur), ...total]) {
curs.set(item.code, curs.get(item.code) ?? item);
}
return [...curs.values()]
.map((cur) => {
const pool = pools.find((item) => item.cur.code === cur.code);
return {
available: pool?.available ?? null,
budgeted: pool?.budgeted ?? 0,
cur,
inBudgets: -(pool?.spent ?? 0),
outside: -amountIn(noBudget, cur.code),
spent: -amountIn(total, cur.code),
};
})
.filter((top) => top.budgeted || top.spent || top.available)
.sort(
(a, b) =>
Number(b.cur.code === primary) - Number(a.cur.code === primary) ||
b.spent - a.spent
);
}
export function pace(line: Line, now = today()): Pace | null {
if (!(line.amount && contains(line.range, now))) {
return null;
@@ -225,16 +273,60 @@ export function pace(line: Line, now = today()): Pace | null {
return { expected, fraction: elapsed / total, over: -line.spent - expected };
}
export function perDay(line: Line, now = today()) {
export function projection(line: Line, now = today()): number | null {
if (!contains(line.range, now)) {
return null;
}
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 elapsed = daysBetween(line.range.start, now) + 1;
return (-line.spent * total) / elapsed;
}
export function safePerDay(line: Line, now = today()): number | null {
if (line.amount === null || !contains(line.range, now)) {
return null;
}
const left = daysBetween(now, line.range.end) + 1;
return Math.max(0, line.amount + line.spent) / left;
}
const AVERAGE_OF = 3;
export function niceCeil(value: number) {
const step = 10 ** Math.max(0, Math.floor(Math.log10(value)) - 1);
return Math.ceil(value / step) * step;
}
export async function averages(
range: Range,
view: ViewRange
): Promise<Map<string, number>> {
const fixed = isDynamic(view) ? "1M" : view;
const ranges: Range[] = [];
let step = range;
for (let i = 0; i < AVERAGE_OF; i += 1) {
step = shiftPeriod(step, fixed, -1);
ranges.push(step);
}
const periods = await Promise.all(
ranges.map((item) =>
all<Budget>("/v1/budgets", { end: item.end, start: item.start })
)
);
const sums = new Map<string, number>();
for (const budget of periods.flat()) {
for (const sum of fromEntries(budget.attributes.spent)) {
const key = `${budget.id}:${sum.code}`;
sums.set(key, (sums.get(key) ?? 0) - sum.amount / AVERAGE_OF);
}
}
const out = new Map<string, number>();
for (const [key, value] of sums) {
if (value > 0) {
out.set(key, niceCeil(value));
}
}
return out;
}
const SPACES = /\s/g;