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
+1693 -131

No files matched your search

@@ -2,32 +2,26 @@
import { createQuery } from "@tanstack/svelte-query";
import { tick } from "svelte";
import { invalidate, lists } from "#lib/api/lists.js";
import type { Split } from "#lib/api/types.js";
import { fuzzyFilter } from "#lib/fuzzy.js";
import {
deleteTargets,
type MassAction,
MODES,
type Mode,
type Outcome,
type Patch,
patchFor,
type Row,
splitsOf,
targetsOf,
updateGroups,
} from "#lib/transactions/mass.js";
import type { Suggestion } from "#lib/transactions/query.js";
import { lastAction } from "#lib/transactions/repeat.svelte.js";
import SuggestInput from "./SuggestInput.svelte";
export type Mode = "category" | "budget" | "tag" | "untag";
const ARM_MS = 3000;
const STATUS_MS = 8000;
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]));
let {
@@ -58,6 +52,10 @@
...lists.tags(),
enabled: mode === "tag",
}));
const bills = createQuery(() => ({
...lists.bills(),
enabled: mode === "bill",
}));
const visible = $derived(
selected > 0 || mode !== null || armed || status !== ""
@@ -80,6 +78,14 @@
"(no budget)",
false,
];
case "bill":
return [
(bills.data ?? [])
.filter((b) => b.attributes.active !== false)
.map((b) => b.attributes.name ?? ""),
"(no subscription)",
false,
];
case "tag":
return [(tags.data ?? []).map((t) => t.attributes.tag), null, true];
case "untag":
@@ -146,28 +152,23 @@
text = "";
}
function patcher(current: Mode, value: string | null) {
const budgetId =
(budgets.data ?? []).find((b) => b.attributes.name === value)?.id ?? null;
const patches: Record<Mode, (split: Split) => Patch | null> = {
budget: (split) =>
split.type !== "withdrawal" || (split.budget_id ?? null) === budgetId
? null
: { budget_id: budgetId },
category: (split) =>
(split.category_name ?? null) === value
? null
: { category_name: value },
tag: (split) =>
!value || split.tags?.includes(value)
? null
: { tags: [...(split.tags ?? []), value] },
untag: (split) =>
value && split.tags?.includes(value)
? { tags: split.tags.filter((tag) => tag !== value) }
: null,
};
return patches[current];
function idOf(current: Mode, value: string | null): string | null {
const items = current === "bill" ? bills.data : budgets.data;
return items?.find((item) => item.attributes.name === value)?.id ?? null;
}
async function run(action: MassAction, rows: Row[]) {
const verb = VERB[action.mode];
busy = true;
const outcome = await updateGroups(
targetsOf(rows),
patchFor(action),
(progress) =>
say(`${verb}: updated ${progress.done} of ${progress.total}`)
);
await invalidate();
busy = false;
report("Updated", outcome);
}
async function apply(raw: string) {
@@ -178,17 +179,25 @@
if (value === null && (mode === "tag" || mode === "untag")) {
return;
}
const verb = VERB[mode];
const patch = patcher(mode, value);
const rows = active;
const byId = mode === "budget" || mode === "bill";
const id = byId ? idOf(mode, value) : null;
if (byId && value !== null && id === null) {
say(`Nothing called “${value}”`);
return;
}
const action = { id, mode, value };
lastAction.current = action;
mode = null;
busy = true;
const outcome = await updateGroups(targetsOf(rows), patch, (progress) =>
say(`${verb}: updated ${progress.done} of ${progress.total}`)
);
await invalidate();
busy = false;
report("Updated", outcome);
await run(action, active);
}
export async function repeat(rows: Row[] = targets) {
const action = lastAction.current;
if (!action || busy || rows.length === 0) {
return;
}
active = rows;
await run(action, rows);
}
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 {
type Group,
type Signed,
signedAmount,
sumByCurrency,
} from "#lib/transactions/split.js";
@@ -16,9 +15,10 @@
["transfer", "Transferred"],
] as const;
interface Entry extends Signed {
interface Entry {
href?: string;
name: string;
sums: Signed[];
}
let {
@@ -36,25 +36,29 @@
})).filter((row) => row.sums.length > 0)
);
const size = (entry: Entry) =>
Math.max(...entry.sums.map((sum) => Math.abs(sum.amount)));
function aggregate(
list: Split[],
pick: (split: Split) => [string, string | undefined]
): { entries: Entry[]; more: number } {
const map = new Map<string, Entry>();
const map = new Map<string, { href?: string; splits: Split[] }>();
for (const split of list) {
const [name, href] = pick(split);
const money = signedAmount(split);
const key = `${name}|${money.code}`;
const entry = map.get(key);
if (entry) {
entry.amount += money.amount;
} else {
map.set(key, { ...money, href, name });
}
const entry = map.get(name) ?? { href, splits: [] };
entry.splits.push(split);
map.set(name, entry);
}
const sorted = [...map.values()].sort(
(a, b) => Math.abs(b.amount) - Math.abs(a.amount)
);
const sorted = [...map]
.map(([name, entry]) => ({
href: entry.href,
name,
sums: sumByCurrency(entry.splits).sort(
(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 };
}
@@ -127,8 +131,10 @@
{#each blocks as block (block.title)}
<Box body="py-1" class="max-md:hidden" title={block.title}>
<ul>
{#each block.entries as entry (`${entry.name}|${entry.code}`)}
<li class="flex h-6 items-center justify-between gap-3 px-3">
{#each block.entries as entry (entry.name)}
<li
class="flex min-h-6 items-start justify-between gap-3 px-3 py-0.5"
>
{#if entry.href}
<a class="truncate hover:underline" href={entry.href}
>{entry.name}</a
@@ -136,7 +142,11 @@
{:else}
<span class="truncate text-cladd-fg-soft">{entry.name}</span>
{/if}
{@render amount(entry)}
<span class="flex flex-col items-end">
{#each entry.sums as sum (sum.code)}
{@render amount(sum)}
{/each}
</span>
</li>
{:else}
<li class="px-3 py-1 text-cladd-fg-soft">
@@ -3,8 +3,10 @@
import ChevronRight from "@lucide/svelte/icons/chevron-right";
import NotebookText from "@lucide/svelte/icons/notebook-text";
import Paperclip from "@lucide/svelte/icons/paperclip";
import { untrack } from "svelte";
import type { Split } from "#lib/api/types.js";
import Amount from "#lib/components/Amount.svelte";
import ConfirmButton from "#lib/components/ConfirmButton.svelte";
import {
ContextMenu,
ContextMenuContent,
@@ -17,7 +19,14 @@
import type { Binding } from "#lib/keys.svelte.js";
import { ListNav } from "#lib/list-nav.svelte.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 {
accountUrl,
balanceAfter,
@@ -34,7 +43,7 @@
} from "#lib/transactions/split.js";
import { ui } from "#lib/ui.svelte.js";
import { goto } from "$app/navigation";
import ActionBar, { type Mode } from "./ActionBar.svelte";
import ActionBar from "./ActionBar.svelte";
import TypeIcon from "./TypeIcon.svelte";
let {
@@ -44,6 +53,7 @@
loading = false,
id = "transactions",
empty = "No transactions.",
section,
}: {
groups: Group[];
context?: { accountId?: string };
@@ -51,8 +61,15 @@
loading?: boolean;
id?: string;
empty?: string;
section?: (group: Group) => Section;
} = $props();
interface Block extends Section {
groups: Group[];
keys: string[];
sums: Signed[];
}
let collapsed = $state(new Set<string>());
let opened = $state(new Set<string>());
let bar = $state<ActionBar>();
@@ -65,6 +82,56 @@
const accountId = $derived(context.accountId);
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) {
const next = new Set(set);
@@ -157,6 +224,7 @@
list.push(
action("category", "Set category", "s c"),
action("budget", "Set budget", "s b"),
action("bill", "Set subscription", "s s"),
action("tag", "Add 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;
}
@@ -402,6 +478,77 @@
</td>
{/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(
row: Row
)}
@@ -534,6 +681,10 @@
</thead>
<tbody class={loading && rows.length > 0 ? "opacity-60" : ""}>
{#each rows as row, index (row.key)}
{@const block = blocks.get(index)}
{#if block}
{@render blockRow(block)}
{/if}
<tr
class="align-top [&>td]:px-2 [&>td]:py-1 {rowClass(row, index)}"
data-index={index}
@@ -597,6 +748,9 @@
<ContextMenuItem onSelect={() => bar?.start("budget", menuRows)}>
Set budget... <ContextMenuShortcut>s b</ContextMenuShortcut>
</ContextMenuItem>
<ContextMenuItem onSelect={() => bar?.start("bill", menuRows)}>
Set subscription... <ContextMenuShortcut>s s</ContextMenuShortcut>
</ContextMenuItem>
<ContextMenuItem onSelect={() => bar?.start("tag", menuRows)}>
Add tag... <ContextMenuShortcut>s t</ContextMenuShortcut>
</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 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 {
count: number;
+5
View File
@@ -0,0 +1,5 @@
import type { MassAction } from "./mass.js";
export const lastAction = $state<{ current: MassAction | null }>({
current: null,
});