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
@@ -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
|
||||
|
||||
@@ -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
|
||||
)}
|
||||
|
||||
Reference in new issue
Block a user