feat(budgets,categories,tags): in-place limits with pace, category shares, tag tree and clean-up
This commit is contained in:
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>
|
||||
@@ -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>
|
||||
@@ -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 "{name}" 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 "{name}"">
|
||||
{#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>
|
||||
@@ -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 "{name}" 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 "{name}"">
|
||||
{#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>
|
||||
@@ -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()];
|
||||
}
|
||||
@@ -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 },
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 "{name}"">
|
||||
{#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>
|
||||
@@ -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>
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user