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

@@ -0,0 +1,205 @@
<script lang="ts">
import { untrack } from "svelte";
import { api } from "#lib/api/client.js";
import { invalidate } from "#lib/api/lists.js";
import type { Budget, Item, One } from "#lib/api/types.js";
import Box from "#lib/components/Box.svelte";
import ConfirmButton from "#lib/components/ConfirmButton.svelte";
import Field from "#lib/components/categories/Field.svelte";
import {
fieldErrors,
SELECT_CLASS,
} from "#lib/components/categories/form.js";
import { Button } from "#lib/components/ui/button/index.js";
import { Input } from "#lib/components/ui/input/index.js";
import { Textarea } from "#lib/components/ui/textarea/index.js";
import { prettyKeys, useKeys } from "#lib/keys.svelte.js";
import { session } from "#lib/session.svelte.js";
import { goto } from "$app/navigation";
const TYPES = [
{ label: "No auto-budget", value: "none" },
{ label: "Set a fixed amount every period", value: "reset" },
{ label: "Add an amount every period", value: "rollover" },
{
label: "Add an amount every period and correct for overspending",
value: "adjusted",
},
];
const PERIODS = [
{ label: "Daily", value: "daily" },
{ label: "Weekly", value: "weekly" },
{ label: "Monthly", value: "monthly" },
{ label: "Quarterly", value: "quarterly" },
{ label: "Every half year", value: "half_year" },
{ label: "Yearly", value: "yearly" },
];
let { budget }: { budget?: Item<Budget> } = $props();
const initial = untrack(() => budget)?.attributes;
let name = $state(initial?.name ?? "");
let active = $state(initial?.active ?? true);
let autoType = $state<string>(initial?.auto_budget_type ?? "none");
let currency = $state(
initial?.currency_code ?? session.primary?.code ?? "EUR"
);
let autoAmount = $state(initial?.auto_budget_amount ?? "");
let autoPeriod = $state<string>(initial?.auto_budget_period ?? "monthly");
let notes = $state(initial?.notes ?? "");
let errors = $state<Record<string, string>>({});
let busy = $state(false);
let armed = $state(false);
const enabled = $derived(
session.currencies.filter((c) => c.attributes.enabled)
);
async function save(event?: Event) {
event?.preventDefault();
if (busy) {
return;
}
busy = true;
errors = {};
const auto =
autoType === "none"
? { auto_budget_type: "none" }
: {
auto_budget_amount: autoAmount,
auto_budget_currency_code: currency,
auto_budget_period: autoPeriod,
auto_budget_type: autoType,
};
const body = budget
? { active, name, notes: notes || null, ...auto }
: { name, ...auto };
try {
const saved = await api<One<Budget>>(
budget ? `/v1/budgets/${budget.id}` : "/v1/budgets",
{ body, method: budget ? "PUT" : "POST" }
);
await invalidate();
await goto(`/budgets/show/${saved.data.id}`);
} catch (failure) {
errors = fieldErrors(failure);
} finally {
busy = false;
}
}
async function remove() {
if (!budget) {
return;
}
await api(`/v1/budgets/${budget.id}`, { method: "DELETE" });
await invalidate();
await goto("/budgets");
}
useKeys(() => [
{
group: "Form",
input: true,
keys: "mod+Enter",
label: budget ? "Update budget" : "Store new budget",
run: () => save(),
},
]);
</script>
<form class="flex flex-col gap-4" onsubmit={save}>
<div class="grid gap-4 lg:grid-cols-2">
<Box title="Mandatory fields">
<Field id="name" issue={errors.name} label="Name">
<Input autofocus id="name" required bind:value={name} />
</Field>
{#if budget}
<Field id="active" issue={errors.active} label="Active">
<input
class="mt-1.5 size-4 accent-brand"
id="active"
type="checkbox"
bind:checked={active}
>
</Field>
{/if}
</Box>
<Box title="Optional fields">
<Field id="auto-type" issue={errors.auto_budget_type} label="Auto-budget">
<select class={SELECT_CLASS} id="auto-type" bind:value={autoType}>
{#each TYPES as type (type.value)}
<option value={type.value}>{type.label}</option>
{/each}
</select>
</Field>
{#if autoType !== "none"}
<Field
id="currency"
issue={errors.auto_budget_currency_code ?? errors.currency_code}
label="Currency"
>
<select class={SELECT_CLASS} id="currency" bind:value={currency}>
{#each enabled as cur (cur.id)}
<option value={cur.attributes.code}>
{cur.attributes.name}
({cur.attributes.symbol})
</option>
{/each}
</select>
</Field>
<Field
id="auto-amount"
issue={errors.auto_budget_amount}
label="Auto-budget amount"
>
<Input
class="num"
id="auto-amount"
inputmode="decimal"
bind:value={autoAmount}
/>
</Field>
<Field
id="auto-period"
issue={errors.auto_budget_period}
label="Auto-budget period"
>
<select class={SELECT_CLASS} id="auto-period" bind:value={autoPeriod}>
{#each PERIODS as option (option.value)}
<option value={option.value}>{option.label}</option>
{/each}
</select>
</Field>
{/if}
{#if budget}
<Field id="notes" issue={errors.notes} label="Notes">
<Textarea id="notes" rows={4} bind:value={notes} />
</Field>
{/if}
</Box>
</div>
<div class="flex items-center gap-3">
<Button disabled={busy} type="submit">
{budget ? "Update budget" : "Store new budget"}
</Button>
<span class="flex gap-1 text-[12px] text-cladd-fg-soft">
{#each prettyKeys("mod+Enter") as key (key)}
<span class="kbd">{key}</span>
{/each}
</span>
{#if busy}
<span class="text-[12px] text-cladd-fg-soft">Saving</span>
{:else if errors.form}
<span class="text-[12px] text-expense">{errors.form}</span>
{/if}
{#if budget}
<span class="ml-auto text-[12px] text-cladd-fg-soft">
{#if armed}
Its transactions stay, without a budget.
{/if}
</span>
<ConfirmButton onconfirm={remove} bind:armed>Delete budget</ConfirmButton>
{/if}
</div>
</form>
+148
View File
@@ -0,0 +1,148 @@
<script lang="ts">
import { untrack } from "svelte";
import { ApiError } from "#lib/api/client.js";
import { invalidate } from "#lib/api/lists.js";
import type { Range } from "#lib/dates.js";
import { formatMoney } from "#lib/format.js";
import { type Line, parseAmount, saveLimit } from "./data.js";
export type Move = 0 | 1 | -1;
let {
line,
budgetId,
range,
editing,
trap = true,
onedit,
ondone,
}: {
line: Line;
budgetId: string;
range: Range;
editing: boolean;
trap?: boolean;
onedit: () => void;
ondone: (move: Move) => void;
} = $props();
let value = $state("");
let busy = $state(false);
let problem = $state("");
$effect(() => {
if (editing) {
untrack(() => {
value = line.amount ? String(line.amount) : "";
problem = "";
});
}
});
function focus(input: HTMLInputElement) {
input.focus();
input.select();
}
function message(failure: unknown) {
if (failure instanceof ApiError) {
return Object.values(failure.errors).flat()[0] ?? failure.message;
}
return failure instanceof Error ? failure.message : String(failure);
}
async function commit(move: Move) {
if (busy) {
return;
}
const amount = parseAmount(value);
if (amount === null) {
problem = "Type a positive number";
return;
}
if (amount === (line.amount ?? 0)) {
ondone(move);
return;
}
busy = true;
problem = "";
try {
await saveLimit(budgetId, line, amount, range);
await invalidate();
ondone(move);
} catch (failure) {
problem = message(failure);
} finally {
busy = false;
}
}
function onkeydown(event: KeyboardEvent) {
if (event.key === "Enter") {
event.preventDefault();
commit(0);
} else if (event.key === "Tab" && trap) {
event.preventDefault();
commit(event.shiftKey ? -1 : 1);
} else if (event.key === "Escape") {
event.preventDefault();
ondone(0);
}
}
function onblur(event: FocusEvent) {
const next = event.relatedTarget;
if (
!editing ||
busy ||
(next instanceof Element && next.closest("[data-limit-editor]"))
) {
return;
}
const amount = parseAmount(value);
if (amount === (line.amount ?? 0)) {
ondone(0);
} else {
commit(0);
}
}
</script>
{#if editing}
<span class="inline-flex items-center gap-1">
<span class="num text-cladd-fg-softer">{line.cur.symbol}</span>
<input
aria-label="Budgeted amount in {line.cur.code}"
class="num cladd-cut cladd-focusable-input h-6 w-28 rounded-cladd-sm px-1.5 text-right text-[13px] text-cladd-fg"
disabled={busy}
inputmode="decimal"
{onblur}
{onkeydown}
placeholder="0"
bind:value
{@attach focus}
>
</span>
{:else}
<button
class="num border-cladd-fg-softest border-b border-dashed leading-tight hover:border-brand hover:text-brand {line.amount
? "text-cladd-fg"
: "text-cladd-fg-softer"}"
onclick={onedit}
title="Set the budgeted amount (e)"
type="button"
>
{line.amount
? formatMoney({
amount: line.amount,
decimals: line.cur.decimals,
symbol: line.cur.symbol,
})
: `${line.cur.symbol} set amount`}
</button>
{/if}
{#if busy}
<span class="text-[11px] text-cladd-fg-soft">Saving</span>
{:else if problem}
<span class="text-[11px] text-expense">{problem}</span>
{/if}
@@ -0,0 +1,42 @@
<script lang="ts">
let {
value,
max,
tick = null,
class: className = "",
}: {
value: number;
max: number;
tick?: number | null;
class?: string;
} = $props();
const ratio = $derived(max > 0 ? value / max : 0);
const over = $derived(ratio > 1);
const fill = $derived(over ? 1 / ratio : Math.min(1, ratio));
const ahead = $derived(tick !== null && !over && ratio > tick);
</script>
<div
aria-hidden="true"
class="relative h-2 overflow-hidden rounded-full bg-cladd-surface-next {className}"
>
<div
class="absolute inset-y-0 left-0 rounded-full {over || ahead
? "bg-warn"
: "bg-brand"}"
style:width="{fill * 100}%"
></div>
{#if over}
<div
class="absolute inset-y-0 right-0 bg-expense"
style:left="{fill * 100}%"
></div>
{/if}
{#if tick !== null}
<div
class="absolute -inset-y-px w-0.5 bg-cladd-fg"
style:left="calc({Math.min(1, tick) * 100}% - 1px)"
></div>
{/if}
</div>
@@ -0,0 +1,68 @@
<script lang="ts">
import { createQuery } from "@tanstack/svelte-query";
import { all } from "#lib/api/lists.js";
import type { TransactionGroup } from "#lib/api/types.js";
import PeriodList from "#lib/components/categories/PeriodList.svelte";
import { windowOf } from "#lib/components/categories/sums.js";
import PageHeader from "#lib/components/shell/PageHeader.svelte";
import { formatDate } from "#lib/format.js";
import { period } from "#lib/period.svelte.js";
import { periodPath, usePeriodPath } from "./period-path.svelte.js";
const BASE = "/budgets/list/no-budget";
let { everything = false }: { everything?: boolean } = $props();
usePeriodPath(() => BASE);
const span = $derived(windowOf(period.end, period.view));
const query = createQuery(() => ({
enabled: period.ready,
queryFn: () =>
all<TransactionGroup>(
"/v1/budgets/transactions-without-budget",
everything
? { type: "withdrawal" }
: { end: span.end, start: span.start, type: "withdrawal" }
),
queryKey: everything
? ["no-budget", "all"]
: ["no-budget", span.start, span.end],
}));
const title = $derived(
everything
? "All transactions without a budget"
: `Transactions without a budget between ${formatDate(period.start)} and ${formatDate(period.end)}`
);
</script>
<PageHeader
crumbs={[
{ href: "/budgets", label: "Budgets" },
{ label: "Expenses without a budget" },
]}
title="Expenses without a budget"
/>
<PeriodList
fields={["spent"]}
groups={query.data ?? []}
href={(range) => periodPath(BASE, range.start, range.end)}
keep={(split) => !split.budget_id && split.type === "withdrawal"}
loading={query.isPending}
sidebar={!everything}
{title}
>
{#snippet links()}
{#if everything}
<a class="text-brand hover:underline" href={BASE}
>Show the current period and overview</a
>
{:else}
<a class="text-brand hover:underline" href="{BASE}/all"
>Show all transactions without grouping them by date</a
>
{/if}
{/snippet}
</PeriodList>
@@ -0,0 +1,82 @@
<script lang="ts">
import ChevronLeft from "@lucide/svelte/icons/chevron-left";
import ChevronRight from "@lucide/svelte/icons/chevron-right";
import { isDynamic, type Range, shiftPeriod } from "#lib/dates.js";
import { period, periodLabel } from "#lib/period.svelte.js";
import { goto } from "$app/navigation";
import { periodPath } from "./period-path.svelte.js";
const COUNT = 12;
let { base }: { base: string } = $props();
const view = $derived(isDynamic(period.view) ? "1M" : period.view);
function walk(dir: 1 | -1): Range[] {
const out: Range[] = [];
let { range } = period;
for (let i = 0; i < COUNT; i += 1) {
range = shiftPeriod(range, view, dir);
out.push(range);
}
return out;
}
const before = $derived(walk(-1));
const after = $derived(walk(1));
const href = (range: Range) => periodPath(base, range.start, range.end);
function pick(event: Event) {
const select = event.currentTarget as HTMLSelectElement;
if (select.value) {
goto(select.value);
}
}
</script>
<nav
aria-label="Period"
class="grid items-center gap-3 pb-3 sm:grid-cols-[1fr_auto_1fr]"
>
<select
aria-label="Earlier periods"
class="cladd-cut h-7 max-w-44 rounded-cladd-sm px-2 text-[12px] text-cladd-fg-soft max-sm:hidden"
onchange={pick}
>
<option value="">Select date..</option>
{#each before as range (range.start)}
<option value={href(range)}>{periodLabel(range, view)}</option>
{/each}
</select>
<div class="flex items-center gap-1 justify-self-center">
<a
aria-label="Previous period"
class="grid size-7 place-items-center rounded-cladd-sm text-cladd-fg-soft hover:bg-cladd-surface-next hover:text-cladd-fg"
href={href(before[0])}
title="Previous period ["
>
<ChevronLeft class="size-4" />
</a>
<span class="min-w-40 text-center font-semibold text-[14px] text-cladd-fg"
>{period.label}</span
>
<a
aria-label="Next period"
class="grid size-7 place-items-center rounded-cladd-sm text-cladd-fg-soft hover:bg-cladd-surface-next hover:text-cladd-fg"
href={href(after[0])}
title="Next period ]"
>
<ChevronRight class="size-4" />
</a>
</div>
<select
aria-label="Later periods"
class="cladd-cut h-7 max-w-44 justify-self-end rounded-cladd-sm px-2 text-[12px] text-cladd-fg-soft max-sm:hidden"
onchange={pick}
>
<option value="">Select date..</option>
{#each after as range (range.start)}
<option value={href(range)}>{periodLabel(range, view)}</option>
{/each}
</select>
</nav>
+146
View File
@@ -0,0 +1,146 @@
<script lang="ts">
import Box from "#lib/components/Box.svelte";
import { amountIn, type Sum } from "#lib/components/categories/sums.js";
import { tones } from "#lib/components/charts/chart.js";
import Meter from "#lib/components/charts/Meter.svelte";
import { formatMoney, formatRange, percent } from "#lib/format.js";
import type { Pool } from "./data.js";
let {
pool,
total,
noBudget,
noBudgetCount,
noBudgetHref,
}: {
pool: Pool;
total: Sum[];
noBudget: Sum[];
noBudgetCount: number;
noBudgetHref: string;
} = $props();
let explain = $state(false);
const money = (amount: number) =>
formatMoney({
amount,
decimals: pool.cur.decimals,
symbol: pool.cur.symbol,
});
const spent = $derived(-pool.spent);
const left = $derived(pool.budgeted - spent);
const allSpent = $derived(-amountIn(total, pool.cur.code));
const outside = $derived(-amountIn(noBudget, pool.cur.code));
const available = $derived(pool.available ?? 0);
const planned = $derived(pool.available !== null || pool.budgeted > 0);
const budgetedSegments = $derived(
pool.budgeted > available && available > 0
? [
{ color: tones.transfer, value: available },
{ color: tones.expense, value: pool.budgeted - available },
]
: [{ color: tones.transfer, value: pool.budgeted }]
);
const spentSegments = $derived(
spent > pool.budgeted && pool.budgeted > 0
? [
{ color: tones.warn, value: pool.budgeted },
{ color: tones.expense, value: spent - pool.budgeted },
]
: [{ color: "var(--ff-brand)", value: spent }]
);
</script>
<Box
body="flex flex-col gap-3 p-3"
title="Total available budget in {pool.cur.code}"
>
{#snippet tools()}
{#if pool.availableRange}
<span class="text-[12px] text-cladd-fg-soft"
>{formatRange(pool.availableRange.start, pool.availableRange.end)}</span
>
{/if}
{/snippet}
{#if !planned}
<p class="text-[13px] text-cladd-fg-soft">
Nothing budgeted in {pool.cur.code} for this period. Budgets spent
<span class="num text-expense">{money(spent)}</span>
in {pool.cur.code}.
</p>
{:else}
<div
class="grid grid-cols-[auto_auto_minmax(0,1fr)] items-center gap-x-4 gap-y-2 text-[13px]"
>
<span class="text-cladd-fg-soft">Available</span>
<button
class="num justify-self-end border-cladd-fg-softest border-b border-dashed text-right font-semibold text-cladd-fg"
onclick={() => (explain = !explain)}
type="button"
>
{pool.available === null ? "not set" : money(available)}
</button>
<span class="text-[12px] text-cladd-fg-soft">
{#if explain}
Firefly works this out from the amounts budgeted below. It can't be
edited here or in the API.
{/if}
</span>
<span class="text-cladd-fg-soft">Budgeted</span>
<span class="num justify-self-end font-semibold text-cladd-fg"
>{money(pool.budgeted)}</span
>
<div class="flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1">
<div class="min-w-16 flex-1 basis-24">
<Meter max={available || pool.budgeted} segments={budgetedSegments} />
</div>
{#if available > 0}
<span class="num shrink-0 text-[12px] text-cladd-fg-soft"
>{percent(pool.budgeted / available)}
of available</span
>
{/if}
</div>
<span class="text-cladd-fg-soft">Spent</span>
<span class="num justify-self-end font-semibold text-expense"
>{money(spent)}</span
>
<div class="flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1">
<div class="min-w-16 flex-1 basis-24">
<Meter max={pool.budgeted || spent} segments={spentSegments} />
</div>
{#if pool.budgeted > 0}
<span
class="num shrink-0 text-[12px] {left < 0
? "text-expense"
: "text-cladd-fg-soft"}"
>{left < 0
? `${money(-left)} overspent`
: `${money(left)} left`}</span
>
{/if}
</div>
</div>
{/if}
{#if allSpent > 0}
<p
class="mt-auto border-cladd-outline border-t pt-2 text-[12px] text-cladd-fg-soft"
>
All expenses in {pool.cur.code}:
<span class="num text-cladd-fg">{money(allSpent)}</span>.
{#if outside > 0}
<a class="text-brand hover:underline" href={noBudgetHref}
><span class="num">{money(outside)}</span>
of it has no budget{noBudgetCount
? ` (${noBudgetCount} transactions in all currencies)`
: ""}</a
>
{/if}
</p>
{/if}
</Box>
@@ -0,0 +1,354 @@
<script lang="ts">
import { createQuery } from "@tanstack/svelte-query";
import { api } from "#lib/api/client.js";
import { all, invalidate } from "#lib/api/lists.js";
import type {
Budget,
BudgetLimit,
One,
TransactionGroup,
} from "#lib/api/types.js";
import Box from "#lib/components/Box.svelte";
import ConfirmButton from "#lib/components/ConfirmButton.svelte";
import BreakdownBox from "#lib/components/categories/BreakdownBox.svelte";
import CurrencyTabs from "#lib/components/categories/CurrencyTabs.svelte";
import GroupsBox from "#lib/components/categories/GroupsBox.svelte";
import {
breakdown,
curOfSplit,
dayOf,
firstDay,
fromEntries,
inRange,
periodRanges,
rows as splitRows,
stepLabel,
} from "#lib/components/categories/sums.js";
import { tones, wash } from "#lib/components/charts/chart.js";
import LineChart from "#lib/components/charts/LineChart.svelte";
import Meter from "#lib/components/charts/Meter.svelte";
import StackedBars from "#lib/components/charts/StackedBars.svelte";
import PageHeader from "#lib/components/shell/PageHeader.svelte";
import { Button } from "#lib/components/ui/button/index.js";
import { addDays, contains, today } from "#lib/dates.js";
import { formatMoney, formatRange, toNumber } from "#lib/format.js";
import { useKeys } from "#lib/keys.svelte.js";
import { period } from "#lib/period.svelte.js";
import { goto } from "$app/navigation";
import { page } from "$app/state";
import { limitRange } from "./data.js";
let { id, limitId }: { id: string; limitId?: string } = $props();
const budget = createQuery(() => ({
queryFn: () => api<One<Budget>>(`/v1/budgets/${id}`),
queryKey: ["budget", id],
}));
const limits = createQuery(() => ({
queryFn: () => all<BudgetLimit>(`/v1/budgets/${id}/limits`),
queryKey: ["budget-limits", id],
}));
const groups = createQuery(() => ({
queryFn: () => all<TransactionGroup>(`/v1/budgets/${id}/transactions`),
queryKey: ["budget-transactions", id],
}));
const name = $derived(budget.data?.data.attributes.name ?? "");
const sortedLimits = $derived(
[...(limits.data ?? [])].sort((a, b) =>
(b.attributes.start ?? "").localeCompare(a.attributes.start ?? "")
)
);
const limit = $derived(sortedLimits.find((l) => l.id === limitId));
const range = $derived(limit ? limitRange(limit) : null);
const allRows = $derived(
splitRows(groups.data ?? [], (s) => s.budget_id === id)
);
const scoped = $derived(
limit && range
? inRange(allRows, range).filter(
(r) => r.split.currency_code === limit.attributes.currency_code
)
: allRows
);
const spentRows = $derived(
scoped.filter((r) => r.split.type === "withdrawal")
);
const codes = $derived(
[...new Set(spentRows.map((r) => r.split.currency_code ?? ""))].filter(
Boolean
)
);
const code = $derived(
limit?.attributes.currency_code ??
(codes.includes(page.url.searchParams.get("currency") ?? "")
? page.url.searchParams.get("currency")
: codes[0]) ??
""
);
const inCur = $derived(
spentRows.filter((r) => r.split.currency_code === code)
);
const cur = $derived(
inCur[0]
? curOfSplit(inCur[0].split)
: {
code,
decimals: limit?.attributes.currency_decimal_places ?? 2,
symbol: limit?.attributes.currency_symbol ?? code,
}
);
const fmt = (value: number) =>
formatMoney({ amount: value, decimals: cur.decimals, symbol: cur.symbol });
const visibleGroups = $derived.by(() => {
if (!range) {
return groups.data ?? [];
}
const ids = new Set(scoped.map((r) => r.group.id));
return (groups.data ?? []).filter((g) => ids.has(g.id));
});
const view = $derived(period.view);
const history = $derived.by(() => {
const first = firstDay(inCur) ?? period.start;
const end = period.end > first ? period.end : today();
const ranges = periodRanges(end, view, 400)
.filter((r) => r.end >= first)
.reverse();
return ranges.map((r) => {
const spent = inRange(inCur, r).reduce(
(sum, row) => sum + toNumber(row.split.amount),
0
);
const own = sortedLimits.find(
(l) =>
l.attributes.currency_code === code &&
contains(r, l.attributes.start ?? "")
);
const amount = own ? toNumber(own.attributes.amount) : 0;
return {
format: fmt,
href: own ? `/budgets/show/${id}/${own.id}` : undefined,
key: r.start,
label: stepLabel(r, view),
values: amount
? {
left: Math.max(0, amount - spent),
over: Math.max(0, spent - amount),
spent: Math.min(spent, amount),
}
: { left: 0, over: 0, spent },
};
});
});
const historyKeys = [
{ color: "var(--ff-brand)", key: "spent", label: "Spent" },
{ color: wash("var(--ff-brand)", 28), key: "left", label: "Left" },
{ color: tones.expense, key: "over", label: "Overspent" },
];
const burn = $derived.by(() => {
if (!(limit && range)) {
return [];
}
const amount = toNumber(limit.attributes.amount);
const last = range.end < today() ? range.end : today();
const byDay = new Map<string, number>();
for (const row of inCur) {
byDay.set(
dayOf(row),
(byDay.get(dayOf(row)) ?? 0) + toNumber(row.split.amount)
);
}
const points: { x: string; y: number }[] = [];
let left = amount;
for (let d = range.start; d <= last; d = addDays(d, 1)) {
left -= byDay.get(d) ?? 0;
points.push({ x: d, y: left });
}
return [
{
area: true,
format: fmt,
key: "left",
label: "Left",
points,
strong: true,
},
{
color: "var(--cladd-fg-softer)",
format: fmt,
key: "pace",
label: "Even pace",
points: [
{ x: range.start, y: amount },
{ x: range.end, y: 0 },
],
},
];
});
const byCategory = $derived(
breakdown(inCur, (s) => ({
href: s.category_id ? `/categories/show/${s.category_id}` : undefined,
label: s.category_name ?? "(no category)",
}))
);
const bySource = $derived(
breakdown(inCur, (s) => ({
href: s.source_id ? `/accounts/show/${s.source_id}` : undefined,
label: s.source_name ?? "",
}))
);
const byDestination = $derived(
breakdown(inCur, (s) => ({
href: s.destination_id ? `/accounts/show/${s.destination_id}` : undefined,
label: s.destination_name ?? "",
}))
);
async function remove() {
await api(`/v1/budgets/${id}`, { method: "DELETE" });
await invalidate();
await goto("/budgets");
}
useKeys(() => [
{
group: "Budget",
keys: "E",
label: "Edit budget",
run: () => goto(`/budgets/edit/${id}`),
},
]);
</script>
<PageHeader
crumbs={[{ href: "/budgets", label: "Budgets" }, { label: name || "Budget" }]}
subtitle={limit && range
? `${formatRange(range.start, range.end)} in ${limit.attributes.currency_code}`
: undefined}
title={name || "Budget"}
>
{#snippet actions()}
<Button
href="/budgets/edit/{id}"
size="sm"
title="Edit budget (E)"
variant="outline"
>Edit</Button
>
<ConfirmButton onconfirm={remove}>Delete</ConfirmButton>
{/snippet}
</PageHeader>
<div class="flex flex-col gap-4">
{#if limit && range}
<Box
title="Chart for all transactions for budget &quot;{name}&quot; between {formatRange(
range.start,
range.end
)} in {cur.code}"
>
{#if burn[0]?.points.length}
<LineChart
label="Amount left in this budget"
marker={contains(range, today()) ? today() : undefined}
series={burn}
zero
/>
{:else}
<p class="text-[12px] text-cladd-fg-soft">Nothing spent yet.</p>
{/if}
</Box>
{:else}
<Box title="Chart of all transactions for budget &quot;{name}&quot;">
{#snippet tools()}
<CurrencyTabs {codes} current={code} />
{/snippet}
{#if history.length > 0}
<StackedBars
format={fmt}
keys={historyKeys}
label="Spent per period against the budgeted amount"
rows={history}
/>
{:else}
<p class="text-[12px] text-cladd-fg-soft">No expenses yet.</p>
{/if}
</Box>
{/if}
<div class="grid gap-4 lg:grid-cols-3">
<BreakdownBox items={byCategory} title="Expenses by category" />
<BreakdownBox items={bySource} title="Expenses by asset account" />
<BreakdownBox
items={byDestination}
title="Expenses by destination account"
/>
</div>
<div class="grid items-start gap-4 2xl:grid-cols-[minmax(0,1fr)_16rem]">
<GroupsBox
groups={visibleGroups}
loading={groups.isPending}
title="Transactions"
>
{#snippet links()}
{#if limit}
<a class="text-brand hover:underline" href="/budgets/show/{id}"
>Show everything</a
>
{/if}
{/snippet}
</GroupsBox>
<nav
aria-label="Budgeted amounts"
class="grid grid-cols-[repeat(auto-fill,minmax(12rem,1fr))] gap-2 2xl:grid-cols-1"
>
{#each sortedLimits as item (item.id)}
{@const r = limitRange(item)}
{@const amount = toNumber(item.attributes.amount)}
{@const spent = -(
fromEntries(item.attributes.spent).find(
(s) => s.code === item.attributes.currency_code
)?.amount ?? 0
)}
{@const money = (value: number) =>
formatMoney({
amount: value,
decimals: item.attributes.currency_decimal_places,
symbol: item.attributes.currency_symbol,
})}
<a
aria-current={item.id === limitId ? "page" : undefined}
class="cladd-surface cladd-outline flex flex-col gap-1 rounded-cladd-md px-3 py-2 text-[12px] {item.id ===
limitId
? "bg-row-cursor outline-2 outline-brand"
: "hover:bg-row-hover"}"
href="/budgets/show/{id}/{item.id}"
>
<span class="font-semibold text-cladd-fg"
>{formatRange(r.start, r.end)}</span
>
<span class="flex justify-between gap-2">
<span class="num text-expense">{money(spent)}</span>
<span class="num text-cladd-fg-soft">of {money(amount)}</span>
</span>
<Meter
height={6}
max={amount}
segments={spent > amount
? [
{ color: tones.warn, value: amount },
{ color: tones.expense, value: spent - amount },
]
: [{ color: "var(--ff-brand)", value: spent }]}
/>
</a>
{/each}
</nav>
</div>
</div>
+283
View File
@@ -0,0 +1,283 @@
import { api } from "#lib/api/client.js";
import { all } from "#lib/api/lists.js";
import type {
AvailableBudget,
Budget,
BudgetLimit,
Currency,
InsightTotalEntry,
Item,
Page,
TransactionGroup,
} from "#lib/api/types.js";
import {
add,
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 { toNumber } from "#lib/format.js";
export interface Line {
amount: number | null;
cur: Cur & { id: string };
key: string;
limit?: Item<BudgetLimit>;
range: Range;
spent: number;
}
export interface BudgetRow {
budget: Item<Budget>;
lines: Line[];
}
export interface Pool {
available: number | null;
availableRange: Range | null;
budgeted: number;
cur: Cur;
spent: number;
}
export interface Pace {
expected: number;
fraction: number;
over: number;
}
export interface BudgetPage {
available: Item<AvailableBudget>[];
budgets: Item<Budget>[];
limits: Item<BudgetLimit>[];
noBudget: Sum[];
noBudgetCount: number;
total: Sum[];
}
export const day = (value: string | undefined) => (value ?? "").slice(0, 10);
export const limitRange = (limit: Item<BudgetLimit>): Range => ({
end: day(limit.attributes.end),
start: day(limit.attributes.start),
});
export function curOf(
currencies: Item<Currency>[],
code: string | undefined
): Cur & { id: string } {
const found = currencies.find((c) => c.attributes.code === code);
return {
code: code ?? "",
decimals: found?.attributes.decimal_places ?? 2,
id: found?.id ?? "",
symbol: found?.attributes.symbol ?? code ?? "",
};
}
function insightSums(
entries: InsightTotalEntry[],
currencies: Item<Currency>[]
): Sum[] {
const sums: Sums = {};
for (const entry of entries) {
add(
sums,
curOf(currencies, entry.currency_code),
toNumber(entry.difference)
);
}
return list(sums);
}
export async function loadBudgetPage(
range: Range,
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" },
}),
]);
return {
available,
budgets,
limits,
noBudget: insightSums(noBudget, currencies),
noBudgetCount: noBudgetPage.meta.pagination?.total ?? 0,
total: insightSums(total, currencies),
};
}
const byOrder = (a: Item<Budget>, b: Item<Budget>) =>
(a.attributes.order ?? 0) - (b.attributes.order ?? 0) ||
a.attributes.name.localeCompare(b.attributes.name);
export function buildRows(
data: BudgetPage,
range: Range,
currencies: Item<Currency>[],
primary: string
): BudgetRow[] {
return data.budgets
.filter((budget) => budget.attributes.active)
.sort(byOrder)
.map((budget) => {
const spent = fromEntries(budget.attributes.spent);
const lines: Line[] = data.limits
.filter((limit) => limit.attributes.budget_id === budget.id)
.map((limit) => {
const code = limit.attributes.currency_code;
const own = fromEntries(limit.attributes.spent);
return {
amount: toNumber(limit.attributes.amount),
cur: curOf(currencies, code),
key: `${budget.id}:${limit.id}`,
limit,
range: limitRange(limit),
spent: own.find((s) => s.code === code)?.amount ?? 0,
};
});
for (const sum of spent) {
if (!lines.some((line) => line.cur.code === sum.code)) {
lines.push({
amount: null,
cur: curOf(currencies, sum.code),
key: `${budget.id}:${sum.code}`,
range,
spent: sum.amount,
});
}
}
if (lines.length === 0) {
lines.push({
amount: null,
cur: curOf(currencies, primary),
key: `${budget.id}:${primary}`,
range,
spent: 0,
});
}
return { budget, lines };
});
}
export function buildPools(
data: BudgetPage,
rows: BudgetRow[],
currencies: Item<Currency>[],
primary: string
): Pool[] {
const pools = new Map<string, Pool>();
const pool = (code: string) => {
let found = pools.get(code);
if (!found) {
found = {
available: null,
availableRange: null,
budgeted: 0,
cur: curOf(currencies, code),
spent: 0,
};
pools.set(code, found);
}
return found;
};
for (const item of data.available) {
const target = pool(item.attributes.currency_code ?? primary);
target.available =
(target.available ?? 0) + toNumber(item.attributes.amount);
target.availableRange = {
end: day(item.attributes.end),
start: day(item.attributes.start),
};
}
for (const row of rows) {
for (const line of row.lines) {
const target = pool(line.cur.code);
target.budgeted += line.amount ?? 0;
target.spent += line.spent;
}
}
const result = [...pools.values()].filter(
(p) => p.available !== null || p.budgeted !== 0 || p.spent !== 0
);
return result.length > 0 ? result : [pool(primary)];
}
export function pace(line: Line, now = today()): Pace | null {
if (!(line.amount && contains(line.range, now))) {
return null;
}
const total = daysBetween(line.range.start, line.range.end) + 1;
const elapsed = daysBetween(line.range.start, now) + 1;
const expected = (line.amount * elapsed) / total;
return { expected, fraction: elapsed / total, over: -line.spent - expected };
}
export function perDay(line: Line, now = today()) {
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 SPACES = /\s/g;
export const parseAmount = (raw: string): number | null => {
const clean = raw.replace(SPACES, "").replace(",", ".");
if (clean === "") {
return 0;
}
const value = Number(clean);
return Number.isFinite(value) && value >= 0 ? value : null;
};
export async function saveLimit(
budgetId: string,
line: Line,
amount: number,
range: Range
) {
const { limit } = line;
if (limit && amount === 0) {
await api(`/v1/budgets/${budgetId}/limits/${limit.id}`, {
method: "DELETE",
});
return;
}
if (limit) {
await api(`/v1/budgets/${budgetId}/limits/${limit.id}`, {
body: { amount: String(amount) },
method: "PUT",
});
return;
}
if (amount === 0) {
return;
}
await api(`/v1/budgets/${budgetId}/limits`, {
body: {
amount: String(amount),
currency_code: line.cur.code,
end: range.end,
start: range.start,
},
method: "POST",
});
}
@@ -0,0 +1,34 @@
import { untrack } from "svelte";
import { period } from "#lib/period.svelte.js";
import { goto } from "$app/navigation";
import { page } from "$app/state";
export function periodPath(base: string, start: string, end: string) {
return `${base}/${start}/${end}?start=${start}&end=${end}`;
}
export function usePeriodPath(base: () => string) {
$effect(() => {
const { start, end } = page.params;
if (period.ready && start && end && start <= end) {
untrack(() => period.set({ end, start }));
}
});
$effect(() => {
if (!period.ready) {
return;
}
const { start, end } = period;
untrack(() => {
const { start: from, end: to } = page.params;
if (from && to && (from !== start || to !== end)) {
const target = base();
setTimeout(() => {
if (page.url.pathname.startsWith(`${target}/${from}`)) {
goto(periodPath(target, start, end), { replace: true });
}
});
}
});
});
}
@@ -0,0 +1,64 @@
<script lang="ts">
import Box from "#lib/components/Box.svelte";
import BarChart from "#lib/components/charts/BarChart.svelte";
import { formatMoney } from "#lib/format.js";
import type { Breakdown } from "./sums.js";
const TOP = 8;
let {
title,
items,
color = "var(--ff-expense)",
}: { title: string; items: Breakdown[]; color?: string } = $props();
function bars(item: Breakdown) {
const top = item.bars.slice(0, TOP);
const rest = item.bars.slice(TOP).reduce((sum, bar) => sum + bar.amount, 0);
const format = (value: number) =>
formatMoney({
amount: value,
decimals: item.cur.decimals,
symbol: item.cur.symbol,
});
const list = top.map((bar) => ({
format,
href: bar.href,
key: bar.label,
label: bar.label,
value: bar.amount,
}));
if (rest > 0) {
list.push({
format,
href: undefined,
key: "\u0000other",
label: `${item.bars.length - TOP} more`,
value: rest,
});
}
return list;
}
</script>
<Box {title}>
{#if items.length === 0}
<p class="text-[12px] text-cladd-fg-soft">Nothing in this range.</p>
{/if}
<div class="flex flex-col gap-3">
{#each items as item (item.cur.code)}
{#if items.length > 1}
<h3 class="font-medium text-[12px] text-cladd-fg-soft">
{item.cur.code}
</h3>
{/if}
<BarChart
bars={bars(item)}
{color}
label={title}
labelWidth="minmax(5rem, 10rem)"
orientation="horizontal"
/>
{/each}
</div>
</Box>
@@ -0,0 +1,107 @@
<script lang="ts">
import { untrack } from "svelte";
import { api } from "#lib/api/client.js";
import { invalidate } from "#lib/api/lists.js";
import type { Category, Item, One } from "#lib/api/types.js";
import Box from "#lib/components/Box.svelte";
import ConfirmButton from "#lib/components/ConfirmButton.svelte";
import { Button } from "#lib/components/ui/button/index.js";
import { Input } from "#lib/components/ui/input/index.js";
import { Textarea } from "#lib/components/ui/textarea/index.js";
import { prettyKeys, useKeys } from "#lib/keys.svelte.js";
import { goto } from "$app/navigation";
import Field from "./Field.svelte";
import { fieldErrors } from "./form.js";
let { category }: { category?: Item<Category> } = $props();
const initial = untrack(() => category)?.attributes;
let name = $state(initial?.name ?? "");
let notes = $state(initial?.notes ?? "");
let errors = $state<Record<string, string>>({});
let busy = $state(false);
let armed = $state(false);
async function save(event?: Event) {
event?.preventDefault();
if (busy) {
return;
}
busy = true;
errors = {};
try {
const saved = await api<One<Category>>(
category ? `/v1/categories/${category.id}` : "/v1/categories",
{
body: { name, notes: notes || null },
method: category ? "PUT" : "POST",
}
);
await invalidate();
await goto(`/categories/show/${saved.data.id}`);
} catch (failure) {
errors = fieldErrors(failure);
} finally {
busy = false;
}
}
async function remove() {
if (!category) {
return;
}
await api(`/v1/categories/${category.id}`, { method: "DELETE" });
await invalidate();
await goto("/categories");
}
useKeys(() => [
{
group: "Form",
input: true,
keys: "mod+Enter",
label: category ? "Update category" : "Store new category",
run: () => save(),
},
]);
</script>
<form class="flex flex-col gap-4" onsubmit={save}>
<div class="grid gap-4 lg:grid-cols-2">
<Box title="Mandatory fields">
<Field id="name" issue={errors.name} label="Name">
<Input autofocus id="name" required bind:value={name} />
</Field>
</Box>
<Box title="Optional fields">
<Field id="notes" issue={errors.notes} label="Notes">
<Textarea id="notes" rows={4} bind:value={notes} />
</Field>
</Box>
</div>
<div class="flex items-center gap-3">
<Button disabled={busy} type="submit">
{category ? "Update category" : "Store new category"}
</Button>
<span class="flex gap-1 text-[12px] text-cladd-fg-soft">
{#each prettyKeys("mod+Enter") as key (key)}
<span class="kbd">{key}</span>
{/each}
</span>
{#if busy}
<span class="text-[12px] text-cladd-fg-soft">Saving</span>
{:else if errors.form}
<span class="text-[12px] text-expense">{errors.form}</span>
{/if}
{#if category}
<span class="ml-auto text-[12px] text-cladd-fg-soft">
{#if armed}
Its transactions stay, without a category.
{/if}
</span>
<ConfirmButton onconfirm={remove} bind:armed
>Delete category</ConfirmButton
>
{/if}
</div>
</form>
@@ -0,0 +1,28 @@
<script lang="ts">
import { page } from "$app/state";
let { codes, current }: { codes: string[]; current: string } = $props();
function href(code: string) {
const url = new URL(page.url.href);
url.searchParams.set("currency", code);
return url.pathname + url.search;
}
</script>
{#if codes.length > 1}
<nav aria-label="Currency" class="flex gap-0.5 text-[12px]">
{#each codes as code (code)}
<a
aria-current={code === current ? "true" : undefined}
class="num rounded-cladd-sm px-1.5 py-0.5 {code === current
? "bg-row-selected font-semibold text-cladd-fg"
: "text-cladd-fg-soft hover:bg-row-hover"}"
data-sveltekit-noscroll
data-sveltekit-replacestate
href={href(code)}
>{code}</a
>
{/each}
</nav>
{/if}
@@ -0,0 +1,27 @@
<script lang="ts">
import type { Snippet } from "svelte";
let {
label,
id,
issue,
children,
}: {
label: string;
id: string;
issue?: string;
children: Snippet;
} = $props();
</script>
<div
class="grid items-start gap-x-4 gap-y-1 py-1.5 sm:grid-cols-[10rem_minmax(0,1fr)]"
>
<label class="pt-1 text-[13px] text-cladd-fg-soft" for={id}>{label}</label>
<div class="flex min-w-0 flex-col gap-1">
{@render children()}
{#if issue}
<span class="text-[12px] text-expense">{issue}</span>
{/if}
</div>
</div>
@@ -0,0 +1,47 @@
<script lang="ts">
import type { Snippet } from "svelte";
import Box from "#lib/components/Box.svelte";
import Pager from "#lib/components/transactions/Pager.svelte";
import TransactionTable from "#lib/components/transactions/TransactionTable.svelte";
import { session } from "#lib/session.svelte.js";
import type { Group } from "#lib/transactions/split.js";
import { page } from "$app/state";
let {
groups,
title,
loading = false,
empty = "No transactions.",
links,
}: {
groups: Group[];
title: string;
loading?: boolean;
empty?: string;
links?: Snippet;
} = $props();
const size = $derived(session.pageSize);
const pages = $derived(Math.max(1, Math.ceil(groups.length / size)));
const current = $derived(
Math.min(pages, Math.max(1, Number(page.url.searchParams.get("page")) || 1))
);
const slice = $derived(groups.slice((current - 1) * size, current * size));
function href(n: number) {
const url = new URL(page.url.href);
url.searchParams.set("page", String(n));
return url.pathname + url.search;
}
</script>
<Box body="p-0" {title}>
{#snippet tools()}
<span class="num text-[12px] text-cladd-fg-soft">{groups.length}</span>
{/snippet}
<TransactionTable {empty} groups={slice} {loading} />
{#snippet footer()}
{@render links?.()}
<div class="ml-auto"><Pager {href} keys page={current} {pages} /></div>
{/snippet}
</Box>
@@ -0,0 +1,70 @@
<script lang="ts">
import type { Snippet } from "svelte";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuTrigger,
} from "#lib/components/ui/context-menu/index.js";
import { prettyKeys } from "#lib/keys.svelte.js";
export interface MenuItem {
keys?: string;
label: string;
run: () => void;
}
let {
items,
ondelete,
deleteLabel = "Delete",
children,
}: {
items: () => MenuItem[];
ondelete?: () => void | Promise<void>;
deleteLabel?: string;
children: Snippet<[Record<string, unknown>]>;
} = $props();
let armed = $state(false);
</script>
<ContextMenu onOpenChange={(open) => !open && (armed = false)}>
<ContextMenuTrigger>
{#snippet child({
props,
})}
{@render children(props)}
{/snippet}
</ContextMenuTrigger>
<ContextMenuContent class="min-w-52">
{#each items() as item (item.label)}
<ContextMenuItem onSelect={item.run}>
{item.label}
{#if item.keys}
<ContextMenuShortcut
>{prettyKeys(item.keys).join(" ")}</ContextMenuShortcut
>
{/if}
</ContextMenuItem>
{/each}
{#if ondelete}
<ContextMenuSeparator />
<ContextMenuItem
onSelect={(event) => {
if (!armed) {
event.preventDefault();
armed = true;
return;
}
ondelete();
}}
variant="destructive"
>
{armed ? "Press again to delete" : deleteLabel}
</ContextMenuItem>
{/if}
</ContextMenuContent>
</ContextMenu>
@@ -0,0 +1,29 @@
<script lang="ts">
import Amount, { type Tone } from "#lib/components/Amount.svelte";
import type { Sum } from "./sums.js";
let {
sums,
tone = "auto",
empty = "",
class: className = "",
}: { sums: Sum[]; tone?: Tone; empty?: string; class?: string } = $props();
</script>
{#if sums.length === 0}
{#if empty}
<span class="num text-cladd-fg-softer {className}">{empty}</span>
{/if}
{:else}
<span class="inline-flex flex-wrap justify-end gap-x-2.5 {className}">
{#each sums as sum (sum.code)}
<Amount
amount={sum.amount}
code={sum.code}
decimals={sum.decimals}
symbol={sum.symbol}
{tone}
/>
{/each}
</span>
{/if}
@@ -0,0 +1,68 @@
<script lang="ts">
import { createQuery } from "@tanstack/svelte-query";
import {
periodPath,
usePeriodPath,
} from "#lib/components/budgets/period-path.svelte.js";
import PageHeader from "#lib/components/shell/PageHeader.svelte";
import { formatDate } from "#lib/format.js";
import { period } from "#lib/period.svelte.js";
import { searchAll } from "./data.js";
import PeriodList from "./PeriodList.svelte";
import { windowOf } from "./sums.js";
const BASE = "/categories/list/no-category";
let { everything = false }: { everything?: boolean } = $props();
usePeriodPath(() => BASE);
const span = $derived(windowOf(period.end, period.view));
const query = createQuery(() => ({
enabled: period.ready,
queryFn: () =>
searchAll(
everything
? "has_no_category:true"
: `has_no_category:true date_after:${span.start} date_before:${span.end}`
),
queryKey: everything
? ["no-category", "all"]
: ["no-category", span.start, span.end],
}));
const title = $derived(
everything
? "All transactions without a category"
: `Without category between ${formatDate(period.start)} and ${formatDate(period.end)}`
);
</script>
<PageHeader
crumbs={[
{ href: "/categories", label: "Categories" },
{ label: "Without a category" },
]}
title="Transactions without a category"
/>
<PeriodList
groups={query.data ?? []}
href={(range) => periodPath(BASE, range.start, range.end)}
keep={(split) => !split.category_id}
loading={query.isPending}
sidebar={!everything}
{title}
>
{#snippet links()}
{#if everything}
<a class="text-brand hover:underline" href={BASE}
>Show the current period and overview</a
>
{:else}
<a class="text-brand hover:underline" href="{BASE}/all"
>Show all transactions without grouping them by date</a
>
{/if}
{/snippet}
</PeriodList>
@@ -0,0 +1,60 @@
<script lang="ts">
import type { Snippet } from "svelte";
import type { Split } from "#lib/api/types.js";
import type { Range } from "#lib/dates.js";
import { period } from "#lib/period.svelte.js";
import type { Group } from "#lib/transactions/split.js";
import GroupsBox from "./GroupsBox.svelte";
import PeriodSidebar, { type Field } from "./PeriodSidebar.svelte";
import { byPeriod, periodRanges, rows, SIDEBAR_PERIODS } from "./sums.js";
let {
groups,
keep,
title,
href,
sidebar = true,
loading = false,
fields,
links,
}: {
groups: Group[];
keep: (split: Split) => boolean;
title: string;
href: (range: Range) => string;
sidebar?: boolean;
loading?: boolean;
fields?: Field[];
links?: Snippet;
} = $props();
const own = $derived(rows(groups, keep));
const visible = $derived.by(() => {
if (!sidebar) {
return groups;
}
const ids = new Set(
own
.filter((r) => {
const day = r.split.date.slice(0, 10);
return day >= period.start && day <= period.end;
})
.map((r) => r.group.id)
);
return groups.filter((g) => ids.has(g.id));
});
const periods = $derived(
byPeriod(own, periodRanges(period.end, period.view, SIDEBAR_PERIODS))
);
</script>
<div
class="grid items-start gap-4 {sidebar
? "2xl:grid-cols-[minmax(0,1fr)_15rem]"
: ""}"
>
<GroupsBox groups={visible} {links} {loading} {title} />
{#if sidebar}
<PeriodSidebar {fields} {href} {periods} />
{/if}
</div>
@@ -0,0 +1,65 @@
<script lang="ts">
import type { Range } from "#lib/dates.js";
import { period, periodLabel } from "#lib/period.svelte.js";
import MoneyList from "./MoneyList.svelte";
import { list, type PeriodTotals } from "./sums.js";
export type Field = "spent" | "earned" | "transferred";
const LABELS: Record<Field, string> = {
earned: "Earned",
spent: "Spent",
transferred: "Transferred",
};
let {
periods,
href,
fields = ["spent", "earned", "transferred"],
}: {
periods: PeriodTotals[];
href: (range: Range) => string;
fields?: Field[];
} = $props();
</script>
<nav
aria-label="Periods"
class="grid grid-cols-[repeat(auto-fill,minmax(12rem,1fr))] gap-2 2xl:grid-cols-1"
>
{#each periods as item (item.range.start)}
{@const current =
item.range.start === period.start && item.range.end === period.end}
<a
aria-current={current ? "page" : undefined}
class="cladd-surface cladd-outline flex flex-col gap-0.5 rounded-cladd-md px-3 py-2 text-[12px] {current
? "bg-row-cursor outline-2 outline-brand"
: "hover:bg-row-hover"}"
href={href(item.range)}
>
<span class="font-semibold text-cladd-fg"
>{periodLabel(item.range, period.view)}</span
>
{#if item.totals.count === 0}
<span class="text-cladd-fg-softer">No transactions</span>
{:else}
<span class="flex justify-between gap-2">
<span class="text-cladd-fg-soft">Transactions</span>
<span class="num text-cladd-fg">{item.totals.count}</span>
</span>
{#each fields as field (field)}
{@const sums = list(item.totals[field])}
{#if sums.length > 0}
<span class="flex justify-between gap-2">
<span class="text-cladd-fg-soft">{LABELS[field]}</span>
<MoneyList
{sums}
tone={field === "transferred" ? "transfer" : "auto"}
/>
</span>
{/if}
{/each}
{/if}
</a>
{/each}
</nav>
@@ -0,0 +1,185 @@
<script lang="ts">
import { createQuery } from "@tanstack/svelte-query";
import { api } from "#lib/api/client.js";
import { all, invalidate } from "#lib/api/lists.js";
import type { Category, One, TransactionGroup } from "#lib/api/types.js";
import Box from "#lib/components/Box.svelte";
import {
periodPath,
usePeriodPath,
} from "#lib/components/budgets/period-path.svelte.js";
import ConfirmButton from "#lib/components/ConfirmButton.svelte";
import StackedBars from "#lib/components/charts/StackedBars.svelte";
import PageHeader from "#lib/components/shell/PageHeader.svelte";
import { Button } from "#lib/components/ui/button/index.js";
import { formatDate, formatMoney } from "#lib/format.js";
import { useKeys } from "#lib/keys.svelte.js";
import { period } from "#lib/period.svelte.js";
import { goto } from "$app/navigation";
import { page } from "$app/state";
import CurrencyTabs from "./CurrencyTabs.svelte";
import { currencies, FLOW_KEYS, flowRows } from "./flow.js";
import PeriodList from "./PeriodList.svelte";
import {
firstDay,
periodRanges,
rows as splitRows,
stepLabel,
stepRanges,
} from "./sums.js";
let { id, everything = false }: { id: string; everything?: boolean } =
$props();
const base = $derived(`/categories/show/${id}`);
usePeriodPath(() => base);
const category = createQuery(() => ({
queryFn: () => api<One<Category>>(`/v1/categories/${id}`),
queryKey: ["category", id],
}));
const groups = createQuery(() => ({
queryFn: () => all<TransactionGroup>(`/v1/categories/${id}/transactions`),
queryKey: ["category-transactions", id],
}));
const name = $derived(category.data?.data.attributes.name ?? "");
const own = $derived(
splitRows(groups.data ?? [], (s) => s.category_id === id)
);
const curs = $derived(currencies(own));
const cur = $derived(
curs.find((c) => c.code === page.url.searchParams.get("currency")) ??
curs[0]
);
const fmt = $derived((value: number) =>
formatMoney({ amount: value, decimals: cur?.decimals, symbol: cur?.symbol })
);
const periodRows = $derived(
cur
? flowRows(own, stepRanges(period.range), cur, (r) =>
r.start === r.end
? String(Number(r.start.slice(8)))
: formatDate(r.start, "short")
)
: []
);
const historyRows = $derived.by(() => {
if (!cur) {
return [];
}
const first = firstDay(own) ?? period.start;
const ranges = periodRanges(period.end, period.view, 400)
.filter((r) => r.end >= first)
.reverse();
return flowRows(
own,
ranges,
cur,
(r) => stepLabel(r, period.view),
(r) => periodPath(base, r.start, r.end)
);
});
async function remove() {
await api(`/v1/categories/${id}`, { method: "DELETE" });
await invalidate();
await goto("/categories");
}
useKeys(() => [
{
group: "Category",
keys: "E",
label: "Edit category",
run: () => goto(`/categories/edit/${id}`),
},
]);
</script>
<PageHeader
crumbs={[
{ href: "/categories", label: "Categories" },
{ label: name || "Category" },
]}
subtitle={everything
? "All transactions"
: `Between ${formatDate(period.start)} and ${formatDate(period.end)}`}
title={name || "Category"}
>
{#snippet actions()}
<Button
href="/categories/edit/{id}"
size="sm"
title="Edit category (E)"
variant="outline"
>Edit</Button
>
<ConfirmButton onconfirm={remove}>Delete</ConfirmButton>
{/snippet}
</PageHeader>
<div class="flex flex-col gap-4">
<div class="grid gap-4 {everything ? "" : "lg:grid-cols-2"}">
{#if !everything}
<Box
title="Chart for all transactions for category &quot;{name}&quot; between {formatDate(
period.start
)} and {formatDate(period.end)}"
>
{#snippet tools()}
<CurrencyTabs
codes={curs.map((c) => c.code)}
current={cur?.code ?? ""}
/>
{/snippet}
<StackedBars
format={fmt}
height={220}
keys={FLOW_KEYS}
label="Spent and earned in this period"
rows={periodRows}
/>
</Box>
{/if}
<Box title="Chart for all transactions for category &quot;{name}&quot;">
{#snippet tools()}
{#if everything}
<CurrencyTabs
codes={curs.map((c) => c.code)}
current={cur?.code ?? ""}
/>
{/if}
{/snippet}
<StackedBars
format={fmt}
height={220}
keys={FLOW_KEYS}
label="Spent and earned per period"
rows={historyRows}
/>
</Box>
</div>
<PeriodList
groups={groups.data ?? []}
href={(r) => periodPath(base, r.start, r.end)}
keep={(s) => s.category_id === id}
loading={groups.isPending}
sidebar={!everything}
title="Transactions"
>
{#snippet links()}
{#if everything}
<a class="text-brand hover:underline" href={base}
>Show the current period and overview</a
>
{:else}
<a class="text-brand hover:underline" href="{base}/all"
>Show all transactions without grouping them by date</a
>
{/if}
{/snippet}
</PeriodList>
</div>
+89
View File
@@ -0,0 +1,89 @@
import { api } from "#lib/api/client.js";
import { all } from "#lib/api/lists.js";
import type {
Category,
Currency,
InsightTotalEntry,
Item,
Page,
TransactionGroup,
} from "#lib/api/types.js";
import type { Range } from "#lib/dates.js";
import { toNumber } from "#lib/format.js";
import { add, fromEntries, list, type Sum, type Sums } from "./sums.js";
export interface CategoryRow {
earned: Sum[];
item: Item<Category>;
spent: Sum[];
}
export interface CategoryPage {
earned: Sum[];
rows: CategoryRow[];
spent: Sum[];
}
function insightSums(
entries: InsightTotalEntry[],
currencies: Item<Currency>[]
): Sum[] {
const sums: Sums = {};
for (const entry of entries) {
const cur = currencies.find(
(c) => c.attributes.code === entry.currency_code
)?.attributes;
add(
sums,
{
code: entry.currency_code ?? "",
decimals: cur?.decimal_places ?? 2,
symbol: cur?.symbol ?? entry.currency_code ?? "",
},
toNumber(entry.difference)
);
}
return list(sums);
}
export async function loadCategoryPage(
range: Range,
currencies: Item<Currency>[]
): Promise<CategoryPage> {
const query = { end: range.end, start: range.start };
const [items, spent, earned] = await Promise.all([
all<Category>("/v1/categories", query),
api<InsightTotalEntry[]>("/v1/insight/expense/no-category", { query }),
api<InsightTotalEntry[]>("/v1/insight/income/no-category", { query }),
]);
return {
earned: insightSums(earned, currencies),
rows: items.map((item) => ({
earned: fromEntries(item.attributes.earned),
item,
spent: fromEntries(item.attributes.spent),
})),
spent: insightSums(spent, currencies),
};
}
const SEARCH_LIMIT = 500;
export async function searchAll(query: string) {
const first = await api<Page<TransactionGroup>>("/v1/search/transactions", {
query: { limit: SEARCH_LIMIT, page: 1, query },
});
const pages = first.meta.pagination?.total_pages ?? 1;
const rest = await Promise.all(
Array.from({ length: Math.max(0, pages - 1) }, (_, i) =>
api<Page<TransactionGroup>>("/v1/search/transactions", {
query: { limit: SEARCH_LIMIT, page: i + 2, query },
})
)
);
const seen = new Map<string, Item<TransactionGroup>>();
for (const group of [first, ...rest].flatMap((page) => page.data)) {
seen.set(group.id, group);
}
return [...seen.values()];
}
+56
View File
@@ -0,0 +1,56 @@
import {
type StackKey,
type StackRow,
tones,
} from "#lib/components/charts/chart.js";
import type { Range } from "#lib/dates.js";
import { formatMoney, toNumber } from "#lib/format.js";
import { type Cur, curOfSplit, inRange, type Row } from "./sums.js";
export const FLOW_KEYS: StackKey[] = [
{ color: tones.income, key: "earned", label: "Earned" },
{ color: tones.expense, key: "spent", label: "Spent" },
];
export function currencies(items: Row[]): Cur[] {
const found = new Map<string, Cur>();
const counts = new Map<string, number>();
for (const { split } of items) {
const cur = curOfSplit(split);
found.set(cur.code, cur);
counts.set(cur.code, (counts.get(cur.code) ?? 0) + 1);
}
return [...found.values()].sort(
(a, b) => (counts.get(b.code) ?? 0) - (counts.get(a.code) ?? 0)
);
}
export function flowRows(
items: Row[],
ranges: Range[],
cur: Cur,
label: (range: Range) => string,
href?: (range: Range) => string
): StackRow[] {
const own = items.filter((row) => row.split.currency_code === cur.code);
const format = (value: number) =>
formatMoney({ amount: value, decimals: cur.decimals, symbol: cur.symbol });
return ranges.map((range) => {
let spent = 0;
let earned = 0;
for (const { split } of inRange(own, range)) {
if (split.type === "withdrawal") {
spent -= toNumber(split.amount);
} else if (split.type === "deposit") {
earned += toNumber(split.amount);
}
}
return {
format,
href: href?.(range),
key: range.start,
label: label(range),
values: { earned, spent },
};
});
}
+20
View File
@@ -0,0 +1,20 @@
import { ApiError } from "#lib/api/client.js";
export const SELECT_CLASS =
"cladd-cut cladd-hoverable cladd-focusable-input h-cladd-md w-full rounded-cladd-md px-2 text-cladd-xs font-medium text-cladd-fg";
export function fieldErrors(failure: unknown): Record<string, string> {
if (!(failure instanceof ApiError)) {
return {
form: failure instanceof Error ? failure.message : String(failure),
};
}
const errors: Record<string, string> = {};
for (const [key, value] of Object.entries(failure.errors)) {
errors[key] = value[0] ?? "";
}
if (Object.keys(errors).length === 0) {
errors.form = failure.message;
}
return errors;
}
+243
View File
@@ -0,0 +1,243 @@
import type { Item, Split, TransactionGroup } from "#lib/api/types.js";
import {
daysBetween,
isDynamic,
periodOf,
type Range,
shiftPeriod,
type ViewRange,
} from "#lib/dates.js";
import { currentLocale, formatDate, toNumber } from "#lib/format.js";
export interface Cur {
code: string;
decimals: number;
symbol: string;
}
export interface Sum extends Cur {
amount: number;
}
export type Sums = Record<string, Sum>;
export interface Entry {
currency_code?: string;
currency_decimal_places?: number;
currency_symbol?: string;
sum?: string;
}
export interface Row {
group: Item<TransactionGroup>;
split: Split;
}
export interface Totals {
count: number;
earned: Sums;
spent: Sums;
transferred: Sums;
}
export interface PeriodTotals {
range: Range;
totals: Totals;
}
export interface Bar {
amount: number;
href?: string;
label: string;
}
export interface Breakdown {
bars: Bar[];
cur: Cur;
total: number;
}
export const curOfSplit = (split: Split): Cur => ({
code: split.currency_code ?? "",
decimals: split.currency_decimal_places ?? 2,
symbol: split.currency_symbol ?? "",
});
export const curOfEntry = (entry: Entry): Cur => ({
code: entry.currency_code ?? "",
decimals: entry.currency_decimal_places ?? 2,
symbol: entry.currency_symbol ?? "",
});
export function add(sums: Sums, cur: Cur, value: number) {
const current = sums[cur.code] ?? { ...cur, amount: 0 };
current.amount += value;
sums[cur.code] = current;
}
export const list = (sums: Sums): Sum[] =>
Object.values(sums)
.filter((sum) => Math.abs(sum.amount) > 1e-9)
.sort((a, b) => Math.abs(b.amount) - Math.abs(a.amount));
export function fromEntries(entries: Entry[] | null | undefined): Sum[] {
const sums: Sums = {};
for (const entry of entries ?? []) {
add(sums, curOfEntry(entry), toNumber(entry.sum));
}
return list(sums);
}
export const amountIn = (sums: Sum[], code: string) =>
sums.find((sum) => sum.code === code)?.amount ?? 0;
export function rows(
groups: Item<TransactionGroup>[],
keep: (split: Split) => boolean = () => true
): Row[] {
return groups.flatMap((group) =>
group.attributes.transactions
.filter(keep)
.map((split) => ({ group, split }))
);
}
export const dayOf = (row: Row) => row.split.date.slice(0, 10);
export function totals(items: Row[]): Totals {
const result: Totals = { count: 0, earned: {}, spent: {}, transferred: {} };
const groups = new Set<string>();
for (const { group, split } of items) {
groups.add(group.id);
const amount = toNumber(split.amount);
const cur = curOfSplit(split);
if (split.type === "withdrawal") {
add(result.spent, cur, -amount);
} else if (split.type === "deposit") {
add(result.earned, cur, amount);
} else if (split.type === "transfer") {
add(result.transferred, cur, amount);
}
}
result.count = groups.size;
return result;
}
export function inRange(items: Row[], range: Range): Row[] {
return items.filter((row) => {
const day = dayOf(row);
return day >= range.start && day <= range.end;
});
}
export function periodRanges(
end: string,
view: ViewRange,
count: number
): Range[] {
const fixed = isDynamic(view) ? "1M" : view;
let range = periodOf(end, fixed);
const out = [range];
for (let i = 1; i < count; i += 1) {
range = shiftPeriod(range, fixed, -1);
out.push(range);
}
return out;
}
export function byPeriod(items: Row[], ranges: Range[]): PeriodTotals[] {
return ranges.map((range) => ({
range,
totals: totals(inRange(items, range)),
}));
}
const DAILY_MAX = 31;
const WEEKLY_MAX = 120;
const MONTHLY_MAX = 1100;
export function stepRanges(range: Range): Range[] {
const days = daysBetween(range.start, range.end) + 1;
let view: ViewRange = "1Y";
if (days <= DAILY_MAX) {
view = "1D";
} else if (days <= WEEKLY_MAX) {
view = "1W";
} else if (days <= MONTHLY_MAX) {
view = "1M";
}
const out: Range[] = [];
let step = periodOf(range.start, view);
while (step.start <= range.end) {
out.push(step);
step = shiftPeriod(step, view, 1);
}
return out;
}
export function firstDay(items: Row[]): string | null {
let first: string | null = null;
for (const row of items) {
const day = dayOf(row);
if (!first || day < first) {
first = day;
}
}
return first;
}
export function breakdown(
items: Row[],
pick: (split: Split) => { href?: string; label: string }
): Breakdown[] {
const byCur = new Map<
string,
{ cur: Cur; bars: Map<string, Bar>; total: number }
>();
for (const { split } of items) {
const cur = curOfSplit(split);
const amount = toNumber(split.amount);
const entry = byCur.get(cur.code) ?? { bars: new Map(), cur, total: 0 };
const { href, label } = pick(split);
const bar = entry.bars.get(label) ?? { amount: 0, href, label };
bar.amount += amount;
entry.bars.set(label, bar);
entry.total += amount;
byCur.set(cur.code, entry);
}
return [...byCur.values()]
.map(({ cur, bars, total }) => ({
bars: [...bars.values()].sort((a, b) => b.amount - a.amount),
cur,
total,
}))
.sort((a, b) => b.bars.length - a.bars.length);
}
export function stepLabel(range: Range, view: ViewRange): string {
const fixed = isDynamic(view) ? "1M" : view;
const month = Number(range.start.slice(5, 7));
const year = range.start.slice(0, 4);
switch (fixed) {
case "1M":
return new Intl.DateTimeFormat(currentLocale(), {
month: "short",
year: "2-digit",
}).format(new Date(Number(year), month - 1, 1));
case "3M":
return `Q${Math.ceil(month / 3)} ${year}`;
case "6M":
return `H${month <= 6 ? 1 : 2} ${year}`;
case "1Y":
return year;
default:
return formatDate(range.start, "short");
}
}
export const SIDEBAR_PERIODS = 12;
export function windowOf(end: string, view: ViewRange): Range {
const ranges = periodRanges(end, view, SIDEBAR_PERIODS);
return { end, start: ranges.at(-1)?.start ?? end };
}
+13
View File
@@ -0,0 +1,13 @@
import { goto } from "$app/navigation";
import { page } from "$app/state";
export function setParam(name: string, value: string) {
const url = new URL(page.url.href);
if (value) {
url.searchParams.set(name, value);
} else {
url.searchParams.delete(name);
}
url.searchParams.delete("page");
return goto(url, { replace: true, reset: false, state: page.state });
}
+115
View File
@@ -0,0 +1,115 @@
<script lang="ts">
import { invalidate } from "#lib/api/lists.js";
import type { Item, Tag } from "#lib/api/types.js";
import Box from "#lib/components/Box.svelte";
import { SELECT_CLASS } from "#lib/components/categories/form.js";
import { Button } from "#lib/components/ui/button/index.js";
import { mergeTag, sequence } from "./actions.js";
let {
clusters,
counts,
}: { clusters: Item<Tag>[][]; counts: Map<string, number> } = $props();
let targets = $state<Record<string, string>>({});
let status = $state<Record<string, string>>({});
let busy = $state(false);
const keyOf = (cluster: Item<Tag>[]) => cluster.map((t) => t.id).join(",");
function best(cluster: Item<Tag>[]) {
return [...cluster].sort(
(a, b) =>
(counts.get(b.id) ?? 0) - (counts.get(a.id) ?? 0) ||
Number(a.attributes.tag !== a.attributes.tag.toLowerCase()) -
Number(b.attributes.tag !== b.attributes.tag.toLowerCase())
)[0];
}
const targetOf = (cluster: Item<Tag>[]) =>
targets[keyOf(cluster)] ?? best(cluster).id;
async function merge(cluster: Item<Tag>[]) {
const key = keyOf(cluster);
const into = cluster.find((t) => t.id === targetOf(cluster));
if (!into || busy) {
return;
}
busy = true;
const errors: string[] = [];
await sequence(
cluster.filter((t) => t.id !== into.id),
async (tag) => {
status[key] = `Merging ${tag.attributes.tag}`;
const outcome = await mergeTag(tag, into.attributes.tag, (o) => {
status[key] =
`Merging ${tag.attributes.tag}: ${o.done} of ${o.total}`;
}).catch((failure: unknown) => ({ errors: [String(failure)] }));
errors.push(...outcome.errors);
}
);
status[key] =
errors.length > 0 ? `${errors.length} failed: ${errors[0]}` : "Merged";
busy = false;
await invalidate();
}
</script>
<Box body="p-0" title="Possible duplicates">
{#if clusters.length === 0}
<p class="px-3 py-2 text-[12px] text-cladd-fg-soft">No tags look alike.</p>
{/if}
<ul class="divide-y divide-cladd-outline text-[13px]">
{#each clusters as cluster (keyOf(cluster))}
{@const key = keyOf(cluster)}
{@const target = targetOf(cluster)}
<li class="flex flex-wrap items-center gap-x-4 gap-y-1 px-3 py-2">
<span class="flex flex-wrap gap-x-3">
{#each cluster as tag (tag.id)}
<a class="hover:underline" href="/tags/show/{tag.id}">
<span class={tag.id === target ? "font-semibold" : ""}
>{tag.attributes.tag}</span
>
<span class="num text-[12px] text-cladd-fg-soft"
>{counts.get(tag.id) ?? ""}</span
>
</a>
{/each}
</span>
<span class="ml-auto flex items-center gap-2">
<label class="text-[12px] text-cladd-fg-soft" for="into-{key}"
>Keep</label
>
<select
class="{SELECT_CLASS} h-7 w-48"
id="into-{key}"
onchange={(event) => (targets[key] = event.currentTarget.value)}
value={target}
>
{#each cluster as tag (tag.id)}
<option value={tag.id}>{tag.attributes.tag}</option>
{/each}
</select>
<Button
disabled={busy}
onclick={() => merge(cluster)}
size="sm"
variant="outline"
>
Merge {cluster.length - 1} into it
</Button>
</span>
{#if status[key]}
<span
class="basis-full text-right text-[12px] {status[key].includes(
"failed"
)
? "text-expense"
: "text-cladd-fg-soft"}"
>{status[key]}</span
>
{/if}
</li>
{/each}
</ul>
</Box>
+123
View File
@@ -0,0 +1,123 @@
<script lang="ts">
import { invalidate } from "#lib/api/lists.js";
import type { Item, Tag } from "#lib/api/types.js";
import { fuzzyFilter } from "#lib/fuzzy.js";
import type { Outcome } from "#lib/transactions/mass.js";
import { mergeTag } from "./actions.js";
const SUGGESTIONS = 6;
let {
tag,
tags,
ondone,
}: { tag: Item<Tag>; tags: Item<Tag>[]; ondone: () => void } = $props();
let value = $state("");
let cursor = $state(0);
let busy = $state(false);
let outcome = $state<Outcome | null>(null);
let problem = $state("");
const options = $derived(
fuzzyFilter(
tags.filter((t) => t.id !== tag.id),
value,
(t) => t.attributes.tag,
SUGGESTIONS
)
);
function focus(input: HTMLInputElement) {
input.focus();
}
async function run(into: string) {
const target = into.trim();
if (!target || busy) {
return;
}
busy = true;
problem = "";
try {
const result = await mergeTag(tag, target, (next) => (outcome = next));
await invalidate();
if (result.errors.length > 0) {
problem = `${result.errors.length} failed: ${result.errors[0]}`;
return;
}
ondone();
} catch (failure) {
problem = failure instanceof Error ? failure.message : String(failure);
} finally {
busy = false;
}
}
function onkeydown(event: KeyboardEvent) {
if (event.key === "ArrowDown") {
event.preventDefault();
cursor = Math.min(options.length - 1, cursor + 1);
} else if (event.key === "ArrowUp") {
event.preventDefault();
cursor = Math.max(0, cursor - 1);
} else if (event.key === "Enter") {
event.preventDefault();
run(options[cursor]?.attributes.tag ?? value);
} else if (event.key === "Escape") {
event.preventDefault();
ondone();
}
}
</script>
<div class="relative flex flex-wrap items-center gap-2 py-0.5">
<span class="text-[12px] text-cladd-fg-soft">Merge into</span>
<input
aria-label="Merge {tag.attributes.tag} into"
class="cladd-cut cladd-focusable-input h-6 w-60 rounded-cladd-sm px-1.5 text-[13px]"
disabled={busy}
oninput={() => (cursor = 0)}
{onkeydown}
placeholder="tag name"
bind:value
{@attach focus}
>
<span class="text-[12px] text-cladd-fg-soft">
{#if busy && outcome}
Moving {outcome.done} of {outcome.total} transactions
{:else if busy}
Finding transactions
{:else}
<span class="kbd">↵</span>
merge <span class="kbd">Esc</span> cancel
{/if}
</span>
{#if problem}
<span class="basis-full text-[12px] text-expense">{problem}</span>
{/if}
{#if !busy && options.length > 0}
<div
class="cladd-surface cladd-outline cladd-level-1 absolute top-full left-[4.6rem] z-20 mt-1 w-60 rounded-cladd-md p-1 shadow-cladd-popover"
role="listbox"
>
{#each options as option, i (option.id)}
<div
aria-selected={i === cursor}
class="cursor-default truncate rounded-cladd-sm px-2 py-1 text-[13px] {i ===
cursor
? "bg-row-cursor"
: "hover:bg-row-hover"}"
onmousedown={(event) => {
event.preventDefault();
run(option.attributes.tag);
}}
role="option"
tabindex="-1"
>
{option.attributes.tag}
</div>
{/each}
</div>
{/if}
</div>
+111
View File
@@ -0,0 +1,111 @@
<script lang="ts">
import { untrack } from "svelte";
import { ApiError } from "#lib/api/client.js";
import { invalidate } from "#lib/api/lists.js";
import type { Item, Tag } from "#lib/api/types.js";
import { renameTag } from "./actions.js";
import { descendants, type TagNode } from "./tree.js";
let {
node,
tags,
ondone,
}: { node: TagNode; tags: Item<Tag>[]; ondone: () => void } = $props();
let value = $state(untrack(() => node.path));
let deep = $state(true);
let busy = $state(false);
let problem = $state("");
const below = $derived(descendants(node).filter((child) => child.tag));
function focus(input: HTMLInputElement) {
input.focus();
input.select();
}
async function save() {
const next = value.trim();
if (!next || next === node.path) {
ondone();
return;
}
const taken = tags.find(
(t) =>
t.id !== node.tag?.id &&
t.attributes.tag.toLowerCase() === next.toLowerCase()
);
if (taken) {
problem = `"${taken.attributes.tag}" already exists. To join them, use merge (m).`;
return;
}
busy = true;
problem = "";
try {
if (node.tag) {
await renameTag(node.tag, next);
}
if (deep || !node.tag) {
await Promise.all(
below.map((child) =>
child.tag
? renameTag(child.tag, next + child.path.slice(node.path.length))
: null
)
);
}
await invalidate();
ondone();
} catch (failure) {
const text =
failure instanceof ApiError
? (Object.values(failure.errors).flat()[0] ?? failure.message)
: String(failure);
problem = `${text} To join it with an existing tag, use merge (m).`;
} finally {
busy = false;
}
}
function onkeydown(event: KeyboardEvent) {
if (event.key === "Enter") {
event.preventDefault();
save();
} else if (event.key === "Escape") {
event.preventDefault();
ondone();
}
}
</script>
<div class="flex flex-wrap items-center gap-2 py-0.5">
<input
aria-label="New name for {node.path}"
class="cladd-cut cladd-focusable-input h-6 w-72 rounded-cladd-sm px-1.5 text-[13px]"
disabled={busy}
{onkeydown}
bind:value
{@attach focus}
>
{#if node.tag && below.length > 0}
<label class="flex items-center gap-1 text-[12px] text-cladd-fg-soft">
<input class="accent-brand" type="checkbox" bind:checked={deep}>
also rename {below.length} sub-tags
</label>
{:else if !node.tag}
<span class="text-[12px] text-cladd-fg-soft"
>renames {below.length} sub-tags</span
>
{/if}
<span class="text-[12px] text-cladd-fg-soft">
{#if busy}
Renaming
{:else}
<span class="kbd">↵</span>
save <span class="kbd">Esc</span> cancel
{/if}
</span>
{#if problem}
<span class="basis-full text-[12px] text-expense">{problem}</span>
{/if}
</div>
+280
View File
@@ -0,0 +1,280 @@
<script lang="ts">
import { createQuery } from "@tanstack/svelte-query";
import { api } from "#lib/api/client.js";
import { all, invalidate, lists } from "#lib/api/lists.js";
import type { One, Tag, TransactionGroup } from "#lib/api/types.js";
import Box from "#lib/components/Box.svelte";
import {
periodPath,
usePeriodPath,
} from "#lib/components/budgets/period-path.svelte.js";
import ConfirmButton from "#lib/components/ConfirmButton.svelte";
import CurrencyTabs from "#lib/components/categories/CurrencyTabs.svelte";
import {
currencies,
FLOW_KEYS,
flowRows,
} from "#lib/components/categories/flow.js";
import MoneyList from "#lib/components/categories/MoneyList.svelte";
import PeriodList from "#lib/components/categories/PeriodList.svelte";
import {
firstDay,
inRange,
list,
periodRanges,
rows as splitRows,
stepLabel,
totals,
} from "#lib/components/categories/sums.js";
import StackedBars from "#lib/components/charts/StackedBars.svelte";
import PageHeader from "#lib/components/shell/PageHeader.svelte";
import { Button } from "#lib/components/ui/button/index.js";
import { formatDate, formatMoney } from "#lib/format.js";
import { useKeys } from "#lib/keys.svelte.js";
import { period } from "#lib/period.svelte.js";
import { goto } from "$app/navigation";
import { page } from "$app/state";
const ID = /^\d+$/;
let { param, everything = false }: { param: string; everything?: boolean } =
$props();
const tags = createQuery(() => lists.tags());
const id = $derived(
ID.test(param)
? param
: tags.data?.find((t) => t.attributes.tag === param)?.id
);
const base = $derived(`/tags/show/${id ?? encodeURIComponent(param)}`);
usePeriodPath(() => base);
const tag = createQuery(() => ({
enabled: Boolean(id),
queryFn: () => api<One<Tag>>(`/v1/tags/${id}`),
queryKey: ["tag", id],
}));
const groups = createQuery(() => ({
enabled: Boolean(id),
queryFn: () =>
all<TransactionGroup>(`/v1/tags/${id}/transactions`, { type: "all" }),
queryKey: ["tag-transactions", id],
}));
const attributes = $derived(tag.data?.data.attributes);
const name = $derived(attributes?.tag ?? param);
const keep = $derived((s: { tags?: string[] | null }) =>
(s.tags ?? []).includes(name)
);
const own = $derived(splitRows(groups.data ?? [], keep));
const scoped = $derived(everything ? own : inRange(own, period.range));
const sums = $derived(totals(scoped));
const net = $derived.by(() => {
const result = { ...sums.earned };
for (const [code, sum] of Object.entries(sums.spent)) {
result[code] = {
...sum,
amount: (result[code]?.amount ?? 0) + sum.amount,
};
}
return list(result);
});
const children = $derived(
(tags.data ?? []).filter((t) => t.attributes.tag.startsWith(`${name}/`))
);
const parentPath = $derived(
name.includes("/") ? name.slice(0, name.lastIndexOf("/")) : ""
);
const parent = $derived(
tags.data?.find((t) => t.attributes.tag === parentPath)
);
const curs = $derived(currencies(own));
const cur = $derived(
curs.find((c) => c.code === page.url.searchParams.get("currency")) ??
curs[0]
);
const fmt = $derived((value: number) =>
formatMoney({ amount: value, decimals: cur?.decimals, symbol: cur?.symbol })
);
const history = $derived.by(() => {
if (!cur) {
return [];
}
const first = firstDay(own) ?? period.start;
const ranges = periodRanges(period.end, period.view, 400)
.filter((r) => r.end >= first)
.reverse();
return flowRows(
own,
ranges,
cur,
(r) => stepLabel(r, period.view),
(r) => periodPath(base, r.start, r.end)
);
});
async function remove() {
if (!id) {
return;
}
await api(`/v1/tags/${id}`, { method: "DELETE" });
await invalidate();
await goto("/tags");
}
useKeys(() => [
{
group: "Tag",
keys: "E",
label: "Edit tag",
run: () => id && goto(`/tags/edit/${id}`),
},
]);
</script>
<PageHeader
crumbs={[
{ href: "/tags", label: "Tags" },
...(parent
? [{ href: `/tags/show/${parent.id}`, label: parent.attributes.tag }]
: []),
{ label: name },
]}
subtitle={everything
? "All transactions"
: `Between ${formatDate(period.start)} and ${formatDate(period.end)}`}
title={name}
>
{#snippet actions()}
<Button
href="/tags/edit/{id}"
size="sm"
title="Edit tag (E)"
variant="outline"
>Edit</Button
>
<ConfirmButton onconfirm={remove}>Delete</ConfirmButton>
{/snippet}
</PageHeader>
{#if tag.isError}
<p class="pb-3 text-expense">{tag.error.message}</p>
{/if}
<div class="flex flex-col gap-4">
<div class="grid items-start gap-4 lg:grid-cols-2">
<Box body="p-0" title="Meta data">
<table class="w-full text-[13px]">
<tbody>
{#if attributes?.description}
<tr class="border-cladd-outline border-b">
<th
class="w-1/2 px-3 py-1.5 text-left font-normal text-cladd-fg-soft"
>
Description
</th>
<td class="px-3 py-1.5">{attributes.description}</td>
</tr>
{/if}
{#if attributes?.date}
<tr class="border-cladd-outline border-b">
<th class="px-3 py-1.5 text-left font-normal text-cladd-fg-soft">
Date
</th>
<td class="num px-3 py-1.5">{formatDate(attributes.date)}</td>
</tr>
{/if}
{#each [
{ label: "Sum (excluding transfers)", sums: net },
{ label: "Sum of withdrawals", sums: list(sums.spent) },
{ label: "Sum of deposits", sums: list(sums.earned) },
{ label: "Sum of transfers", sums: list(sums.transferred) },
] as row (row.label)}
{#if row.sums.length > 0}
<tr class="border-cladd-outline border-b">
<th
class="px-3 py-1.5 text-left font-normal text-cladd-fg-soft"
>
{row.label}
</th>
<td class="px-3 py-1.5">
<MoneyList
class="!justify-start"
sums={row.sums}
tone={row.label === "Sum of transfers"
? "transfer"
: "auto"}
/>
</td>
</tr>
{/if}
{/each}
{#if children.length > 0}
<tr class="border-cladd-outline border-b">
<th class="px-3 py-1.5 text-left font-normal text-cladd-fg-soft">
Sub-tags
</th>
<td class="px-3 py-1.5">
{#each children as child, i (child.id)}
<a
class="text-brand hover:underline"
href="/tags/show/{child.id}"
>{child.attributes.tag.slice(name.length + 1)}</a
>{i < children.length - 1 ? ", " : ""}
{/each}
</td>
</tr>
{/if}
{#if scoped.length === 0 && !groups.isPending}
<tr>
<td class="px-3 py-1.5 text-cladd-fg-soft" colspan="2">
No transactions in this range.
</td>
</tr>
{/if}
</tbody>
</table>
</Box>
<Box title="Chart for all transactions for tag &quot;{name}&quot;">
{#snippet tools()}
<CurrencyTabs
codes={curs.map((c) => c.code)}
current={cur?.code ?? ""}
/>
{/snippet}
{#if history.length > 0}
<StackedBars
format={fmt}
height={200}
keys={FLOW_KEYS}
label="Spent and earned per period"
rows={history}
/>
{:else}
<p class="text-[12px] text-cladd-fg-soft">No transactions yet.</p>
{/if}
</Box>
</div>
<PeriodList
groups={groups.data ?? []}
href={(r) => periodPath(base, r.start, r.end)}
{keep}
loading={groups.isPending}
sidebar={!everything}
title="Transactions"
>
{#snippet links()}
{#if everything}
<a class="text-brand hover:underline" href={base}
>Show the current period and overview</a
>
{:else}
<a class="text-brand hover:underline" href="{base}/all"
>Show all transactions without grouping them by date</a
>
{/if}
{/snippet}
</PeriodList>
</div>
+113
View File
@@ -0,0 +1,113 @@
<script lang="ts">
import { untrack } from "svelte";
import { api } from "#lib/api/client.js";
import { invalidate } from "#lib/api/lists.js";
import type { Item, One, Tag } from "#lib/api/types.js";
import Box from "#lib/components/Box.svelte";
import ConfirmButton from "#lib/components/ConfirmButton.svelte";
import Field from "#lib/components/categories/Field.svelte";
import { fieldErrors } from "#lib/components/categories/form.js";
import { Button } from "#lib/components/ui/button/index.js";
import { Input } from "#lib/components/ui/input/index.js";
import { Textarea } from "#lib/components/ui/textarea/index.js";
import { prettyKeys, useKeys } from "#lib/keys.svelte.js";
import { goto } from "$app/navigation";
let { tag }: { tag?: Item<Tag> } = $props();
const initial = untrack(() => tag)?.attributes;
let name = $state(initial?.tag ?? "");
let date = $state(initial?.date ?? "");
let description = $state(initial?.description ?? "");
let errors = $state<Record<string, string>>({});
let busy = $state(false);
let armed = $state(false);
async function save(event?: Event) {
event?.preventDefault();
if (busy) {
return;
}
busy = true;
errors = {};
try {
const saved = await api<One<Tag>>(
tag ? `/v1/tags/${tag.id}` : "/v1/tags",
{
body: {
date: date || null,
description: description || null,
tag: name,
},
method: tag ? "PUT" : "POST",
}
);
await invalidate();
await goto(`/tags/show/${saved.data.id}`);
} catch (failure) {
errors = fieldErrors(failure);
} finally {
busy = false;
}
}
async function remove() {
if (!tag) {
return;
}
await api(`/v1/tags/${tag.id}`, { method: "DELETE" });
await invalidate();
await goto("/tags");
}
useKeys(() => [
{
group: "Form",
input: true,
keys: "mod+Enter",
label: tag ? "Update tag" : "Store new tag",
run: () => save(),
},
]);
</script>
<form class="flex flex-col gap-4" onsubmit={save}>
<div class="grid gap-4 lg:grid-cols-[5fr_7fr]">
<Box title="Mandatory fields">
<Field id="tag" issue={errors.tag} label="Tag">
<Input autofocus id="tag" required bind:value={name} />
</Field>
</Box>
<Box title="Optional fields">
<Field id="date" issue={errors.date} label="Date">
<Input class="num w-44" id="date" type="date" bind:value={date} />
</Field>
<Field id="description" issue={errors.description} label="Description">
<Textarea id="description" rows={3} bind:value={description} />
</Field>
</Box>
</div>
<div class="flex items-center gap-3">
<Button disabled={busy} type="submit">
{tag ? "Update tag" : "Store new tag"}
</Button>
<span class="flex gap-1 text-[12px] text-cladd-fg-soft">
{#each prettyKeys("mod+Enter") as key (key)}
<span class="kbd">{key}</span>
{/each}
</span>
{#if busy}
<span class="text-[12px] text-cladd-fg-soft">Saving</span>
{:else if errors.form}
<span class="text-[12px] text-expense">{errors.form}</span>
{/if}
{#if tag}
<span class="ml-auto text-[12px] text-cladd-fg-soft">
{#if armed}
Its transactions stay, without this tag.
{/if}
</span>
<ConfirmButton onconfirm={remove} bind:armed>Delete tag</ConfirmButton>
{/if}
</div>
</form>
+98
View File
@@ -0,0 +1,98 @@
import { api } from "#lib/api/client.js";
import { all } from "#lib/api/lists.js";
import type { Item, Page, Tag, TransactionGroup } from "#lib/api/types.js";
import {
type Outcome,
type Targets,
updateGroups,
} from "#lib/transactions/mass.js";
const lower = (tag: Item<Tag>) => tag.attributes.tag.toLowerCase();
const tagged = (groups: Item<TransactionGroup>[], name: string) =>
groups.filter((group) =>
group.attributes.transactions.some((split) => split.tags?.includes(name))
);
export async function renameTag(tag: Item<Tag>, next: string) {
await api(`/v1/tags/${tag.id}`, { body: { tag: next }, method: "PUT" });
}
export async function deleteTag(tag: Item<Tag>) {
await api(`/v1/tags/${tag.id}`, { method: "DELETE" });
}
export async function mergeTag(
from: Item<Tag>,
into: string,
progress: (outcome: Outcome) => void
): Promise<Outcome> {
let name = from.attributes.tag;
if (name === into) {
return { done: 0, errors: [], total: 0 };
}
if (name.toLowerCase() === into.toLowerCase()) {
name = `${name} (merging into ${into})`;
await renameTag(from, name);
}
const groups = tagged(
await all<TransactionGroup>(`/v1/tags/${from.id}/transactions`, {
type: "all",
}),
name
);
const targets: Targets = new Map(groups.map((group) => [group.id, null]));
const outcome = await updateGroups(
targets,
(split) => {
const tags = split.tags ?? [];
if (!tags.includes(name)) {
return null;
}
return {
tags: [...new Set([...tags.filter((tag) => tag !== name), into])],
};
},
progress
);
if (outcome.errors.length === 0) {
await deleteTag(from);
}
return outcome;
}
export async function usage(tags: Item<Tag>[]): Promise<Map<string, number>> {
const keys = tags.map(lower);
const clash = new Set(keys.filter((key, i) => keys.indexOf(key) !== i));
const plain = tags.filter((tag) => !clash.has(lower(tag)));
const pages = await Promise.all(
plain.map((tag) =>
api<Page<TransactionGroup>>(`/v1/tags/${tag.id}/transactions`, {
query: { limit: 1, type: "all" },
})
)
);
const counts = new Map(
plain.map((tag, i) => [tag.id, pages[i].meta.pagination?.total ?? 0])
);
await Promise.all(
[...clash].map(async (key) => {
const variants = tags.filter((tag) => lower(tag) === key);
const groups = await all<TransactionGroup>(
`/v1/tags/${variants[0].id}/transactions`,
{ type: "all" }
);
for (const variant of variants) {
counts.set(variant.id, tagged(groups, variant.attributes.tag).length);
}
})
);
return counts;
}
export function sequence<T>(items: T[], run: (item: T) => Promise<void>) {
return items.reduce<Promise<void>>(
(previous, item) => previous.then(() => run(item)),
Promise.resolve()
);
}
+221
View File
@@ -0,0 +1,221 @@
import type { Item, Tag } from "#lib/api/types.js";
import {
add,
curOfSplit,
type Row,
type Sums,
} from "#lib/components/categories/sums.js";
import { toNumber } from "#lib/format.js";
export interface TagNode {
children: TagNode[];
depth: number;
name: string;
path: string;
tag?: Item<Tag>;
}
export interface Stats {
count: number;
earned: Sums;
spent: Sums;
}
const SEPARATOR = "/";
const TOKEN = /[-_/.\s]+/;
const DIACRITICS = /\p{Diacritic}/gu;
const NOT_WORD = /[^\p{L}\p{N}/]+/gu;
const NUMERIC = /^\d+$/;
const SHORT_TOKEN = 2;
const NEAR_ONE = 5;
const NEAR_TWO = 10;
const byName = (a: TagNode, b: TagNode) =>
a.name.localeCompare(b.name, undefined, { sensitivity: "base" }) ||
a.name.localeCompare(b.name);
export function buildTree(tags: Item<Tag>[]): TagNode[] {
const nodes = new Map<string, TagNode>();
const roots: TagNode[] = [];
const ensure = (path: string): TagNode => {
const found = nodes.get(path);
if (found) {
return found;
}
const cut = path.lastIndexOf(SEPARATOR);
const node: TagNode = {
children: [],
depth: 0,
name: cut < 0 ? path : path.slice(cut + 1),
path,
};
nodes.set(path, node);
if (cut > 0) {
const parent = ensure(path.slice(0, cut));
node.depth = parent.depth + 1;
parent.children.push(node);
} else {
roots.push(node);
}
return node;
};
for (const tag of tags) {
ensure(tag.attributes.tag).tag = tag;
}
const sortDeep = (list: TagNode[]) => {
list.sort(byName);
for (const node of list) {
sortDeep(node.children);
}
};
sortDeep(roots);
return roots;
}
export function flatten(roots: TagNode[]): TagNode[] {
return roots.flatMap((node) => [node, ...flatten(node.children)]);
}
export function flatNodes(tags: Item<Tag>[]): TagNode[] {
return tags
.map((tag) => ({
children: [],
depth: 0,
name: tag.attributes.tag,
path: tag.attributes.tag,
tag,
}))
.sort(byName);
}
export function descendants(node: TagNode): TagNode[] {
return flatten(node.children);
}
const emptyStats = (): Stats => ({ count: 0, earned: {}, spent: {} });
function pathsOf(tags: string[], rollup: boolean): Set<string> {
const paths = new Set<string>();
for (const tag of tags) {
paths.add(tag);
let cut = rollup ? tag.lastIndexOf(SEPARATOR) : -1;
while (cut > 0) {
paths.add(tag.slice(0, cut));
cut = tag.lastIndexOf(SEPARATOR, cut - 1);
}
}
return paths;
}
export function tagStats(items: Row[], rollup: boolean): Map<string, Stats> {
const stats = new Map<string, Stats>();
const groups = new Map<string, Set<string>>();
for (const { group, split } of items) {
const paths = pathsOf(split.tags ?? [], rollup);
const amount = toNumber(split.amount);
const cur = curOfSplit(split);
for (const path of paths) {
const entry = stats.get(path) ?? emptyStats();
const seen = groups.get(path) ?? new Set<string>();
seen.add(group.id);
groups.set(path, seen);
entry.count = seen.size;
if (split.type === "withdrawal") {
add(entry.spent, cur, -amount);
} else if (split.type === "deposit") {
add(entry.earned, cur, amount);
}
stats.set(path, entry);
}
}
return stats;
}
const normal = (value: string) =>
value.normalize("NFKD").replace(DIACRITICS, "").toLowerCase();
const squash = (value: string) => normal(value).replace(NOT_WORD, "");
function distance(a: string, b: string): number {
const rows = a.length + 1;
const cols = b.length + 1;
const d: number[][] = Array.from({ length: rows }, (_, row) =>
Array.from(
{ length: cols },
(__, col) => Math.max(row, col) * (row * col === 0 ? 1 : 0)
)
);
for (let i = 1; i < rows; i += 1) {
for (let j = 1; j < cols; j += 1) {
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
d[i][j] = Math.min(
d[i - 1][j] + 1,
d[i][j - 1] + 1,
d[i - 1][j - 1] + cost
);
if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) {
d[i][j] = Math.min(d[i][j], d[i - 2][j - 2] + 1);
}
}
}
return d[a.length][b.length];
}
function variantOnly(a: string, b: string): boolean {
const ta = normal(a).split(TOKEN);
const tb = normal(b).split(TOKEN);
if (ta.length !== tb.length) {
return false;
}
const diff = ta.flatMap((token, i) =>
token === tb[i] ? [] : [[token, tb[i]]]
);
if (diff.length !== 1) {
return false;
}
const [x, y] = diff[0];
return (
(NUMERIC.test(x) && NUMERIC.test(y)) ||
x.length <= SHORT_TOKEN ||
y.length <= SHORT_TOKEN
);
}
function near(a: string, b: string): boolean {
const x = squash(a);
const y = squash(b);
if (x === y) {
return true;
}
const length = Math.min(x.length, y.length);
if (length < NEAR_ONE || variantOnly(a, b)) {
return false;
}
const allowed = length >= NEAR_TWO ? 2 : 1;
return Math.abs(x.length - y.length) <= allowed && distance(x, y) <= allowed;
}
export function duplicates(tags: Item<Tag>[]): Item<Tag>[][] {
const parent = tags.map((_, i) => i);
const find = (i: number): number => {
let root = i;
while (parent[root] !== root) {
root = parent[root];
}
parent[i] = root;
return root;
};
for (let i = 0; i < tags.length; i += 1) {
for (let j = i + 1; j < tags.length; j += 1) {
if (near(tags[i].attributes.tag, tags[j].attributes.tag)) {
parent[find(j)] = find(i);
}
}
}
const clusters = new Map<number, Item<Tag>[]>();
tags.forEach((tag, i) => {
const root = find(i);
clusters.set(root, [...(clusters.get(root) ?? []), tag]);
});
return [...clusters.values()].filter((cluster) => cluster.length > 1);
}
+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}