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

@@ -3,38 +3,56 @@
import { createQuery } from "@tanstack/svelte-query";
import { api } from "#lib/api/client.js";
import { invalidate } from "#lib/api/lists.js";
import type { Budget, One } from "#lib/api/types.js";
import { learnBudget, learnedBudget } from "#lib/budgets/map.js";
import {
type Place,
placesOf,
refreshExcept,
type Scope,
withoutBudget,
} from "#lib/budgets/triage.js";
import Amount from "#lib/components/Amount.svelte";
import Box from "#lib/components/Box.svelte";
import {
averages,
type BudgetRow,
buildPools,
buildRows,
curOf,
isOpen,
type Line,
loadBudgetPage,
pace,
perDay,
projection,
safePerDay,
saveLimit,
topLines,
} from "#lib/components/budgets/data.js";
import LimitCell, {
type Move,
} from "#lib/components/budgets/LimitCell.svelte";
import MeterBar from "#lib/components/budgets/MeterBar.svelte";
import PeriodNav from "#lib/components/budgets/PeriodNav.svelte";
import PoolBox from "#lib/components/budgets/PoolBox.svelte";
import {
periodPath,
usePeriodPath,
} from "#lib/components/budgets/period-path.svelte.js";
import TopLine from "#lib/components/budgets/TopLine.svelte";
import Triage from "#lib/components/budgets/Triage.svelte";
import ListMenu from "#lib/components/categories/ListMenu.svelte";
import MoneyList from "#lib/components/categories/MoneyList.svelte";
import { setParam } from "#lib/components/categories/url.js";
import PageHeader from "#lib/components/shell/PageHeader.svelte";
import { Button } from "#lib/components/ui/button/index.js";
import { formatMoney, formatRange } from "#lib/format.js";
import type { Binding } from "#lib/keys.svelte.js";
import { type Binding, useKeys } from "#lib/keys.svelte.js";
import { ListNav } from "#lib/list-nav.svelte.js";
import { period } from "#lib/period.svelte.js";
import { session } from "#lib/session.svelte.js";
import { oldUiUrl } from "#lib/spa-paths.js";
import { goto } from "$app/navigation";
import { page } from "$app/state";
const PERIOD_WORDS: Record<string, string> = {
daily: "a day",
@@ -59,8 +77,15 @@
? buildRows(query.data, period.range, session.currencies, primary)
: []
);
const pools = $derived(
query.data ? buildPools(query.data, rows, session.currencies, primary) : []
const tops = $derived(
query.data
? topLines(
buildPools(query.data, rows, session.currencies, primary),
query.data.total,
query.data.noBudget,
primary
)
: []
);
const inactive = $derived(
query.data?.budgets.filter((b) => !b.attributes.active) ?? []
@@ -72,6 +97,33 @@
session.currencies.filter((c) => c.attributes.enabled)
);
const scope = $derived<Scope>(
page.url.searchParams.get("scope") === "all" ? "all" : "period"
);
const triageKey = $derived(
scope === "all"
? ["budget-triage", "all"]
: ["budget-triage", period.start, period.end]
);
const triage = createQuery(() => ({
enabled: period.ready,
queryFn: () => withoutBudget(scope === "all" ? null : period.range),
queryKey: triageKey,
}));
const places = $derived(triage.data ? placesOf(triage.data, isOpen) : []);
const options = $derived(
rows.map((row) => ({ id: row.budget.id, name: row.budget.attributes.name }))
);
const nowCurrent = $derived(period.isCurrent);
const endsLabel = $derived(
period.view === "1M" ? "Ends month at" : "Ends period at"
);
let triaging = $state(false);
let suggested = $state<Map<string, number> | null>(null);
let suggestNote = $state("");
let saving = $state(false);
let editKey = $state<string | null>(null);
let adding = $state<{ budgetId: string; code: string } | null>(null);
let newName = $state("");
@@ -114,6 +166,102 @@
};
}
const suggestionFor = (row: BudgetRow, line: Line) =>
line.amount === null
? (suggested?.get(`${row.budget.id}:${line.cur.code}`) ?? null)
: null;
function linesOf(row: BudgetRow): Line[] {
const extra = addingLine(row);
const lines = extra ? [...row.lines, extra] : row.lines;
const prefix = `${row.budget.id}:`;
const more = [...(suggested?.keys() ?? [])]
.filter((key) => key.startsWith(prefix))
.map((key) => key.slice(prefix.length))
.filter((code) => !lines.some((line) => line.cur.code === code))
.map((code) => ({
amount: null,
cur: curOf(session.currencies, code),
key: `${prefix}${code}`,
range: period.range,
spent: 0,
}));
return [...lines, ...more];
}
const pending = $derived(
rows.flatMap((row) =>
linesOf(row).flatMap((line) => {
const amount = suggestionFor(row, line);
return amount ? [{ amount, line, row }] : [];
})
)
);
async function suggest() {
suggestNote = "Looking at the last 3 months";
try {
suggested = await averages(period.range, period.view);
suggestNote =
pending.length > 0
? ""
: "Every budget with spending already has an amount";
} catch (failure) {
suggestNote = failure instanceof Error ? failure.message : "Failed";
}
}
function discard() {
suggested = null;
suggestNote = "";
}
async function saveSuggested() {
if (saving || pending.length === 0) {
return;
}
const items = pending;
saving = true;
let saved = 0;
try {
await items.reduce(
(chain, item) =>
chain.then(async () => {
await saveLimit(
item.row.budget.id,
item.line,
item.amount,
period.range
);
saved += 1;
suggestNote = `Saving ${saved} of ${items.length}`;
}),
Promise.resolve()
);
await invalidate();
suggested = null;
suggestNote = `Saved ${saved} ${saved === 1 ? "amount" : "amounts"}`;
} catch (failure) {
suggestNote = failure instanceof Error ? failure.message : "Failed";
} finally {
saving = false;
}
}
async function createBudget(name: string) {
const made = await api<One<Budget>>("/v1/budgets", {
body: { name },
method: "POST",
});
return { id: made.data.id, name: made.data.attributes.name };
}
function remember(place: Place, option: { id: string; name: string }) {
const before = learnedBudget(place.accountId);
learnBudget(place.accountId, option);
return () => learnBudget(place.accountId, before);
}
function done(index: number, move: Move) {
adding = null;
const next = rows[index + move];
@@ -191,7 +339,38 @@
key: (row) => row.budget.id,
open: (row) => goto(showHref(row)),
});
nav.use();
useKeys(() => [
...(triaging ? [] : nav.bindings()),
{
group: "Budgets",
keys: "t",
label: triaging ? "Stop sorting" : "Sort expenses without a budget",
run: () => (triaging = !triaging),
},
{
group: "Budgets",
keys: "A",
label: "Suggest empty amounts from the last 3 months",
run: suggest,
},
...(pending.length > 0 && !triaging
? [
{
group: "Budgets",
keys: "mod+Enter",
label: "Save the suggested amounts",
run: saveSuggested,
},
{
group: "Budgets",
keys: "Escape",
label: "Discard the suggested amounts",
run: discard,
},
]
: []),
]);
const money = (line: Line, amount: number) =>
formatMoney({
@@ -215,26 +394,31 @@
<p class="text-expense">Could not load budgets: {query.error.message}</p>
{/if}
<div class="grid gap-4 {pools.length > 1 ? "xl:grid-cols-2" : ""}">
{#each pools as pool (pool.cur.code)}
<PoolBox
noBudget={query.data?.noBudget ?? []}
noBudgetCount={query.data?.noBudgetCount ?? 0}
{noBudgetHref}
{pool}
total={query.data?.total ?? []}
/>
{/each}
</div>
<TopLine onsort={() => (triaging = true)} {tops} />
<Box body="p-0" class="mt-4" title="Budgets">
{#snippet tools()}
<span class="text-[12px] text-cladd-fg-soft"
><span class="kbd">e</span>
set amount
<span class="kbd">Tab</span>
next</span
>
{#if pending.length > 0}
<span class="text-[12px] text-cladd-fg-soft max-sm:hidden"
>{pending.length}
suggested from the last 3 months</span
>
<Button disabled={saving} onclick={saveSuggested} size="sm">
Save {pending.length === 1 ? "it" : `all ${pending.length}`}
</Button>
<Button onclick={discard} size="sm" variant="ghost">Discard</Button>
{:else if suggestNote}
<span aria-live="polite" class="text-[12px] text-cladd-fg-soft"
>{suggestNote}</span
>
{:else}
<span class="text-[12px] text-cladd-fg-soft max-sm:hidden"
><span class="kbd">e</span>
set amount
<span class="kbd">A</span>
suggest from the last 3 months</span
>
{/if}
{/snippet}
<div class="overflow-x-auto">
<table class="w-full text-[13px] sm:min-w-[46rem]">
@@ -246,7 +430,15 @@
<th class="px-3 text-right font-medium">Budgeted</th>
<th class="px-3 text-right font-medium">Spent</th>
<th class="px-3 text-right font-medium max-sm:hidden">Left</th>
<th class="w-[30%] px-3 font-medium max-sm:hidden">Progress</th>
{#if nowCurrent}
<th class="px-3 text-right font-medium max-sm:hidden">Safe/day</th>
<th
class="whitespace-nowrap px-3 text-right font-medium max-md:hidden"
>
{endsLabel}
</th>
{/if}
<th class="w-[24%] px-3 font-medium max-sm:hidden">Progress</th>
</tr>
</thead>
<ListMenu
@@ -292,18 +484,22 @@
<tbody {...props}>
{#each rows as row, i (row.budget.id)}
{@const extra = addingLine(row)}
{@const lines = extra ? [...row.lines, extra] : row.lines}
{@const lines = linesOf(row)}
{#each lines as line, j (line.key)}
{@const p = pace(line)}
{@const day = perDay(line)}
{@const safe = safePerDay(line)}
{@const ends = projection(line)}
<tr
class="h-8 border-cladd-outline {j === lines.length - 1
? "border-b"
: ""} {nav.cursor === i
: ""} {nav.cursor === i && !triaging
? "bg-row-cursor"
: "hover:bg-row-hover"}"
id={j === 0 ? nav.rowId(i) : undefined}
onclick={(event) => nav.click(i, event)}
onclick={(event) => {
triaging = false;
nav.click(i, event);
}}
oncontextmenu={() => (nav.cursor = i)}
>
{#if j === 0}
@@ -397,6 +593,7 @@
editKey = line.key;
}}
range={period.range}
suggested={suggestionFor(row, line)}
trap={line !== extra}
/>
</div>
@@ -418,13 +615,28 @@
: "text-cladd-fg"}"
>{money(line, line.amount + line.spent)}</span
>
{#if !day.past && day.left !== null && day.left > 0}
<span class="num ml-1 text-[11px] text-cladd-fg-soft"
>{money(line, day.left)}/day</span
>
{/if}
{/if}
</td>
{#if nowCurrent}
<td class="num px-3 text-right max-sm:hidden">
{#if safe !== null}
<span
class={safe > 0 ? "text-cladd-fg" : "text-expense"}
>{money(line, safe)}</span
>
{/if}
</td>
<td class="num px-3 text-right max-md:hidden">
{#if ends && line.spent !== 0}
<span
class={line.amount && ends > line.amount
? "text-expense"
: "text-cladd-fg-soft"}
>~{money(line, ends)}</span
>
{/if}
</td>
{/if}
<td class="px-3 max-sm:hidden">
<div class="flex items-center gap-2.5">
{#if line.amount}
@@ -468,7 +680,7 @@
<td class="px-3 py-1.5 text-right">
<MoneyList sums={query.data?.noBudget ?? []} />
</td>
<td colspan="2"></td>
<td colspan={nowCurrent ? 4 : 2}></td>
</tr>
<tr
class="border-cladd-outline border-t bg-cladd-surface-next/40 sm:hidden"
@@ -506,6 +718,32 @@
{/snippet}
</Box>
<Triage
active={triaging}
assignPatch={(id) => ({ budget_id: id })}
clearPatch={{ budget_id: 0 }}
create={createBudget}
empty={scope === "all"
? "Every expense has a budget."
: `Every expense in ${period.label} has a budget.`}
everything={scope === "all"}
hint={(place) => learnedBudget(place.accountId)?.name ?? null}
ignoreKey="ffui.budget-ignored"
loading={triage.isPending}
noun="budget"
onactivate={() => (triaging = true)}
onassign={remember}
onchange={() => refreshExcept(triageKey)}
onleave={() => (triaging = false)}
onswitch={() => setParam("scope", scope === "all" ? "" : "all")}
{options}
periodLabel={period.label}
pickKey="b"
{places}
problem={triage.isError ? `Could not load: ${triage.error.message}` : ""}
title="Expenses without a budget"
/>
{#snippet noBudgetLabel()}
<a class="text-brand italic hover:underline" href={noBudgetHref}
>Expenses without a budget</a
+84 -4
View File
@@ -4,8 +4,15 @@
import { createQuery } from "@tanstack/svelte-query";
import { api } from "#lib/api/client.js";
import { invalidate } from "#lib/api/lists.js";
import type { Category, One } from "#lib/api/types.js";
import {
placesOf,
refreshExcept,
withoutCategory,
} from "#lib/budgets/triage.js";
import Box from "#lib/components/Box.svelte";
import { periodPath } from "#lib/components/budgets/period-path.svelte.js";
import Triage from "#lib/components/budgets/Triage.svelte";
import {
type CategoryRow,
loadCategoryPage,
@@ -18,7 +25,7 @@
import { Button } from "#lib/components/ui/button/index.js";
import { formatDate, percent } from "#lib/format.js";
import { fuzzyFilter } from "#lib/fuzzy.js";
import type { Binding } from "#lib/keys.svelte.js";
import { type Binding, useKeys } from "#lib/keys.svelte.js";
import { ListNav } from "#lib/list-nav.svelte.js";
import { period } from "#lib/period.svelte.js";
import { session } from "#lib/session.svelte.js";
@@ -98,6 +105,43 @@
periodPath("/categories/list/no-category", period.start, period.end)
);
const everything = $derived(page.url.searchParams.get("scope") === "all");
const triageKey = $derived(
everything
? ["category-triage", "all"]
: ["category-triage", period.start, period.end]
);
const triage = createQuery(() => ({
enabled: period.ready,
queryFn: () => withoutCategory(everything ? null : period.range),
queryKey: triageKey,
}));
const places = $derived(
triage.data
? placesOf(
triage.data,
(split) =>
(split.type === "withdrawal" || split.type === "deposit") &&
!split.category_id
)
: []
);
const options = $derived(
(query.data?.rows ?? [])
.map((row) => ({ id: row.item.id, name: row.item.attributes.name }))
.sort((a, b) => a.name.localeCompare(b.name))
);
let triaging = $state(false);
async function createCategory(name: string) {
const made = await api<One<Category>>("/v1/categories", {
body: { name },
method: "POST",
});
return { id: made.data.id, name: made.data.attributes.name };
}
let filterInput = $state<HTMLInputElement>();
let createInput = $state<HTMLInputElement>();
let newName = $state("");
@@ -163,7 +207,16 @@
key: (row) => row.item.id,
open: (row) => goto(showHref(row)),
});
nav.use();
useKeys(() => [
...(triaging ? [] : nav.bindings()),
{
group: "Categories",
keys: "t",
label: triaging ? "Stop sorting" : "Sort transactions without a category",
run: () => (triaging = !triaging),
},
]);
</script>
<PageHeader crumbs={[{ label: "Categories" }]} title="Categories">
@@ -285,11 +338,14 @@
{#each rows as row, i (row.item.id)}
<tr
class="h-7 border-cladd-outline border-b last:border-b-0 {nav.cursor ===
i
i && !triaging
? "bg-row-cursor"
: "hover:bg-row-hover"}"
id={nav.rowId(i)}
onclick={(event) => nav.click(i, event)}
onclick={(event) => {
triaging = false;
nav.click(i, event);
}}
oncontextmenu={() => (nav.cursor = i)}
>
<td class="px-3">
@@ -344,6 +400,30 @@
{/snippet}
</Box>
<Triage
active={triaging}
assignPatch={(id) => ({ category_id: id })}
clearPatch={{ category_name: null }}
create={createCategory}
empty={everything
? "Every transaction has a category."
: `Every transaction in ${period.label} has a category.`}
{everything}
ignoreKey="ffui.category-ignored"
loading={triage.isPending}
noun="category"
onactivate={() => (triaging = true)}
onchange={() => refreshExcept(triageKey)}
onleave={() => (triaging = false)}
onswitch={() => setParam("scope", everything ? "" : "all")}
{options}
periodLabel={period.label}
pickKey="c"
{places}
problem={triage.isError ? `Could not load: ${triage.error.message}` : ""}
title="Transactions without a category"
/>
{#snippet bar(
spent: number
)}