feat(budgets,categories,tags): in-place limits with pace, category shares, tag tree and clean-up

This commit is contained in:
hh committed 2026-10-16 03:38:31 +00:00
1 parent 4a693a83c9
commit 89bbf56062
54 files changed
+5135 -1

No files matched your search

+1 -1
View File
@@ -61,7 +61,7 @@
<svelte:window onkeydown={keys.handle} />
<QueryClientProvider client={queryClient}>
{#if session.ready}
{#if session.ready && period.ready}
<Shell>
{@render children()}
{#snippet quick()}
@@ -0,0 +1,508 @@
<script lang="ts">
import Plus from "@lucide/svelte/icons/plus";
import { createQuery } from "@tanstack/svelte-query";
import { api } from "#lib/api/client.js";
import { invalidate } from "#lib/api/lists.js";
import Amount from "#lib/components/Amount.svelte";
import Box from "#lib/components/Box.svelte";
import {
type BudgetRow,
buildPools,
buildRows,
type Line,
loadBudgetPage,
pace,
perDay,
} 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 ListMenu from "#lib/components/categories/ListMenu.svelte";
import MoneyList from "#lib/components/categories/MoneyList.svelte";
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 { 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";
const PERIOD_WORDS: Record<string, string> = {
daily: "a day",
half_year: "every half year",
monthly: "a month",
quarterly: "a quarter",
weekly: "a week",
yearly: "a year",
};
usePeriodPath(() => "/budgets");
const query = createQuery(() => ({
enabled: period.ready,
queryFn: () => loadBudgetPage(period.range, session.currencies),
queryKey: ["budgets-page", period.start, period.end],
}));
const primary = $derived(session.primary?.code ?? "EUR");
const rows = $derived(
query.data
? buildRows(query.data, period.range, session.currencies, primary)
: []
);
const pools = $derived(
query.data ? buildPools(query.data, rows, session.currencies, primary) : []
);
const inactive = $derived(
query.data?.budgets.filter((b) => !b.attributes.active) ?? []
);
const noBudgetHref = $derived(
periodPath("/budgets/list/no-budget", period.start, period.end)
);
const enabled = $derived(
session.currencies.filter((c) => c.attributes.enabled)
);
let editKey = $state<string | null>(null);
let adding = $state<{ budgetId: string; code: string } | null>(null);
let newName = $state("");
let createError = $state("");
let creating = $state(false);
let createInput = $state<HTMLInputElement>();
function showHref(row: BudgetRow) {
const limits = row.lines.filter((line) => line.limit);
return limits.length === 1
? `/budgets/show/${row.budget.id}/${limits[0].limit?.id}`
: `/budgets/show/${row.budget.id}`;
}
function startAdding(row: BudgetRow) {
const used = new Set(row.lines.map((line) => line.cur.code));
const free = enabled.find((c) => !used.has(c.attributes.code));
if (free) {
editKey = null;
adding = { budgetId: row.budget.id, code: free.attributes.code };
}
}
function addingLine(row: BudgetRow): Line | null {
if (adding?.budgetId !== row.budget.id) {
return null;
}
const cur = enabled.find((c) => c.attributes.code === adding?.code);
return {
amount: null,
cur: {
code: adding.code,
decimals: cur?.attributes.decimal_places ?? 2,
id: cur?.id ?? "",
symbol: cur?.attributes.symbol ?? adding.code,
},
key: `${row.budget.id}:new`,
range: period.range,
spent: 0,
};
}
function done(index: number, move: Move) {
adding = null;
const next = rows[index + move];
if (move === 0 || !next) {
editKey = null;
return;
}
nav.move(index + move);
editKey = next.lines[0].key;
}
async function remove(row: BudgetRow) {
await api(`/v1/budgets/${row.budget.id}`, { method: "DELETE" });
await invalidate();
}
async function create(event: SubmitEvent) {
event.preventDefault();
const name = newName.trim();
if (!name || creating) {
return;
}
creating = true;
createError = "";
try {
await api("/v1/budgets", { body: { name }, method: "POST" });
newName = "";
await invalidate();
} catch (failure) {
createError = failure instanceof Error ? failure.message : "Failed";
} finally {
creating = false;
}
}
const nav: ListNav<BudgetRow> = new ListNav<BudgetRow>({
edit: (row) => {
adding = null;
editKey = row.lines[0].key;
},
extra: (): Binding[] => [
{
group: "Budgets",
keys: "E",
label: "Edit budget",
run: () =>
nav.current && goto(`/budgets/edit/${nav.current.budget.id}`),
},
{
group: "Budgets",
keys: "a",
label: "Budget an amount in another currency",
run: () => nav.current && startAdding(nav.current),
},
{
group: "Budgets",
keys: "c",
label: "New budget",
run: () => createInput?.focus(),
},
{
group: "Budgets",
keys: "w",
label: "Expenses without a budget",
run: () => goto(noBudgetHref),
},
],
id: "budgets",
items: () => rows,
key: (row) => row.budget.id,
open: (row) => goto(showHref(row)),
});
nav.use();
const money = (line: Line, amount: number) =>
formatMoney({
amount,
decimals: line.cur.decimals,
symbol: line.cur.symbol,
});
</script>
<PageHeader crumbs={[{ label: "Budgets" }]} title="Budgets">
{#snippet actions()}
<Button href="/budgets/create" size="sm" variant="outline">
<Plus />New budget
</Button>
{/snippet}
</PageHeader>
<PeriodNav base="/budgets" />
{#if query.isError}
<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>
<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
>
{/snippet}
<div class="overflow-x-auto">
<table class="w-full text-[13px] sm:min-w-[46rem]">
<thead>
<tr
class="h-8 border-cladd-outline border-b text-left text-[12px] text-cladd-fg-soft"
>
<th class="px-3 font-medium">Budget</th>
<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>
</tr>
</thead>
<ListMenu
deleteLabel="Delete budget"
items={() => [
{
keys: "Enter",
label: "Show",
run: () => nav.current && goto(showHref(nav.current)),
},
{
keys: "E",
label: "Edit budget",
run: () =>
nav.current && goto(`/budgets/edit/${nav.current.budget.id}`),
},
{
keys: "e",
label: "Set budgeted amount",
run: () => nav.current && (editKey = nav.current.lines[0].key),
},
{
keys: "a",
label: "Budget in another currency",
run: () => nav.current && startAdding(nav.current),
},
{
label: "Open in old UI",
run: () =>
nav.current &&
location.assign(
oldUiUrl(
`${location.origin}/budgets/show/${nav.current.budget.id}`
)
),
},
]}
ondelete={() => nav.current && remove(nav.current)}
>
{#snippet children(
props
)}
<tbody {...props}>
{#each rows as row, i (row.budget.id)}
{@const extra = addingLine(row)}
{@const lines = extra ? [...row.lines, extra] : row.lines}
{#each lines as line, j (line.key)}
{@const p = pace(line)}
{@const day = perDay(line)}
<tr
class="h-8 border-cladd-outline {j === lines.length - 1
? "border-b"
: ""} {nav.cursor === i
? "bg-row-cursor"
: "hover:bg-row-hover"}"
id={j === 0 ? nav.rowId(i) : undefined}
onclick={(event) => nav.click(i, event)}
oncontextmenu={() => (nav.cursor = i)}
>
{#if j === 0}
<td class="px-3 align-top" rowspan={lines.length}>
<div
class="flex min-h-8 items-center gap-2 sm:whitespace-nowrap"
>
<a
class="text-cladd-fg hover:underline"
href={showHref(row)}
>{row.budget.attributes.name}</a
>
{#if row.budget.attributes.auto_budget_type &&
row.budget.attributes.auto_budget_amount}
<span
class="text-[12px] text-cladd-fg-soft max-sm:hidden"
>
auto
<span class="num"
>{formatMoney({
amount:
row.budget.attributes.auto_budget_amount,
decimals:
row.budget.attributes.currency_decimal_places,
symbol: row.budget.attributes.currency_symbol,
})}</span
>
{PERIOD_WORDS[
row.budget.attributes.auto_budget_period ?? ""
] ?? ""}
</span>
{/if}
{#if !extra && row.lines.length < enabled.length}
<button
class="ml-auto text-[11px] text-cladd-fg-softer hover:text-brand max-sm:hidden"
onclick={() => startAdding(row)}
title="Budget an amount in another currency (a)"
type="button"
>
+ currency
</button>
{/if}
</div>
</td>
{/if}
<td class="px-3 text-right">
<div
class="flex items-center justify-end gap-1.5"
data-limit-editor={line === extra ? "" : undefined}
>
{#if line === extra}
<select
aria-label="Currency"
class="cladd-cut h-6 rounded-cladd-sm px-1 text-[12px]"
onchange={(event) =>
adding &&
(adding = {
...adding,
code: event.currentTarget.value,
})}
value={adding?.code}
>
{#each enabled.filter(
(c) =>
c.attributes.code === adding?.code ||
!row.lines.some(
(l) => l.cur.code === c.attributes.code
)
) as cur (cur.id)}
<option value={cur.attributes.code}>
{cur.attributes.code}
</option>
{/each}
</select>
{/if}
{#if line.limit &&
(line.range.start !== period.start ||
line.range.end !== period.end)}
<span class="text-[11px] text-cladd-fg-softer"
>{formatRange(line.range.start, line.range.end)}</span
>
{/if}
<LimitCell
budgetId={row.budget.id}
editing={line === extra || editKey === line.key}
{line}
ondone={(move) => done(i, move)}
onedit={() => {
nav.cursor = i;
adding = null;
editKey = line.key;
}}
range={period.range}
trap={line !== extra}
/>
</div>
</td>
<td class="px-3 text-right">
{#if line !== extra}
<Amount
amount={line.spent}
decimals={line.cur.decimals}
symbol={line.cur.symbol}
/>
{/if}
</td>
<td class="whitespace-nowrap px-3 text-right max-sm:hidden">
{#if line.amount !== null}
<span
class="num {line.amount + line.spent < 0
? "text-expense"
: "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>
<td class="px-3 max-sm:hidden">
<div class="flex items-center gap-2.5">
{#if line.amount}
<MeterBar
class="w-28 shrink-0"
max={line.amount}
tick={p?.fraction ?? null}
value={-line.spent}
/>
{#if -line.spent > line.amount}
<span class="text-[12px] text-expense"
>Overspent</span
>
{:else if p && p.over > 0}
<span class="num text-[12px] text-warn"
>{money(line, p.over)}
over pace</span
>
{:else if p}
<span class="text-[12px] text-cladd-fg-soft"
>On track</span
>
{/if}
{:else if line.spent !== 0}
<span class="text-[12px] text-cladd-fg-soft"
>No amount set</span
>
{/if}
</div>
</td>
</tr>
{/each}
{/each}
<tr class="border-cladd-outline border-t bg-cladd-surface-next/40">
<td class="px-3 py-1.5 sm:whitespace-nowrap">
<a class="text-brand italic hover:underline" href={noBudgetHref}
>Expenses without a budget</a
>
{#if query.data?.noBudgetCount}
<span class="ml-1.5 text-[12px] text-cladd-fg-soft"
>{query.data.noBudgetCount}
transactions</span
>
{/if}
</td>
<td></td>
<td class="px-3 py-1.5 text-right">
<MoneyList sums={query.data?.noBudget ?? []} />
</td>
<td class="max-sm:hidden" colspan="2"></td>
</tr>
</tbody>
{/snippet}
</ListMenu>
</table>
</div>
{#snippet footer()}
<form class="flex flex-1 items-center gap-2" onsubmit={create}>
<input
aria-label="New budget name"
class="cladd-cut cladd-focusable-input h-7 w-64 min-w-0 flex-1 rounded-cladd-sm px-2 text-[13px] sm:flex-none"
placeholder="New budget name (c)"
bind:this={createInput}
bind:value={newName}
>
<Button disabled={creating || !newName.trim()} size="sm" type="submit"
>Create</Button
>
{#if createError}
<span class="text-expense">{createError}</span>
{/if}
</form>
{/snippet}
</Box>
{#if inactive.length > 0}
<Box class="mt-4" title="Inactive budgets">
<p class="text-[13px]">
{#each inactive as budget, i (budget.id)}
<a class="text-brand hover:underline" href="/budgets/show/{budget.id}"
>{budget.attributes.name}</a
>{i < inactive.length - 1 ? ", " : ""}
{/each}
</p>
</Box>
{/if}
+10
View File
@@ -0,0 +1,10 @@
<script lang="ts">
import BudgetForm from "#lib/components/budgets/BudgetForm.svelte";
import PageHeader from "#lib/components/shell/PageHeader.svelte";
</script>
<PageHeader
crumbs={[{ href: "/budgets", label: "Budgets" }, { label: "Create" }]}
title="Create a new budget"
/>
<BudgetForm />
@@ -0,0 +1,5 @@
import { redirect } from "@sveltejs/kit";
export function load({ params }: { params: { id: string } }) {
redirect(307, `/budgets/edit/${params.id}`);
}
@@ -0,0 +1,31 @@
<script lang="ts">
import { createQuery } from "@tanstack/svelte-query";
import { api } from "#lib/api/client.js";
import type { Budget, One } from "#lib/api/types.js";
import BudgetForm from "#lib/components/budgets/BudgetForm.svelte";
import PageHeader from "#lib/components/shell/PageHeader.svelte";
import { page } from "$app/state";
const id = $derived(page.params.id ?? "");
const query = createQuery(() => ({
queryFn: () => api<One<Budget>>(`/v1/budgets/${id}`),
queryKey: ["budget", id],
}));
const name = $derived(query.data?.data.attributes.name ?? "");
</script>
<PageHeader
crumbs={[
{ href: "/budgets", label: "Budgets" },
{ href: `/budgets/show/${id}`, label: name || "Budget" },
{ label: "Edit" },
]}
title={name ? `Edit budget "${name}"` : "Edit budget"}
/>
{#if query.data}
{#key query.data.data.id}
<BudgetForm budget={query.data.data} />
{/key}
{:else if query.isError}
<p class="text-expense">{query.error.message}</p>
{/if}
@@ -0,0 +1,5 @@
<script lang="ts">
import NoBudget from "#lib/components/budgets/NoBudget.svelte";
</script>
<NoBudget />
@@ -0,0 +1,5 @@
<script lang="ts">
import NoBudget from "#lib/components/budgets/NoBudget.svelte";
</script>
<NoBudget everything />
@@ -0,0 +1,8 @@
<script lang="ts">
import ShowBudget from "#lib/components/budgets/ShowBudget.svelte";
import { page } from "$app/state";
</script>
{#key page.params.id}
<ShowBudget id={page.params.id ?? ""} limitId={page.params.limit} />
{/key}
+362
View File
@@ -0,0 +1,362 @@
<script lang="ts">
import ArrowDown from "@lucide/svelte/icons/arrow-down";
import Plus from "@lucide/svelte/icons/plus";
import { createQuery } from "@tanstack/svelte-query";
import { api } from "#lib/api/client.js";
import { invalidate } from "#lib/api/lists.js";
import Box from "#lib/components/Box.svelte";
import { periodPath } from "#lib/components/budgets/period-path.svelte.js";
import {
type CategoryRow,
loadCategoryPage,
} from "#lib/components/categories/data.js";
import ListMenu from "#lib/components/categories/ListMenu.svelte";
import MoneyList from "#lib/components/categories/MoneyList.svelte";
import { amountIn } from "#lib/components/categories/sums.js";
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 { formatDate, percent } from "#lib/format.js";
import { fuzzyFilter } from "#lib/fuzzy.js";
import type { Binding } 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";
type Sort = "name" | "spent" | "earned" | "activity";
const SORTS: { key: Sort; label: string }[] = [
{ key: "name", label: "Name" },
{ key: "spent", label: "Spent" },
{ key: "earned", label: "Earned" },
{ key: "activity", label: "Last activity" },
];
const query = createQuery(() => ({
enabled: period.ready,
queryFn: () => loadCategoryPage(period.range, session.currencies),
queryKey: ["categories-page", period.start, period.end],
}));
const sort = $derived<Sort>(
(SORTS.find((s) => s.key === page.url.searchParams.get("sort"))?.key ??
"name") as Sort
);
const filter = $derived(page.url.searchParams.get("q") ?? "");
const primary = $derived(session.primary?.code ?? "");
const main = $derived.by(() => {
const codes = new Set(
(query.data?.rows ?? []).flatMap((r) => r.spent.map((s) => s.code))
);
return codes.has(primary) ? primary : ([...codes][0] ?? primary);
});
const totalSpent = $derived(
-(
(query.data?.rows ?? []).reduce(
(sum, r) => sum + amountIn(r.spent, main),
0
) + amountIn(query.data?.spent ?? [], main)
)
);
const share = (spent: number) => (totalSpent > 0 ? -spent / totalSpent : 0);
const rows = $derived.by(() => {
const source = query.data?.rows ?? [];
const matched = filter
? fuzzyFilter(
source,
filter,
(r) => r.item.attributes.name,
source.length
)
: source;
const sorted = [...matched];
const name = (r: CategoryRow) => r.item.attributes.name;
if (sort === "name") {
sorted.sort((a, b) => name(a).localeCompare(name(b)));
} else if (sort === "spent") {
sorted.sort((a, b) => amountIn(a.spent, main) - amountIn(b.spent, main));
} else if (sort === "earned") {
sorted.sort(
(a, b) => amountIn(b.earned, main) - amountIn(a.earned, main)
);
} else {
sorted.sort((a, b) =>
(b.item.attributes.last_activity ?? "").localeCompare(
a.item.attributes.last_activity ?? ""
)
);
}
return sorted;
});
const noCategoryHref = $derived(
periodPath("/categories/list/no-category", period.start, period.end)
);
let filterInput = $state<HTMLInputElement>();
let createInput = $state<HTMLInputElement>();
let newName = $state("");
let creating = $state(false);
let createError = $state("");
const showHref = (row: CategoryRow) =>
periodPath(`/categories/show/${row.item.id}`, period.start, period.end);
async function remove(row: CategoryRow) {
await api(`/v1/categories/${row.item.id}`, { method: "DELETE" });
await invalidate();
}
async function create(event: SubmitEvent) {
event.preventDefault();
const name = newName.trim();
if (!name || creating) {
return;
}
creating = true;
createError = "";
try {
await api("/v1/categories", { body: { name }, method: "POST" });
newName = "";
await invalidate();
} catch (failure) {
createError = failure instanceof Error ? failure.message : "Failed";
} finally {
creating = false;
}
}
const nav: ListNav<CategoryRow> = new ListNav<CategoryRow>({
edit: (row) => goto(`/categories/edit/${row.item.id}`),
extra: (): Binding[] => [
{
group: "Categories",
keys: "/",
label: "Filter categories",
run: () => filterInput?.focus(),
},
{
group: "Categories",
keys: "c",
label: "New category",
run: () => createInput?.focus(),
},
{
group: "Categories",
keys: "w",
label: "Transactions without a category",
run: () => goto(noCategoryHref),
},
],
id: "categories",
items: () => rows,
key: (row) => row.item.id,
open: (row) => goto(showHref(row)),
});
nav.use();
</script>
<PageHeader crumbs={[{ label: "Categories" }]} title="Categories">
{#snippet actions()}
<Button href="/categories/create" size="sm" variant="outline">
<Plus />New category
</Button>
{/snippet}
</PageHeader>
{#if query.isError}
<p class="text-expense">Could not load categories: {query.error.message}</p>
{/if}
<Box body="p-0" title="Categories">
{#snippet tools()}
<input
aria-label="Filter categories"
class="cladd-cut cladd-focusable-input h-7 w-48 rounded-cladd-sm px-2 text-[12px]"
oninput={(event) => setParam("q", event.currentTarget.value)}
onkeydown={(event) => {
if (event.key === "Enter" || event.key === "ArrowDown") {
event.preventDefault();
event.currentTarget.blur();
nav.move(0);
}
}}
placeholder="Filter (/)"
value={filter}
bind:this={filterInput}
>
{/snippet}
<div class="overflow-x-auto">
<table class="w-full min-w-[40rem] text-[13px]">
<thead>
<tr
class="h-8 border-cladd-outline border-b text-left text-[12px] text-cladd-fg-soft"
>
{#each SORTS as column (column.key)}
<th
aria-sort={sort === column.key ? "ascending" : undefined}
class="px-3 font-medium {column.key === "name"
? ""
: "text-right"} {column.key === "activity"
? "hidden md:table-cell"
: ""}"
>
<button
class="inline-flex items-center gap-1 hover:text-cladd-fg {sort ===
column.key
? "text-cladd-fg"
: ""}"
onclick={() =>
setParam("sort", column.key === "name" ? "" : column.key)}
type="button"
>
{column.label}
{#if sort === column.key}
<ArrowDown class="size-3" />
{/if}
</button>
</th>
{#if column.key === "earned"}
<th class="w-[22%] px-3 font-medium">Share of {main} spending</th>
{/if}
{/each}
</tr>
</thead>
<ListMenu
deleteLabel="Delete category"
items={() => [
{
keys: "Enter",
label: "Show",
run: () => nav.current && goto(showHref(nav.current)),
},
{
keys: "e",
label: "Edit category",
run: () =>
nav.current && goto(`/categories/edit/${nav.current.item.id}`),
},
{
label: "Open in old UI",
run: () =>
nav.current &&
location.assign(
oldUiUrl(
`${location.origin}/categories/show/${nav.current.item.id}`
)
),
},
]}
ondelete={() => nav.current && remove(nav.current)}
>
{#snippet children(
props
)}
<tbody {...props}>
<tr class="h-7 border-cladd-outline border-b">
<td class="px-3">
<a
class="text-brand italic hover:underline"
href={noCategoryHref}
>Without a category</a
>
</td>
<td class="px-3 text-right">
<MoneyList sums={query.data?.spent ?? []} />
</td>
<td class="px-3 text-right">
<MoneyList sums={query.data?.earned ?? []} />
</td>
<td class="px-3">
{@render bar(amountIn(query.data?.spent ?? [], main))}
</td>
<td class="hidden md:table-cell"></td>
</tr>
{#each rows as row, i (row.item.id)}
<tr
class="h-7 border-cladd-outline border-b last:border-b-0 {nav.cursor ===
i
? "bg-row-cursor"
: "hover:bg-row-hover"}"
id={nav.rowId(i)}
onclick={(event) => nav.click(i, event)}
oncontextmenu={() => (nav.cursor = i)}
>
<td class="px-3">
<a class="text-cladd-fg hover:underline" href={showHref(row)}
>{row.item.attributes.name}</a
>
</td>
<td class="px-3 text-right">
<MoneyList sums={row.spent} />
</td>
<td class="px-3 text-right">
<MoneyList sums={row.earned} />
</td>
<td class="px-3">{@render bar(amountIn(row.spent, main))}</td>
<td
class="num hidden px-3 text-right text-[12px] text-cladd-fg-soft md:table-cell"
>
{row.item.attributes.last_activity
? formatDate(row.item.attributes.last_activity)
: "never"}
</td>
</tr>
{:else}
{#if !query.isPending}
<tr>
<td class="px-3 py-3 text-cladd-fg-soft" colspan="5">
{filter ? "No category matches." : "No categories yet."}
</td>
</tr>
{/if}
{/each}
</tbody>
{/snippet}
</ListMenu>
</table>
</div>
{#snippet footer()}
<form class="flex flex-1 items-center gap-2" onsubmit={create}>
<input
aria-label="New category name"
class="cladd-cut cladd-focusable-input h-7 w-64 rounded-cladd-sm px-2 text-[13px]"
placeholder="New category name (c)"
bind:this={createInput}
bind:value={newName}
>
<Button disabled={creating || !newName.trim()} size="sm" type="submit"
>Create</Button
>
{#if createError}
<span class="text-expense">{createError}</span>
{/if}
</form>
{/snippet}
</Box>
{#snippet bar(
spent: number
)}
{@const value = share(spent)}
{#if value > 0}
<div class="flex items-center gap-2">
<div
class="h-2 flex-1 overflow-hidden rounded-full bg-cladd-surface-next"
>
<div
class="h-full rounded-full bg-expense/70"
style:width="{value * 100}%"
></div>
</div>
<span class="num w-10 text-right text-[12px] text-cladd-fg-soft"
>{percent(value)}</span
>
</div>
{/if}
{/snippet}
+10
View File
@@ -0,0 +1,10 @@
<script lang="ts">
import CategoryForm from "#lib/components/categories/CategoryForm.svelte";
import PageHeader from "#lib/components/shell/PageHeader.svelte";
</script>
<PageHeader
crumbs={[{ href: "/categories", label: "Categories" }, { label: "Create" }]}
title="Create a new category"
/>
<CategoryForm />
@@ -0,0 +1,5 @@
import { redirect } from "@sveltejs/kit";
export function load({ params }: { params: { id: string } }) {
redirect(307, `/categories/edit/${params.id}`);
}
@@ -0,0 +1,31 @@
<script lang="ts">
import { createQuery } from "@tanstack/svelte-query";
import { api } from "#lib/api/client.js";
import type { Category, One } from "#lib/api/types.js";
import CategoryForm from "#lib/components/categories/CategoryForm.svelte";
import PageHeader from "#lib/components/shell/PageHeader.svelte";
import { page } from "$app/state";
const id = $derived(page.params.id ?? "");
const query = createQuery(() => ({
queryFn: () => api<One<Category>>(`/v1/categories/${id}`),
queryKey: ["category", id],
}));
const name = $derived(query.data?.data.attributes.name ?? "");
</script>
<PageHeader
crumbs={[
{ href: "/categories", label: "Categories" },
{ href: `/categories/show/${id}`, label: name || "Category" },
{ label: "Edit" },
]}
title={name ? `Edit category "${name}"` : "Edit category"}
/>
{#if query.data}
{#key query.data.data.id}
<CategoryForm category={query.data.data} />
{/key}
{:else if query.isError}
<p class="text-expense">{query.error.message}</p>
{/if}
@@ -0,0 +1,5 @@
<script lang="ts">
import NoCategory from "#lib/components/categories/NoCategory.svelte";
</script>
<NoCategory />
@@ -0,0 +1,5 @@
<script lang="ts">
import NoCategory from "#lib/components/categories/NoCategory.svelte";
</script>
<NoCategory everything />
@@ -0,0 +1,8 @@
<script lang="ts">
import ShowCategory from "#lib/components/categories/ShowCategory.svelte";
import { page } from "$app/state";
</script>
{#key page.params.id}
<ShowCategory id={page.params.id ?? ""} />
{/key}
@@ -0,0 +1,8 @@
<script lang="ts">
import ShowCategory from "#lib/components/categories/ShowCategory.svelte";
import { page } from "$app/state";
</script>
{#key page.params.id}
<ShowCategory everything id={page.params.id ?? ""} />
{/key}
+472
View File
@@ -0,0 +1,472 @@
<script lang="ts">
import Plus from "@lucide/svelte/icons/plus";
import { createQuery } from "@tanstack/svelte-query";
import { api } from "#lib/api/client.js";
import { all, invalidate, lists } from "#lib/api/lists.js";
import type { TransactionGroup } from "#lib/api/types.js";
import Box from "#lib/components/Box.svelte";
import { periodPath } from "#lib/components/budgets/period-path.svelte.js";
import ListMenu from "#lib/components/categories/ListMenu.svelte";
import MoneyList from "#lib/components/categories/MoneyList.svelte";
import { list, rows as splitRows } from "#lib/components/categories/sums.js";
import { setParam } from "#lib/components/categories/url.js";
import PageHeader from "#lib/components/shell/PageHeader.svelte";
import { deleteTag, usage } from "#lib/components/tags/actions.js";
import Duplicates from "#lib/components/tags/Duplicates.svelte";
import MergeField from "#lib/components/tags/MergeField.svelte";
import RenameField from "#lib/components/tags/RenameField.svelte";
import {
buildTree,
duplicates,
flatNodes,
flatten,
type TagNode,
tagStats,
} from "#lib/components/tags/tree.js";
import { Button } from "#lib/components/ui/button/index.js";
import { formatDate } from "#lib/format.js";
import { fuzzyFilter } from "#lib/fuzzy.js";
import type { Binding } from "#lib/keys.svelte.js";
import { ListNav } from "#lib/list-nav.svelte.js";
import { period } from "#lib/period.svelte.js";
import { oldUiUrl } from "#lib/spa-paths.js";
import { goto } from "$app/navigation";
import { page } from "$app/state";
const ARM_MS = 3000;
const tags = createQuery(() => lists.tags());
const groups = createQuery(() => ({
enabled: period.ready,
queryFn: () =>
all<TransactionGroup>("/v1/transactions", {
end: period.end,
start: period.start,
type: "all",
}),
queryKey: ["tags-period", period.start, period.end],
}));
const flat = $derived(page.url.searchParams.get("view") === "flat");
const cleanup = $derived(page.url.searchParams.get("mode") === "cleanup");
const filter = $derived(page.url.searchParams.get("q") ?? "");
const counts = createQuery(() => ({
enabled: cleanup && Boolean(tags.data),
queryFn: () => usage(tags.data ?? []),
queryKey: ["tags-usage", (tags.data ?? []).map((t) => t.id).join(",")],
}));
const items = $derived(splitRows(groups.data ?? []));
const stats = $derived(tagStats(items, !(flat || filter)));
const nodes = $derived.by(() => {
const source = tags.data ?? [];
if (filter) {
return flatNodes(
fuzzyFilter(source, filter, (t) => t.attributes.tag, source.length)
);
}
return flat ? flatNodes(source) : flatten(buildTree(source));
});
const clusters = $derived(cleanup ? duplicates(tags.data ?? []) : []);
let editing = $state<{ path: string; mode: "rename" | "merge" } | null>(null);
let armed = $state<string | null>(null);
let armTimer: ReturnType<typeof setTimeout> | undefined;
let rowStatus = $state<Record<string, string>>({});
let filterInput = $state<HTMLInputElement>();
let createInput = $state<HTMLInputElement>();
let newTag = $state("");
let creating = $state(false);
let createError = $state("");
const showHref = (node: TagNode) =>
node.tag
? periodPath(`/tags/show/${node.tag.id}`, period.start, period.end)
: `/search?search=${encodeURIComponent(`tag_starts:"${node.path}/"`)}`;
function rename(node: TagNode | undefined) {
if (node) {
armed = null;
editing = { mode: "rename", path: node.path };
}
}
function merge(node: TagNode | undefined) {
if (node?.tag) {
armed = null;
editing = { mode: "merge", path: node.path };
}
}
async function remove(node: TagNode) {
if (!node.tag) {
return;
}
rowStatus[node.path] = "Deleting";
try {
await deleteTag(node.tag);
await invalidate();
} catch (failure) {
rowStatus[node.path] =
failure instanceof Error ? failure.message : String(failure);
}
}
function pressDelete(node: TagNode | undefined) {
if (!node?.tag) {
return;
}
clearTimeout(armTimer);
if (armed === node.path) {
armed = null;
remove(node);
return;
}
armed = node.path;
armTimer = setTimeout(() => (armed = null), ARM_MS);
}
async function create(event: SubmitEvent) {
event.preventDefault();
const tag = newTag.trim();
if (!tag || creating) {
return;
}
creating = true;
createError = "";
try {
await api("/v1/tags", { body: { tag }, method: "POST" });
newTag = "";
await invalidate();
} catch (failure) {
createError = failure instanceof Error ? failure.message : "Failed";
} finally {
creating = false;
}
}
const nav: ListNav<TagNode> = new ListNav<TagNode>({
edit: (node) => node.tag && goto(`/tags/edit/${node.tag.id}`),
extra: (): Binding[] => [
{
group: "Tags",
keys: "r",
label: "Rename tag",
run: () => rename(nav.current),
},
{
group: "Tags",
keys: "m",
label: "Merge tag into another",
run: () => merge(nav.current),
},
{
group: "Tags",
keys: "d d",
label: "Delete tag (twice)",
run: () => pressDelete(nav.current),
},
{
group: "Tags",
keys: "/",
label: "Filter tags",
run: () => filterInput?.focus(),
},
{
group: "Tags",
keys: "c",
label: "New tag",
run: () => createInput?.focus(),
},
{
group: "Tags",
keys: "v",
label: "Tree or flat view",
run: () => setParam("view", flat ? "" : "flat"),
},
{
group: "Tags",
keys: "C",
label: "Clean-up mode",
run: () => setParam("mode", cleanup ? "" : "cleanup"),
},
],
id: "tags",
items: () => nodes,
key: (node) => node.path,
open: (node) => goto(showHref(node)),
});
nav.use();
</script>
<PageHeader crumbs={[{ label: "Tags" }]} title="Tags">
{#snippet actions()}
<Button href="/tags/create" size="sm" variant="outline">
<Plus />Create a tag
</Button>
{/snippet}
</PageHeader>
{#if tags.isError}
<p class="text-expense">Could not load tags: {tags.error.message}</p>
{/if}
<div class="flex flex-col gap-4">
{#if cleanup}
<Duplicates {clusters} counts={counts.data ?? new Map()} />
{/if}
<Box body="p-0" title="Tags">
{#snippet tools()}
<div class="flex flex-wrap items-center gap-2">
{#if !(flat || filter)}
<span class="hidden text-[12px] text-cladd-fg-soft md:inline"
>parent rows include sub-tags</span
>
{/if}
<div
class="flex rounded-cladd-sm bg-cladd-surface-next p-0.5 text-[12px]"
>
<button
aria-pressed={!flat}
class="rounded-cladd-xs px-2 py-0.5 {flat
? "text-cladd-fg-soft"
: "cladd-surface font-semibold"}"
onclick={() => setParam("view", "")}
title="Tree view (v)"
type="button"
>
Tree
</button>
<button
aria-pressed={flat}
class="rounded-cladd-xs px-2 py-0.5 {flat
? "cladd-surface font-semibold"
: "text-cladd-fg-soft"}"
onclick={() => setParam("view", "flat")}
title="Flat view (v)"
type="button"
>
Flat
</button>
</div>
<button
aria-pressed={cleanup}
class="rounded-cladd-sm px-2 py-1 text-[12px] {cleanup
? "bg-row-selected font-semibold"
: "text-cladd-fg-soft hover:bg-row-hover"}"
onclick={() => setParam("mode", cleanup ? "" : "cleanup")}
title="Clean-up mode (C)"
type="button"
>
Clean up
</button>
<input
aria-label="Filter tags"
class="cladd-cut cladd-focusable-input h-7 w-32 rounded-cladd-sm px-2 text-[12px] sm:w-44"
oninput={(event) => setParam("q", event.currentTarget.value)}
onkeydown={(event) => {
if (event.key === "Enter" || event.key === "ArrowDown") {
event.preventDefault();
event.currentTarget.blur();
nav.move(0);
}
}}
placeholder="Filter (/)"
value={filter}
bind:this={filterInput}
>
</div>
{/snippet}
<div class="overflow-x-auto">
<table class="w-full min-w-[44rem] text-[13px]">
<thead>
<tr
class="h-8 border-cladd-outline border-b text-left text-[12px] text-cladd-fg-soft"
>
<th class="px-3 font-medium">Tag</th>
<th class="px-3 text-right font-medium">Transactions</th>
<th class="px-3 text-right font-medium">Spent</th>
<th class="px-3 text-right font-medium">Earned</th>
{#if cleanup}
<th class="px-3 text-right font-medium">
{counts.isFetching ? "Counting" : "All time"}
</th>
{/if}
<th class="hidden px-3 font-medium md:table-cell">Date</th>
<th class="hidden px-3 font-medium lg:table-cell">Description</th>
</tr>
</thead>
<ListMenu
deleteLabel="Delete tag"
items={() => [
{
keys: "Enter",
label: "Show",
run: () => nav.current && goto(showHref(nav.current)),
},
{
keys: "e",
label: "Edit tag",
run: () =>
nav.current?.tag && goto(`/tags/edit/${nav.current.tag.id}`),
},
{ keys: "r", label: "Rename", run: () => rename(nav.current) },
{
keys: "m",
label: "Merge into another tag",
run: () => merge(nav.current),
},
{
label: "Open in old UI",
run: () =>
nav.current?.tag &&
location.assign(
oldUiUrl(`${location.origin}/tags/show/${nav.current.tag.id}`)
),
},
]}
ondelete={() => nav.current && remove(nav.current)}
>
{#snippet children(
props
)}
<tbody {...props}>
{#each nodes as node, i (node.path)}
{@const stat = stats.get(node.path)}
{@const total = node.tag
? counts.data?.get(node.tag.id)
: undefined}
<tr
class="h-7 border-cladd-outline border-b last:border-b-0 {nav.cursor ===
i
? "bg-row-cursor"
: "hover:bg-row-hover"}"
id={nav.rowId(i)}
onclick={(event) => nav.click(i, event)}
oncontextmenu={() => (nav.cursor = i)}
>
<td
class="px-3"
style:padding-left="{0.75 + node.depth * 1.25}rem"
>
{#if editing?.path === node.path &&
editing.mode === "rename"}
<RenameField
{node}
ondone={() => (editing = null)}
tags={tags.data ?? []}
/>
{:else if editing?.path === node.path &&
editing.mode === "merge" &&
node.tag}
<MergeField
ondone={() => (editing = null)}
tag={node.tag}
tags={tags.data ?? []}
/>
{:else}
<a
class="hover:underline {node.tag
? "text-cladd-fg"
: "text-cladd-fg-soft italic"}"
href={showHref(node)}
>
{node.name}
</a>
{#if !node.tag}
<span class="ml-1 text-[11px] text-cladd-fg-softer"
>no tag of its own</span
>
{/if}
{#if armed === node.path}
<span class="ml-2 text-[12px] text-expense"
>Press d d again to delete</span
>
{:else if rowStatus[node.path]}
<span class="ml-2 text-[12px] text-cladd-fg-soft"
>{rowStatus[node.path]}</span
>
{/if}
{/if}
</td>
<td
class="num px-3 text-right {node.children.length > 0 &&
!flat
? "font-semibold"
: ""}"
>
{stat?.count || ""}
</td>
<td class="px-3 text-right">
<MoneyList sums={list(stat?.spent ?? {})} />
</td>
<td class="px-3 text-right">
<MoneyList sums={list(stat?.earned ?? {})} />
</td>
{#if cleanup}
<td class="num px-3 text-right">
{#if total === 0}
<span class="text-warn">unused</span>
{:else}
{total ?? ""}
{/if}
</td>
{/if}
<td
class="num hidden px-3 text-[12px] text-cladd-fg-soft md:table-cell"
>
{node.tag?.attributes.date
? formatDate(node.tag.attributes.date)
: ""}
</td>
<td
class="hidden max-w-80 truncate px-3 text-[12px] text-cladd-fg-soft lg:table-cell"
>
{node.tag?.attributes.description ?? ""}
</td>
</tr>
{:else}
{#if !tags.isPending}
<tr>
<td class="px-3 py-3 text-cladd-fg-soft" colspan="7">
{filter ? "No tag matches." : "No tags yet."}
</td>
</tr>
{/if}
{/each}
</tbody>
{/snippet}
</ListMenu>
</table>
</div>
{#snippet footer()}
<form class="flex flex-1 flex-wrap items-center gap-2" onsubmit={create}>
<input
aria-label="New tag"
class="cladd-cut cladd-focusable-input h-7 w-64 rounded-cladd-sm px-2 text-[13px]"
placeholder="New tag, parent/child works (c)"
bind:this={createInput}
bind:value={newTag}
>
<Button disabled={creating || !newTag.trim()} size="sm" type="submit"
>Create</Button
>
{#if createError}
<span class="text-expense">{createError}</span>
{/if}
<span
class="ml-auto flex flex-wrap gap-x-3 text-[12px] text-cladd-fg-soft"
>
<span><span class="kbd">r</span> rename</span>
<span><span class="kbd">m</span> merge</span>
<span
><span class="kbd">d</span><span class="kbd">d</span>
delete</span
>
</span>
</form>
{/snippet}
</Box>
</div>
+10
View File
@@ -0,0 +1,10 @@
<script lang="ts">
import PageHeader from "#lib/components/shell/PageHeader.svelte";
import TagForm from "#lib/components/tags/TagForm.svelte";
</script>
<PageHeader
crumbs={[{ href: "/tags", label: "Tags" }, { label: "Create" }]}
title="Create a tag"
/>
<TagForm />
+5
View File
@@ -0,0 +1,5 @@
import { redirect } from "@sveltejs/kit";
export function load({ params }: { params: { tag: string } }) {
redirect(307, `/tags/edit/${encodeURIComponent(params.tag)}`);
}
+31
View File
@@ -0,0 +1,31 @@
<script lang="ts">
import { createQuery } from "@tanstack/svelte-query";
import { api } from "#lib/api/client.js";
import type { One, Tag } from "#lib/api/types.js";
import PageHeader from "#lib/components/shell/PageHeader.svelte";
import TagForm from "#lib/components/tags/TagForm.svelte";
import { page } from "$app/state";
const id = $derived(page.params.tag ?? "");
const query = createQuery(() => ({
queryFn: () => api<One<Tag>>(`/v1/tags/${encodeURIComponent(id)}`),
queryKey: ["tag", id],
}));
const name = $derived(query.data?.data.attributes.tag ?? "");
</script>
<PageHeader
crumbs={[
{ href: "/tags", label: "Tags" },
{ href: `/tags/show/${query.data?.data.id ?? id}`, label: name || "Tag" },
{ label: "Edit" },
]}
title={name ? `Edit tag "${name}"` : "Edit tag"}
/>
{#if query.data}
{#key query.data.data.id}
<TagForm tag={query.data.data} />
{/key}
{:else if query.isError}
<p class="text-expense">{query.error.message}</p>
{/if}
@@ -0,0 +1,8 @@
<script lang="ts">
import ShowTag from "#lib/components/tags/ShowTag.svelte";
import { page } from "$app/state";
</script>
{#key page.params.tag}
<ShowTag param={page.params.tag ?? ""} />
{/key}
@@ -0,0 +1,8 @@
<script lang="ts">
import ShowTag from "#lib/components/tags/ShowTag.svelte";
import { page } from "$app/state";
</script>
{#key page.params.tag}
<ShowTag everything param={page.params.tag ?? ""} />
{/key}