feat(transactions): the period as a local ledger with Excel-style filters

The list loads the whole period (or twelve months with Y) and filters it
in the browser: a text line over descriptions, notes, accounts and
amounts, dropdowns with counts per type, category, budget, account,
currency, tag and subscription, group-by with subtotals per currency,
pinned filters on 1 to 9. Possible duplicates show with a delete for the
newer one, one account adds the balance column, s s sets a subscription
and s . repeats the last mass action.
This commit is contained in:
hh committed 2026-10-16 15:13:35 +00:00
1 parent 01c53d982d
commit 153a8cb191
9 files changed
+1658 -96

No files matched your search

@@ -2,32 +2,26 @@
import { createQuery } from "@tanstack/svelte-query"; import { createQuery } from "@tanstack/svelte-query";
import { tick } from "svelte"; import { tick } from "svelte";
import { invalidate, lists } from "#lib/api/lists.js"; import { invalidate, lists } from "#lib/api/lists.js";
import type { Split } from "#lib/api/types.js";
import { fuzzyFilter } from "#lib/fuzzy.js"; import { fuzzyFilter } from "#lib/fuzzy.js";
import { import {
deleteTargets, deleteTargets,
type MassAction,
MODES,
type Mode,
type Outcome, type Outcome,
type Patch, patchFor,
type Row, type Row,
splitsOf, splitsOf,
targetsOf, targetsOf,
updateGroups, updateGroups,
} from "#lib/transactions/mass.js"; } from "#lib/transactions/mass.js";
import type { Suggestion } from "#lib/transactions/query.js"; import type { Suggestion } from "#lib/transactions/query.js";
import { lastAction } from "#lib/transactions/repeat.svelte.js";
import SuggestInput from "./SuggestInput.svelte"; import SuggestInput from "./SuggestInput.svelte";
export type Mode = "category" | "budget" | "tag" | "untag";
const ARM_MS = 3000; const ARM_MS = 3000;
const STATUS_MS = 8000; const STATUS_MS = 8000;
const NONE = "\u0000"; const NONE = "\u0000";
const MODES: { mode: Mode; keys: string; label: string; verb: string }[] = [
{ keys: "s c", label: "Category", mode: "category", verb: "Set category" },
{ keys: "s b", label: "Budget", mode: "budget", verb: "Set budget" },
{ keys: "s t", label: "Add tag", mode: "tag", verb: "Add tag" },
{ keys: "s T", label: "Remove tag", mode: "untag", verb: "Remove tag" },
];
const VERB = Object.fromEntries(MODES.map((m) => [m.mode, m.verb])); const VERB = Object.fromEntries(MODES.map((m) => [m.mode, m.verb]));
let { let {
@@ -58,6 +52,10 @@
...lists.tags(), ...lists.tags(),
enabled: mode === "tag", enabled: mode === "tag",
})); }));
const bills = createQuery(() => ({
...lists.bills(),
enabled: mode === "bill",
}));
const visible = $derived( const visible = $derived(
selected > 0 || mode !== null || armed || status !== "" selected > 0 || mode !== null || armed || status !== ""
@@ -80,6 +78,14 @@
"(no budget)", "(no budget)",
false, false,
]; ];
case "bill":
return [
(bills.data ?? [])
.filter((b) => b.attributes.active !== false)
.map((b) => b.attributes.name ?? ""),
"(no subscription)",
false,
];
case "tag": case "tag":
return [(tags.data ?? []).map((t) => t.attributes.tag), null, true]; return [(tags.data ?? []).map((t) => t.attributes.tag), null, true];
case "untag": case "untag":
@@ -146,28 +152,23 @@
text = ""; text = "";
} }
function patcher(current: Mode, value: string | null) { function idOf(current: Mode, value: string | null): string | null {
const budgetId = const items = current === "bill" ? bills.data : budgets.data;
(budgets.data ?? []).find((b) => b.attributes.name === value)?.id ?? null; return items?.find((item) => item.attributes.name === value)?.id ?? null;
const patches: Record<Mode, (split: Split) => Patch | null> = { }
budget: (split) =>
split.type !== "withdrawal" || (split.budget_id ?? null) === budgetId async function run(action: MassAction, rows: Row[]) {
? null const verb = VERB[action.mode];
: { budget_id: budgetId }, busy = true;
category: (split) => const outcome = await updateGroups(
(split.category_name ?? null) === value targetsOf(rows),
? null patchFor(action),
: { category_name: value }, (progress) =>
tag: (split) => say(`${verb}: updated ${progress.done} of ${progress.total}`)
!value || split.tags?.includes(value) );
? null await invalidate();
: { tags: [...(split.tags ?? []), value] }, busy = false;
untag: (split) => report("Updated", outcome);
value && split.tags?.includes(value)
? { tags: split.tags.filter((tag) => tag !== value) }
: null,
};
return patches[current];
} }
async function apply(raw: string) { async function apply(raw: string) {
@@ -178,17 +179,25 @@
if (value === null && (mode === "tag" || mode === "untag")) { if (value === null && (mode === "tag" || mode === "untag")) {
return; return;
} }
const verb = VERB[mode]; const byId = mode === "budget" || mode === "bill";
const patch = patcher(mode, value); const id = byId ? idOf(mode, value) : null;
const rows = active; if (byId && value !== null && id === null) {
say(`Nothing called “${value}”`);
return;
}
const action = { id, mode, value };
lastAction.current = action;
mode = null; mode = null;
busy = true; await run(action, active);
const outcome = await updateGroups(targetsOf(rows), patch, (progress) => }
say(`${verb}: updated ${progress.done} of ${progress.total}`)
); export async function repeat(rows: Row[] = targets) {
await invalidate(); const action = lastAction.current;
busy = false; if (!action || busy || rows.length === 0) {
report("Updated", outcome); return;
}
active = rows;
await run(action, rows);
} }
export async function remove(rows: Row[] = targets, confirmed = false) { export async function remove(rows: Row[] = targets, confirmed = false) {
@@ -0,0 +1,418 @@
<script lang="ts">
import ChevronDown from "@lucide/svelte/icons/chevron-down";
import Funnel from "@lucide/svelte/icons/funnel";
import Search from "@lucide/svelte/icons/search";
import X from "@lucide/svelte/icons/x";
import { DropdownMenu } from "bits-ui";
import DropdownMenuCheckboxItem from "#lib/components/ui/dropdown-menu/dropdown-menu-checkbox-item.svelte";
import DropdownMenuContent from "#lib/components/ui/dropdown-menu/dropdown-menu-content.svelte";
import DropdownMenuItem from "#lib/components/ui/dropdown-menu/dropdown-menu-item.svelte";
import DropdownMenuRadioGroup from "#lib/components/ui/dropdown-menu/dropdown-menu-radio-group.svelte";
import DropdownMenuRadioItem from "#lib/components/ui/dropdown-menu/dropdown-menu-radio-item.svelte";
import DropdownMenuSeparator from "#lib/components/ui/dropdown-menu/dropdown-menu-separator.svelte";
import { fuzzyFilter } from "#lib/fuzzy.js";
import { useKeys } from "#lib/keys.svelte.js";
import {
type Count,
countFacets,
FACETS,
type FacetId,
GROUP_BY,
GROUP_LABEL,
type GroupBy,
isFiltered,
toggleValue,
valueLabel,
} from "#lib/transactions/ledger.js";
import type { LedgerView } from "#lib/transactions/ledger-view.svelte.js";
import type { Group } from "#lib/transactions/split.js";
interface Option extends Count {
facet: string;
id: FacetId;
}
const NOTE_MS = 4000;
const SUGGESTIONS = 6;
const LABEL = Object.fromEntries(FACETS.map((f) => [f.id, f.label]));
const TRIGGER =
"inline-flex h-7 shrink-0 items-center gap-1 rounded-cladd-sm px-2 text-[12px] hover:bg-row-hover hover:text-cladd-fg data-[state=open]:bg-row-hover";
const ITEM =
"min-h-7 gap-3 rounded-cladd-sm py-0.5 ps-9 pe-2 font-normal text-[13px]";
const LINK = "inline-flex items-center gap-1.5 hover:text-cladd-fg";
let {
view,
groups,
keep,
ids,
shown,
total,
duplicates,
onsearch,
}: {
view: LedgerView;
groups: Group[];
keep: (group: Group) => boolean;
ids: FacetId[];
shown: number;
total: number;
duplicates: number;
onsearch: () => void;
} = $props();
let input = $state<HTMLInputElement>();
let focused = $state(false);
let cursor = $state(-1);
let note = $state("");
let noteTimer: ReturnType<typeof setTimeout> | undefined;
const filters = $derived(view.filters);
const typed = $derived(filters.text.trim());
const counts = $derived(countFacets(groups, filters, ids, keep));
const options = $derived.by<Option[]>(() => {
if (!(typed && focused)) {
return [];
}
const loose = countFacets(groups, { ...filters, text: "" }, ids, keep);
return [...loose].flatMap(([id, list]) =>
list
.filter((c) => c.count > 0 && !filters.facets[id]?.includes(c.key))
.map((c) => ({ ...c, facet: LABEL[id], id }))
);
});
const suggestions = $derived(
fuzzyFilter(options, typed, (o) => `${o.label} ${o.facet}`, SUGGESTIONS)
);
const open = $derived(focused && typed !== "");
const nextGroup = $derived(
[null, ...GROUP_BY][
(GROUP_BY.indexOf(filters.group as GroupBy) + 2) % (GROUP_BY.length + 1)
]
);
function focus() {
input?.focus();
input?.select();
}
function say(message: string) {
note = message;
clearTimeout(noteTimer);
noteTimer = setTimeout(() => (note = ""), NOTE_MS);
}
function choose(option: Option | undefined) {
if (!option) {
return;
}
view.set({
...toggleValue(filters, option.id, option.key, true),
text: "",
});
input?.blur();
}
function onkeydown(event: KeyboardEvent) {
const count = suggestions.length;
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
event.preventDefault();
const step = event.key === "ArrowDown" ? 1 : -1;
cursor = ((cursor + 1 + step + count + 1) % (count + 1)) - 1;
} else if (event.key === "Tab" && !event.shiftKey && count > 0) {
event.preventDefault();
choose(suggestions[Math.max(cursor, 0)]);
} else if (event.key === "Enter") {
event.preventDefault();
if (cursor >= 0) {
choose(suggestions[cursor]);
} else if (typed) {
onsearch();
}
}
}
function summary(id: FacetId, keys: string[]) {
return keys.length === 1
? valueLabel(id, keys[0])
: `${keys.length} values`;
}
function setGroup(value: string) {
view.set({
...filters,
group: GROUP_BY.includes(value as GroupBy) ? (value as GroupBy) : null,
});
}
useKeys(() => [
{ group: "Filter", keys: "f", label: "Filter this list", run: focus },
{ group: "Filter", keys: "/", label: "Filter this list", run: focus },
{
group: "Filter",
keys: "F",
label: "Clear filters",
run: () => view.clear(),
},
{
group: "Filter",
keys: ";",
label: `Group by ${nextGroup ? GROUP_LABEL[nextGroup].toLowerCase() : "nothing"}`,
run: () => view.cycleGroup(),
},
{
group: "Filter",
keys: "P",
label: view.pinned >= 0 ? "Unpin this filter" : "Pin this filter",
run: () => say(view.togglePin()),
},
...view.pins.map((pin, index) => ({
group: "Pinned filters",
keys: String(index + 1),
label: `Filter: ${pin.label}`,
run: () => view.apply(pin),
})),
]);
</script>
{#snippet facetMenu(
id: FacetId
)}
{@const values = counts.get(id) ?? []}
{@const selected = filters.facets[id] ?? []}
<DropdownMenu.Root>
<DropdownMenu.Trigger
class="{TRIGGER} {selected.length
? "text-cladd-fg"
: "text-cladd-fg-soft"}"
>
{#if selected.length}
<Funnel class="size-3 text-brand" />
{LABEL[id]}:
<span class="max-w-40 truncate font-semibold"
>{summary(id, selected)}</span
>
{:else}
{LABEL[id]}
{/if}
<ChevronDown class="size-3 opacity-60" />
</DropdownMenu.Trigger>
<DropdownMenuContent align="start" class="w-72 p-1">
{#if selected.length}
<DropdownMenuItem
class={ITEM}
onSelect={() =>
view.set({ ...filters, facets: { ...filters.facets, [id]: [] } })}
>
Clear this filter
</DropdownMenuItem>
<DropdownMenuSeparator />
{/if}
{#each values as value (value.key)}
<DropdownMenuCheckboxItem
checked={selected.includes(value.key)}
class={ITEM}
closeOnSelect={false}
onCheckedChange={(on) =>
view.set(toggleValue(filters, id, value.key, on))}
textValue={value.label}
>
<span class="min-w-0 flex-1 truncate">{value.label}</span>
<span class="num text-[12px] text-cladd-fg-soft">{value.count}</span>
</DropdownMenuCheckboxItem>
{:else}
<p class="px-2 py-1 text-cladd-fg-soft">Nothing to filter by</p>
{/each}
</DropdownMenuContent>
</DropdownMenu.Root>
{/snippet}
<div class="flex flex-col gap-1 border-cladd-outline border-b px-2 pt-2 pb-1.5">
<div class="flex flex-wrap items-center gap-1">
<div class="relative w-64 max-md:w-full">
<Search
class="pointer-events-none absolute top-1/2 left-2 size-3.5 -translate-y-1/2 text-cladd-fg-softer"
/>
<input
aria-activedescendant={open ? `ledger-filter-${cursor}` : undefined}
aria-autocomplete="list"
aria-controls="ledger-filter-list"
aria-expanded={open}
aria-label="Filter this list"
autocomplete="off"
class="cladd-cut cladd-focusable-input h-7 w-full rounded-cladd-md pr-2 pl-7 text-[13px] text-cladd-fg placeholder:text-cladd-fg-softer"
id="ledger-filter"
onblur={() => (focused = false)}
onfocus={() => (focused = true)}
oninput={(event) => {
cursor = -1;
view.type(event.currentTarget.value);
}}
{onkeydown}
placeholder="Filter: text, amount, >100"
role="combobox"
spellcheck="false"
value={view.text}
bind:this={input}
>
{#if open}
<div
class="cladd-surface cladd-outline absolute top-full left-0 z-30 mt-1 w-80 max-w-[calc(100vw-2rem)] rounded-cladd-md p-1 shadow-cladd-popover"
id="ledger-filter-list"
role="listbox"
>
<div
aria-selected={cursor === -1}
class="flex h-7 cursor-pointer items-center gap-2 rounded-cladd-sm px-2 {cursor ===
-1
? "bg-row-cursor"
: "hover:bg-row-hover"}"
id="ledger-filter--1"
onmousedown={(event) => {
event.preventDefault();
onsearch();
}}
role="option"
tabindex="-1"
>
<span class="min-w-0 flex-1 truncate"
>Search Firefly for “{typed}”</span
>
<span class="kbd">↵</span>
</div>
{#each suggestions as option, index (`${option.id}:${option.key}`)}
<div
aria-selected={index === cursor}
class="flex h-7 cursor-pointer items-center gap-2 rounded-cladd-sm px-2 {index ===
cursor
? "bg-row-cursor"
: "hover:bg-row-hover"}"
id={`ledger-filter-${index}`}
onmousedown={(event) => {
event.preventDefault();
choose(option);
}}
role="option"
tabindex="-1"
>
<span class="w-22 shrink-0 text-[12px] text-cladd-fg-soft"
>{option.facet}</span
>
<span class="min-w-0 flex-1 truncate">{option.label}</span>
<span class="num text-[12px] text-cladd-fg-soft"
>{option.count}</span
>
{#if index === Math.max(cursor, 0)}
<span class="kbd">Tab</span>
{/if}
</div>
{/each}
</div>
{/if}
</div>
<div
class="flex min-w-0 items-center max-md:-mx-2 max-md:w-[calc(100%+1rem)] max-md:overflow-x-auto max-md:px-1 md:flex-wrap"
>
{#each ids as id (id)}
{@render facetMenu(id)}
{/each}
{#if duplicates > 0 || filters.dupes}
<button
aria-pressed={filters.dupes}
class="{TRIGGER} {filters.dupes ? "text-cladd-fg" : "text-warn"}"
onclick={() => view.set({ ...filters, dupes: !filters.dupes })}
type="button"
>
{#if filters.dupes}
<Funnel class="size-3 text-brand" />
{/if}
Possible duplicates
<span class="num font-semibold">{duplicates}</span>
</button>
{/if}
</div>
</div>
<div
class="flex min-h-6 flex-wrap items-center gap-x-3 gap-y-1 px-1 text-[12px] text-cladd-fg-soft"
>
{#each view.pins as pin, index (pin.query)}
{@const active = index === view.pinned}
<span class="inline-flex items-center gap-0.5">
<button
aria-label={`Filter: ${pin.label}`}
aria-pressed={active}
class="{LINK} {active ? "font-semibold text-cladd-fg" : ""}"
onclick={() => view.apply(pin)}
type="button"
>
<span class="kbd max-md:hidden">{index + 1}</span>
{pin.label}
</button>
<button
aria-label={`Unpin ${pin.label}`}
class="grid size-4 place-items-center rounded text-cladd-fg-softer hover:bg-row-hover hover:text-cladd-fg"
onclick={() => view.unpin(pin)}
type="button"
>
<X class="size-3" />
</button>
</span>
{/each}
<span class="ml-auto flex flex-wrap items-center gap-x-3 gap-y-1">
<span aria-live="polite">{note}</span>
<span class="flex items-center gap-0.5">
<DropdownMenu.Root>
<DropdownMenu.Trigger
class="{TRIGGER} {filters.group
? "text-cladd-fg"
: "text-cladd-fg-soft"}"
>
Group by:
<span class="font-semibold"
>{filters.group ? GROUP_LABEL[filters.group] : "None"}</span
>
<ChevronDown class="size-3 opacity-60" />
</DropdownMenu.Trigger>
<DropdownMenuContent align="end" class="w-44 p-1">
<DropdownMenuRadioGroup
onValueChange={setGroup}
value={filters.group ?? "none"}
>
<DropdownMenuRadioItem class={ITEM} value="none"
>None</DropdownMenuRadioItem
>
{#each GROUP_BY as value (value)}
<DropdownMenuRadioItem class={ITEM} {value}
>{GROUP_LABEL[value]}</DropdownMenuRadioItem
>
{/each}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu.Root>
<span class="kbd max-md:hidden">;</span>
</span>
<span class="num text-cladd-fg" id="ledger-count">
{#if isFiltered(filters)}
<strong>{shown}</strong>
of {total}
{:else}
{total}
{total === 1 ? "transaction" : "transactions"}
{/if}
</span>
{#if isFiltered(filters)}
<button class={LINK} onclick={() => view.clear()} type="button">
Clear <span class="kbd max-md:hidden">F</span>
</button>
<button
class={LINK}
onclick={() => say(view.togglePin())}
type="button"
>
{view.pinned >= 0 ? "Unpin" : "Pin"}
<span class="kbd max-md:hidden">P</span>
</button>
{/if}
</span>
</div>
</div>
@@ -5,7 +5,6 @@
import { import {
type Group, type Group,
type Signed, type Signed,
signedAmount,
sumByCurrency, sumByCurrency,
} from "#lib/transactions/split.js"; } from "#lib/transactions/split.js";
@@ -16,9 +15,10 @@
["transfer", "Transferred"], ["transfer", "Transferred"],
] as const; ] as const;
interface Entry extends Signed { interface Entry {
href?: string; href?: string;
name: string; name: string;
sums: Signed[];
} }
let { let {
@@ -36,25 +36,29 @@
})).filter((row) => row.sums.length > 0) })).filter((row) => row.sums.length > 0)
); );
const size = (entry: Entry) =>
Math.max(...entry.sums.map((sum) => Math.abs(sum.amount)));
function aggregate( function aggregate(
list: Split[], list: Split[],
pick: (split: Split) => [string, string | undefined] pick: (split: Split) => [string, string | undefined]
): { entries: Entry[]; more: number } { ): { entries: Entry[]; more: number } {
const map = new Map<string, Entry>(); const map = new Map<string, { href?: string; splits: Split[] }>();
for (const split of list) { for (const split of list) {
const [name, href] = pick(split); const [name, href] = pick(split);
const money = signedAmount(split); const entry = map.get(name) ?? { href, splits: [] };
const key = `${name}|${money.code}`; entry.splits.push(split);
const entry = map.get(key); map.set(name, entry);
if (entry) {
entry.amount += money.amount;
} else {
map.set(key, { ...money, href, name });
} }
} const sorted = [...map]
const sorted = [...map.values()].sort( .map(([name, entry]) => ({
href: entry.href,
name,
sums: sumByCurrency(entry.splits).sort(
(a, b) => Math.abs(b.amount) - Math.abs(a.amount) (a, b) => Math.abs(b.amount) - Math.abs(a.amount)
); ),
}))
.sort((a, b) => size(b) - size(a));
return { entries: sorted.slice(0, TOP), more: sorted.length - TOP }; return { entries: sorted.slice(0, TOP), more: sorted.length - TOP };
} }
@@ -127,8 +131,10 @@
{#each blocks as block (block.title)} {#each blocks as block (block.title)}
<Box body="py-1" class="max-md:hidden" title={block.title}> <Box body="py-1" class="max-md:hidden" title={block.title}>
<ul> <ul>
{#each block.entries as entry (`${entry.name}|${entry.code}`)} {#each block.entries as entry (entry.name)}
<li class="flex h-6 items-center justify-between gap-3 px-3"> <li
class="flex min-h-6 items-start justify-between gap-3 px-3 py-0.5"
>
{#if entry.href} {#if entry.href}
<a class="truncate hover:underline" href={entry.href} <a class="truncate hover:underline" href={entry.href}
>{entry.name}</a >{entry.name}</a
@@ -136,7 +142,11 @@
{:else} {:else}
<span class="truncate text-cladd-fg-soft">{entry.name}</span> <span class="truncate text-cladd-fg-soft">{entry.name}</span>
{/if} {/if}
{@render amount(entry)} <span class="flex flex-col items-end">
{#each entry.sums as sum (sum.code)}
{@render amount(sum)}
{/each}
</span>
</li> </li>
{:else} {:else}
<li class="px-3 py-1 text-cladd-fg-soft"> <li class="px-3 py-1 text-cladd-fg-soft">
@@ -3,8 +3,10 @@
import ChevronRight from "@lucide/svelte/icons/chevron-right"; import ChevronRight from "@lucide/svelte/icons/chevron-right";
import NotebookText from "@lucide/svelte/icons/notebook-text"; import NotebookText from "@lucide/svelte/icons/notebook-text";
import Paperclip from "@lucide/svelte/icons/paperclip"; import Paperclip from "@lucide/svelte/icons/paperclip";
import { untrack } from "svelte";
import type { Split } from "#lib/api/types.js"; import type { Split } from "#lib/api/types.js";
import Amount from "#lib/components/Amount.svelte"; import Amount from "#lib/components/Amount.svelte";
import ConfirmButton from "#lib/components/ConfirmButton.svelte";
import { import {
ContextMenu, ContextMenu,
ContextMenuContent, ContextMenuContent,
@@ -17,7 +19,14 @@
import type { Binding } from "#lib/keys.svelte.js"; import type { Binding } from "#lib/keys.svelte.js";
import { ListNav } from "#lib/list-nav.svelte.js"; import { ListNav } from "#lib/list-nav.svelte.js";
import { oldUiUrl } from "#lib/spa-paths.js"; import { oldUiUrl } from "#lib/spa-paths.js";
import { buildRows, type Row } from "#lib/transactions/mass.js"; import { type Section, subtotals } from "#lib/transactions/ledger.js";
import {
actionLabel,
buildRows,
type Mode,
type Row,
} from "#lib/transactions/mass.js";
import { lastAction } from "#lib/transactions/repeat.svelte.js";
import { import {
accountUrl, accountUrl,
balanceAfter, balanceAfter,
@@ -34,7 +43,7 @@
} from "#lib/transactions/split.js"; } from "#lib/transactions/split.js";
import { ui } from "#lib/ui.svelte.js"; import { ui } from "#lib/ui.svelte.js";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import ActionBar, { type Mode } from "./ActionBar.svelte"; import ActionBar from "./ActionBar.svelte";
import TypeIcon from "./TypeIcon.svelte"; import TypeIcon from "./TypeIcon.svelte";
let { let {
@@ -44,6 +53,7 @@
loading = false, loading = false,
id = "transactions", id = "transactions",
empty = "No transactions.", empty = "No transactions.",
section,
}: { }: {
groups: Group[]; groups: Group[];
context?: { accountId?: string }; context?: { accountId?: string };
@@ -51,8 +61,15 @@
loading?: boolean; loading?: boolean;
id?: string; id?: string;
empty?: string; empty?: string;
section?: (group: Group) => Section;
} = $props(); } = $props();
interface Block extends Section {
groups: Group[];
keys: string[];
sums: Signed[];
}
let collapsed = $state(new Set<string>()); let collapsed = $state(new Set<string>());
let opened = $state(new Set<string>()); let opened = $state(new Set<string>());
let bar = $state<ActionBar>(); let bar = $state<ActionBar>();
@@ -65,6 +82,56 @@
const accountId = $derived(context.accountId); const accountId = $derived(context.accountId);
const rows = $derived(buildRows(groups, collapsed)); const rows = $derived(buildRows(groups, collapsed));
const blocks = $derived.by(() => {
const starts = new Map<number, Block>();
if (!section) {
return starts;
}
const byKey = new Map<string, Block>();
let previous: string | null = null;
rows.forEach((row, index) => {
const value = section(row.group);
let block = byKey.get(value.key);
if (!block) {
block = { ...value, groups: [], keys: [], sums: [] };
byKey.set(value.key, block);
}
if (row.kind !== "split") {
block.groups.push(row.group);
}
block.keys.push(row.key);
if (value.key !== previous) {
starts.set(index, block);
previous = value.key;
}
});
for (const block of byKey.values()) {
block.sums = subtotals(block.groups, context.accountId);
}
return starts;
});
$effect(() => {
const present = new Set(rows.map((row) => row.key));
untrack(() => {
const kept = [...nav.selected].filter((key) => present.has(key));
if (kept.length !== nav.selected.size) {
nav.selected = new Set(kept);
}
});
});
function selectKeys(keys: string[], on: boolean) {
const next = new Set(nav.selected);
for (const key of keys) {
if (on) {
next.add(key);
} else {
next.delete(key);
}
}
nav.selected = next;
}
function toggled(set: Set<string>, key: string, on: boolean) { function toggled(set: Set<string>, key: string, on: boolean) {
const next = new Set(set); const next = new Set(set);
@@ -157,6 +224,7 @@
list.push( list.push(
action("category", "Set category", "s c"), action("category", "Set category", "s c"),
action("budget", "Set budget", "s b"), action("budget", "Set budget", "s b"),
action("bill", "Set subscription", "s s"),
action("tag", "Add tag", "s t"), action("tag", "Add tag", "s t"),
action("untag", "Remove tag", "s T"), action("untag", "Remove tag", "s T"),
{ {
@@ -167,6 +235,14 @@
} }
); );
} }
if (selectable && lastAction.current) {
list.push({
group: "Selection",
keys: "s .",
label: `Repeat: ${actionLabel(lastAction.current)}`,
run: () => bar?.repeat(),
});
}
return list; return list;
} }
@@ -402,6 +478,77 @@
</td> </td>
{/snippet} {/snippet}
{#snippet blockRow(
block: Block
)}
{@const all = block.keys.every((key) => nav.selected.has(key))}
<tr
class="border-cladd-outline border-t bg-cladd-surface-next [&>td]:px-2 [&>td]:py-1"
>
{#if selectable}
<td class="max-sm:hidden">
<input
aria-label={`Select ${block.label}`}
checked={all}
class={CHECKBOX}
onclick={(event) => {
event.currentTarget.blur();
selectKeys(block.keys, !all);
}}
type="checkbox"
>
</td>
{/if}
<td class="max-sm:hidden"></td>
<td class="min-w-0">
<div class="flex min-w-0 items-center gap-2">
{#if block.href}
<a class="truncate font-semibold hover:underline" href={block.href}
>{block.label}</a
>
{:else}
<span class="truncate font-semibold">{block.label}</span>
{/if}
<span class="num shrink-0 text-[12px] text-cladd-fg-soft"
>{block.groups.length}</span
>
</div>
{#if block.actions?.length}
<div class="mt-1 flex flex-wrap items-center gap-2">
{#each block.actions as item (item.label)}
{#if item.confirm}
<ConfirmButton onconfirm={item.run}>{item.label}</ConfirmButton>
{:else}
<button
class="inline-flex h-cladd-sm items-center rounded-cladd-sm px-2 text-[12px] text-cladd-fg-soft hover:bg-row-hover hover:text-cladd-fg"
onclick={item.run}
type="button"
>
{item.label}
</button>
{/if}
{/each}
</div>
{/if}
</td>
<td class="text-right font-semibold">
{#each block.sums as sum (`${sum.tone}|${sum.code}`)}
<div>{@render money(sum)}</div>
{/each}
</td>
<td class="max-lg:hidden"></td>
{#if accountId}
<td></td>
{/if}
<td></td>
<td class="max-md:hidden"></td>
<td class="max-md:hidden"></td>
<td class="max-lg:hidden"></td>
<td class="max-xl:hidden"></td>
<td class="max-2xl:hidden"></td>
</tr>
{/snippet}
{#snippet headCells( {#snippet headCells(
row: Row row: Row
)} )}
@@ -534,6 +681,10 @@
</thead> </thead>
<tbody class={loading && rows.length > 0 ? "opacity-60" : ""}> <tbody class={loading && rows.length > 0 ? "opacity-60" : ""}>
{#each rows as row, index (row.key)} {#each rows as row, index (row.key)}
{@const block = blocks.get(index)}
{#if block}
{@render blockRow(block)}
{/if}
<tr <tr
class="align-top [&>td]:px-2 [&>td]:py-1 {rowClass(row, index)}" class="align-top [&>td]:px-2 [&>td]:py-1 {rowClass(row, index)}"
data-index={index} data-index={index}
@@ -597,6 +748,9 @@
<ContextMenuItem onSelect={() => bar?.start("budget", menuRows)}> <ContextMenuItem onSelect={() => bar?.start("budget", menuRows)}>
Set budget... <ContextMenuShortcut>s b</ContextMenuShortcut> Set budget... <ContextMenuShortcut>s b</ContextMenuShortcut>
</ContextMenuItem> </ContextMenuItem>
<ContextMenuItem onSelect={() => bar?.start("bill", menuRows)}>
Set subscription... <ContextMenuShortcut>s s</ContextMenuShortcut>
</ContextMenuItem>
<ContextMenuItem onSelect={() => bar?.start("tag", menuRows)}> <ContextMenuItem onSelect={() => bar?.start("tag", menuRows)}>
Add tag... <ContextMenuShortcut>s t</ContextMenuShortcut> Add tag... <ContextMenuShortcut>s t</ContextMenuShortcut>
</ContextMenuItem> </ContextMenuItem>
+135
View File
@@ -0,0 +1,135 @@
import { untrack } from "svelte";
import { goto } from "$app/navigation";
import { page } from "$app/state";
import {
describe,
type Filters,
filterParams,
GROUP_BY,
isFiltered,
readFilters,
withFilters,
} from "./ledger.js";
const TYPING_MS = 300;
const MAX_PINS = 9;
const PINS_KEY = "ffui.ledger.pins";
const IGNORED_KEY = "ffui.ledger.notDuplicates";
export interface Pin {
label: string;
query: string;
}
const DEFAULT_PINS: Pin[] = [
{ label: "No budget", query: "budget=none" },
{ label: "No category", query: "category=none" },
{ label: "With notes", query: "notes=yes" },
];
function stored<T>(key: string, fallback: T): T {
try {
const raw = localStorage.getItem(key);
return raw ? (JSON.parse(raw) as T) : fallback;
} catch {
return fallback;
}
}
const pinQuery = (filters: Filters) =>
filterParams({ ...filters, group: null }).toString();
export class LedgerView {
text = $state("");
pins = $state<Pin[]>(stored(PINS_KEY, DEFAULT_PINS));
ignored = $state(new Set<string>(stored<string[]>(IGNORED_KEY, [])));
private written = "";
private timer: ReturnType<typeof setTimeout> | undefined;
url = $derived(readFilters(page.url.searchParams));
filters = $derived<Filters>({ ...this.url, text: this.text });
pinned = $derived(
this.pins.findIndex((pin) => pin.query === pinQuery(this.filters))
);
constructor() {
this.text = this.url.text;
this.written = this.text;
$effect(() => {
const incoming = this.url.text;
untrack(() => {
if (incoming !== this.written) {
this.written = incoming;
this.text = incoming;
}
});
});
}
private go(filters: Filters) {
this.written = filters.text;
goto(withFilters(page.url, filters), { replace: true, reset: false });
}
set(next: Filters) {
clearTimeout(this.timer);
this.text = next.text;
this.go(next);
}
type(value: string) {
this.text = value;
clearTimeout(this.timer);
this.timer = setTimeout(
() => this.go({ ...this.url, text: value }),
TYPING_MS
);
}
clear() {
this.set({ ...this.filters, dupes: false, facets: {}, text: "" });
}
cycleGroup() {
const order = [null, ...GROUP_BY];
const next = order[(order.indexOf(this.filters.group) + 1) % order.length];
this.set({ ...this.filters, group: next });
}
apply(pin: Pin) {
const active = pin.query === pinQuery(this.filters);
const next = readFilters(new URLSearchParams(active ? "" : pin.query));
this.set({ ...next, group: this.filters.group });
}
private savePins(pins: Pin[]) {
this.pins = pins;
localStorage.setItem(PINS_KEY, JSON.stringify(pins));
}
togglePin(): string {
if (this.pinned >= 0) {
const { label } = this.pins[this.pinned];
this.savePins(this.pins.filter((_, index) => index !== this.pinned));
return `Unpinned ${label}`;
}
if (!isFiltered(this.filters)) {
return "Filter first, then pin";
}
if (this.pins.length >= MAX_PINS) {
return `Up to ${MAX_PINS} pins`;
}
const label = describe({ ...this.filters, group: null });
this.savePins([...this.pins, { label, query: pinQuery(this.filters) }]);
return `Pinned as ${this.pins.length}`;
}
unpin(pin: Pin) {
this.savePins(this.pins.filter((item) => item.query !== pin.query));
}
ignore(id: string) {
this.ignored = new Set([...this.ignored, id]);
localStorage.setItem(IGNORED_KEY, JSON.stringify([...this.ignored]));
}
}
+617
View File
@@ -0,0 +1,617 @@
import type { Split } from "#lib/api/types.js";
import { addDays, addMonths, type Range } from "#lib/dates.js";
import { formatDate, formatMoney } from "#lib/format.js";
import {
accountUrl,
type Group,
type Signed,
signedAmount,
TYPE_LABEL,
} from "./split.js";
export type FacetId =
| "kind"
| "category"
| "budget"
| "account"
| "currency"
| "tag"
| "bill"
| "notes"
| "foreign";
export type GroupBy = "day" | "destination" | "category" | "budget";
export interface Facet {
id: FacetId;
keys: (split: Split) => string[];
label: string;
names?: Record<string, string>;
none?: string;
}
export interface Count {
count: number;
key: string;
label: string;
}
export interface Filters {
dupes: boolean;
facets: Partial<Record<FacetId, string[]>>;
group: GroupBy | null;
text: string;
}
export interface Section {
actions?: {
confirm?: boolean;
label: string;
run: () => void | Promise<void>;
}[];
href?: string;
key: string;
label: string;
}
export interface Cluster {
groups: Group[];
id: string;
label: string;
}
export const NONE = "none";
export const GROUP_BY: GroupBy[] = ["day", "destination", "category", "budget"];
export const GROUP_LABEL: Record<GroupBy, string> = {
budget: "Budget",
category: "Category",
day: "Day",
destination: "Destination",
};
const OWN = new Set([
"Asset account",
"Default account",
"Debt",
"Loan",
"Mortgage",
]);
const TONES = ["expense", "income", "transfer", "neutral"];
const WINDOW_MS = 48 * 3600 * 1000;
const HOUR_MS = 3600 * 1000;
const WHITESPACE = /\s+/;
const COMPARE = /^([<>])(\d+(?:[.,]\d+)?)$/;
const NEEDS_QUOTES = /[\s"]/;
const QUOTE = /"/g;
const PARENS = /\(([^)]*)\)/g;
const YES_NO = (yes: string, no: string) => ({ no, yes });
const flag = (on: boolean) => [on ? "yes" : "no"];
const named = (name: string | null | undefined, applies: boolean) => {
if (name) {
return [name];
}
return applies ? [NONE] : [];
};
export const FACETS: Facet[] = [
{
id: "kind",
keys: (s) => [s.type],
label: "Type",
names: TYPE_LABEL,
},
{
id: "category",
keys: (s) => named(s.category_name, true),
label: "Category",
none: "(no category)",
},
{
id: "budget",
keys: (s) => named(s.budget_name, s.type === "withdrawal"),
label: "Budget",
none: "(no budget)",
},
{
id: "account",
keys: (s) =>
[
OWN.has(s.source_type ?? "") ? s.source_name : null,
OWN.has(s.destination_type ?? "") ? s.destination_name : null,
].filter((name): name is string => Boolean(name)),
label: "Account",
},
{
id: "currency",
keys: (s) =>
[s.currency_code, s.foreign_currency_code].filter(
(code): code is string => Boolean(code)
),
label: "Currency",
},
{
id: "tag",
keys: (s) => (s.tags?.length ? s.tags : [NONE]),
label: "Tag",
none: "(no tags)",
},
{
id: "bill",
keys: (s) =>
named(s.bill_name ?? s.subscription_name, s.type === "withdrawal"),
label: "Subscription",
none: "(no subscription)",
},
{
id: "notes",
keys: (s) => flag(Boolean(s.notes?.trim())),
label: "Notes",
names: YES_NO("With notes", "Without notes"),
},
{
id: "foreign",
keys: (s) => flag(Boolean(s.foreign_amount && s.foreign_currency_code)),
label: "Foreign",
names: YES_NO("With foreign amount", "Without foreign amount"),
},
];
const FACET = Object.fromEntries(FACETS.map((f) => [f.id, f])) as Record<
FacetId,
Facet
>;
const PARAMS = ["q", "group", "dupes", "page", ...FACETS.map((f) => f.id)];
export function valueLabel(id: FacetId, key: string): string {
const facet = FACET[id];
if (key === NONE && facet.none) {
return facet.none;
}
return facet.names?.[key] ?? key;
}
export function readFilters(
params: Pick<URLSearchParams, "get" | "getAll">
): Filters {
const facets: Filters["facets"] = {};
for (const facet of FACETS) {
const values = params.getAll(facet.id).filter(Boolean);
if (values.length > 0) {
facets[facet.id] = [...new Set(values)];
}
}
const group = params.get("group") as GroupBy;
return {
dupes: params.get("dupes") === "1",
facets,
group: GROUP_BY.includes(group) ? group : null,
text: params.get("q") ?? "",
};
}
export function filterParams(filters: Filters): URLSearchParams {
const params = new URLSearchParams();
for (const facet of FACETS) {
for (const value of [...(filters.facets[facet.id] ?? [])].sort()) {
params.append(facet.id, value);
}
}
if (filters.text.trim()) {
params.set("q", filters.text);
}
if (filters.group) {
params.set("group", filters.group);
}
if (filters.dupes) {
params.set("dupes", "1");
}
return params;
}
export function withFilters(url: { href: string }, filters: Filters): URL {
const next = new URL(url.href);
for (const name of PARAMS) {
next.searchParams.delete(name);
}
for (const [name, value] of filterParams(filters)) {
next.searchParams.append(name, value);
}
return next;
}
export function isFiltered(filters: Filters): boolean {
return (
filters.dupes ||
filters.text.trim() !== "" ||
Object.values(filters.facets).some((values) => values.length > 0)
);
}
export function toggleValue(
filters: Filters,
id: FacetId,
key: string,
on?: boolean
): Filters {
const current = new Set(filters.facets[id] ?? []);
if (on ?? !current.has(key)) {
current.add(key);
} else {
current.delete(key);
}
return { ...filters, facets: { ...filters.facets, [id]: [...current] } };
}
const haystacks = new WeakMap<Split, string>();
function plainAmount(
value: string | null | undefined,
decimals?: number | null
) {
return value ? Number(value).toFixed(decimals ?? 2) : "";
}
function haystack(split: Split): string {
let text = haystacks.get(split);
if (text === undefined) {
text = [
split.description,
split.notes,
split.source_name,
split.destination_name,
split.category_name,
split.budget_name,
split.bill_name,
split.currency_code,
split.foreign_currency_code,
plainAmount(split.amount, split.currency_decimal_places),
plainAmount(split.foreign_amount, split.foreign_currency_decimal_places),
...(split.tags ?? []),
]
.filter(Boolean)
.join("\n")
.toLowerCase();
haystacks.set(split, text);
}
return text;
}
function wordTest(word: string): (split: Split) => boolean {
const match = COMPARE.exec(word);
if (!match) {
return (split) => haystack(split).includes(word);
}
const limit = Number(match[2].replace(",", "."));
const size = (split: Split) => Math.abs(Number(split.amount));
return match[1] === ">"
? (split) => size(split) > limit
: (split) => size(split) < limit;
}
export function splitTest(
filters: Filters,
skip?: FacetId
): (split: Split) => boolean {
const words = filters.text
.toLowerCase()
.split(WHITESPACE)
.filter((word) => word && !word.includes(":"))
.map(wordTest);
const active = FACETS.filter(
(facet) => facet.id !== skip && filters.facets[facet.id]?.length
).map((facet) => ({ facet, wanted: new Set(filters.facets[facet.id]) }));
return (split) =>
active.every(({ facet, wanted }) =>
facet.keys(split).some((key) => wanted.has(key))
) && words.every((test) => test(split));
}
export function narrow(
groups: Group[],
test: (split: Split) => boolean,
keep: (group: Group) => boolean = () => true
): Group[] {
const out: Group[] = [];
for (const group of groups) {
if (!keep(group)) {
continue;
}
const splits = group.attributes.transactions;
const kept = splits.filter(test);
if (kept.length === splits.length) {
out.push(group);
} else if (kept.length > 0) {
out.push({
...group,
attributes: { ...group.attributes, transactions: kept },
});
}
}
return out;
}
export function countFacets(
groups: Group[],
filters: Filters,
ids: FacetId[],
keep: (group: Group) => boolean
): Map<FacetId, Count[]> {
const result = new Map<FacetId, Count[]>();
for (const id of ids) {
const facet = FACET[id];
const test = splitTest(filters, id);
const found = new Map<string, Set<string>>(
(filters.facets[id] ?? []).map((key) => [key, new Set()])
);
for (const group of groups) {
if (!keep(group)) {
continue;
}
for (const split of group.attributes.transactions) {
if (!test(split)) {
continue;
}
for (const key of facet.keys(split)) {
const seen = found.get(key) ?? new Set<string>();
seen.add(group.id);
found.set(key, seen);
}
}
}
const counts = [...found].map(([key, seen]) => ({
count: seen.size,
key,
label: valueLabel(id, key),
}));
counts.sort(
(a, b) =>
Number(b.key === NONE) - Number(a.key === NONE) ||
b.count - a.count ||
a.label.localeCompare(b.label)
);
result.set(id, counts);
}
return result;
}
export function sectionOf(by: GroupBy, group: Group): Section {
const [split] = group.attributes.transactions;
switch (by) {
case "day": {
const day = split?.date?.slice(0, 10) ?? "";
return { key: day, label: formatDate(day, "day") };
}
case "destination":
return {
href: accountUrl(split?.destination_id),
key: `account:${split?.destination_id ?? ""}`,
label: split?.destination_name ?? "(cash)",
};
case "category":
return {
href: split?.category_id
? `/categories/show/${split.category_id}`
: undefined,
key: split?.category_name ?? NONE,
label: split?.category_name ?? "(no category)",
};
default:
return {
href: split?.budget_id ? `/budgets/show/${split.budget_id}` : undefined,
key: split?.budget_name ?? NONE,
label: split?.budget_name ?? "(no budget)",
};
}
}
export function bySection(
groups: Group[],
section: (group: Group) => Section
): Group[] {
const buckets = new Map<string, Group[]>();
for (const group of groups) {
const { key } = section(group);
const list = buckets.get(key) ?? [];
list.push(group);
buckets.set(key, list);
}
return [...buckets]
.sort(
([a, left], [b, right]) =>
Number(b === NONE) - Number(a === NONE) || right.length - left.length
)
.flatMap(([, list]) => list);
}
export function subtotals(groups: Group[], accountId?: string): Signed[] {
const sums = new Map<string, Signed>();
for (const group of groups) {
for (const split of group.attributes.transactions) {
const money = signedAmount(split, accountId);
const key = `${money.tone}|${money.code}`;
const sum = sums.get(key);
if (sum) {
sum.amount += money.amount;
} else {
sums.set(key, { ...money });
}
}
}
return [...sums.values()].sort(
(a, b) =>
TONES.indexOf(a.tone) - TONES.indexOf(b.tone) ||
(a.code ?? "").localeCompare(b.code ?? "")
);
}
function newestFirst(a: Group, b: Group) {
const dateA = a.attributes.transactions[0]?.date ?? "";
const dateB = b.attributes.transactions[0]?.date ?? "";
return dateB.localeCompare(dateA) || Number(b.id) - Number(a.id);
}
function spanLabel(ms: number): string {
const hours = Math.max(1, Math.round(ms / HOUR_MS));
if (hours < 24) {
return `${hours} h`;
}
const days = Math.round(hours / 24);
return days === 1 ? "1 day" : `${days} days`;
}
function clusterOf(
run: { group: Group; time: number }[],
split: Split
): Cluster {
const groups = [...new Set(run.map((item) => item.group))].sort(newestFirst);
const span = (run.at(-1)?.time ?? 0) - (run[0]?.time ?? 0);
const money = formatMoney({
amount: split.amount,
code: split.currency_code,
decimals: split.currency_decimal_places,
symbol: split.currency_symbol,
});
return {
groups,
id: groups
.map((group) => group.id)
.sort()
.join("+"),
label: `${split.destination_name ?? "(cash)"} · ${money} · ${spanLabel(span)} apart`,
};
}
export function duplicates(
groups: Group[],
ignored: Set<string>
): Map<string, Cluster> {
const buckets = new Map<
string,
{ group: Group; split: Split; time: number }[]
>();
for (const group of groups) {
for (const split of group.attributes.transactions) {
if (!split.destination_id) {
continue;
}
const key = `${split.type}|${split.destination_id}|${split.currency_code}|${Number(split.amount)}`;
const list = buckets.get(key) ?? [];
list.push({ group, split, time: Date.parse(split.date) });
buckets.set(key, list);
}
}
const found = new Map<string, Cluster>();
const flush = (run: { group: Group; split: Split; time: number }[]) => {
if (new Set(run.map((item) => item.group.id)).size < 2) {
return;
}
const cluster = clusterOf(run, run[0].split);
if (ignored.has(cluster.id)) {
return;
}
for (const group of cluster.groups) {
found.set(group.id, cluster);
}
};
for (const list of buckets.values()) {
list.sort((a, b) => a.time - b.time);
let run: typeof list = [];
for (const item of list) {
const last = run.at(-1);
if (last && item.time - last.time > WINDOW_MS) {
flush(run);
run = [];
}
run.push(item);
}
flush(run);
}
return found;
}
export function accountIdOf(groups: Group[], name: string): string | undefined {
for (const group of groups) {
for (const split of group.attributes.transactions) {
if (split.source_name === name && OWN.has(split.source_type ?? "")) {
return split.source_id ?? undefined;
}
if (
split.destination_name === name &&
OWN.has(split.destination_type ?? "")
) {
return split.destination_id ?? undefined;
}
}
}
}
export function yearRange(end: string): Range {
return { end, start: addDays(addMonths(end, -12), 1) };
}
const quote = (value: string) =>
NEEDS_QUOTES.test(value) ? `"${value.replace(QUOTE, '\\"')}"` : value;
const OPERATOR: Partial<Record<FacetId, [string, string]>> = {
account: ["account_is", ""],
bill: ["bill_is", "has_no_bill:true"],
budget: ["budget_is", "has_no_budget:true"],
category: ["category_is", "has_no_category:true"],
currency: ["currency_is", ""],
kind: ["type", ""],
tag: ["tag_is", "has_no_tag:true"],
};
const FLAG_OPERATOR: Record<string, Record<string, string>> = {
foreign: { no: "-has_foreign_amount:true", yes: "has_foreign_amount:true" },
notes: { no: "no_notes:true", yes: "has_any_notes:true" },
};
export function searchQuery(
filters: Filters,
type: string,
range: Range | null
): string {
const parts = [filters.text.trim()];
if (type !== "all") {
parts.push(`type:${type}`);
}
for (const [id, values] of Object.entries(filters.facets)) {
if (values?.length !== 1) {
continue;
}
const [value] = values;
const flagged = FLAG_OPERATOR[id]?.[value];
const [operator, none] = OPERATOR[id as FacetId] ?? ["", ""];
if (flagged) {
parts.push(flagged);
} else if (value === NONE && none) {
parts.push(none);
} else if (operator) {
parts.push(`${operator}:${quote(value)}`);
}
}
if (range) {
parts.push(`date_after:${range.start}`, `date_before:${range.end}`);
}
return parts.filter(Boolean).join(" ");
}
export function describe(filters: Filters): string {
const parts: string[] = [];
for (const facet of FACETS) {
const values = filters.facets[facet.id] ?? [];
if (values.length > 0) {
parts.push(values.map((key) => valueLabel(facet.id, key)).join(" or "));
}
}
if (filters.text.trim()) {
parts.push(`“${filters.text.trim()}”`);
}
if (filters.dupes) {
parts.push("Duplicates");
}
if (filters.group) {
parts.push(`by ${GROUP_LABEL[filters.group].toLowerCase()}`);
}
const label = parts.join(", ").replace(PARENS, "$1");
return label.charAt(0).toUpperCase() + label.slice(1);
}
+80
View File
@@ -4,6 +4,86 @@ import { type Group, groupTitle, journalId } from "./split.js";
export type Patch = Record<string, unknown>; export type Patch = Record<string, unknown>;
export type Targets = Map<string, Set<string> | null>; export type Targets = Map<string, Set<string> | null>;
export type Mode = "category" | "budget" | "bill" | "tag" | "untag";
export interface MassAction {
id: string | null;
mode: Mode;
value: string | null;
}
export const MODES: {
keys: string;
label: string;
mode: Mode;
none?: string;
verb: string;
}[] = [
{
keys: "s c",
label: "Category",
mode: "category",
none: "(no category)",
verb: "Set category",
},
{
keys: "s b",
label: "Budget",
mode: "budget",
none: "(no budget)",
verb: "Set budget",
},
{
keys: "s s",
label: "Subscription",
mode: "bill",
none: "(no subscription)",
verb: "Set subscription",
},
{ keys: "s t", label: "Add tag", mode: "tag", verb: "Add tag" },
{ keys: "s T", label: "Remove tag", mode: "untag", verb: "Remove tag" },
];
const MODE = Object.fromEntries(MODES.map((m) => [m.mode, m]));
export function actionLabel(action: MassAction): string {
const { verb, none } = MODE[action.mode];
return `${verb} “${action.value ?? none}”`;
}
export function patchFor({
mode,
value,
id,
}: MassAction): (split: Split) => Patch | null {
switch (mode) {
case "budget":
return (split) =>
split.type !== "withdrawal" || (split.budget_id ?? null) === id
? null
: { budget_id: id };
case "bill":
return (split) =>
split.type !== "withdrawal" || (split.bill_id ?? null) === id
? null
: { bill_id: id };
case "category":
return (split) =>
(split.category_name ?? null) === value
? null
: { category_name: value };
case "tag":
return (split) =>
!value || split.tags?.includes(value)
? null
: { tags: [...(split.tags ?? []), value] };
default:
return (split) =>
value && split.tags?.includes(value)
? { tags: split.tags.filter((tag) => tag !== value) }
: null;
}
}
export interface Row { export interface Row {
count: number; count: number;
+5
View File
@@ -0,0 +1,5 @@
import type { MassAction } from "./mass.js";
export const lastAction = $state<{ current: MassAction | null }>({
current: null,
});
@@ -3,10 +3,11 @@
import { createQuery, keepPreviousData } from "@tanstack/svelte-query"; import { createQuery, keepPreviousData } from "@tanstack/svelte-query";
import { tick, untrack } from "svelte"; import { tick, untrack } from "svelte";
import { api } from "#lib/api/client.js"; import { api } from "#lib/api/client.js";
import { all } from "#lib/api/lists.js"; import { all, invalidate } from "#lib/api/lists.js";
import type { Page, TransactionGroup } from "#lib/api/types.js"; import type { Page, TransactionGroup } from "#lib/api/types.js";
import Box from "#lib/components/Box.svelte"; import Box from "#lib/components/Box.svelte";
import PageHeader from "#lib/components/shell/PageHeader.svelte"; import PageHeader from "#lib/components/shell/PageHeader.svelte";
import FilterBar from "#lib/components/transactions/FilterBar.svelte";
import Pager from "#lib/components/transactions/Pager.svelte"; import Pager from "#lib/components/transactions/Pager.svelte";
import PeriodSummary from "#lib/components/transactions/PeriodSummary.svelte"; import PeriodSummary from "#lib/components/transactions/PeriodSummary.svelte";
import Periods from "#lib/components/transactions/Periods.svelte"; import Periods from "#lib/components/transactions/Periods.svelte";
@@ -17,8 +18,25 @@
import { useKeys } from "#lib/keys.svelte.js"; import { useKeys } from "#lib/keys.svelte.js";
import { period } from "#lib/period.svelte.js"; import { period } from "#lib/period.svelte.js";
import { session } from "#lib/session.svelte.js"; import { session } from "#lib/session.svelte.js";
import {
accountIdOf,
bySection,
type Cluster,
duplicates,
FACETS,
type FacetId,
isFiltered,
narrow,
type Section,
searchQuery,
sectionOf,
splitTest,
yearRange,
} from "#lib/transactions/ledger.js";
import { LedgerView } from "#lib/transactions/ledger-view.svelte.js";
import { import {
createType, createType,
type Group,
LIST_TITLE, LIST_TITLE,
listPath, listPath,
listType, listType,
@@ -32,6 +50,9 @@
transfer: "All transfers", transfer: "All transfers",
withdrawal: "All expenses", withdrawal: "All expenses",
}; };
const WITHDRAWAL_ONLY = new Set<FacetId>(["budget", "bill"]);
const TOOL =
"inline-flex h-7 items-center gap-1.5 rounded-cladd-sm px-2 text-[12px] text-cladd-fg-soft hover:bg-row-hover hover:text-cladd-fg";
let { let {
param, param,
@@ -49,13 +70,20 @@
let input = $state<HTMLInputElement>(); let input = $state<HTMLInputElement>();
let lastRange = ""; let lastRange = "";
const view = new LedgerView();
const type = $derived(listType(param)); const type = $derived(listType(param));
const pageNo = $derived( const pageNo = $derived(
Math.max(1, Number(page.url.searchParams.get("page")) || 1) Math.max(1, Number(page.url.searchParams.get("page")) || 1)
); );
const range = $derived( const year = $derived(page.url.searchParams.get("span") === "year");
everything ? null : { end: period.end, start: period.start } const range = $derived.by(() => {
); if (everything) {
return null;
}
return year
? yearRange(period.end)
: { end: period.end, start: period.start };
});
$effect(() => { $effect(() => {
if (period.ready && start && end) { if (period.ready && start && end) {
@@ -89,29 +117,16 @@
}); });
const list = createQuery(() => ({ const list = createQuery(() => ({
enabled: everything || period.ready, enabled: everything,
placeholderData: keepPreviousData, placeholderData: keepPreviousData,
queryFn: () => queryFn: () =>
api<Page<TransactionGroup>>("/v1/transactions", { api<Page<TransactionGroup>>("/v1/transactions", {
query: { query: { limit: session.pageSize, page: pageNo, type },
end: range?.end,
limit: session.pageSize,
page: pageNo,
start: range?.start,
type,
},
}), }),
queryKey: [ queryKey: ["transactions", type, pageNo, session.pageSize],
"transactions",
type,
range?.start,
range?.end,
pageNo,
session.pageSize,
],
})); }));
const summary = createQuery(() => ({ const ledger = createQuery(() => ({
enabled: !everything && period.ready, enabled: !everything && period.ready,
placeholderData: keepPreviousData, placeholderData: keepPreviousData,
queryFn: () => queryFn: () =>
@@ -120,16 +135,80 @@
start: range?.start, start: range?.start,
type, type,
}), }),
queryKey: ["transactions-summary", type, range?.start, range?.end], queryKey: ["transactions-ledger", type, range?.start, range?.end],
refetchOnWindowFocus: "always" as const,
})); }));
const pagination = $derived(list.data?.meta.pagination); const groups = $derived(ledger.data ?? []);
const filters = $derived(view.filters);
const filtered = $derived(isFiltered(filters));
const clusters = $derived(duplicates(groups, view.ignored));
const clusterCount = $derived(new Set(clusters.values()).size);
const keep = $derived(
filters.dupes ? (group: Group) => clusters.has(group.id) : () => true
);
const matched = $derived(narrow(groups, splitTest(filters), keep));
const section = $derived.by<((group: Group) => Section) | undefined>(() => {
if (filters.dupes) {
return (group) => clusterSection(clusters.get(group.id));
}
const by = filters.group;
return by ? (group) => sectionOf(by, group) : undefined;
});
const ordered = $derived(
section && filters.group !== "day" ? bySection(matched, section) : matched
);
const paged = $derived(!(filtered || section));
const pages = $derived(
paged ? Math.max(1, Math.ceil(matched.length / session.pageSize)) : 1
);
const visible = $derived(
paged
? ordered.slice(
(pageNo - 1) * session.pageSize,
pageNo * session.pageSize
)
: ordered
);
const facetIds = $derived(
FACETS.map((facet) => facet.id).filter(
(id) =>
(id !== "kind" || type === "all") &&
!(WITHDRAWAL_ONLY.has(id) && ["deposit", "transfer"].includes(type))
)
);
const narrowedTo = $derived.by(() => {
const accounts = filters.facets.account ?? [];
return accounts.length === 1 ? accountIdOf(groups, accounts[0]) : undefined;
});
const subtitle = $derived( const subtitle = $derived(
range range
? `${SUBTITLE[type]} between ${formatDate(range.start)} and ${formatDate(range.end)}` ? `${SUBTITLE[type]} between ${formatDate(range.start)} and ${formatDate(range.end)}`
: SUBTITLE[type] : SUBTITLE[type]
); );
function clusterSection(cluster: Cluster | undefined): Section {
if (!cluster) {
return { key: "", label: "" };
}
const [newest] = cluster.groups;
return {
actions: [
{
confirm: true,
label: "Delete newer",
run: async () => {
await api(`/v1/transactions/${newest.id}`, { method: "DELETE" });
await invalidate();
},
},
{ label: "Not duplicates", run: () => view.ignore(cluster.id) },
],
key: cluster.id,
label: cluster.label,
};
}
function pageHref(n: number) { function pageHref(n: number) {
const url = new URL(page.url.href); const url = new URL(page.url.href);
url.searchParams.set("page", String(n)); url.searchParams.set("page", String(n));
@@ -140,7 +219,20 @@
goto(`/search?search=${encodeURIComponent(text)}`); goto(`/search?search=${encodeURIComponent(text)}`);
} }
useKeys(() => [ function toggleYear() {
const url = new URL(page.url.href);
if (year) {
url.searchParams.delete("span");
} else {
url.searchParams.set("span", "year");
}
url.searchParams.delete("page");
goto(url, { replace: true, reset: false });
}
useKeys(() =>
everything
? [
{ {
group: "Global", group: "Global",
keys: "/", keys: "/",
@@ -153,7 +245,16 @@
input?.setSelectionRange(query.length, query.length); input?.setSelectionRange(query.length, query.length);
}, },
}, },
]); ]
: [
{
group: "Filter",
keys: "Y",
label: year ? "Back to the period" : "Load the last 12 months",
run: toggleYear,
},
]
);
</script> </script>
<PageHeader <PageHeader
@@ -165,6 +266,7 @@
title="Transactions" title="Transactions"
> >
{#snippet actions()} {#snippet actions()}
{#if everything}
<QueryInput <QueryInput
class="w-80 max-md:w-full" class="w-80 max-md:w-full"
id="list-query" id="list-query"
@@ -173,6 +275,7 @@
bind:input bind:input
bind:value={query} bind:value={query}
/> />
{/if}
<Button <Button
color="green" color="green"
href={`/transactions/create/${createType(type)}`} href={`/transactions/create/${createType(type)}`}
@@ -185,11 +288,7 @@
</PageHeader> </PageHeader>
{#if !everything} {#if !everything}
<PeriodSummary <PeriodSummary groups={matched} loading={ledger.isPending} {type} />
groups={summary.data ?? []}
loading={summary.isPending}
{type}
/>
{/if} {/if}
<div <div
@@ -197,23 +296,54 @@
> >
<Box body="p-0" title={LIST_TITLE[type] ?? "Transactions"}> <Box body="p-0" title={LIST_TITLE[type] ?? "Transactions"}>
{#snippet tools()} {#snippet tools()}
{#if !everything}
<button class={TOOL} onclick={toggleYear} type="button">
{year ? "Back to the period" : "Load year"}
<span class="kbd max-md:hidden">Y</span>
</button>
{/if}
<span class="max-sm:hidden"> <span class="max-sm:hidden">
{#if everything}
<Pager <Pager
href={pageHref} href={pageHref}
keys keys
page={pageNo} page={pageNo}
pages={pagination?.total_pages ?? 1} pages={list.data?.meta.pagination?.total_pages ?? 1}
/> />
{:else if paged}
<Pager href={pageHref} keys page={pageNo} {pages} />
{/if}
</span> </span>
{/snippet} {/snippet}
{#if everything}
<TransactionTable <TransactionTable
empty={everything empty="No transactions."
? "No transactions."
: "No transactions in this period."}
groups={list.data?.data ?? []} groups={list.data?.data ?? []}
id="tx-list" id="tx-list"
loading={list.isFetching} loading={list.isFetching}
/> />
{:else}
<FilterBar
duplicates={clusterCount}
{groups}
ids={facetIds}
{keep}
onsearch={() => search(searchQuery(filters, type, range))}
shown={matched.length}
total={groups.length}
{view}
/>
<TransactionTable
context={{ accountId: narrowedTo }}
empty={filtered
? "Nothing matches this filter."
: "No transactions in this period."}
groups={visible}
id="tx-list"
loading={ledger.isPending || ledger.isPlaceholderData}
{section}
/>
{/if}
{#snippet footer()} {#snippet footer()}
{#if everything} {#if everything}
<a class="text-brand hover:underline" href={listPath(type)} <a class="text-brand hover:underline" href={listPath(type)}
@@ -225,11 +355,15 @@
> >
{/if} {/if}
<span class="ml-auto"> <span class="ml-auto">
{#if everything}
<Pager <Pager
href={pageHref} href={pageHref}
page={pageNo} page={pageNo}
pages={pagination?.total_pages ?? 1} pages={list.data?.meta.pagination?.total_pages ?? 1}
/> />
{:else if paged}
<Pager href={pageHref} page={pageNo} {pages} />
{/if}
</span> </span>
{/snippet} {/snippet}
</Box> </Box>