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

@@ -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<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 {
param,
@@ -49,13 +70,20 @@
let input = $state<HTMLInputElement>();
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<Page<TransactionGroup>>("/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,
},
]
);
</script>
<PageHeader
@@ -165,14 +266,16 @@
title="Transactions"
>
{#snippet actions()}
<QueryInput
class="w-80 max-md:w-full"
id="list-query"
onsubmit={search}
placeholder="Search, press / to start"
bind:input
bind:value={query}
/>
{#if everything}
<QueryInput
class="w-80 max-md:w-full"
id="list-query"
onsubmit={search}
placeholder="Search, press / to start"
bind:input
bind:value={query}
/>
{/if}
<Button
color="green"
href={`/transactions/create/${createType(type)}`}
@@ -185,11 +288,7 @@
</PageHeader>
{#if !everything}
<PeriodSummary
groups={summary.data ?? []}
loading={summary.isPending}
{type}
/>
<PeriodSummary groups={matched} loading={ledger.isPending} {type} />
{/if}
<div
@@ -197,23 +296,54 @@
>
<Box body="p-0" title={LIST_TITLE[type] ?? "Transactions"}>
{#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">
<Pager
href={pageHref}
keys
page={pageNo}
pages={pagination?.total_pages ?? 1}
/>
{#if everything}
<Pager
href={pageHref}
keys
page={pageNo}
pages={list.data?.meta.pagination?.total_pages ?? 1}
/>
{:else if paged}
<Pager href={pageHref} keys page={pageNo} {pages} />
{/if}
</span>
{/snippet}
<TransactionTable
empty={everything
? "No transactions."
: "No transactions in this period."}
groups={list.data?.data ?? []}
id="tx-list"
loading={list.isFetching}
/>
{#if everything}
<TransactionTable
empty="No transactions."
groups={list.data?.data ?? []}
id="tx-list"
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()}
{#if everything}
<a class="text-brand hover:underline" href={listPath(type)}
@@ -225,11 +355,15 @@
>
{/if}
<span class="ml-auto">
<Pager
href={pageHref}
page={pageNo}
pages={pagination?.total_pages ?? 1}
/>
{#if everything}
<Pager
href={pageHref}
page={pageNo}
pages={list.data?.meta.pagination?.total_pages ?? 1}
/>
{:else if paged}
<Pager href={pageHref} page={pageNo} {pages} />
{/if}
</span>
{/snippet}
</Box>