From 153a8cb1913e375b722f1b60b6fbe3f39c76cda6 Mon Sep 17 00:00:00 2001 From: h Date: Fri, 16 Oct 2026 15:13:35 +0000 Subject: [PATCH] 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. --- .../components/transactions/ActionBar.svelte | 95 +-- .../components/transactions/FilterBar.svelte | 418 ++++++++++++ .../transactions/PeriodSummary.svelte | 44 +- .../transactions/TransactionTable.svelte | 158 ++++- src/lib/transactions/ledger-view.svelte.ts | 135 ++++ src/lib/transactions/ledger.ts | 617 ++++++++++++++++++ src/lib/transactions/mass.ts | 80 +++ src/lib/transactions/repeat.svelte.ts | 5 + .../[type=txtype]/ListPage.svelte | 272 ++++++-- 9 files changed, 1693 insertions(+), 131 deletions(-) create mode 100644 src/lib/components/transactions/FilterBar.svelte create mode 100644 src/lib/transactions/ledger-view.svelte.ts create mode 100644 src/lib/transactions/ledger.ts create mode 100644 src/lib/transactions/repeat.svelte.ts diff --git a/src/lib/components/transactions/ActionBar.svelte b/src/lib/components/transactions/ActionBar.svelte index 12655af..520eaf1 100644 --- a/src/lib/components/transactions/ActionBar.svelte +++ b/src/lib/components/transactions/ActionBar.svelte @@ -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 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) { diff --git a/src/lib/components/transactions/FilterBar.svelte b/src/lib/components/transactions/FilterBar.svelte new file mode 100644 index 0000000..4175f84 --- /dev/null +++ b/src/lib/components/transactions/FilterBar.svelte @@ -0,0 +1,418 @@ + + +{#snippet facetMenu( + id: FacetId +)} + {@const values = counts.get(id) ?? []} + {@const selected = filters.facets[id] ?? []} + + + {#if selected.length} + + {LABEL[id]}: + {summary(id, selected)} + {:else} + {LABEL[id]} + {/if} + + + + {#if selected.length} + + view.set({ ...filters, facets: { ...filters.facets, [id]: [] } })} + > + Clear this filter + + + {/if} + {#each values as value (value.key)} + + view.set(toggleValue(filters, id, value.key, on))} + textValue={value.label} + > + {value.label} + {value.count} + + {:else} +

Nothing to filter by

+ {/each} +
+
+{/snippet} + +
+
+
+ + (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} +
+
{ + event.preventDefault(); + onsearch(); + }} + role="option" + tabindex="-1" + > + Search Firefly for “{typed}” + ↵ +
+ {#each suggestions as option, index (`${option.id}:${option.key}`)} +
{ + event.preventDefault(); + choose(option); + }} + role="option" + tabindex="-1" + > + {option.facet} + {option.label} + {option.count} + {#if index === Math.max(cursor, 0)} + Tab + {/if} +
+ {/each} +
+ {/if} +
+
+ {#each ids as id (id)} + {@render facetMenu(id)} + {/each} + {#if duplicates > 0 || filters.dupes} + + {/if} +
+
+
+ {#each view.pins as pin, index (pin.query)} + {@const active = index === view.pinned} + + + + + {/each} + + {note} + + + + Group by: + {filters.group ? GROUP_LABEL[filters.group] : "None"} + + + + + None + {#each GROUP_BY as value (value)} + {GROUP_LABEL[value]} + {/each} + + + + ; + + + {#if isFiltered(filters)} + {shown} + of {total} + {:else} + {total} + {total === 1 ? "transaction" : "transactions"} + {/if} + + {#if isFiltered(filters)} + + + {/if} + +
+
diff --git a/src/lib/components/transactions/PeriodSummary.svelte b/src/lib/components/transactions/PeriodSummary.svelte index 924e395..db73368 100644 --- a/src/lib/components/transactions/PeriodSummary.svelte +++ b/src/lib/components/transactions/PeriodSummary.svelte @@ -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(); + const map = new Map(); 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)}
    - {#each block.entries as entry (`${entry.name}|${entry.code}`)} -
  • + {#each block.entries as entry (entry.name)} +
  • {#if entry.href} {entry.name}{entry.name} {/if} - {@render amount(entry)} + + {#each entry.sums as sum (sum.code)} + {@render amount(sum)} + {/each} +
  • {:else}
  • diff --git a/src/lib/components/transactions/TransactionTable.svelte b/src/lib/components/transactions/TransactionTable.svelte index fe89777..c99af70 100644 --- a/src/lib/components/transactions/TransactionTable.svelte +++ b/src/lib/components/transactions/TransactionTable.svelte @@ -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()); let opened = $state(new Set()); let bar = $state(); @@ -65,6 +82,56 @@ const accountId = $derived(context.accountId); const rows = $derived(buildRows(groups, collapsed)); + const blocks = $derived.by(() => { + const starts = new Map(); + if (!section) { + return starts; + } + const byKey = new Map(); + 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, 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 @@ {/snippet} +{#snippet blockRow( + block: Block +)} + {@const all = block.keys.every((key) => nav.selected.has(key))} + + {#if selectable} + + { + event.currentTarget.blur(); + selectKeys(block.keys, !all); + }} + type="checkbox" + > + + {/if} + + +
    + {#if block.href} + {block.label} + {:else} + {block.label} + {/if} + {block.groups.length} +
    + {#if block.actions?.length} +
    + {#each block.actions as item (item.label)} + {#if item.confirm} + {item.label} + {:else} + + {/if} + {/each} +
    + {/if} + + + {#each block.sums as sum (`${sum.tone}|${sum.code}`)} +
    {@render money(sum)}
    + {/each} + + + {#if accountId} + + {/if} + + + + + + + +{/snippet} + {#snippet headCells( row: Row )} @@ -534,6 +681,10 @@ 0 ? "opacity-60" : ""}> {#each rows as row, index (row.key)} + {@const block = blocks.get(index)} + {#if block} + {@render blockRow(block)} + {/if} bar?.start("budget", menuRows)}> Set budget... s b + bar?.start("bill", menuRows)}> + Set subscription... s s + bar?.start("tag", menuRows)}> Add tag... s t diff --git a/src/lib/transactions/ledger-view.svelte.ts b/src/lib/transactions/ledger-view.svelte.ts new file mode 100644 index 0000000..5ae60f5 --- /dev/null +++ b/src/lib/transactions/ledger-view.svelte.ts @@ -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(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(stored(PINS_KEY, DEFAULT_PINS)); + ignored = $state(new Set(stored(IGNORED_KEY, []))); + private written = ""; + private timer: ReturnType | undefined; + + url = $derived(readFilters(page.url.searchParams)); + filters = $derived({ ...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])); + } +} diff --git a/src/lib/transactions/ledger.ts b/src/lib/transactions/ledger.ts new file mode 100644 index 0000000..c96e3dc --- /dev/null +++ b/src/lib/transactions/ledger.ts @@ -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; + none?: string; +} + +export interface Count { + count: number; + key: string; + label: string; +} + +export interface Filters { + dupes: boolean; + facets: Partial>; + group: GroupBy | null; + text: string; +} + +export interface Section { + actions?: { + confirm?: boolean; + label: string; + run: () => void | Promise; + }[]; + 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 = { + 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 +): 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(); + +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 { + const result = new Map(); + for (const id of ids) { + const facet = FACET[id]; + const test = splitTest(filters, id); + const found = new Map>( + (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(); + 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(); + 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(); + 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 +): Map { + 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(); + 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> = { + 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> = { + 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); +} diff --git a/src/lib/transactions/mass.ts b/src/lib/transactions/mass.ts index 7b43437..371c4f3 100644 --- a/src/lib/transactions/mass.ts +++ b/src/lib/transactions/mass.ts @@ -4,6 +4,86 @@ import { type Group, groupTitle, journalId } from "./split.js"; export type Patch = Record; export type Targets = Map | 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; diff --git a/src/lib/transactions/repeat.svelte.ts b/src/lib/transactions/repeat.svelte.ts new file mode 100644 index 0000000..14bab4d --- /dev/null +++ b/src/lib/transactions/repeat.svelte.ts @@ -0,0 +1,5 @@ +import type { MassAction } from "./mass.js"; + +export const lastAction = $state<{ current: MassAction | null }>({ + current: null, +}); diff --git a/src/routes/transactions/[type=txtype]/ListPage.svelte b/src/routes/transactions/[type=txtype]/ListPage.svelte index 4cda7af..2e440de 100644 --- a/src/routes/transactions/[type=txtype]/ListPage.svelte +++ b/src/routes/transactions/[type=txtype]/ListPage.svelte @@ -3,10 +3,11 @@ import { createQuery, keepPreviousData } from "@tanstack/svelte-query"; import { tick, untrack } from "svelte"; 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 Box from "#lib/components/Box.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 PeriodSummary from "#lib/components/transactions/PeriodSummary.svelte"; import Periods from "#lib/components/transactions/Periods.svelte"; @@ -17,8 +18,25 @@ import { useKeys } from "#lib/keys.svelte.js"; import { period } from "#lib/period.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 { createType, + type Group, LIST_TITLE, listPath, listType, @@ -32,6 +50,9 @@ transfer: "All transfers", withdrawal: "All expenses", }; + const WITHDRAWAL_ONLY = new Set(["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 { param, @@ -49,13 +70,20 @@ let input = $state(); let lastRange = ""; + const view = new LedgerView(); const type = $derived(listType(param)); const pageNo = $derived( Math.max(1, Number(page.url.searchParams.get("page")) || 1) ); - const range = $derived( - everything ? null : { end: period.end, start: period.start } - ); + const year = $derived(page.url.searchParams.get("span") === "year"); + const range = $derived.by(() => { + if (everything) { + return null; + } + return year + ? yearRange(period.end) + : { end: period.end, start: period.start }; + }); $effect(() => { if (period.ready && start && end) { @@ -89,29 +117,16 @@ }); const list = createQuery(() => ({ - enabled: everything || period.ready, + enabled: everything, placeholderData: keepPreviousData, queryFn: () => api>("/v1/transactions", { - query: { - end: range?.end, - limit: session.pageSize, - page: pageNo, - start: range?.start, - type, - }, + query: { limit: session.pageSize, page: pageNo, type }, }), - queryKey: [ - "transactions", - type, - range?.start, - range?.end, - pageNo, - session.pageSize, - ], + queryKey: ["transactions", type, pageNo, session.pageSize], })); - const summary = createQuery(() => ({ + const ledger = createQuery(() => ({ enabled: !everything && period.ready, placeholderData: keepPreviousData, queryFn: () => @@ -120,16 +135,80 @@ start: range?.start, 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( range ? `${SUBTITLE[type]} between ${formatDate(range.start)} and ${formatDate(range.end)}` : 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) { const url = new URL(page.url.href); url.searchParams.set("page", String(n)); @@ -140,20 +219,42 @@ goto(`/search?search=${encodeURIComponent(text)}`); } - useKeys(() => [ - { - group: "Global", - keys: "/", - label: "Search in this list", - run: () => { - if (!query && type !== "all") { - query = `type:${type} `; - } - input?.focus(); - input?.setSelectionRange(query.length, query.length); - }, - }, - ]); + 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", + keys: "/", + label: "Search in this list", + run: () => { + if (!query && type !== "all") { + query = `type:${type} `; + } + input?.focus(); + input?.setSelectionRange(query.length, query.length); + }, + }, + ] + : [ + { + group: "Filter", + keys: "Y", + label: year ? "Back to the period" : "Load the last 12 months", + run: toggleYear, + }, + ] + ); {#snippet actions()} - + {#if everything} + + {/if} + {/if} - + {#if everything} + + {:else if paged} + + {/if} {/snippet} - + {#if everything} + + {:else} + search(searchQuery(filters, type, range))} + shown={matched.length} + total={groups.length} + {view} + /> + + {/if} {#snippet footer()} {#if everything} {/if} - + {#if everything} + + {:else if paged} + + {/if} {/snippet}