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:
1 parent
68f64893f7
commit
cea51281cd
12 files changed
+1535
-214
No files matched your search
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user