From fffb770cac500692b86f0adef8a20dec9f197bcd Mon Sep 17 00:00:00 2001 From: h Date: Fri, 16 Oct 2026 15:35:38 +0000 Subject: [PATCH] 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. --- src/lib/accounts/merchants.svelte.ts | 89 +++ src/lib/accounts/merchants.ts | 294 ++++++++ .../components/accounts/MerchantCell.svelte | 143 ++++ src/lib/components/accounts/Merchants.svelte | 705 ++++++++++++++++++ .../components/accounts/MerchantsTop.svelte | 72 ++ src/lib/components/accounts/MonthSpark.svelte | 46 ++ .../accounts/[type=accounttype]/+page.svelte | 3 + 7 files changed, 1352 insertions(+) create mode 100644 src/lib/accounts/merchants.svelte.ts create mode 100644 src/lib/accounts/merchants.ts create mode 100644 src/lib/components/accounts/MerchantCell.svelte create mode 100644 src/lib/components/accounts/Merchants.svelte create mode 100644 src/lib/components/accounts/MerchantsTop.svelte create mode 100644 src/lib/components/accounts/MonthSpark.svelte diff --git a/src/lib/accounts/merchants.svelte.ts b/src/lib/accounts/merchants.svelte.ts new file mode 100644 index 0000000..51af343 --- /dev/null +++ b/src/lib/accounts/merchants.svelte.ts @@ -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("/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; + }, + }; +} diff --git a/src/lib/accounts/merchants.ts b/src/lib/accounts/merchants.ts new file mode 100644 index 0000000..2ba3b97 --- /dev/null +++ b/src/lib/accounts/merchants.ts @@ -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 { + const map = new Map(); + 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(); + 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(); + set.add(flow.journal); + targets.set(flow.group, set); + } + return targets; +} + +function bucket(items: T[], key: (item: T) => string): Map { + const map = new Map(); + 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 { + 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[], + 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(); + 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 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(); + 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; +} diff --git a/src/lib/components/accounts/MerchantCell.svelte b/src/lib/components/accounts/MerchantCell.svelte new file mode 100644 index 0000000..81ea920 --- /dev/null +++ b/src/lib/components/accounts/MerchantCell.svelte @@ -0,0 +1,143 @@ + + + + +{#if col === "name"} + {m.name} +{:else if col === "month"} + {#each m.lines as line (line.code)} +
{line.amount ? money(line, line.amount) : ""}
+ {/each} +{:else if col === "share"} + {#if m.share > 0} +
+
+
+
+ {m.share < SMALL ? "<1%" : percent(m.share)} +
+ {/if} +{:else if col === "visits"} + {m.visits || ""} +{:else if col === "avg"} + {#each m.lines as line (line.code)} +
+ {line.visits ? money(line, line.amount / line.visits, 0) : ""} +
+ {/each} +{:else if col === "spark"} + {#each m.lines as line (line.code)} +
+ +
+ {/each} +{:else if col === "typical"} + {#each m.lines as line (line.code)} + {@const value = compare && !m.quiet ? line.base : line.typical} +
+ {value ? money(line, value, 0) : ""} +
+ {/each} +{:else if col === "change"} + {#each m.lines as line (line.code)} + {@const [text, tone] = change(line)} +
{text}
+ {/each} +{:else if col === "last"} + {lastText} +{:else} + + {m.category ?? "none"} + {#if m.others} + +{m.others} + {/if} + {#if note} + {note} + {/if} + +{/if} diff --git a/src/lib/components/accounts/Merchants.svelte b/src/lib/components/accounts/Merchants.svelte new file mode 100644 index 0000000..3897d4a --- /dev/null +++ b/src/lib/components/accounts/Merchants.svelte @@ -0,0 +1,705 @@ + + +{#snippet sums( + list: { amount: number; code: string }[] +)} + {#each list as t (t.code)} +
+ {formatMoney({ ...currency(t.code), amount: t.amount })} +
+ {/each} +{/snippet} + +{#snippet row( + m: Merchant +)} + {@const i = index.get(m.id) ?? -1} + nav.click(i, event)} + > + {#each columns as col (col.key)} + + + + {/each} + + {#if anchorId === m.id && picking} + + +
+ {#if progress} + {progress} + {:else} + + { + apply(item.insert); + return true; + }} + onblur={cancel} + oncancel={cancel} + onsubmit={() => apply(text)} + placeholder="Type to filter" + {suggestions} + bind:input + bind:value={text} + /> + ↵ + apply Esc + cancel + {/if} +
+ + + {/if} +{/snippet} + + + {#snippet actions()} + + + Create a new {TYPE_ONE[kind]} + + {/snippet} + + + + {#snippet tools()} + + + {/snippet} + {#if data.error} +

{data.error.message}

+ {:else if data.loading || !data.board} +
+ {:else} + {@const b = data.board} +
+

+ {kind === "expense" ? "Spent" : "Earned"} + {#each b.totals as t (t.code)} + + {formatMoney({ ...currency(t.code), amount: t.amount })} + {#if t.ratio !== null} + {versusText(t.ratio)}{current ? ` by ${asOf}` : ""} + {/if} + + {:else} + nothing yet + {/each} +

+

+ {activeCount} + of {all.length} + {noun.toLowerCase()} + {#if activeCount > 5} + · top 5 take {percent(b.top)} + {/if} + {#if b.fresh > 0} + · {b.fresh} new + {/if} +

+
+ + + + + + {#each columns as col (col.key)} + + {/each} + + + + {#if sections} + {#each sections as s (s.key)} + + + + + + + + {#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} + + + + {#if quietOpen} + {#each quiet as m (m.id)} + {@render row(m)} + {/each} + {/if} + {/if} + {#if rows.length === 0 && needle} + + + + {/if} + +
+ {#if col.sort} + {@const n = SORT_KEYS.indexOf(col.sort) + 1} + + {:else} + {col.label} + {/if} +
+ {s.label} + {s.merchants.length} + + {@render sums(s.sums)} +
+ +
+ Nothing matches “{filter}”. Press Escape to clear. +
+
+ + {#if menu} + {@const target = menu} + goto(showUrl(target.id))}> + Show ↵ + + goto(editUrl(target.id))}> + Edit e + + + Set category s c + + + { + if (armed) { + remove(target); + return; + } + event.preventDefault(); + armed = true; + }} + variant="destructive" + > + {armed ? `Press again to delete ${target.name}` : "Delete"} + + + openOld(showUrl(target.id))}> + Open in old UI + + {:else} + Right-click a row + {/if} + +
+ {/if} + {#snippet footer()} + Create a new {TYPE_ONE[kind]} + {#if failure} + {failure} + {/if} + Inactive {TYPE_TITLE[kind].toLowerCase()} + {/snippet} +
diff --git a/src/lib/components/accounts/MerchantsTop.svelte b/src/lib/components/accounts/MerchantsTop.svelte new file mode 100644 index 0000000..5d6c6a1 --- /dev/null +++ b/src/lib/components/accounts/MerchantsTop.svelte @@ -0,0 +1,72 @@ + + + + {#if data.error} +

{data.error.message}

+ {:else if data.board && top.length > 0} + + + + + + + + + + + {#each top as m (m.id)} + + {#each COLUMNS as col (col)} + + {/each} + + {/each} + +
{kind === "expense" ? "Merchant" : "Payer"}This periodSharevs typical
+ +
+ {:else if data.board} +

Nothing in this period.

+ {:else} +
+ {/if} + {#snippet footer()} + See all {all.length} + {/snippet} +
diff --git a/src/lib/components/accounts/MonthSpark.svelte b/src/lib/components/accounts/MonthSpark.svelte new file mode 100644 index 0000000..995b920 --- /dev/null +++ b/src/lib/components/accounts/MonthSpark.svelte @@ -0,0 +1,46 @@ + + + diff --git a/src/routes/accounts/[type=accounttype]/+page.svelte b/src/routes/accounts/[type=accounttype]/+page.svelte index d2537c5..67da14c 100644 --- a/src/routes/accounts/[type=accounttype]/+page.svelte +++ b/src/routes/accounts/[type=accounttype]/+page.svelte @@ -2,6 +2,7 @@ import type { AccountType } from "#lib/api/types.js"; import AccountsIndex from "#lib/components/accounts/AccountsIndex.svelte"; import Ledger from "#lib/components/accounts/Ledger.svelte"; + import Merchants from "#lib/components/accounts/Merchants.svelte"; import { page } from "$app/state"; const type = $derived(page.params.type as AccountType); @@ -10,6 +11,8 @@ {#key type} {#if type === "liabilities"} + {:else if type === "expense" || type === "revenue"} + {:else} {/if}