feat(reports): a year board per currency instead of the generator

/reports opens the year: per currency spent so far, a typical month and
this month against typical by today, one lane of category columns per
currency with a dotted typical line and income as dots, what grew next
to the month under the cursor, an honest by-currency table and net worth
lanes weighted by size. Arrows move the month, Enter opens its
transactions, [ ] change the year, m shows the old tables, ?view=month
pins a month with the money flow. Old report URLs still work.

Shared pieces for other pages: Lanes, ledgerQuery for twelve months,
typical-month helpers.
This commit is contained in:
hh committed 2026-10-16 15:35:37 +00:00
1 parent 8c4e72666b
commit 26903b0066
20 files changed
+2519 -593

No files matched your search

+403
View File
@@ -0,0 +1,403 @@
import {
type Lane,
OTHER_COLOR,
type StackKey,
seriesColor,
} from "#lib/components/charts/chart.js";
import { addMonths, periodOf, type Range, today } from "#lib/dates.js";
import { formatDate } from "#lib/format.js";
import { session } from "#lib/session.svelte.js";
import { buckets, type Flow, keys, NONE } from "./flows.js";
import { money } from "./report.svelte.js";
import {
type Pace,
pace,
series,
typical,
typicalMonths,
typicalToDay,
versus,
} from "./typical.js";
import { modeAxis } from "./view.js";
export const OTHER = "other";
const NO_CATEGORY_COLOR = "var(--cladd-fg-softer)";
const MAX_CATEGORIES = 6;
const TOP_PLACES = 3;
export const monthOf = (date: string) => date.slice(0, 7);
export const shiftMonth = (month: string, delta: number) =>
monthOf(addMonths(`${month}-01`, delta));
export const monthRange = (month: string): Range =>
periodOf(`${month}-01`, "1M");
export function cursorOf(range: Range, now = today()): string {
const start = monthOf(range.start);
if (start === monthOf(range.end)) {
return start;
}
if (range.start <= now && now <= range.end) {
return monthOf(now);
}
return monthOf(range.end) > monthOf(now) ? monthOf(now) : monthOf(range.end);
}
export const shortMonth = (month: string) =>
new Intl.DateTimeFormat(session.locale, { month: "short" }).format(
new Date(Number(month.slice(0, 4)), Number(month.slice(5, 7)) - 1, 1)
);
export const longMonth = (month: string) => formatDate(`${month}-01`, "month");
const ordinals = new Intl.PluralRules("en", { type: "ordinal" });
const SUFFIX: Record<string, string> = {
few: "rd",
one: "st",
other: "th",
two: "nd",
};
export const ordinal = (n: number) => `${n}${SUFFIX[ordinals.select(n)]}`;
const key = (code: string, part: string) => `${code} ${part}`;
export interface Board {
categories: Map<string, Map<string, number>>;
catKeys: StackKey[];
catLabel: Map<string, string>;
codes: string[];
earned: Map<string, Map<string, number>>;
months: string[];
now: string;
partial: string | null;
primary: string;
spending: Flow[];
spent: Map<string, Map<string, number>>;
stackOf: (flow: Flow) => string;
stacks: Map<string, Map<string, number>>;
typical: Map<string, number>;
typicalMonths: string[];
}
function rankCategories(spending: Flow[], months: Set<string>) {
const totals = new Map<string, number>();
const byCat = new Map<string, number>();
const labels = new Map<string, string>([
[NONE, "(no category)"],
[OTHER, "Other"],
]);
for (const flow of spending) {
if (!months.has(monthOf(flow.date))) {
continue;
}
const cat = keys.category(flow);
labels.set(cat.key, cat.label);
totals.set(flow.currency, (totals.get(flow.currency) ?? 0) + flow.amount);
const k = key(flow.currency, cat.key);
byCat.set(k, (byCat.get(k) ?? 0) + flow.amount);
}
const score = new Map<string, number>();
for (const [k, value] of byCat) {
const [code, cat] = [
k.slice(0, k.indexOf(" ")),
k.slice(k.indexOf(" ") + 1),
];
score.set(cat, (score.get(cat) ?? 0) + value / (totals.get(code) || 1));
}
const ranked = [...score.entries()]
.filter(([cat]) => cat !== NONE)
.sort((a, b) => b[1] - a[1])
.map(([cat]) => cat);
const top = new Set(ranked.slice(0, MAX_CATEGORIES));
const catKeys: StackKey[] = [...top].map((cat, i) => ({
color: seriesColor(i),
key: cat,
label: labels.get(cat) ?? cat,
}));
if (score.has(NONE)) {
catKeys.push({
color: NO_CATEGORY_COLOR,
key: NONE,
label: labels.get(NONE) ?? "",
});
}
if (ranked.length > top.size) {
catKeys.push({ color: OTHER_COLOR, key: OTHER, label: "Other" });
}
const stackOf = (flow: Flow) => {
const cat = flow.category?.id ?? NONE;
return cat === NONE || top.has(cat) ? cat : OTHER;
};
return { catKeys, labels, stackOf };
}
export function buildBoard(
flows: Flow[],
windowMonths: string[],
year: string,
order: string[],
now = today()
): Board {
const months = buckets(`${year}-01-01`, `${year}-12-31`, "month");
const closed = year < now.slice(0, 4) ? `${Number(year) + 1}-01-01` : now;
const first = flows.reduce(
(min, flow) => (flow.date < min ? flow.date : min),
"9999"
);
const typMonths = typicalMonths(windowMonths, closed, first);
const spending = flows.filter((flow) => flow.dir === -1);
const spent = series(spending, (flow) => flow.currency);
const earned = series(
flows.filter((flow) => flow.dir === 1),
(flow) => flow.currency
);
const shown = new Set([...months, ...typMonths]);
const { catKeys, labels, stackOf } = rankCategories(
spending,
new Set(months)
);
const counts = new Map<string, number>();
for (const flow of flows) {
if (shown.has(monthOf(flow.date))) {
counts.set(flow.currency, (counts.get(flow.currency) ?? 0) + 1);
}
}
const primary = order[0] ?? "";
const rank = (code: string) => (code === primary ? 0 : 1);
const codes = [...counts.keys()].sort(
(a, b) => rank(a) - rank(b) || (counts.get(b) ?? 0) - (counts.get(a) ?? 0)
);
return {
categories: series(spending, (flow) =>
key(flow.currency, keys.category(flow).key)
),
catKeys,
catLabel: labels,
codes,
earned,
months,
now,
partial: months.includes(monthOf(now)) ? monthOf(now) : null,
primary,
spending,
spent,
stackOf,
stacks: series(spending, (flow) => key(flow.currency, stackOf(flow))),
typical: new Map(
codes.map((code) => [code, typical(spent.get(code), typMonths)])
),
typicalMonths: typMonths,
};
}
const QUIET_HEIGHT = 56;
export function spendingLanes(board: Board): Lane[] {
return board.codes.map((code) => ({
axisFormat: modeAxis(code),
dots: Object.fromEntries(
board.months.map((month) => [
month,
board.earned.get(code)?.get(month) ?? 0,
])
),
format: (value: number) => money(code, value, false, 0),
height: board.typical.get(code) ? undefined : QUIET_HEIGHT,
key: code,
keys: board.catKeys,
label: code,
rows: board.months.map((month) => ({
key: month,
label: shortMonth(month),
values: Object.fromEntries(
board.catKeys.map((k) => [
k.key,
board.stacks.get(key(code, k.key))?.get(month) ?? 0,
])
),
})),
typical: board.typical.get(code) ?? null,
}));
}
export function mergedLane(board: Board): Lane {
const code = board.primary;
const pc = (flow: Flow) => flow.pc ?? 0;
const stacks = series(board.spending, board.stackOf, pc);
const spent = series(board.spending, () => code, pc).get(code);
return {
axisFormat: modeAxis(code),
format: (value: number) => money(code, value, false, 0),
key: "pc",
keys: board.catKeys,
label: `≈${code}`,
rows: board.months.map((month) => ({
key: month,
label: shortMonth(month),
values: Object.fromEntries(
board.catKeys.map((k) => [k.key, stacks.get(k.key)?.get(month) ?? 0])
),
})),
typical: typical(spent, board.typicalMonths),
};
}
const sumOf = (byMonth: Map<string, number> | undefined, months: string[]) =>
months.reduce((sum, month) => sum + (byMonth?.get(month) ?? 0), 0);
export interface Headline {
code: string;
earned: number;
pace: Pace | null;
spent: number;
typical: number;
}
export function headlines(board: Board, months = board.months): Headline[] {
return board.codes.map((code) => ({
code,
earned: sumOf(board.earned.get(code), months),
pace:
board.partial && months.includes(board.partial)
? pace(
board.spending.filter((flow) => flow.currency === code),
board.typicalMonths,
board.now
)
: null,
spent: sumOf(board.spent.get(code), months),
typical: board.typical.get(code) ?? 0,
}));
}
export interface Grew {
cat: string;
code: string;
impact: number;
key: string;
label: string;
month: number;
ratio: number | null;
typical: number;
values: number[];
year: number;
}
export function grew(board: Board, month: string): Grew[] {
const partial = month === board.partial;
const day = Number(board.now.slice(8, 10));
const lastMonth = board.partial ?? board.months.at(-1) ?? month;
const months = board.months.filter((m) => m <= lastMonth);
const byKey = new Map<string, Flow[]>();
if (partial) {
for (const flow of board.spending) {
const k = key(flow.currency, keys.category(flow).key);
const list = byKey.get(k) ?? [];
list.push(flow);
byKey.set(k, list);
}
}
const rows: Grew[] = [];
for (const [k, byMonth] of board.categories) {
const split = k.indexOf(" ");
const code = k.slice(0, split);
const cat = k.slice(split + 1);
const base = board.typical.get(code) || 1;
const now = partial
? (byKey.get(k) ?? [])
.filter((f) => monthOf(f.date) === month && f.date <= board.now)
.reduce((sum, f) => sum + f.amount, 0)
: (byMonth.get(month) ?? 0);
const typ = partial
? typicalToDay(byKey.get(k) ?? [], board.typicalMonths, day)
: typical(byMonth, board.typicalMonths);
const year = sumOf(byMonth, board.months);
if (now === 0 && typ === 0 && year === 0) {
continue;
}
rows.push({
cat,
code,
impact: (now - typ) / base,
key: k,
label: board.catLabel.get(cat) ?? cat,
month: now,
ratio: versus(now, typ),
typical: typ,
values: months.map((m) => byMonth.get(m) ?? 0),
year,
});
}
return rows.sort((a, b) => b.impact - a.impact);
}
export interface Place {
count: number;
id: string;
name: string;
value: number;
}
export interface MonthFacts {
code: string;
day: { date: string; value: number; where: string; count: number } | null;
earned: number;
places: Place[];
ratio: number | null;
spent: number;
}
export function monthFacts(board: Board, month: string): MonthFacts[] {
const partial = month === board.partial;
return board.codes.map((code) => {
const flows = board.spending.filter(
(flow) => flow.currency === code && monthOf(flow.date) === month
);
const places = new Map<string, Place>();
const days = new Map<string, Flow[]>();
for (const flow of flows) {
const place = places.get(flow.other.id) ?? {
count: 0,
id: flow.other.id,
name: flow.other.name,
value: 0,
};
place.count += 1;
place.value += flow.amount;
places.set(flow.other.id, place);
const list = days.get(flow.date) ?? [];
list.push(flow);
days.set(flow.date, list);
}
let day: MonthFacts["day"] = null;
for (const [date, list] of days) {
const value = list.reduce((sum, f) => sum + f.amount, 0);
if (!day || value > day.value) {
const top = list.reduce((a, b) => (b.amount > a.amount ? b : a));
day = { count: list.length, date, value, where: top.other.name };
}
}
const spent = board.spent.get(code)?.get(month) ?? 0;
const p = partial
? pace(
board.spending.filter((flow) => flow.currency === code),
board.typicalMonths,
board.now
)
: null;
return {
code,
day,
earned: board.earned.get(code)?.get(month) ?? 0,
places: [...places.values()]
.sort((a, b) => b.value - a.value)
.slice(0, TOP_PLACES),
ratio: p
? versus(p.sofar, p.typical)
: versus(spent, board.typical.get(code) ?? 0),
spent,
};
});
}
+50
View File
@@ -0,0 +1,50 @@
import type { Account, Item, TransactionGroup } from "#lib/api/types.js";
import { addDays, addMonths, today } from "#lib/dates.js";
import { buckets, type Flow, toFlows } from "./flows.js";
import { pages } from "./queries.js";
export const LEDGER_MONTHS = 13;
export interface LedgerWindow {
end: string;
months: string[];
start: string;
}
export function ledgerWindow(anchor = today()): LedgerWindow {
const month = `${anchor.slice(0, 7)}-01`;
const start = addMonths(month, 1 - LEDGER_MONTHS);
const end = addDays(addMonths(month, 1), -1);
return { end, months: buckets(start, end, "month"), start };
}
export function ledgerQuery(anchor = today()) {
const { start, end } = ledgerWindow(anchor);
return {
queryFn: () =>
pages<TransactionGroup>("/v1/transactions", {
end,
start,
type: "default",
}),
queryKey: ["ledger", start, end],
};
}
export function ownFlows(
groups: Item<TransactionGroup>[],
accounts: Item<Account>[]
): Flow[] {
const own = accounts.filter(
(a) =>
a.attributes.type === "asset" ||
a.attributes.type === "liabilities" ||
a.attributes.type === "liability"
);
const currency = new Map(own.map((a) => [a.id, a.attributes.currency_code]));
return toFlows(
groups,
new Set(own.map((a) => a.id)),
(id) => currency.get(id) ?? undefined
).filter((flow) => flow.type === "withdrawal" || flow.type === "deposit");
}
+48
View File
@@ -0,0 +1,48 @@
import type { Item, TransactionGroup } from "#lib/api/types.js";
export type Rate = (from: string, to: string) => number | null;
type Pairs = Map<string, Map<string, [number, number]>>;
function pairs(groups: Item<TransactionGroup>[]): Pairs {
const sums: Pairs = new Map();
const add = (a: string, b: string, x: number, y: number) => {
const row = sums.get(a) ?? new Map<string, [number, number]>();
const pair = row.get(b) ?? [0, 0];
row.set(b, [pair[0] + x, pair[1] + y]);
sums.set(a, row);
};
for (const group of groups) {
for (const split of group.attributes.transactions) {
const from = split.currency_code;
const to = split.foreign_currency_code;
const x = Math.abs(Number(split.amount));
const y = Math.abs(Number(split.foreign_amount));
if (from && to && from !== to && x > 0 && y > 0) {
add(from, to, x, y);
add(to, from, y, x);
}
}
}
return sums;
}
function walk(sums: Pairs, from: string, to: string): number | null {
const rates = new Map([[from, 1]]);
const queue = [from];
for (const code of queue) {
const rate = rates.get(code) ?? 1;
for (const [other, [x, y]] of sums.get(code) ?? []) {
if (!rates.has(other)) {
rates.set(other, (rate * y) / x);
queue.push(other);
}
}
}
return rates.get(to) ?? null;
}
export function impliedRates(groups: Item<TransactionGroup>[]): Rate {
const sums = pairs(groups);
return (from, to) => walk(sums, from, to);
}
+36 -9
View File
@@ -1,7 +1,7 @@
import { createQuery } from "@tanstack/svelte-query";
import { getContext, setContext } from "svelte";
import { lists } from "#lib/api/lists.js";
import type { Account, Item } from "#lib/api/types.js";
import type { Account, Item, TransactionGroup } from "#lib/api/types.js";
import { formatMoney } from "#lib/format.js";
import { session } from "#lib/session.svelte.js";
import { goto } from "$app/navigation";
@@ -136,6 +136,7 @@ export interface Drill {
asset?: string;
at?: string;
box: string;
currency?: string;
dim?: Dimension | "day" | "flow";
dir?: 1 | -1;
key?: string;
@@ -172,6 +173,7 @@ function readDrill(url: {
asset: url.searchParams.get("acc") ?? undefined,
at: url.searchParams.get("at") ?? undefined,
box,
currency: url.searchParams.get("dc") ?? undefined,
dim: DRILL_DIMS.has(dim) ? (dim as Drill["dim"]) : undefined,
dir: DIRS[dir ?? ""],
key: split > 0 ? raw.slice(split + 1) : undefined,
@@ -181,12 +183,21 @@ function readDrill(url: {
export function drillQuery(drill: Drill | null): Record<string, string | null> {
if (!drill) {
return { acc: null, at: null, box: null, dir: null, dl: null, drill: null };
return {
acc: null,
at: null,
box: null,
dc: null,
dir: null,
dl: null,
drill: null,
};
}
return {
acc: drill.asset ?? null,
at: drill.at ?? null,
box: drill.box,
dc: drill.currency ?? null,
dir: drill.dir ? DIR_NAMES[drill.dir] : null,
dl: drill.label || null,
drill:
@@ -208,6 +219,9 @@ export function drillFilter(
if (drill.asset && flow.asset.id !== drill.asset) {
return false;
}
if (drill.currency && flow.currency !== drill.currency) {
return false;
}
if (!drill.dim || drill.key === undefined) {
return true;
}
@@ -246,7 +260,15 @@ function twins(
);
}
export function createReport(params: () => ReportParams) {
const inRange = (group: Item<TransactionGroup>, start: string, end: string) => {
const date = group.attributes.transactions[0]?.date.slice(0, 10) ?? "";
return date >= start && date <= end;
};
export function createReport(
params: () => ReportParams,
source?: () => Item<TransactionGroup>[] | undefined
) {
const p = $derived(params());
const kind = $derived(kindOf(p.type));
const assets = createQuery(() => lists.accounts("asset"));
@@ -273,8 +295,13 @@ export function createReport(params: () => ReportParams) {
}));
const tx = createQuery(() => ({
...reportQueries.transactions(p.start, p.end),
enabled: p.type !== "audit",
enabled: !source && p.type !== "audit",
}));
const groups = $derived(
source
? source()?.filter((group) => inRange(group, p.start, p.end))
: tx.data
);
const accounts = $derived([
...(assets.data ?? []),
@@ -347,14 +374,14 @@ export function createReport(params: () => ReportParams) {
);
const flows = $derived(
tx.data && accountsReady
? toFlows(tx.data, selectedSet, (id) => byId.get(id)?.currency_code)
groups && accountsReady
? toFlows(groups, selectedSet, (id) => byId.get(id)?.currency_code)
: undefined
);
const swaps = $derived(
tx.data && accountsReady
? exchanges(tx.data, selectedSet, (id) => byId.get(id)?.currency_code)
groups && accountsReady
? exchanges(groups, selectedSet, (id) => byId.get(id)?.currency_code)
: []
);
@@ -451,7 +478,7 @@ export function createReport(params: () => ReportParams) {
return grain;
},
get groups() {
return tx.data;
return groups;
},
itemName: (kindKey: string, id: string) =>
itemNames.get(`${kindKey}:${id}`),
+113
View File
@@ -0,0 +1,113 @@
import { today } from "#lib/dates.js";
import { percent } from "#lib/format.js";
import type { Flow } from "./flows.js";
export const TYPICAL_MONTHS = 12;
const SAME = 0.03;
export function median(values: number[]): number {
if (values.length === 0) {
return 0;
}
const sorted = [...values].sort((a, b) => a - b);
const mid = Math.floor(sorted.length / 2);
return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
}
export function typicalMonths(
months: string[],
now = today(),
since = ""
): string[] {
const current = now.slice(0, 7);
const first = since.slice(0, 7);
return months
.filter((month) => month < current && month >= first)
.slice(-TYPICAL_MONTHS);
}
export function series(
flows: Iterable<Flow>,
keyOf: (flow: Flow) => string | null,
measure: (flow: Flow) => number = (flow) => flow.amount
): Map<string, Map<string, number>> {
const result = new Map<string, Map<string, number>>();
for (const flow of flows) {
const key = keyOf(flow);
if (key === null) {
continue;
}
let months = result.get(key);
if (!months) {
months = new Map();
result.set(key, months);
}
const month = flow.date.slice(0, 7);
months.set(month, (months.get(month) ?? 0) + measure(flow));
}
return result;
}
export function typical(
byMonth: ReadonlyMap<string, number> | undefined,
months: string[]
): number {
return median(months.map((month) => byMonth?.get(month) ?? 0));
}
export function typicalToDay(
flows: Iterable<Flow>,
months: string[],
day: number
): number {
const sums = new Map(months.map((month) => [month, 0]));
for (const flow of flows) {
const month = flow.date.slice(0, 7);
const sum = sums.get(month);
if (sum !== undefined && Number(flow.date.slice(8, 10)) <= day) {
sums.set(month, sum + flow.amount);
}
}
return median([...sums.values()]);
}
export interface Pace {
day: number;
month: string;
sofar: number;
typical: number;
}
export function pace(
flows: Iterable<Flow>,
months: string[],
now = today()
): Pace {
const month = now.slice(0, 7);
const day = Number(now.slice(8, 10));
let sofar = 0;
const past: Flow[] = [];
for (const flow of flows) {
if (flow.date.slice(0, 7) === month) {
sofar += flow.date <= now ? flow.amount : 0;
} else {
past.push(flow);
}
}
return { day, month, sofar, typical: typicalToDay(past, months, day) };
}
export function versus(value: number, base: number): number | null {
return base > 0 ? (value - base) / base : null;
}
export function versusText(ratio: number | null, noun = "typical"): string {
if (ratio === null) {
return "";
}
if (Math.abs(ratio) < SAME) {
return noun ? `about ${noun}` : "about the same";
}
const side = ratio > 0 ? "above" : "below";
return `${percent(Math.abs(ratio))} ${side}${noun ? ` ${noun}` : ""}`;
}