feat: one page for a category, tag, budget or merchant

The page answers how much and whether that is normal: this month per
currency with count, median and pace against a typical month, thirteen
months as lanes per currency, and what is inside (merchants, cards,
budgets, tags, subtags) on 1 to 4 with Enter narrowing the transactions
below. Budgets keep spent, left and overspent with the limit line;
expense and revenue accounts use the same page with visits and cadence.
The twelve period cards and per-day charts are gone.
This commit is contained in:
hh committed 2026-10-16 15:51:00 +00:00
1 parent 349a6a0b32
commit 722fdc2a20
19 files changed
+1667 -794

No files matched your search

+163
View File
@@ -0,0 +1,163 @@
import { type CreateQueryResult, createQuery } from "@tanstack/svelte-query";
import { lists } from "#lib/api/lists.js";
import type { Account, Item, Split, TransactionGroup } from "#lib/api/types.js";
import { contains, type Range, today } from "#lib/dates.js";
import { period } from "#lib/period.svelte.js";
import { monthOf } from "#lib/reports/board.js";
import { buckets, type Flow } from "#lib/reports/flows.js";
import { ledgerQuery, ledgerWindow, ownFlows } from "#lib/reports/ledger.js";
import { pages } from "#lib/reports/queries.js";
import { impliedRates } from "#lib/reports/rates.js";
import { typicalMonths } from "#lib/reports/typical.js";
import { session } from "#lib/session.svelte.js";
import type { Group } from "#lib/transactions/split.js";
import type { Spec } from "./spec.js";
import { codesOf, dirOf, factsOf, normsOf, weigh } from "./stats.js";
export interface Args {
allTime?: boolean;
everything?: boolean;
spec: Spec;
}
interface Kept {
groups: Group[];
journals: Set<string>;
}
function keptOf(
groups: Group[],
keep: (split: Split) => boolean,
range: Range | null
): Kept {
const journals = new Set<string>();
const kept: Group[] = [];
for (const group of groups) {
let hit = false;
for (const split of group.attributes.transactions) {
if (keep(split) && (!range || contains(range, split.date.slice(0, 10)))) {
journals.add(String(split.transaction_journal_id));
hit = true;
}
}
if (hit) {
kept.push(group);
}
}
return { groups: kept, journals };
}
const flowsOf = (kept: Kept, accounts: Item<Account>[]): Flow[] =>
ownFlows(kept.groups, accounts).filter((flow) =>
kept.journals.has(flow.journal)
);
export class DimensionModel {
readonly now = today();
readonly args: () => Args;
readonly ledger: CreateQueryResult<Group[]>;
readonly accounts: CreateQueryResult<Item<Account>[]>;
readonly source: CreateQueryResult<Group[]>;
spec = $derived.by(() => this.args().spec);
everything = $derived.by(() => Boolean(this.args().everything));
allTime = $derived.by(() => this.everything || Boolean(this.args().allTime));
range = $derived<Range | null>(this.everything ? null : period.range);
anchor = $derived(
this.range && this.range.end < ledgerWindow(this.now).start
? this.range.end
: this.now
);
window = $derived(ledgerWindow(this.anchor));
inside = $derived(
this.range !== null &&
this.range.start >= this.window.start &&
this.range.end <= this.window.end
);
sourceRange = $derived(this.allTime ? null : this.range);
raw = $derived.by(
() =>
(this.allTime ? this.source.data : this.ledger.data) ?? ([] as Group[])
);
loading = $derived.by(
() =>
this.accounts.isPending ||
(this.allTime ? this.source.isPending : this.ledger.isPending) ||
(!(this.allTime || this.inside) && this.source.isPending)
);
base = $derived(keptOf(this.raw, this.spec.keep, null));
inPeriod = $derived.by(() => {
if (this.everything) {
return this.base;
}
const from =
this.allTime || this.inside ? this.raw : (this.source.data ?? []);
return keptOf(from, this.spec.keep, this.range);
});
groups = $derived(this.inPeriod.groups);
flows = $derived.by(() => flowsOf(this.base, this.accounts.data ?? []));
periodFlows = $derived.by(() =>
flowsOf(this.inPeriod, this.accounts.data ?? [])
);
dir = $derived(dirOf(this.flows.length > 0 ? this.flows : this.periodFlows));
main = $derived(this.flows.filter((flow) => flow.dir === this.dir));
periodMain = $derived(
this.periodFlows.filter((flow) => flow.dir === this.dir)
);
codes = $derived(codesOf([...this.main, ...this.periodMain]));
history = $derived(factsOf(this.main, this.codes));
months = $derived(
this.everything && this.history.first
? buckets(this.history.first, this.now, "month")
: this.window.months
);
typicalMonths = $derived(
typicalMonths(this.months, this.now, period.first ?? this.history.first)
);
focus = $derived(
this.range && monthOf(this.range.start) === monthOf(this.range.end)
? monthOf(this.range.start)
: null
);
partial = $derived(
this.months.includes(monthOf(this.now)) ? monthOf(this.now) : null
);
norms = $derived(
normsOf(this.main, this.codes, this.typicalMonths, this.focus, this.now)
);
facts = $derived(factsOf(this.periodMain, this.codes));
weight = $derived(
weigh(
[...this.main, ...this.periodMain],
session.primary?.code ?? "",
impliedRates(this.raw)
)
);
constructor(args: () => Args) {
this.args = args;
this.ledger = createQuery(() => ({
...ledgerQuery(this.anchor),
enabled: !this.allTime,
}));
this.accounts = createQuery(() => lists.accounts("all"));
this.source = createQuery(() => {
const range = this.sourceRange;
return {
enabled: this.allTime || !this.inside,
queryFn: () =>
pages<TransactionGroup>(this.spec.source, {
...(range ?? {}),
type: "default",
}),
queryKey: [
"dimension",
this.spec.source,
range?.start ?? "all",
range?.end ?? "all",
],
};
});
}
}
+109
View File
@@ -0,0 +1,109 @@
import type { Item, Split, Tag } from "#lib/api/types.js";
import { buildTree, flatten } from "#lib/components/tags/tree.js";
import { type Flow, type Key, keys, NONE } from "#lib/reports/flows.js";
export interface Facet {
id: string;
incomeLabel?: string;
label: string;
of: (flow: Flow) => Key[];
}
export interface Spec {
facets: Facet[];
keep: (split: Split) => boolean;
source: string;
}
const listOf = (pick: (flow: Flow) => Key | null) => (flow: Flow) => {
const key = pick(flow);
return key ? [key] : [];
};
const facets = {
budgets: { id: "budgets", label: "Budgets", of: listOf(keys.budget) },
cards: { id: "cards", label: "Cards", of: listOf(keys.asset) },
categories: {
id: "categories",
label: "Categories",
of: listOf(keys.category),
},
merchants: {
id: "merchants",
incomeLabel: "Payers",
label: "Merchants",
of: listOf(keys.other),
},
tags: { id: "tags", label: "Tags", of: keys.tag },
} satisfies Record<string, Facet>;
export const categorySpec = (id: string): Spec => ({
facets: [facets.merchants, facets.cards, facets.budgets, facets.tags],
keep: (split) => String(split.category_id ?? "") === id,
source: `/v1/categories/${id}/transactions`,
});
export const budgetSpec = (id: string): Spec => ({
facets: [facets.merchants, facets.cards, facets.categories, facets.tags],
keep: (split) => String(split.budget_id ?? "") === id,
source: `/v1/budgets/${id}/transactions`,
});
export const merchantSpec = (id: string): Spec => ({
facets: [facets.categories, facets.cards, facets.budgets, facets.tags],
keep: (split) =>
String(split.source_id) === id || String(split.destination_id) === id,
source: `/v1/accounts/${id}/transactions`,
});
export const underTag = (name: string, tag: string) =>
tag === name || tag.startsWith(`${name}/`);
function subtagFacet(name: string, tags: Item<Tag>[]): Facet | null {
const node = flatten(buildTree(tags)).find((n) => n.path === name);
const children = node?.children ?? [];
if (children.length === 0) {
return null;
}
const childOf = (tag: string): Key | null => {
if (tag === name) {
return { key: NONE, label: "(no subtag)" };
}
const child = children.find((c) => underTag(c.path, tag));
return child
? {
href: `/tags/show/${encodeURIComponent(child.path)}`,
key: child.path,
label: child.name,
}
: null;
};
return {
id: "subtags",
label: "Subtags",
of: (flow) => {
const found = new Map<string, Key>();
for (const tag of flow.tags) {
const key = childOf(tag);
if (key) {
found.set(key.key, key);
}
}
return [...found.values()];
},
};
}
export function tagSpec(name: string, tags: Item<Tag>[]): Spec {
const subtags = subtagFacet(name, tags);
return {
facets: [
...(subtags ? [subtags] : []),
facets.merchants,
facets.cards,
facets.categories,
],
keep: (split) => (split.tags ?? []).some((tag) => underTag(name, tag)),
source: "/v1/transactions",
};
}
+356
View File
@@ -0,0 +1,356 @@
import type { BudgetLimit, Item } from "#lib/api/types.js";
import {
type Lane,
type StackKey,
tones,
wash,
} from "#lib/components/charts/chart.js";
import { contains, daysBetween, type Range } from "#lib/dates.js";
import { monthOf, shortMonth } from "#lib/reports/board.js";
import { type Flow, group, type Totals } from "#lib/reports/flows.js";
import type { Rate } from "#lib/reports/rates.js";
import { money } from "#lib/reports/report.svelte.js";
import { median, pace, series, typical, versus } from "#lib/reports/typical.js";
import { modeAxis } from "#lib/reports/view.js";
import type { Facet } from "./spec.js";
export type Dir = 1 | -1;
export interface Money {
amount: number;
code: string;
}
export interface Facts {
count: number;
first: string;
last: string;
median: Money | null;
sums: Money[];
}
export interface Norm {
code: string;
projected: number | null;
ratio: number | null;
typical: number;
}
export interface FacetRow {
href?: string;
key: string;
label: string;
share: number;
spark: number[];
sums: Money[];
}
export interface LimitFact {
amount: number;
code: string;
day: number | null;
days: number;
id: string;
spent: number;
}
export interface StripCell {
amount: number;
id: string | null;
month: string;
spent: number;
}
const BRAND = "var(--ff-brand)";
const PLURAL = /s$/;
const LEFT_WASH = 28;
export const counted = (count: number, noun: string) =>
`${count} ${count === 1 ? noun.replace(PLURAL, "") : noun}`;
export const fmt = (code: string, amount: number, decimals?: number) =>
money(code, amount, false, decimals);
export function dirOf(flows: Flow[]): Dir {
const out = flows.filter((flow) => flow.dir === -1).length;
return out * 2 >= flows.length ? -1 : 1;
}
function tally(flows: Flow[]) {
const by = new Map<string, { count: number; sum: number }>();
for (const flow of flows) {
const t = by.get(flow.currency) ?? { count: 0, sum: 0 };
t.count += 1;
t.sum += flow.amount;
by.set(flow.currency, t);
}
return by;
}
export function codesOf(flows: Flow[]): string[] {
const by = tally(flows);
return [...by.keys()].sort(
(a, b) => (by.get(b)?.count ?? 0) - (by.get(a)?.count ?? 0)
);
}
export function sumsOf(flows: Flow[], codes: string[]): Money[] {
const by = tally(flows);
return codes
.filter((code) => by.has(code))
.map((code) => ({ amount: by.get(code)?.sum ?? 0, code }));
}
export function factsOf(flows: Flow[], codes: string[]): Facts {
const main = codes.find((code) => flows.some((f) => f.currency === code));
let first = "";
let last = "";
for (const flow of flows) {
first = !first || flow.date < first ? flow.date : first;
last = flow.date > last ? flow.date : last;
}
const amounts = flows.filter((f) => f.currency === main).map((f) => f.amount);
return {
count: new Set(flows.map((flow) => flow.group)).size,
first,
last,
median: main ? { amount: median(amounts), code: main } : null,
sums: sumsOf(flows, codes),
};
}
export function normsOf(
flows: Flow[],
codes: string[],
months: string[],
focus: string | null,
now: string
): Norm[] {
const by = series(flows, (flow) => flow.currency);
return codes.map((code) => {
const typ = typical(by.get(code), months);
if (focus === monthOf(now)) {
const p = pace(
flows.filter((flow) => flow.currency === code),
months,
now
);
const projected = p.sofar + typ - p.typical;
return { code, projected, ratio: versus(projected, typ), typical: typ };
}
const value = focus ? (by.get(code)?.get(focus) ?? 0) : 0;
return {
code,
projected: null,
ratio: focus ? versus(value, typ) : null,
typical: typ,
};
});
}
export function lanesOf(
flows: Flow[],
months: string[],
codes: string[],
dir: Dir,
norms: Norm[],
height: number
): Lane[] {
const key: StackKey = {
color: BRAND,
key: "value",
label: dir === -1 ? "Spent" : "Earned",
};
const main = series(
flows.filter((flow) => flow.dir === dir),
(flow) => flow.currency
);
const back = series(
flows.filter((flow) => flow.dir !== dir),
(flow) => flow.currency
);
return codes.map((code) => ({
axisFormat: modeAxis(code),
dots: back.has(code)
? Object.fromEntries(
months.map((month) => [month, back.get(code)?.get(month) ?? 0])
)
: undefined,
format: (value: number) => fmt(code, value, 0),
height,
key: code,
keys: [key],
label: code,
rows: months.map((month) => ({
key: month,
label: shortMonth(month),
values: { value: main.get(code)?.get(month) ?? 0 },
})),
typical: norms.find((n) => n.code === code)?.typical || null,
}));
}
const LIMIT_KEYS: StackKey[] = [
{ color: BRAND, key: "spent", label: "Spent" },
{ color: wash(BRAND, LEFT_WASH), key: "left", label: "Left" },
{ color: tones.expense, key: "over", label: "Overspent" },
];
const limitKey = (code: string, month: string) => `${code} ${month}`;
function limitsByMonth(limits: Item<BudgetLimit>[]) {
const by = new Map<string, number>();
for (const limit of limits) {
const k = limitKey(
limit.attributes.currency_code ?? "",
monthOf(limit.attributes.start ?? "")
);
by.set(k, (by.get(k) ?? 0) + Number(limit.attributes.amount));
}
return by;
}
export function withLimits(lanes: Lane[], limits: Item<BudgetLimit>[]): Lane[] {
const by = limitsByMonth(limits);
return lanes.map((lane) => ({
...lane,
keys: LIMIT_KEYS,
rows: lane.rows.map((row) => {
const spent = row.values.value ?? 0;
const limit = by.get(limitKey(lane.key, row.key));
const values: Record<string, number> = limit
? {
left: Math.max(0, limit - spent),
over: Math.max(0, spent - limit),
spent: Math.min(spent, limit),
}
: { spent };
return { ...row, values };
}),
}));
}
const limitRangeOf = (limit: Item<BudgetLimit>): Range => ({
end: (limit.attributes.end ?? "").slice(0, 10),
start: (limit.attributes.start ?? "").slice(0, 10),
});
const spentIn = (flows: Flow[], code: string, range: Range) =>
flows
.filter((flow) => flow.currency === code && contains(range, flow.date))
.reduce((sum, flow) => sum + flow.amount, 0);
export function limitFacts(
limits: Item<BudgetLimit>[],
flows: Flow[],
range: Range,
now: string
): LimitFact[] {
return limits
.filter((limit) => {
const r = limitRangeOf(limit);
return r.start <= range.end && r.end >= range.start;
})
.map((limit) => {
const r = limitRangeOf(limit);
const code = limit.attributes.currency_code ?? "";
return {
amount: Number(limit.attributes.amount),
code,
day: contains(r, now) ? daysBetween(r.start, now) + 1 : null,
days: daysBetween(r.start, r.end) + 1,
id: limit.id,
spent: spentIn(flows, code, r),
};
});
}
export function stripsOf(
limits: Item<BudgetLimit>[],
flows: Flow[],
months: string[]
): Map<string, StripCell[]> {
const spent = series(flows, (flow) => flow.currency);
const strips = new Map<string, StripCell[]>();
const codes = new Set(limits.map((l) => l.attributes.currency_code ?? ""));
for (const code of codes) {
const own = limits.filter((l) => l.attributes.currency_code === code);
const cells = months.map((month) => {
const limit = own.find(
(l) => monthOf(l.attributes.start ?? "") === month
);
return {
amount: limit ? Number(limit.attributes.amount) : 0,
id: limit?.id ?? null,
month,
spent: spent.get(code)?.get(month) ?? 0,
};
});
if (cells.some((cell) => cell.id)) {
strips.set(code, cells);
}
}
return strips;
}
export function weigh(
flows: Flow[],
primary: string,
rate: Rate
): (code: string) => number {
const codes = [...new Set(flows.map((flow) => flow.currency))];
const rates = new Map(
codes.map((code) => [code, code === primary ? 1 : rate(code, primary)])
);
if ([...rates.values()].every((r) => r !== null)) {
return (code) => rates.get(code) ?? 0;
}
const by = tally(flows);
return (code) => {
const t = by.get(code);
return t && t.sum > 0 ? t.count / t.sum : 0;
};
}
export function facetRows(
facet: Facet,
period: Flow[],
window: Flow[],
months: string[],
codes: string[],
weight: (code: string) => number
): FacetRow[] {
const worth = (totals: Totals | undefined) => {
let sum = 0;
for (const [code, m] of totals?.cur ?? []) {
sum += (m.in + m.out) * weight(code);
}
return sum;
};
const sparks = new Map(
group(window, facet.of, (flow) => monthOf(flow.date)).map((row) => [
row.key,
months.map((month) => worth(row.cells.get(month))),
])
);
const total =
period.reduce(
(sum, flow) => sum + flow.amount * weight(flow.currency),
0
) || 1;
return group(period, facet.of)
.map((row) => ({
href: row.href,
key: row.key,
label: row.label,
share: worth(row.totals) / total,
spark: sparks.get(row.key) ?? [],
sums: codes
.filter((code) => row.totals.cur.has(code))
.map((code) => {
const m = row.totals.cur.get(code);
return { amount: (m?.in ?? 0) + (m?.out ?? 0), code };
}),
}))
.sort((a, b) => b.share - a.share || a.label.localeCompare(b.label));
}