feat(accounts): merchants and payers as a league table

Expense and revenue accounts list this month per currency, share,
visits, average, twelve months, typical and the change against it, last
visit and category; quiet ones fold into one line behind i. 1 to 5 sort,
; groups by category with subtotals, x and s c set the category on all
of a merchant's transactions in the period.
This commit is contained in:
hh committed 2026-10-16 15:35:38 +00:00
1 parent 26903b0066
commit fffb770cac
7 files changed
+1352

No files matched your search

+89
View File
@@ -0,0 +1,89 @@
import { createQuery, keepPreviousData } from "@tanstack/svelte-query";
import { lists } from "#lib/api/lists.js";
import type { TransactionGroup } from "#lib/api/types.js";
import { ratesQuery } from "#lib/dashboard/primary.svelte.js";
import { today } from "#lib/dates.js";
import { period } from "#lib/period.svelte.js";
import { ledgerQuery, ledgerWindow, ownFlows } from "#lib/reports/ledger.js";
import { pages } from "#lib/reports/queries.js";
import { typicalMonths } from "#lib/reports/typical.js";
import { session } from "#lib/session.svelte.js";
import { board, type Kind, monthDay } from "./merchants.js";
export function useMerchants(kind: () => Kind) {
const now = today();
const current = ledgerWindow(now);
const anchor = $derived(period.end >= current.start ? now : period.end);
const window = $derived(ledgerWindow(anchor));
const inside = $derived(
period.start >= window.start && period.end <= window.end
);
const accounts = createQuery(() => lists.accounts("all"));
const ledger = createQuery(() => ({
...ledgerQuery(anchor),
placeholderData: keepPreviousData,
}));
const extra = createQuery(() => ({
enabled: !inside,
placeholderData: keepPreviousData,
queryFn: () =>
pages<TransactionGroup>("/v1/transactions", {
end: period.end,
start: period.start,
type: "default",
}),
queryKey: ["merchants-period", period.start, period.end],
}));
const rates = createQuery(() => ({ ...ratesQuery(), enabled: true }));
const flows = $derived(
ledger.data && accounts.data ? ownFlows(ledger.data, accounts.data) : []
);
const periodFlows = $derived.by(() => {
if (inside) {
return flows;
}
return extra.data && accounts.data
? ownFlows(extra.data, accounts.data)
: [];
});
const day = $derived(monthDay(period.range, now));
const result = $derived.by(() => {
if (!(accounts.data && ledger.data && (inside || extra.data))) {
return null;
}
const month = period.end.slice(0, 7);
const since = flows.reduce(
(first, flow) => (flow.date < first ? flow.date : first),
now
);
const primary = session.primary?.code ?? "";
return board(accounts.data, flows, periodFlows, {
day,
kind: kind(),
months: window.months.filter((m) => m <= month),
past: typicalMonths(window.months, period.end, since),
range: period.range,
rate: (code) => rates.data?.[code]?.rate ?? (code === primary ? 1 : 0),
});
});
return {
get board() {
return result;
},
get day() {
return day;
},
get error() {
return accounts.error ?? ledger.error ?? extra.error ?? null;
},
get loading() {
return result === null || rates.isPending;
},
get stale() {
return ledger.isFetching || extra.isPlaceholderData;
},
};
}
+294
View File
@@ -0,0 +1,294 @@
import type { Account, Item } from "#lib/api/types.js";
import type { Range } from "#lib/dates.js";
import type { Flow } from "#lib/reports/flows.js";
import { typical, typicalToDay, versus } from "#lib/reports/typical.js";
import type { Targets } from "#lib/transactions/mass.js";
export type Kind = "expense" | "revenue";
export type SortKey = "month" | "year" | "visits" | "last" | "name";
export const SORT_KEYS: SortKey[] = ["month", "year", "visits", "last", "name"];
export const SPARK_MONTHS = 12;
const TOP = 5;
export interface Line {
amount: number;
base: number;
code: string;
months: number[];
ratio: number | null;
seen: number;
typical: number;
visits: number;
}
export interface Merchant {
category: string | null;
fresh: boolean;
id: string;
last: string;
lines: Line[];
name: string;
others: number;
quiet: boolean;
share: number;
targets: Targets;
value: number;
visits: number;
year: number;
}
export interface Total {
amount: number;
base: number;
code: string;
ratio: number | null;
}
export interface Section {
key: string;
label: string;
merchants: Merchant[];
share: number;
sums: Total[];
visits: number;
}
export interface Board {
fresh: number;
merchants: Merchant[];
past: number;
top: number;
totals: Total[];
}
export interface Context {
day: number | null;
kind: Kind;
months: string[];
past: string[];
range: Range;
rate: (code: string) => number;
}
const sum = (values: number[]) => values.reduce((a, b) => a + b, 0);
function monthly(flows: Flow[]): Map<string, number> {
const map = new Map<string, number>();
for (const flow of flows) {
const month = flow.date.slice(0, 7);
map.set(month, (map.get(month) ?? 0) + flow.amount);
}
return map;
}
function lineOf(code: string, year: Flow[], now: Flow[], ctx: Context): Line {
const byMonth = monthly(year);
const amount = sum(now.map((flow) => flow.amount));
const base = ctx.day === null ? 0 : typicalToDay(year, ctx.past, ctx.day);
return {
amount,
base,
code,
months: ctx.months.slice(-SPARK_MONTHS).map((m) => byMonth.get(m) ?? 0),
ratio: ctx.day === null ? null : versus(amount, base),
seen: ctx.past.filter((m) => byMonth.has(m)).length,
typical: typical(byMonth, ctx.past),
visits: now.length,
};
}
function dominant(flows: Flow[]): [string | null, number] {
const counts = new Map<string, number>();
for (const flow of flows) {
const name = flow.category?.name ?? "";
counts.set(name, (counts.get(name) ?? 0) + 1);
}
const [top] = [...counts].sort((a, b) => b[1] - a[1]);
return [top?.[0] || null, Math.max(0, counts.size - 1)];
}
function targetsOf(flows: Flow[]): Targets {
const targets: Targets = new Map();
for (const flow of flows) {
const set = targets.get(flow.group) ?? new Set<string>();
set.add(flow.journal);
targets.set(flow.group, set);
}
return targets;
}
function bucket<T>(items: T[], key: (item: T) => string): Map<string, T[]> {
const map = new Map<string, T[]>();
for (const item of items) {
const list = map.get(key(item));
if (list) {
list.push(item);
} else {
map.set(key(item), [item]);
}
}
return map;
}
const latest = (flows: Flow[]) =>
flows.reduce((last, flow) => (flow.date > last ? flow.date : last), "");
function merchantOf(
id: string,
name: string,
seen: string,
year: Flow[],
now: Flow[],
ctx: Context
): Omit<Merchant, "share"> {
const yearBy = bucket(year, (flow) => flow.currency);
const nowBy = bucket(now, (flow) => flow.currency);
const codes = new Set([...yearBy.keys(), ...nowBy.keys()]);
const quiet = now.length === 0;
const lines = [...codes]
.map((code) =>
lineOf(code, yearBy.get(code) ?? [], nowBy.get(code) ?? [], ctx)
)
.filter((line) => (quiet ? sum(line.months) > 0 : line.amount > 0))
.sort(
(a, b) =>
b.amount * ctx.rate(b.code) - a.amount * ctx.rate(a.code) ||
sum(b.months) * ctx.rate(b.code) - sum(a.months) * ctx.rate(a.code)
);
const [category, others] = dominant(quiet ? year : now);
const before = year.filter((flow) => flow.date < ctx.range.start);
return {
category,
fresh: !quiet && before.length === 0,
id,
last: latest(now) || seen || latest(year),
lines,
name,
others,
quiet,
targets: targetsOf(now),
value: sum(lines.map((line) => line.amount * ctx.rate(line.code))),
visits: now.length,
year: sum(lines.map((line) => sum(line.months) * ctx.rate(line.code))),
};
}
export function board(
accounts: Item<Account>[],
flows: Flow[],
periodFlows: Flow[],
ctx: Context
): Board {
const dir = ctx.kind === "expense" ? -1 : 1;
const inRange = (flow: Flow) =>
flow.date >= ctx.range.start && flow.date <= ctx.range.end;
const mine = flows.filter((flow) => flow.dir === dir);
const now = periodFlows.filter((flow) => flow.dir === dir && inRange(flow));
const yearBy = bucket(mine, (flow) => flow.other.id);
const nowBy = bucket(now, (flow) => flow.other.id);
const names = new Map(
[...mine, ...now].map((flow) => [flow.other.id, flow.other.name])
);
const seen = new Map<string, string>();
for (const { id, attributes: a } of accounts) {
if (a.type === ctx.kind && (a.active !== false || names.has(id))) {
names.set(id, a.name);
seen.set(id, a.last_activity?.slice(0, 10) ?? "");
}
}
const drafts = [...names].map(([id, name]) =>
merchantOf(
id,
name,
seen.get(id) ?? "",
yearBy.get(id) ?? [],
nowBy.get(id) ?? [],
ctx
)
);
const total = sum(drafts.map((m) => m.value)) || 1;
const merchants = drafts
.map((m) => ({ ...m, share: m.value / total }))
.sort(sortBy("month"));
return {
fresh: merchants.filter((m) => m.fresh).length,
merchants,
past: ctx.past.length,
top: sum(merchants.slice(0, TOP).map((m) => m.share)),
totals: totalsOf(mine, now, ctx),
};
}
function totalsOf(year: Flow[], now: Flow[], ctx: Context): Total[] {
const nowBy = bucket(now, (flow) => flow.currency);
const yearBy = bucket(year, (flow) => flow.currency);
return [...nowBy]
.map(([code, list]) => {
const amount = sum(list.map((flow) => flow.amount));
const base =
ctx.day === null
? 0
: typicalToDay(yearBy.get(code) ?? [], ctx.past, ctx.day);
return {
amount,
base,
code,
ratio: ctx.day === null ? null : versus(amount, base),
};
})
.sort((a, b) => b.amount * ctx.rate(b.code) - a.amount * ctx.rate(a.code));
}
const ORDER: Record<SortKey, (a: Merchant, b: Merchant) => number> = {
last: (a, b) => b.last.localeCompare(a.last),
month: (a, b) => b.value - a.value,
name: (a, b) => a.name.localeCompare(b.name),
visits: (a, b) => b.visits - a.visits,
year: (a, b) => b.year - a.year,
};
export const sortBy =
(key: SortKey) =>
(a: Merchant, b: Merchant): number =>
ORDER[key](a, b) || ORDER.last(a, b) || ORDER.name(a, b);
export function sumLines(merchants: Merchant[]): Total[] {
const map = new Map<string, number>();
for (const line of merchants.flatMap((m) => m.lines)) {
map.set(line.code, (map.get(line.code) ?? 0) + line.amount);
}
return [...map]
.filter(([, amount]) => amount > 0)
.map(([code, amount]) => ({ amount, base: 0, code, ratio: null }));
}
export function byCategory(merchants: Merchant[], none: string): Section[] {
return [...bucket(merchants, (m) => m.category ?? "")]
.map(([key, list]) => ({
key: key || "none",
label: key || none,
merchants: list,
share: sum(list.map((m) => m.share)),
sums: sumLines(list),
visits: sum(list.map((m) => m.visits)),
}))
.sort((a, b) => b.share - a.share || a.label.localeCompare(b.label));
}
const WHOLE_MONTH = 31;
export function monthDay(range: Range, now: string): number | null {
const month = range.start.slice(0, 7);
const last = new Date(
Number(month.slice(0, 4)),
Number(month.slice(5, 7)),
0
).getDate();
const whole =
range.start === `${month}-01` && range.end === `${month}-${last}`;
if (!whole || month > now.slice(0, 7)) {
return null;
}
return month === now.slice(0, 7) ? Number(now.slice(8, 10)) : WHOLE_MONTH;
}
@@ -0,0 +1,143 @@
<script lang="ts" module>
export type Column =
| "name"
| "month"
| "share"
| "visits"
| "avg"
| "spark"
| "typical"
| "change"
| "last"
| "category";
</script>
<script lang="ts">
import type { Kind, Line, Merchant } from "#lib/accounts/merchants.js";
import { showUrl } from "#lib/components/accounts/links.js";
import { currency } from "#lib/components/dashboard/data.js";
import { today } from "#lib/dates.js";
import { formatDate, formatMoney, percent } from "#lib/format.js";
import MonthSpark from "./MonthSpark.svelte";
const LOUD = 0.15;
const SMALL = 0.005;
let {
col,
merchant: m,
kind,
compare,
max,
past,
note = "",
}: {
col: Column;
merchant: Merchant;
kind: Kind;
compare: boolean;
max: number;
past: number;
note?: string;
} = $props();
const money = (line: Line, amount: number, decimals?: number) => {
const cur = currency(line.code);
return formatMoney({ ...cur, amount, decimals: decimals ?? cur.decimals });
};
function change(line: Line): [string, string] {
if (!compare || m.quiet) {
return ["", ""];
}
if (line.seen === 0) {
return ["new", "text-warn"];
}
if (line.ratio === null) {
return [`${line.seen} of ${past} months`, "text-cladd-fg-soft"];
}
const pct = Math.round(line.ratio * 100);
let sign = "";
if (pct !== 0) {
sign = pct > 0 ? "+" : "−";
}
const bad = kind === "expense" ? line.ratio > LOUD : line.ratio < -LOUD;
return [
`${sign}${percent(Math.abs(line.ratio))}`,
bad ? "text-expense" : "text-cladd-fg-soft",
];
}
const lastText = $derived(
m.last.slice(0, 4) === today().slice(0, 4)
? formatDate(m.last, "short")
: formatDate(m.last)
);
</script>
{#if col === "name"}
<a
class="block max-w-[9rem] truncate sm:max-w-[18rem] text-brand dark:text-(--ff-series-1) hover:underline"
href={showUrl(m.id)}
title={m.name}
>{m.name}</a
>
{:else if col === "month"}
{#each m.lines as line (line.code)}
<div class="num">{line.amount ? money(line, line.amount) : ""}</div>
{/each}
{:else if col === "share"}
{#if m.share > 0}
<div class="flex items-center gap-2">
<div class="h-2 w-16 shrink-0">
<div
class="h-full rounded-[2px] bg-brand/70"
style:width="{Math.max(2, (m.share / (max || 1)) * 100)}%"
></div>
</div>
<span class="num w-8 text-right text-cladd-fg-soft"
>{m.share < SMALL ? "<1%" : percent(m.share)}</span
>
</div>
{/if}
{:else if col === "visits"}
<span class="num">{m.visits || ""}</span>
{:else if col === "avg"}
{#each m.lines as line (line.code)}
<div class="num text-cladd-fg-soft">
{line.visits ? money(line, line.amount / line.visits, 0) : ""}
</div>
{/each}
{:else if col === "spark"}
{#each m.lines as line (line.code)}
<div class="flex h-[1lh] items-center justify-end">
<MonthSpark typical={line.typical} values={line.months} />
</div>
{/each}
{:else if col === "typical"}
{#each m.lines as line (line.code)}
{@const value = compare && !m.quiet ? line.base : line.typical}
<div class="num text-cladd-fg-soft">
{value ? money(line, value, 0) : ""}
</div>
{/each}
{:else if col === "change"}
{#each m.lines as line (line.code)}
{@const [text, tone] = change(line)}
<div class="num {tone}">{text}</div>
{/each}
{:else if col === "last"}
<span class="num text-cladd-fg-soft">{lastText}</span>
{:else}
<span class="flex max-w-[16rem] items-baseline gap-1.5">
<span class="truncate {m.category ? "" : "text-cladd-fg-softer"}"
>{m.category ?? "none"}</span
>
{#if m.others}
<span class="shrink-0 text-cladd-fg-softer">+{m.others}</span>
{/if}
{#if note}
<span class="shrink-0 text-cladd-fg-soft">{note}</span>
{/if}
</span>
{/if}
@@ -0,0 +1,705 @@
<script lang="ts">
import ArrowDown from "@lucide/svelte/icons/arrow-down";
import Plus from "@lucide/svelte/icons/plus";
import { createQuery } from "@tanstack/svelte-query";
import { tick } from "svelte";
import {
byCategory,
type Kind,
type Merchant,
SORT_KEYS,
type SortKey,
sortBy,
} from "#lib/accounts/merchants.js";
import { useMerchants } from "#lib/accounts/merchants.svelte.js";
import { api } from "#lib/api/client.js";
import { invalidate, lists } from "#lib/api/lists.js";
import Box from "#lib/components/Box.svelte";
import { currency } from "#lib/components/dashboard/data.js";
import PageHeader from "#lib/components/shell/PageHeader.svelte";
import SuggestInput from "#lib/components/transactions/SuggestInput.svelte";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuTrigger,
} from "#lib/components/ui/context-menu/index.js";
import { today } from "#lib/dates.js";
import { formatDate, formatMoney, percent } from "#lib/format.js";
import { fuzzyFilter } from "#lib/fuzzy.js";
import { ListNav } from "#lib/list-nav.svelte.js";
import { period } from "#lib/period.svelte.js";
import { versusText } from "#lib/reports/typical.js";
import {
type Outcome,
patchFor,
type Targets,
updateGroups,
} from "#lib/transactions/mass.js";
import type { Suggestion } from "#lib/transactions/query.js";
import { goto } from "$app/navigation";
import { page } from "$app/state";
import { editUrl, openOld, showUrl, TYPE_ONE, TYPE_TITLE } from "./links.js";
import MerchantCell, { type Column } from "./MerchantCell.svelte";
const NONE = "\u0000";
const SAME = 0.03;
const WIDE: Column[] = [
"share",
"visits",
"avg",
"spark",
"typical",
"last",
"category",
];
let { kind }: { kind: Kind } = $props();
const data = useMerchants(() => kind);
const noun = $derived(kind === "expense" ? "Merchants" : "Payers");
const createHref = $derived(`/accounts/create/${kind}`);
const params = $derived(page.url.searchParams);
const sort = $derived<SortKey>(
SORT_KEYS.find((key) => key === params.get("sort")) ?? "month"
);
const grouped = $derived(params.get("group") === "category");
const quietParam = $derived(params.has("quiet"));
let filter = $state(page.url.searchParams.get("filter") ?? "");
let filterInput = $state<HTMLInputElement>();
let menu = $state<Merchant | null>(null);
let armed = $state(false);
let failure = $state("");
let picking = $state<Merchant[] | null>(null);
let text = $state("");
let input = $state<HTMLInputElement>();
let progress = $state("");
let notes = $state<Record<string, string>>({});
const day = $derived(data.day);
const compare = $derived(day !== null);
const current = $derived(day !== null && day < 31);
const asOf = $derived(formatDate(today(), "short"));
const needle = $derived(filter.trim().toLowerCase());
const all = $derived(data.board?.merchants ?? []);
const matching = $derived(
needle
? all.filter((m) =>
`${m.name} ${m.category ?? ""}`.toLowerCase().includes(needle)
)
: all
);
const order = $derived(sortBy(sort));
const active = $derived(matching.filter((m) => !m.quiet).sort(order));
const quiet = $derived(matching.filter((m) => m.quiet).sort(order));
const quietOpen = $derived(quietParam || needle !== "");
const sections = $derived(grouped ? byCategory(active, "No category") : null);
const rows = $derived([
...(sections ? sections.flatMap((s) => s.merchants) : active),
...(quietOpen ? quiet : []),
]);
const index = $derived(new Map(rows.map((m, i) => [m.id, i])));
const max = $derived(Math.max(0, ...all.map((m) => m.share)));
const activeCount = $derived(all.filter((m) => !m.quiet).length);
const categorised = $derived(all.some((m) => m.category));
const columns = $derived<{ key: Column; label: string; sort?: SortKey }[]>([
{
key: "name",
label: kind === "expense" ? "Merchant" : "Payer",
sort: "name",
},
{
key: "month",
label: current ? "This month" : "In period",
sort: "month",
},
{ key: "share", label: "Share" },
{
key: "visits",
label: kind === "expense" ? "Visits" : "Payments",
sort: "visits",
},
{ key: "avg", label: "Average" },
{ key: "spark", label: "12 months", sort: "year" },
{ key: "typical", label: current ? `Typical by ${asOf}` : "Typical month" },
{ key: "change", label: "vs typical" },
{ key: "last", label: "Last", sort: "last" },
...(categorised ? [{ key: "category" as const, label: "Category" }] : []),
]);
const NUMERIC = new Set<Column>([
"month",
"visits",
"avg",
"spark",
"typical",
"change",
"last",
]);
const categories = createQuery(() => ({
...lists.categories(),
enabled: picking !== null,
}));
const suggestions = $derived.by<Suggestion[]>(() => {
const names = (categories.data ?? []).map((c) => c.attributes.name);
const query = text.trim();
const found: Suggestion[] = fuzzyFilter(names, query, (n) => n, 8).map(
(name) => ({ id: name, insert: name, label: name })
);
if (query && !names.some((n) => n.toLowerCase() === query.toLowerCase())) {
found.push({
hint: "new",
id: `new:${query}`,
insert: query,
label: query,
});
}
if (!query) {
found.unshift({ id: NONE, insert: NONE, label: "(no category)" });
}
return found;
});
const journals = (targets: Targets) =>
[...targets.values()].reduce((n, set) => n + (set?.size ?? 0), 0);
function merge(list: Merchant[]): Targets {
const targets: Targets = new Map();
for (const m of list) {
for (const [group, set] of m.targets) {
targets.set(
group,
new Set([...(targets.get(group) ?? []), ...(set ?? [])])
);
}
}
return targets;
}
const pickCount = $derived(picking ? journals(merge(picking)) : 0);
async function pick() {
const list = chosen.filter((m) => m.targets.size > 0);
if (list.length === 0) {
notes = Object.fromEntries(
chosen.map((m) => [m.id, `nothing in ${period.label}`])
);
return;
}
picking = list;
text = "";
progress = "";
await tick();
input?.focus();
}
function cancel() {
if (!progress) {
picking = null;
}
}
async function apply(raw: string) {
const list = picking;
if (!list || progress) {
return;
}
const value = raw === NONE ? null : raw.trim() || null;
const say = (o: Outcome) => {
progress = `Setting “${value ?? "no category"}”: ${o.done} of ${o.total}`;
};
const outcome = await updateGroups(
merge(list),
patchFor({ id: null, mode: "category", value }),
say
);
await invalidate();
const failed = outcome.errors.join("; ");
const next = { ...notes };
for (const m of list) {
next[m.id] = failed
? `failed: ${failed}`
: `${value ? "set" : "cleared"} on ${journals(m.targets)}`;
}
notes = next;
progress = "";
picking = null;
nav.clear();
}
let pending = "";
function setParam(name: string, value: string | null) {
const url = new URL(pending || page.url.href);
if (value === null) {
url.searchParams.delete(name);
} else {
url.searchParams.set(name, value);
}
url.searchParams.set("start", period.start);
url.searchParams.set("end", period.end);
pending = url.href;
goto(url, { replace: true, reset: false }).finally(() => {
if (pending === url.href) {
pending = "";
}
});
}
const setSort = (key: SortKey) =>
setParam("sort", key === "month" ? null : key);
const toggleGroup = () => setParam("group", grouped ? null : "category");
const toggleQuiet = () => setParam("quiet", quietParam ? null : "1");
const nav = new ListNav<Merchant>({
edit: (m) => goto(editUrl(m.id)),
extra: () => [
{
group: noun,
keys: "/",
label: `Filter ${noun.toLowerCase()}`,
run: () => {
filterInput?.focus();
filterInput?.select();
},
},
{
group: noun,
keys: "c",
label: `Create a new ${TYPE_ONE[kind]}`,
run: () => goto(createHref),
},
...SORT_KEYS.map((key, i) => ({
group: noun,
keys: String(i + 1),
label: `Sort by ${columns.find((c) => c.sort === key)?.label.toLowerCase()}`,
run: () => setSort(key),
})),
{
group: noun,
keys: ";",
label: grouped ? "Ungroup" : "Group by category",
run: toggleGroup,
},
{
group: noun,
keys: "i",
label: quietParam
? `Hide quiet ${noun.toLowerCase()}`
: `Show quiet ${noun.toLowerCase()}`,
run: toggleQuiet,
},
{
group: noun,
keys: "s c",
label: "Set category on their transactions",
run: pick,
},
],
id: "merchants",
items: () => rows,
key: (m) => m.id,
open: (m) => goto(showUrl(m.id)),
selectable: true,
});
nav.use();
const chosen = $derived.by(() => {
if (nav.selected.size > 0) {
return all.filter((m) => nav.selected.has(m.id));
}
return nav.current ? [nav.current] : [];
});
const anchorId = $derived(
picking
? (rows.find((m) => picking?.some((p) => p.id === m.id)) ?? nav.current)
?.id
: null
);
function onfilter() {
nav.cursor = -1;
setParam("filter", filter || null);
}
function onfilterkey(event: KeyboardEvent) {
if (event.key === "Escape" && filter) {
filter = "";
onfilter();
} else if (event.key === "Enter" && rows[0]) {
event.preventDefault();
filterInput?.blur();
nav.move(0);
}
}
function oncontextmenu(event: MouseEvent) {
const tr = (event.target as Element).closest("tr[data-id]");
armed = false;
menu = rows.find((m) => m.id === tr?.getAttribute("data-id")) ?? null;
if (menu) {
nav.cursor = index.get(menu.id) ?? -1;
}
}
async function remove(m: Merchant) {
failure = "";
try {
await api(`/v1/accounts/${m.id}`, { method: "DELETE" });
await invalidate();
} catch (e) {
failure = e instanceof Error ? e.message : String(e);
}
}
function rowClass(m: Merchant, i: number) {
if (nav.cursor === i) {
return "bg-row-cursor shadow-[inset_2px_0_0_var(--ff-brand)]";
}
return nav.isSelected(m) ? "bg-row-selected" : "hover:bg-row-hover";
}
function cellClass(key: Column) {
const align = NUMERIC.has(key) ? "text-right" : "";
const wide = WIDE.includes(key) ? "hidden md:table-cell" : "";
return `${align} ${wide}`;
}
function tone(ratio: number | null) {
if (ratio === null || Math.abs(ratio) < SAME) {
return "text-cladd-fg-soft";
}
const bad = kind === "expense" ? ratio > 0 : ratio < 0;
return bad ? "text-expense" : "text-cladd-fg-soft";
}
</script>
{#snippet sums(
list: { amount: number; code: string }[]
)}
{#each list as t (t.code)}
<div class="num">
{formatMoney({ ...currency(t.code), amount: t.amount })}
</div>
{/each}
{/snippet}
{#snippet row(
m: Merchant
)}
{@const i = index.get(m.id) ?? -1}
<tr
class="border-cladd-outline/60 border-t align-top {rowClass(m, i)}"
data-id={m.id}
id={nav.rowId(i)}
onclick={(event) => nav.click(i, event)}
>
{#each columns as col (col.key)}
<td class="h-7 whitespace-nowrap px-2 py-1 sm:px-3 {cellClass(col.key)}">
<MerchantCell
col={col.key}
{compare}
{kind}
{max}
merchant={m}
note={notes[m.id]}
past={data.board?.past ?? 0}
/>
</td>
{/each}
</tr>
{#if anchorId === m.id && picking}
<tr class="bg-row-selected/50">
<td class="px-3 py-1.5" colspan={columns.length}>
<div class="flex flex-wrap items-center gap-2 text-[12px]">
{#if progress}
<span aria-live="polite">{progress}</span>
{:else}
<label class="text-cladd-fg-soft" for="merchant-category"
>Category for {pickCount}
{pickCount === 1 ? "transaction" : "transactions"}
at
{picking.length === 1
? picking[0].name
: `${picking.length} ${noun.toLowerCase()}`}:</label
>
<SuggestInput
class="w-72"
id="merchant-category"
onaccept={(item) => {
apply(item.insert);
return true;
}}
onblur={cancel}
oncancel={cancel}
onsubmit={() => apply(text)}
placeholder="Type to filter"
{suggestions}
bind:input
bind:value={text}
/>
<span class="text-cladd-fg-softer"
><span class="kbd">↵</span>
apply <span class="kbd">Esc</span>
cancel</span
>
{/if}
</div>
</td>
</tr>
{/if}
{/snippet}
<PageHeader crumbs={[{ label: TYPE_TITLE[kind] }]} title="Accounts">
{#snippet actions()}
<a
class="cladd-surface cladd-gradient cladd-outline cladd-hoverable cladd-color-green inline-flex h-cladd-sm items-center gap-1.5 rounded-cladd-sm px-2.5 font-semibold text-[12px] text-cladd-primary"
href={createHref}
title="Create a new {TYPE_ONE[kind]} c"
>
<Plus class="size-3.5" />
Create a new {TYPE_ONE[kind]}
</a>
{/snippet}
</PageHeader>
<Box body="p-0" title="{noun} in {period.label}">
{#snippet tools()}
<button
aria-pressed={grouped}
class="hidden h-7 sm:inline-flex items-center gap-1.5 rounded-cladd-sm px-2 text-[12px] {grouped
? "bg-row-selected text-cladd-fg"
: "text-cladd-fg-soft hover:text-cladd-fg"}"
onclick={toggleGroup}
type="button"
>
By category <span class="kbd">;</span>
</button>
<input
aria-label="Filter {noun.toLowerCase()}"
class="cladd-cut h-7 w-36 rounded-cladd-sm px-2 text-[12px] outline-none placeholder:text-cladd-fg-softer sm:w-56"
oninput={onfilter}
onkeydown={onfilterkey}
placeholder="Filter /"
type="search"
bind:this={filterInput}
bind:value={filter}
>
{/snippet}
{#if data.error}
<p class="p-3 text-expense">{data.error.message}</p>
{:else if data.loading || !data.board}
<div class="h-40"></div>
{:else}
{@const b = data.board}
<div
class="flex flex-col gap-0.5 border-cladd-outline border-b px-3 py-2 text-[13px]"
>
<p class="flex flex-wrap items-baseline gap-x-4 gap-y-0.5">
<span class="text-cladd-fg-soft"
>{kind === "expense" ? "Spent" : "Earned"}</span
>
{#each b.totals as t (t.code)}
<span class="whitespace-nowrap">
<span class="num font-semibold text-[15px]"
>{formatMoney({ ...currency(t.code), amount: t.amount })}</span
>
{#if t.ratio !== null}
<span class={tone(t.ratio)}
>{versusText(t.ratio)}{current ? ` by ${asOf}` : ""}</span
>
{/if}
</span>
{:else}
<span class="text-cladd-fg-soft">nothing yet</span>
{/each}
</p>
<p class="text-[12px] text-cladd-fg-soft">
{activeCount}
of {all.length}
{noun.toLowerCase()}
{#if activeCount > 5}
· top 5 take {percent(b.top)}
{/if}
{#if b.fresh > 0}
· {b.fresh} new
{/if}
</p>
</div>
<ContextMenu>
<ContextMenuTrigger
class="block {picking ? "overflow-visible" : "overflow-x-auto"}"
{oncontextmenu}
>
<table
class="w-full border-collapse text-[13px] transition-opacity {data.stale
? "opacity-60"
: ""}"
>
<thead class="border-cladd-outline border-b text-left">
<tr class="h-8">
{#each columns as col (col.key)}
<th
aria-sort={col.sort && sort === col.sort
? "descending"
: undefined}
class="whitespace-nowrap px-2 font-semibold text-[12px] text-cladd-fg-soft sm:px-3 {cellClass(
col.key
)}"
scope="col"
>
{#if col.sort}
{@const n = SORT_KEYS.indexOf(col.sort) + 1}
<button
class="inline-flex items-center gap-1 hover:text-cladd-fg {sort ===
col.sort
? "text-cladd-fg"
: ""}"
onclick={() => col.sort && setSort(col.sort)}
title="Sort {n}"
type="button"
>
{col.label}
{#if sort === col.sort}
<ArrowDown class="size-3" />
{:else}
<span
class="font-normal text-[11px] text-cladd-fg-softer"
>{n}</span
>
{/if}
</button>
{:else}
{col.label}
{/if}
</th>
{/each}
</tr>
</thead>
<tbody>
{#if sections}
{#each sections as s (s.key)}
<tr class="border-cladd-outline border-t-2 align-top">
<th
class="px-3 py-1 text-left font-semibold"
scope="rowgroup"
>
{s.label}
<span class="font-normal text-cladd-fg-soft"
>{s.merchants.length}</span
>
</th>
<td class="px-3 py-1 text-right font-semibold">
{@render sums(s.sums)}
</td>
<td class="hidden px-3 py-1 md:table-cell">
<span class="num text-cladd-fg-soft"
>{percent(s.share)}</span
>
</td>
<td class="hidden px-3 py-1 text-right md:table-cell">
<span class="num">{s.visits}</span>
</td>
<td class="px-3 py-1" colspan={columns.length - 4}></td>
</tr>
{#each s.merchants as m (m.id)}
{@render row(m)}
{/each}
{/each}
{:else}
{#each active as m (m.id)}
{@render row(m)}
{/each}
{/if}
{#if quiet.length > 0}
<tr class="border-cladd-outline border-t-2">
<td class="px-3 py-1" colspan={columns.length}>
<button
class="inline-flex items-center gap-1.5 text-[12px] text-cladd-fg-soft hover:text-cladd-fg"
onclick={toggleQuiet}
type="button"
>
{quietOpen ? "Quiet" : `${quiet.length} quiet`}
{current ? "this month" : `in ${period.label}`}
{#if !needle}
<span class="kbd">i</span>
{/if}
</button>
</td>
</tr>
{#if quietOpen}
{#each quiet as m (m.id)}
{@render row(m)}
{/each}
{/if}
{/if}
{#if rows.length === 0 && needle}
<tr>
<td
class="px-3 py-6 text-center text-cladd-fg-soft"
colspan={columns.length}
>
Nothing matches “{filter}”. Press Escape to clear.
</td>
</tr>
{/if}
</tbody>
</table>
</ContextMenuTrigger>
<ContextMenuContent class="w-56">
{#if menu}
{@const target = menu}
<ContextMenuItem onSelect={() => goto(showUrl(target.id))}>
Show <ContextMenuShortcut>↵</ContextMenuShortcut>
</ContextMenuItem>
<ContextMenuItem onSelect={() => goto(editUrl(target.id))}>
Edit <ContextMenuShortcut>e</ContextMenuShortcut>
</ContextMenuItem>
<ContextMenuItem onSelect={pick}>
Set category <ContextMenuShortcut>s c</ContextMenuShortcut>
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem
onSelect={(event) => {
if (armed) {
remove(target);
return;
}
event.preventDefault();
armed = true;
}}
variant="destructive"
>
{armed ? `Press again to delete ${target.name}` : "Delete"}
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem onSelect={() => openOld(showUrl(target.id))}>
Open in old UI
</ContextMenuItem>
{:else}
<ContextMenuItem disabled>Right-click a row</ContextMenuItem>
{/if}
</ContextMenuContent>
</ContextMenu>
{/if}
{#snippet footer()}
<a
class="text-brand dark:text-(--ff-series-1) hover:underline"
href={createHref}
>Create a new {TYPE_ONE[kind]}</a
>
{#if failure}
<span class="text-expense">{failure}</span>
{/if}
<a
class="ml-auto text-cladd-fg-soft hover:underline"
href="/inactive-accounts/{kind}"
>Inactive {TYPE_TITLE[kind].toLowerCase()}</a
>
{/snippet}
</Box>
@@ -0,0 +1,72 @@
<script lang="ts">
import type { Kind } from "#lib/accounts/merchants.js";
import { useMerchants } from "#lib/accounts/merchants.svelte.js";
import Box from "#lib/components/Box.svelte";
import { indexUrl } from "./links.js";
import MerchantCell, { type Column } from "./MerchantCell.svelte";
const COLUMNS: Column[] = ["name", "month", "share", "change"];
let { kind, limit = 8 }: { kind: Kind; limit?: number } = $props();
const data = useMerchants(() => kind);
const all = $derived(data.board?.merchants ?? []);
const top = $derived(all.filter((m) => !m.quiet).slice(0, limit));
const href = $derived(indexUrl(kind));
</script>
<Box
body="p-0"
{href}
title={kind === "expense" ? "Where it went" : "Where it came from"}
>
{#if data.error}
<p class="p-3 text-expense">{data.error.message}</p>
{:else if data.board && top.length > 0}
<table class="w-full border-collapse text-[13px]">
<thead class="sr-only">
<tr>
<th scope="col">{kind === "expense" ? "Merchant" : "Payer"}</th>
<th scope="col">This period</th>
<th scope="col">Share</th>
<th scope="col">vs typical</th>
</tr>
</thead>
<tbody>
{#each top as m (m.id)}
<tr
class="border-cladd-outline/60 border-t align-top first:border-t-0"
>
{#each COLUMNS as col (col)}
<td
class="h-7 whitespace-nowrap px-2 py-1 sm:px-3 {col === "name"
? ""
: "text-right"} {col === "share"
? "hidden sm:table-cell"
: ""}"
>
<MerchantCell
{col}
compare={data.day !== null}
{kind}
max={top[0]?.share ?? 0}
merchant={m}
past={data.board.past}
/>
</td>
{/each}
</tr>
{/each}
</tbody>
</table>
{:else if data.board}
<p class="p-3 text-cladd-fg-soft">Nothing in this period.</p>
{:else}
<div class="h-40"></div>
{/if}
{#snippet footer()}
<a class="text-brand dark:text-(--ff-series-1) hover:underline" {href}
>See all {all.length}</a
>
{/snippet}
</Box>
@@ -0,0 +1,46 @@
<script lang="ts">
let {
values,
typical = 0,
width = 72,
height = 14,
}: {
values: number[];
typical?: number;
width?: number;
height?: number;
} = $props();
const GAP = 1;
const max = $derived(Math.max(typical, ...values) || 1);
const step = $derived(width / Math.max(1, values.length));
const y = (value: number) => height - (value / max) * height;
</script>
<svg aria-hidden="true" class="block shrink-0" {height} {width}>
{#each values as value, i (i)}
{#if value > 0}
<rect
class={i === values.length - 1
? "fill-brand"
: "fill-cladd-fg-softer opacity-60"}
height={Math.max(1, height - y(value))}
width={Math.max(1, step - GAP)}
x={i * step}
y={Math.min(y(value), height - 1)}
/>
{/if}
{/each}
{#if typical > 0}
<line
class="stroke-cladd-fg"
stroke-dasharray="2 2"
stroke-width="1"
x1="0"
x2={width}
y1={y(typical) + 0.5}
y2={y(typical) + 0.5}
/>
{/if}
</svg>
@@ -2,6 +2,7 @@
import type { AccountType } from "#lib/api/types.js"; import type { AccountType } from "#lib/api/types.js";
import AccountsIndex from "#lib/components/accounts/AccountsIndex.svelte"; import AccountsIndex from "#lib/components/accounts/AccountsIndex.svelte";
import Ledger from "#lib/components/accounts/Ledger.svelte"; import Ledger from "#lib/components/accounts/Ledger.svelte";
import Merchants from "#lib/components/accounts/Merchants.svelte";
import { page } from "$app/state"; import { page } from "$app/state";
const type = $derived(page.params.type as AccountType); const type = $derived(page.params.type as AccountType);
@@ -10,6 +11,8 @@
{#key type} {#key type}
{#if type === "liabilities"} {#if type === "liabilities"}
<Ledger /> <Ledger />
{:else if type === "expense" || type === "revenue"}
<Merchants kind={type} />
{:else} {:else}
<AccountsIndex {type} /> <AccountsIndex {type} />
{/if} {/if}