feat(reports): live firefly reports with drill-down, money flow and spending calendar

This commit is contained in:
hh committed 2026-10-16 03:45:43 +00:00
1 parent 60caee10dc
commit 62cd17ecde
39 files changed
+6596

No files matched your search

+96
View File
@@ -0,0 +1,96 @@
import type { Item, Split, TransactionGroup } from "#lib/api/types.js";
export interface LedgerRow {
after: number;
amount: number;
before: number;
created: string | null;
group: string;
split: Split;
title: string | null;
updated: string | null;
}
export const AUDIT_COLUMNS = [
{ key: "icon", label: "Icon" },
{ key: "description", label: "Description" },
{ key: "balance_before", label: "Balance before" },
{ key: "amount", label: "Amount" },
{ key: "balance_after", label: "Balance after" },
{ key: "date", label: "Date" },
{ key: "from", label: "From" },
{ key: "to", label: "To" },
{ key: "budget", label: "Budget" },
{ key: "category", label: "Category" },
{ key: "bill", label: "Subscription" },
{ key: "created_at", label: "Created at" },
{ key: "updated_at", label: "Updated at" },
{ key: "notes", label: "Notes" },
{ key: "interest_date", label: "Interest date" },
{ key: "book_date", label: "Book date" },
{ key: "process_date", label: "Processing date" },
{ key: "due_date", label: "Due date" },
{ key: "payment_date", label: "Payment date" },
{ key: "invoice_date", label: "Invoice date" },
] as const;
export type AuditColumn = (typeof AUDIT_COLUMNS)[number]["key"];
export const DEFAULT_AUDIT: AuditColumn[] = [
"icon",
"description",
"balance_before",
"amount",
"balance_after",
"date",
"to",
];
function signed(split: Split, account: string, code: string | undefined) {
const foreign =
split.foreign_amount !== null &&
split.foreign_amount !== undefined &&
split.foreign_currency_code === code &&
split.currency_code !== code;
const value = Number(foreign ? split.foreign_amount : split.amount);
return String(split.source_id) === account ? -value : value;
}
export function ledger(
groups: Item<TransactionGroup>[],
account: string,
code: string | undefined,
start: number
): LedgerRow[] {
const rows = groups.flatMap((group) =>
group.attributes.transactions
.filter(
(split) =>
String(split.source_id) === account ||
String(split.destination_id) === account
)
.map((split) => ({ group, split }))
);
rows.sort(
(a, b) =>
a.split.date.localeCompare(b.split.date) ||
Number(a.split.transaction_journal_id) -
Number(b.split.transaction_journal_id)
);
let balance = start;
return rows.map(({ group, split }) => {
const amount = signed(split, account, code);
const before = balance;
balance += amount;
return {
after: balance,
amount,
before,
created: group.attributes.created_at ?? null,
group: group.id,
split,
title: group.attributes.group_title ?? null,
updated: group.attributes.updated_at ?? null,
};
});
}
+27
View File
@@ -0,0 +1,27 @@
/// <reference types="bun" />
import { expect, test } from "bun:test";
import type { Bill } from "#lib/api/types.js";
import { expectedDates } from "./bills.js";
test("monthly bills land once per month", () => {
const bill = {
date: "2025-11-03T00:00:00+01:00",
repeat_freq: "monthly",
skip: 0,
} as Bill;
expect(
expectedDates(bill, { end: "2026-09-30", start: "2026-09-01" })
).toEqual(["2026-09-03"]);
});
test("skip and end date are honoured", () => {
const bill = {
date: "2026-01-31",
end_date: "2026-06-01",
repeat_freq: "monthly",
skip: 1,
} as Bill;
expect(
expectedDates(bill, { end: "2026-12-31", start: "2026-01-01" })
).toEqual(["2026-01-31", "2026-03-31", "2026-05-31"]);
});
+33
View File
@@ -0,0 +1,33 @@
import type { Bill } from "#lib/api/types.js";
import { addDays, addMonths, type Range } from "#lib/dates.js";
const STEP: Record<string, (date: string, n: number) => string> = {
"half-year": (date, n) => addMonths(date, 6 * n),
monthly: (date, n) => addMonths(date, n),
quarterly: (date, n) => addMonths(date, 3 * n),
weekly: (date, n) => addDays(date, 7 * n),
yearly: (date, n) => addMonths(date, 12 * n),
};
const LIMIT = 500;
export function expectedDates(bill: Bill, range: Range): string[] {
const first = bill.date?.slice(0, 10);
const step = STEP[bill.repeat_freq ?? ""];
if (!(first && step)) {
return [];
}
const every = (bill.skip ?? 0) + 1;
const stop = bill.end_date?.slice(0, 10);
const dates: string[] = [];
for (let i = 0; i < LIMIT; i += 1) {
const date = step(first, i * every);
if (date > range.end || (stop && date > stop)) {
break;
}
if (date >= range.start) {
dates.push(date);
}
}
return dates;
}
+186
View File
@@ -0,0 +1,186 @@
/// <reference types="bun" />
import { expect, test } from "bun:test";
import type { Item, Split, TransactionGroup } from "#lib/api/types.js";
import { ledger } from "./audit.js";
import {
amountOf,
bucketRange,
buckets,
exchanges,
group,
keys,
matches,
rank,
toFlows,
topFlows,
total,
} from "./flows.js";
let nextId = 1;
function tx(split: Partial<Split>): Item<TransactionGroup> {
nextId += 1;
const id = String(nextId);
return {
attributes: {
transactions: [
{
amount: "10",
currency_code: "UAH",
date: "2026-03-05T00:00:00+02:00",
description: `tx ${id}`,
destination_id: "100",
destination_name: "Shop",
source_id: "1",
source_name: "Mono",
tags: [],
transaction_journal_id: id,
type: "withdrawal",
...split,
} as Split,
],
} as TransactionGroup,
id,
type: "transactions",
};
}
const currencyOf = (id: string) => ({ "1": "UAH", "2": "USD", "3": "UAH" })[id];
const groups = [
tx({ amount: "100", category_id: "7", category_name: "Food" }),
tx({ amount: "50", tags: ["trip", "fun"] }),
tx({
amount: "4000",
destination_id: "2",
destination_name: "Wise",
foreign_amount: "100",
foreign_currency_code: "USD",
type: "transfer",
}),
tx({
amount: "300",
destination_id: "1",
source_id: "200",
source_name: "Employer",
type: "deposit",
}),
tx({
amount: "20",
date: "2026-04-01T00:00:00+02:00",
destination_id: "3",
type: "transfer",
}),
];
test("only flows that cross the selection count", () => {
const flows = toFlows(groups, new Set(["1", "3"]), currencyOf);
expect(flows.map((flow) => flow.amount)).toEqual([100, 50, 4000, 300]);
const totals = total(flows);
expect(amountOf(totals, "UAH", "out")).toBe(4150);
expect(amountOf(totals, "UAH", "in")).toBe(300);
expect(amountOf(totals, "UAH", "net")).toBe(-3850);
});
test("transfers into a selected account use the destination currency", () => {
const flows = toFlows(groups, new Set(["2"]), currencyOf);
expect(flows).toHaveLength(1);
expect(flows[0]).toMatchObject({ amount: 100, currency: "USD", dir: 1 });
});
test("groups by dimension, counting multi-tag journals once per tag", () => {
const flows = toFlows(groups, new Set(["1", "3"]), currencyOf);
const tags = group(flows, keys.tag);
expect(tags.map((row) => [row.label, row.totals.n])).toEqual([
["(no tags)", 3],
["trip", 1],
["fun", 1],
]);
const categories = rank(group(flows, keys.category), "UAH", "out");
expect(categories[0].label).toBe("(no category)");
expect(matches(flows[0], "category", "7")).toBe(true);
expect(matches(flows[3], "budget", "0")).toBe(false);
});
test("pivots into period columns", () => {
const flows = toFlows(groups, new Set(["1"]), currencyOf);
const rows = group(flows, keys.category, (flow) => flow.date.slice(0, 7));
const none = rows.find((row) => row.key === "0");
expect([...(none?.cells.keys() ?? [])]).toEqual(["2026-03", "2026-04"]);
expect(amountOf(none?.cells.get("2026-04"), "UAH", "out")).toBe(20);
});
test("buckets cover the range", () => {
expect(buckets("2025-11-15", "2026-02-02", "month")).toEqual([
"2025-11",
"2025-12",
"2026-01",
"2026-02",
]);
expect(buckets("2026-02-27", "2026-03-02", "day")).toEqual([
"2026-02-27",
"2026-02-28",
"2026-03-01",
"2026-03-02",
]);
expect(buckets("2024-06-01", "2026-01-01", "year")).toEqual([
"2024",
"2025",
"2026",
]);
expect(
bucketRange("2026-02", "month", { end: "2026-02-20", start: "2026-01-01" })
).toEqual({ end: "2026-02-20", start: "2026-02-01" });
});
test("top flows stay inside one currency", () => {
const flows = toFlows(groups, new Set(["1"]), currencyOf);
expect(topFlows(flows, "UAH", -1, 2).map((flow) => flow.amount)).toEqual([
4000, 100,
]);
expect(topFlows(flows, "USD", -1, 2)).toHaveLength(0);
});
test("aggregates a year of transactions quickly", () => {
const many = Array.from({ length: 1500 }, (_, i) =>
tx({
amount: String(i),
category_id: String(i % 7),
category_name: `c${i % 7}`,
date: `2026-${String((i % 12) + 1).padStart(2, "0")}-10T00:00:00+02:00`,
tags: [`t${i % 5}`],
})
);
const started = performance.now();
const flows = toFlows(many, new Set(["1"]), currencyOf);
group(flows, keys.category, (flow) => flow.date.slice(0, 7));
group(flows, keys.tag);
group(flows, keys.other);
expect(performance.now() - started).toBeLessThan(50);
});
test("currency exchanges between selected accounts are kept apart", () => {
const list = exchanges(groups, new Set(["1", "2"]), currencyOf);
expect(list).toEqual([
{
date: "2026-03-05",
from: "UAH",
fromAmount: 4000,
to: "USD",
toAmount: 100,
},
]);
});
test("audit ledger runs the balance forward in the account currency", () => {
const rows = ledger(groups, "1", "UAH", 1000);
expect(rows.map((row) => [row.amount, row.after])).toEqual([
[-100, 900],
[-50, 850],
[-4000, -3150],
[300, -2850],
[-20, -2870],
]);
const usd = ledger(groups, "2", "USD", 0);
expect(usd.map((row) => row.amount)).toEqual([100]);
});
+486
View File
@@ -0,0 +1,486 @@
import type { Item, Split, TransactionGroup } from "#lib/api/types.js";
import type { Grain } from "./params.js";
export interface Ref {
id: string;
name: string;
}
export interface Party extends Ref {
type: string;
}
export interface Flow {
amount: number;
asset: Party;
bill: Ref | null;
budget: Ref | null;
category: Ref | null;
currency: string;
date: string;
description: string;
dir: 1 | -1;
group: string;
journal: string;
other: Party;
pc: number | null;
tags: string[];
type: string;
}
export interface Measure {
in: number;
n: number;
out: number;
}
export interface Totals {
cur: Map<string, Measure>;
n: number;
pc: Measure;
pcOk: boolean;
}
export interface Key {
href?: string;
key: string;
label: string;
}
export interface Row extends Key {
cells: Map<string, Totals>;
totals: Totals;
}
export type Side = "in" | "out" | "net";
const NUMERIC = (value: string | null | undefined) =>
value === null || value === undefined || value === "" ? null : Number(value);
const ref = (id: string | null | undefined, name: string | null | undefined) =>
id ? { id: String(id), name: name ?? "" } : null;
function party(split: Split, side: "source" | "destination"): Party {
return {
id: String(split[`${side}_id`] ?? ""),
name: split[`${side}_name`] ?? "",
type: split[`${side}_type`] ?? "",
};
}
function inbound(
split: Split,
currencyOf: (id: string) => string | undefined
): { amount: number; currency: string } {
const foreign = NUMERIC(split.foreign_amount);
const code = split.foreign_currency_code;
if (
foreign !== null &&
code &&
currencyOf(String(split.destination_id)) === code
) {
return { amount: foreign, currency: code };
}
return { amount: Number(split.amount), currency: split.currency_code ?? "" };
}
function toFlow(
split: Split,
groupId: string,
outgoing: boolean,
currencyOf: (id: string) => string | undefined
): Flow {
const value = outgoing
? { amount: Number(split.amount), currency: split.currency_code ?? "" }
: inbound(split, currencyOf);
return {
...value,
asset: party(split, outgoing ? "source" : "destination"),
bill: ref(split.bill_id, split.bill_name),
budget: ref(split.budget_id, split.budget_name),
category: ref(split.category_id, split.category_name),
date: split.date.slice(0, 10),
description: split.description,
dir: outgoing ? -1 : 1,
group: groupId,
journal: String(split.transaction_journal_id ?? ""),
other: party(split, outgoing ? "destination" : "source"),
pc: NUMERIC(split.pc_amount),
tags: split.tags ?? [],
type: split.type,
};
}
export function toFlows(
groups: Item<TransactionGroup>[],
selected: ReadonlySet<string>,
currencyOf: (id: string) => string | undefined = () => undefined
): Flow[] {
const flows: Flow[] = [];
for (const item of groups) {
for (const split of item.attributes.transactions) {
const outgoing = selected.has(String(split.source_id));
if (outgoing !== selected.has(String(split.destination_id))) {
flows.push(toFlow(split, item.id, outgoing, currencyOf));
}
}
}
return flows;
}
export function flowValue(flow: Flow, mode: string): number | null {
if (mode === "pc") {
return flow.pc;
}
return flow.currency === mode ? flow.amount : null;
}
export const emptyTotals = (): Totals => ({
cur: new Map(),
n: 0,
pc: { in: 0, n: 0, out: 0 },
pcOk: true,
});
export function add(totals: Totals, flow: Flow) {
let measure = totals.cur.get(flow.currency);
if (!measure) {
measure = { in: 0, n: 0, out: 0 };
totals.cur.set(flow.currency, measure);
}
const side = flow.dir === 1 ? "in" : "out";
measure[side] += flow.amount;
measure.n += 1;
totals.n += 1;
if (flow.pc === null) {
totals.pcOk = false;
} else {
totals.pc[side] += flow.pc;
totals.pc.n += 1;
}
}
export function total(flows: Iterable<Flow>): Totals {
const totals = emptyTotals();
for (const flow of flows) {
add(totals, flow);
}
return totals;
}
export function measureOf(totals: Totals, mode: string): Measure | undefined {
return mode === "pc" ? totals.pc : totals.cur.get(mode);
}
export function amountOf(
totals: Totals | undefined,
mode: string,
side: Side
): number {
const measure = totals ? measureOf(totals, mode) : undefined;
if (!measure) {
return 0;
}
if (side === "net") {
return measure.in - measure.out;
}
return measure[side];
}
export type KeyFn = (flow: Flow) => Key | Key[] | null;
export type ColFn = (flow: Flow) => string | null;
export function group(flows: Iterable<Flow>, keyOf: KeyFn, colOf?: ColFn) {
const rows = new Map<string, Row>();
for (const flow of flows) {
const found = keyOf(flow);
if (!found) {
continue;
}
const col = colOf?.(flow) ?? null;
for (const key of Array.isArray(found) ? found : [found]) {
let row = rows.get(key.key);
if (!row) {
row = { ...key, cells: new Map(), totals: emptyTotals() };
rows.set(key.key, row);
}
add(row.totals, flow);
if (col !== null) {
let cell = row.cells.get(col);
if (!cell) {
cell = emptyTotals();
row.cells.set(col, cell);
}
add(cell, flow);
}
}
}
return [...rows.values()];
}
export function rank<T extends { label: string; totals: Totals }>(
rows: T[],
mode: string,
side: Side
): T[] {
const score = (row: T) => Math.abs(amountOf(row.totals, mode, side));
return [...rows].sort(
(a, b) => score(b) - score(a) || a.label.localeCompare(b.label)
);
}
export function currencies(totals: Totals, codes?: string[]): string[] {
const present = [...totals.cur.keys()];
if (!codes) {
return present.sort();
}
return [
...codes.filter((code) => totals.cur.has(code)),
...present.filter((code) => !codes.includes(code)).sort(),
];
}
export const NONE = "0";
export const keys = {
asset: (flow: Flow): Key => ({
href: `/accounts/show/${flow.asset.id}`,
key: flow.asset.id,
label: flow.asset.name,
}),
bill: (flow: Flow): Key =>
flow.bill
? {
href: `/subscriptions/show/${flow.bill.id}`,
key: flow.bill.id,
label: flow.bill.name,
}
: { key: NONE, label: "(no subscription)" },
budget: (flow: Flow): Key | null => {
if (flow.dir === 1 || flow.type !== "withdrawal") {
return null;
}
return flow.budget
? {
href: `/budgets/show/${flow.budget.id}`,
key: flow.budget.id,
label: flow.budget.name,
}
: { key: NONE, label: "(no budget)" };
},
category: (flow: Flow): Key =>
flow.category
? {
href: `/categories/show/${flow.category.id}`,
key: flow.category.id,
label: flow.category.name,
}
: { key: NONE, label: "(no category)" },
name: (flow: Flow): Key => ({
key: flow.other.name.toLowerCase(),
label: flow.other.name,
}),
other: (flow: Flow): Key => ({
href: `/accounts/show/${flow.other.id}`,
key: flow.other.id,
label: flow.other.name,
}),
tag: (flow: Flow): Key[] =>
flow.tags.length > 0
? flow.tags.map((tag) => ({
href: `/tags/show/${encodeURIComponent(tag)}`,
key: tag,
label: tag,
}))
: [{ key: NONE, label: "(no tags)" }],
};
export type Dimension = keyof typeof keys;
export function matches(flow: Flow, dim: Dimension, key: string): boolean {
const found = keys[dim](flow);
if (!found) {
return false;
}
return Array.isArray(found)
? found.some((item) => item.key === key)
: found.key === key;
}
export function bucketOf(date: string, grain: Grain): string {
if (grain === "year") {
return date.slice(0, 4);
}
return grain === "month" ? date.slice(0, 7) : date.slice(0, 10);
}
const pad = (n: number) => String(n).padStart(2, "0");
export function buckets(start: string, end: string, grain: Grain): string[] {
const list: string[] = [];
if (grain === "year") {
for (
let y = Number(start.slice(0, 4));
y <= Number(end.slice(0, 4));
y += 1
) {
list.push(String(y));
}
return list;
}
if (grain === "month") {
let y = Number(start.slice(0, 4));
let m = Number(start.slice(5, 7));
const last = end.slice(0, 7);
while (`${y}-${pad(m)}` <= last) {
list.push(`${y}-${pad(m)}`);
m += 1;
if (m > 12) {
m = 1;
y += 1;
}
}
return list;
}
const d = new Date(
Number(start.slice(0, 4)),
Number(start.slice(5, 7)) - 1,
Number(start.slice(8, 10))
);
for (;;) {
const key = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
if (key > end) {
return list;
}
list.push(key);
d.setDate(d.getDate() + 1);
}
}
export function bucketRange(
key: string,
grain: Grain,
clip?: { start: string; end: string }
): { start: string; end: string } {
let range: { start: string; end: string };
if (grain === "year") {
range = { end: `${key}-12-31`, start: `${key}-01-01` };
} else if (grain === "month") {
const [y, m] = key.split("-").map(Number);
range = {
end: `${key}-${pad(new Date(y, m, 0).getDate())}`,
start: `${key}-01`,
};
} else {
range = { end: key, start: key };
}
if (!clip) {
return range;
}
return {
end: range.end > clip.end ? clip.end : range.end,
start: range.start < clip.start ? clip.start : range.start,
};
}
export function topFlows(
flows: Iterable<Flow>,
mode: string,
dir: 1 | -1,
limit: number
): Flow[] {
const list: Flow[] = [];
for (const flow of flows) {
if (flow.dir !== dir) {
continue;
}
if (mode === "pc" ? flow.pc !== null : flow.currency === mode) {
list.push(flow);
}
}
const amount = (flow: Flow) => (mode === "pc" ? (flow.pc ?? 0) : flow.amount);
return list.sort((a, b) => amount(b) - amount(a)).slice(0, limit);
}
export function modes(totals: Totals, codes: string[]): string[] {
const list = currencies(totals, codes);
return totals.pcOk && list.length > 1 ? ["pc", ...list] : list;
}
export function daily(flows: Iterable<Flow>, mode: string, dir: 1 | -1) {
const days = new Map<string, { value: number; n: number }>();
for (const flow of flows) {
if (flow.dir !== dir) {
continue;
}
const value = flowValue(flow, mode);
if (value === null) {
continue;
}
const day = days.get(flow.date) ?? { n: 0, value: 0 };
day.value += value;
day.n += 1;
days.set(flow.date, day);
}
return days;
}
export function merge(list: (Totals | undefined)[]): Totals {
const result = emptyTotals();
for (const totals of list) {
if (!totals) {
continue;
}
for (const [code, m] of totals.cur) {
const target = result.cur.get(code) ?? { in: 0, n: 0, out: 0 };
target.in += m.in;
target.out += m.out;
target.n += m.n;
result.cur.set(code, target);
}
result.n += totals.n;
result.pc.in += totals.pc.in;
result.pc.out += totals.pc.out;
result.pc.n += totals.pc.n;
result.pcOk &&= totals.pcOk;
}
return result;
}
export interface Exchange {
date: string;
from: string;
fromAmount: number;
to: string;
toAmount: number;
}
export function exchanges(
groups: Item<TransactionGroup>[],
selected: ReadonlySet<string>,
currencyOf: (id: string) => string | undefined
): Exchange[] {
const list: Exchange[] = [];
for (const item of groups) {
for (const split of item.attributes.transactions) {
const source = String(split.source_id);
const destination = String(split.destination_id);
if (!(selected.has(source) && selected.has(destination))) {
continue;
}
const from = split.currency_code ?? currencyOf(source) ?? "";
const to = split.foreign_currency_code ?? currencyOf(destination) ?? from;
const foreign = NUMERIC(split.foreign_amount);
if (from !== to && foreign !== null) {
list.push({
date: split.date.slice(0, 10),
from,
fromAmount: Number(split.amount),
to,
toAmount: foreign,
});
}
}
}
return list;
}
+43
View File
@@ -0,0 +1,43 @@
import {
kindOf,
type ListKind,
type ReportParams,
type ReportType,
} from "./params.js";
const KEY = "ffui.reports.form";
export interface Memory {
accounts: string[];
items: Partial<Record<ListKind, string[]>>;
type: ReportType;
}
export function recall(): Memory {
try {
const value = JSON.parse(localStorage.getItem(KEY) ?? "null");
if (value && kindOf(value.type) && Array.isArray(value.accounts)) {
return {
accounts: value.accounts,
items: value.items ?? {},
type: value.type,
};
}
} catch {
localStorage.removeItem(KEY);
}
return { accounts: [], items: {}, type: "default" };
}
export function remember(
params: Pick<ReportParams, "type" | "accounts" | "items">
) {
const memory = recall();
const list = kindOf(params.type)?.list;
const next: Memory = {
accounts: params.accounts,
items: list ? { ...memory.items, [list]: params.items } : memory.items,
type: params.type,
};
localStorage.setItem(KEY, JSON.stringify(next));
}
+115
View File
@@ -0,0 +1,115 @@
import type { Account, Budget, Category, Item, Tag } from "#lib/api/types.js";
import type { ListKind } from "./params.js";
import type { Option } from "./report.svelte.js";
const byLabel = (a: Option, b: Option) =>
(a.group ?? "").localeCompare(b.group ?? "") ||
a.label.localeCompare(b.label);
export function budgetOptions(budgets: Item<Budget>[] = []): Option[] {
return budgets
.filter((b) => b.attributes.active !== false)
.map((b) => ({ id: b.id, label: b.attributes.name }))
.sort(byLabel);
}
export function categoryOptions(categories: Item<Category>[] = []): Option[] {
return categories
.map((c) => ({ id: c.id, label: c.attributes.name }))
.sort(byLabel);
}
export function tagOptions(tags: Item<Tag>[] = []): Option[] {
return tags
.map((t) => ({
group: t.attributes.date ? t.attributes.date.slice(0, 4) : "(no date)",
id: t.id,
label: t.attributes.tag,
}))
.sort(byLabel);
}
export function doubleOptions(
expense: Item<Account>[] = [],
revenue: Item<Account>[] = []
): Option[] {
const revenueNames = new Set(
revenue.map((a) => a.attributes.name.toLowerCase())
);
const revenueIbans = new Set(
revenue.map((a) => a.attributes.iban).filter(Boolean)
);
const paired = (a: Account) =>
revenueNames.has(a.name.toLowerCase()) ||
(!!a.iban && revenueIbans.has(a.iban));
const label = (a: Account) => (a.iban ? `${a.name} (${a.iban})` : a.name);
const pairs = expense.filter((a) => paired(a.attributes));
const pairedNames = new Set(
pairs.map((a) => a.attributes.name.toLowerCase())
);
return [
...pairs.map((a) => ({
group: "Expense and revenue",
id: a.id,
label: label(a.attributes),
})),
...expense
.filter((a) => !paired(a.attributes))
.map((a) => ({
group: "Expense accounts",
id: a.id,
label: label(a.attributes),
})),
...revenue
.filter((a) => !pairedNames.has(a.attributes.name.toLowerCase()))
.map((a) => ({
group: "Revenue accounts",
id: a.id,
label: label(a.attributes),
})),
].sort(
(a, b) =>
GROUP_ORDER.indexOf(a.group) - GROUP_ORDER.indexOf(b.group) ||
a.label.localeCompare(b.label)
);
}
const GROUP_ORDER = [
"Expense and revenue",
"Expense accounts",
"Revenue accounts",
];
export interface ListSources {
budgets?: Item<Budget>[];
categories?: Item<Category>[];
expense?: Item<Account>[];
revenue?: Item<Account>[];
tags?: Item<Tag>[];
}
export function listOptions(kind: ListKind, sources: ListSources): Option[] {
switch (kind) {
case "budget":
return budgetOptions(sources.budgets);
case "category":
return categoryOptions(sources.categories);
case "tag":
return tagOptions(sources.tags);
default:
return doubleOptions(sources.expense, sources.revenue);
}
}
export function listReady(kind: ListKind, sources: ListSources): boolean {
switch (kind) {
case "budget":
return !!sources.budgets;
case "category":
return !!sources.categories;
case "tag":
return !!sources.tags;
default:
return !!(sources.expense && sources.revenue);
}
}
+82
View File
@@ -0,0 +1,82 @@
/// <reference types="bun" />
import { expect, test } from "bun:test";
import {
fiscalYear,
grainOf,
parseReport,
parseReportDate,
reportPath,
templateOf,
} from "./params.js";
const NOW = "2026-10-16";
test("parses Firefly dates and magic words", () => {
expect(parseReportDate("20260101", "01-01", NOW)).toBe("2026-01-01");
expect(parseReportDate("2026-02-03", "01-01", NOW)).toBe("2026-02-03");
expect(parseReportDate("20260231", "01-01", NOW)).toBeNull();
expect(parseReportDate("currentMonthStart", "01-01", NOW)).toBe("2026-10-01");
expect(parseReportDate("currentMonthEnd", "01-01", NOW)).toBe("2026-10-31");
expect(parseReportDate("previousMonthStart", "01-01", NOW)).toBe(
"2026-09-01"
);
expect(parseReportDate("previousMonthEnd", "01-01", NOW)).toBe("2026-09-30");
expect(parseReportDate("previousYearEnd", "01-01", NOW)).toBe("2025-12-31");
expect(parseReportDate("currentFiscalYearStart", "04-01", NOW)).toBe(
"2026-04-01"
);
expect(parseReportDate("previousFiscalYearEnd", "04-01", NOW)).toBe(
"2026-03-31"
);
});
test("fiscal year wraps around the start day", () => {
expect(fiscalYear("2026-02-10", "04-01")).toEqual({
end: "2026-03-31",
start: "2025-04-01",
});
});
test("round-trips a report path", () => {
const parsed = parseReport("category", "1,2/5,7/20260101/20261231");
expect(parsed.ok).toBe(true);
if (parsed.ok) {
expect(parsed.params).toEqual({
accounts: ["1", "2"],
end: "2026-12-31",
items: ["5", "7"],
start: "2026-01-01",
type: "category",
});
expect(reportPath(parsed.params)).toBe(
"/reports/category/1,2/5,7/20260101/20261231"
);
}
});
test("rejects bad addresses like Firefly does", () => {
expect(parseReport("nope", "1/20260101/20260131").ok).toBe(false);
expect(parseReport("default", "1/20260201/20260101").ok).toBe(false);
expect(parseReport("budget", "1/20260101/20260131").ok).toBe(false);
const swapped = parseReport("double", "1/9/20260201/20260101");
expect(swapped.ok && swapped.params.start).toBe("2026-01-01");
});
test("keeps magic list words for the page to resolve", () => {
const parsed = parseReport(
"tag",
"allAssetAccounts/allTags/20260101/20260131"
);
expect(parsed.ok && parsed.params.accounts).toEqual(["allAssetAccounts"]);
expect(parsed.ok && parsed.params.items).toEqual(["allTags"]);
});
test("picks grain and template like Firefly", () => {
expect(grainOf("2026-10-01", "2026-10-31")).toBe("day");
expect(templateOf("2026-10-01", "2026-10-31")).toBe("month");
expect(grainOf("2026-01-01", "2026-12-31")).toBe("month");
expect(templateOf("2026-01-01", "2026-12-31")).toBe("year");
expect(templateOf("2026-01-01", "2026-02-28")).toBe("year");
expect(grainOf("2025-01-01", "2026-12-31")).toBe("year");
expect(templateOf("2025-01-01", "2026-12-31")).toBe("multi-year");
});
+283
View File
@@ -0,0 +1,283 @@
import {
addDays,
iso,
parse,
periodOf,
type Range,
shiftPeriod,
today,
} from "#lib/dates.js";
export type ReportType =
| "default"
| "audit"
| "budget"
| "category"
| "tag"
| "double";
export type ListKind = "budget" | "category" | "tag" | "double";
export interface ReportKind {
label: string;
list?: ListKind;
type: ReportType;
}
export const REPORT_KINDS: ReportKind[] = [
{ label: "Default financial report", type: "default" },
{ label: "Transaction history overview (audit)", type: "audit" },
{ label: "Budget report", list: "budget", type: "budget" },
{ label: "Category report", list: "category", type: "category" },
{ label: "Tag report", list: "tag", type: "tag" },
{ label: "Expense/revenue account report", list: "double", type: "double" },
];
export const LIST_LABELS: Record<ListKind, { one: string; many: string }> = {
budget: { many: "Budgets", one: "budget" },
category: { many: "Categories", one: "category" },
double: { many: "Expense/revenue accounts", one: "account" },
tag: { many: "Tags", one: "tag" },
};
export const ALL_ASSETS = "allAssetAccounts";
export interface ReportParams {
accounts: string[];
end: string;
items: string[];
start: string;
type: ReportType;
}
export type Parsed =
| { ok: true; params: ReportParams }
| { ok: false; error: string };
const YMD = /^(\d{4})(\d{2})(\d{2})$/;
const ISO = /^(\d{4})-(\d{2})-(\d{2})/;
const LIST_ITEM = /^[^,/]+$/;
export const kindOf = (type: string) =>
REPORT_KINDS.find((kind) => kind.type === type);
export const ymd = (value: string) => value.replaceAll("-", "");
function valid(y: number, m: number, d: number): string | null {
const date = new Date(y, m - 1, d);
if (
date.getFullYear() !== y ||
date.getMonth() !== m - 1 ||
date.getDate() !== d
) {
return null;
}
return iso(date);
}
export function fiscalYear(value: string, fiscalStart = "01-01"): Range {
const [month, day] = fiscalStart.split("-").map(Number);
const year = Number(value.slice(0, 4));
const thisYear = iso(new Date(year, (month || 1) - 1, day || 1));
const startYear = thisYear <= value ? year : year - 1;
const start = iso(new Date(startYear, (month || 1) - 1, day || 1));
const next = iso(new Date(startYear + 1, (month || 1) - 1, day || 1));
return { end: addDays(next, -1), start };
}
function magic(now: string, fiscalStart: string): Record<string, string> {
const d = parse(now);
const y = d.getFullYear();
const m = d.getMonth();
const fiscal = fiscalYear(now, fiscalStart);
const previousFiscal = fiscalYear(addDays(fiscal.start, -1), fiscalStart);
return {
currentFiscalYearEnd: fiscal.end,
currentFiscalYearStart: fiscal.start,
currentMonthEnd: iso(new Date(y, m + 1, 0)),
currentMonthStart: iso(new Date(y, m, 1)),
currentYearEnd: `${y}-12-31`,
currentYearStart: `${y}-01-01`,
previousFiscalYearEnd: previousFiscal.end,
previousFiscalYearStart: previousFiscal.start,
previousMonthEnd: iso(new Date(y, m, 0)),
previousMonthStart: iso(new Date(y, m - 1, 1)),
previousYearEnd: `${y - 1}-12-31`,
previousYearStart: `${y - 1}-01-01`,
};
}
export function parseReportDate(
value: string,
fiscalStart = "01-01",
now = today()
): string | null {
const word = magic(now, fiscalStart)[value];
if (word) {
return word;
}
const match = YMD.exec(value) ?? ISO.exec(value);
if (!match) {
return null;
}
return valid(Number(match[1]), Number(match[2]), Number(match[3]));
}
function list(segment: string | undefined): string[] | null {
if (!segment) {
return null;
}
const items = segment
.split(",")
.map((item) => item.trim())
.filter(Boolean);
return items.length > 0 && items.every((item) => LIST_ITEM.test(item))
? [...new Set(items)]
: null;
}
export function parseReport(
type: string,
rest: string,
fiscalStart = "01-01",
now = today()
): Parsed {
const kind = kindOf(type);
if (!kind) {
return { error: `There is no "${type}" report.`, ok: false };
}
const segments = rest.split("/").filter(Boolean);
const expected = kind.list ? 4 : 3;
if (segments.length !== expected) {
return { error: "This report address is incomplete.", ok: false };
}
const accounts = list(segments[0]);
const items = kind.list ? list(segments[1]) : [];
let start = parseReportDate(segments.at(-2) ?? "", fiscalStart, now);
let end = parseReportDate(segments.at(-1) ?? "", fiscalStart, now);
if (!accounts) {
return { error: "Please select at least one asset account", ok: false };
}
if (!items) {
return {
error: `Please select at least one ${LIST_LABELS[kind.list as ListKind].one}`,
ok: false,
};
}
if (!(start && end)) {
return {
error: "The dates in this report address are not valid.",
ok: false,
};
}
if (end < start) {
if (type !== "double") {
return {
error: "End date of report must be after start date.",
ok: false,
};
}
[start, end] = [end, start];
}
return { ok: true, params: { accounts, end, items, start, type: kind.type } };
}
export function reportPath(params: ReportParams): string {
const parts = [
"/reports",
params.type,
params.accounts.join(","),
...(kindOf(params.type)?.list ? [params.items.join(",")] : []),
ymd(params.start),
ymd(params.end),
];
return parts.join("/");
}
export function missing(params: ReportParams): string | null {
if (params.accounts.length === 0) {
return "Please select at least one asset account";
}
const kind = kindOf(params.type);
if (kind?.list && params.items.length === 0) {
return kind.list === "double"
? "Please select at least one combination of expense/revenue accounts. If you have none (the list is empty) this report is not available."
: `Please select at least one ${LIST_LABELS[kind.list].one}`;
}
if (!(params.start && params.end)) {
return "Please choose a date range";
}
if (params.end < params.start) {
return "End date of report must be after start date.";
}
return null;
}
export type Grain = "day" | "month" | "year";
export type Template = "month" | "year" | "multi-year";
const daysIn = (y: number, m: number) => new Date(y, m + 1, 0).getDate();
export function monthsBetween(start: string, end: string): number {
const a = parse(start);
const b = parse(end);
const whole =
(b.getFullYear() - a.getFullYear()) * 12 + b.getMonth() - a.getMonth();
const days = b.getDate() - a.getDate();
return whole + days / daysIn(a.getFullYear(), a.getMonth());
}
const GRAIN_EPSILON = 0.001;
export function grainOf(start: string, end: string): Grain {
const months = monthsBetween(start, end);
if (months >= 12 + GRAIN_EPSILON) {
return "year";
}
return months >= 1 + GRAIN_EPSILON ? "month" : "day";
}
export function templateOf(start: string, end: string): Template {
const months = monthsBetween(start, end);
if (months > 12) {
return "multi-year";
}
return months > 1 ? "year" : "month";
}
const ALIGNED_VIEWS = ["1Y", "6M", "3M", "1M"] as const;
export function shiftRange(range: Range, dir: 1 | -1): Range {
for (const view of ALIGNED_VIEWS) {
const aligned = periodOf(range.start, view);
if (aligned.start === range.start && aligned.end === range.end) {
return shiftPeriod(range, view, dir);
}
}
return shiftPeriod(range, "1M", dir);
}
export interface RangePreset {
key: string;
label: string;
range: Range;
}
export function rangePresets(now = today()): RangePreset[] {
const month = periodOf(now, "1M");
const year = periodOf(now, "1Y");
return [
{ key: "this-month", label: "This month", range: month },
{
key: "last-month",
label: "Last month",
range: shiftPeriod(month, "1M", -1),
},
{ key: "this-year", label: "This year", range: year },
{
key: "last-year",
label: "Last year",
range: shiftPeriod(year, "1Y", -1),
},
];
}
+130
View File
@@ -0,0 +1,130 @@
import { api } from "#lib/api/client.js";
import { all } from "#lib/api/lists.js";
import type {
Account,
Bill,
BudgetLimit,
ChartDataSet,
InsightGroupEntry,
Item,
Page,
TransactionGroup,
} from "#lib/api/types.js";
import { addDays } from "#lib/dates.js";
const PAGE = 500;
export async function pages<T>(
path: string,
query: Record<string, unknown>
): Promise<Item<T>[]> {
const first = await api<Page<T>>(path, {
query: { ...query, limit: PAGE, page: 1 },
});
const count = first.meta.pagination?.total_pages ?? 1;
const rest = await Promise.all(
Array.from({ length: Math.max(0, count - 1) }, (_, i) =>
api<Page<T>>(path, { query: { ...query, limit: PAGE, page: i + 2 } })
)
);
return [first, ...rest].flatMap((page) => page.data);
}
const balancesAt = async (date: string) => {
const [assets, liabilities] = await Promise.all([
all<Account>("/v1/accounts", { date, type: "asset" }),
all<Account>("/v1/accounts", { date, type: "liabilities" }),
]);
return new Map(
[...assets, ...liabilities].map((item) => [
item.id,
Number(item.attributes.current_balance ?? 0),
])
);
};
export const reportQueries = {
balances: (start: string, end: string) => ({
queryFn: async () => {
const [before, after] = await Promise.all([
balancesAt(addDays(start, -1)),
balancesAt(end),
]);
return { after, before };
},
queryKey: ["report", "balances", start, end],
}),
bills: (start: string, end: string) => ({
queryFn: () => all<Bill>("/v1/bills", { end, start }),
queryKey: ["report", "bills", start, end],
}),
chart: (start: string, end: string, period: string, accounts: string[]) => ({
enabled: accounts.length > 0,
queryFn: () =>
api<ChartDataSet[]>("/v1/chart/account/overview", {
query: { "accounts[]": accounts, end, period, start },
}),
queryKey: ["report", "chart", start, end, period, accounts.join(",")],
}),
first: () => ({
queryFn: async () => {
const probe = await api<Page<TransactionGroup>>("/v1/transactions", {
query: {
end: "2038-01-01",
limit: 1,
start: "1970-01-02",
type: "all",
},
});
const last = probe.meta.pagination?.total_pages ?? 1;
const page = await api<Page<TransactionGroup>>("/v1/transactions", {
query: {
end: "2038-01-01",
limit: 1,
page: last,
start: "1970-01-02",
type: "all",
},
});
return (
page.data[0]?.attributes.transactions[0]?.date.slice(0, 10) ?? null
);
},
queryKey: ["report", "first"],
staleTime: 3_600_000,
}),
ledger: (account: string, start: string, end: string) => ({
queryFn: () =>
pages<TransactionGroup>(`/v1/accounts/${account}/transactions`, {
end,
start,
types: "all",
}),
queryKey: ["report", "ledger", account, start, end],
}),
limitSpent: (
budget: string,
start: string,
end: string,
accounts: string[]
) => ({
queryFn: () =>
api<InsightGroupEntry[]>("/v1/insight/expense/budget", {
query: { "accounts[]": accounts, "budgets[]": [budget], end, start },
}),
queryKey: ["report", "limit-spent", budget, start, end, accounts.join(",")],
}),
limits: (start: string, end: string) => ({
queryFn: () => all<BudgetLimit>("/v1/budget-limits", { end, start }),
queryKey: ["report", "limits", start, end],
}),
transactions: (start: string, end: string) => ({
queryFn: () =>
pages<TransactionGroup>("/v1/transactions", {
end,
start,
type: "default",
}),
queryKey: ["report", "transactions", start, end],
}),
};
+497
View File
@@ -0,0 +1,497 @@
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 { formatMoney } from "#lib/format.js";
import { session } from "#lib/session.svelte.js";
import { goto } from "$app/navigation";
import { page } from "$app/state";
import {
type Dimension,
exchanges,
type Flow,
matches,
modes,
toFlows,
total,
} from "./flows.js";
import {
ALL_ASSETS,
grainOf,
kindOf,
type ReportParams,
reportPath,
templateOf,
} from "./params.js";
import { reportQueries } from "./queries.js";
export interface Option {
group?: string;
id: string;
label: string;
note?: string;
}
const ROLE_GROUPS: Record<string, string> = {
cashWalletAsset: "Cash wallets",
ccAsset: "Credit cards",
defaultAsset: "Default asset accounts",
savingAsset: "Savings accounts",
sharedAsset: "Shared asset accounts",
};
const LIABILITY_GROUP: Record<string, string> = {
debt: "Liability: Debt",
loan: "Liability: Loan",
mortgage: "Liability: Mortgage",
};
export const LIST_LENGTH = 10;
export function accountOptions(accounts: Item<Account>[]): Option[] {
return accounts
.map((item) => {
const a = item.attributes;
const group =
a.type === "liabilities" || a.type === "liability"
? (LIABILITY_GROUP[a.liability_type ?? ""] ?? "Liabilities")
: (ROLE_GROUPS[a.account_role ?? ""] ?? "Default asset accounts");
const shared = a.account_role === "sharedAsset" ? " (shared)" : "";
return {
group,
id: item.id,
label: `${a.name}${shared}`,
note: [a.currency_code, a.active ? "" : "inactive"]
.filter(Boolean)
.join(" · "),
};
})
.sort(
(a, b) =>
(a.group ?? "").localeCompare(b.group ?? "") ||
a.label.localeCompare(b.label)
);
}
export const isLiability = (account: Account) =>
account.type === "liabilities" || account.type === "liability";
export function allAssetIds(accounts: Item<Account>[]): string[] {
return accounts.filter((a) => a.attributes.active).map((a) => a.id);
}
export function currencyMeta(code: string) {
const found = session.currencies.find((c) => c.attributes.code === code);
return {
code,
decimals: found?.attributes.decimal_places ?? 2,
symbol: found?.attributes.symbol ?? code,
};
}
export function money(
code: string,
amount: number,
signed = false,
decimals?: number
) {
const { primary } = session;
const meta =
code === "pc" && primary
? {
code: primary.code,
decimals: primary.decimal_places,
symbol: primary.symbol,
}
: currencyMeta(code);
return formatMoney(
{ amount, ...meta, decimals: decimals ?? meta.decimals },
signed
);
}
export function modeLabel(mode: string) {
return mode === "pc" ? `All in ${session.primary?.code ?? ""}` : mode;
}
export function navigate(
changes: Record<string, string | null>,
path?: string
) {
const url = new URL(page.url.href);
if (path) {
url.pathname = path;
}
for (const [name, value] of Object.entries(changes)) {
if (value === null || value === "") {
url.searchParams.delete(name);
} else {
url.searchParams.set(name, value);
}
}
return goto(url, { replace: true, reset: false });
}
export interface Drill {
asset?: string;
at?: string;
box: string;
dim?: Dimension | "day" | "flow";
dir?: 1 | -1;
key?: string;
label: string;
}
const DRILL_DIMS = new Set([
"asset",
"bill",
"budget",
"category",
"name",
"other",
"tag",
"day",
"flow",
]);
const DIRS: Record<string, 1 | -1> = { in: 1, out: -1 };
const DIR_NAMES = { "-1": "out", "1": "in" } as const;
function readDrill(url: {
searchParams: { get: (name: string) => string | null };
}): Drill | null {
const box = url.searchParams.get("box");
if (!box) {
return null;
}
const raw = url.searchParams.get("drill") ?? "";
const split = raw.indexOf(":");
const dim = split > 0 ? raw.slice(0, split) : "";
const dir = url.searchParams.get("dir");
return {
asset: url.searchParams.get("acc") ?? undefined,
at: url.searchParams.get("at") ?? undefined,
box,
dim: DRILL_DIMS.has(dim) ? (dim as Drill["dim"]) : undefined,
dir: DIRS[dir ?? ""],
key: split > 0 ? raw.slice(split + 1) : undefined,
label: url.searchParams.get("dl") ?? "",
};
}
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: drill.asset ?? null,
at: drill.at ?? null,
box: drill.box,
dir: drill.dir ? DIR_NAMES[drill.dir] : null,
dl: drill.label || null,
drill:
drill.dim && drill.key !== undefined ? `${drill.dim}:${drill.key}` : null,
};
}
export function drillFilter(
drill: Drill,
bucketOf: (date: string) => string
): (flow: Flow) => boolean {
return (flow) => {
if (drill.dir && flow.dir !== drill.dir) {
return false;
}
if (drill.at && bucketOf(flow.date) !== drill.at) {
return false;
}
if (drill.asset && flow.asset.id !== drill.asset) {
return false;
}
if (!drill.dim || drill.key === undefined) {
return true;
}
if (drill.dim === "day") {
return flow.date === drill.key;
}
if (drill.dim === "flow") {
return flow.journal === drill.key;
}
return matches(flow, drill.dim, drill.key);
};
}
function twins(
ids: string[],
expense: Item<Account>[],
revenue: Item<Account>[]
): Set<string> {
const pool = [...expense, ...revenue];
const chosen = pool.filter((a) => ids.includes(a.id));
const names = new Set(chosen.map((a) => a.attributes.name.toLowerCase()));
const ibans = new Set(
chosen
.map((a) => a.attributes.iban)
.filter((iban): iban is string => !!iban)
);
return new Set(
pool
.filter(
(a) =>
ids.includes(a.id) ||
names.has(a.attributes.name.toLowerCase()) ||
(a.attributes.iban && ibans.has(a.attributes.iban))
)
.map((a) => a.id)
);
}
export function createReport(params: () => ReportParams) {
const p = $derived(params());
const kind = $derived(kindOf(p.type));
const assets = createQuery(() => lists.accounts("asset"));
const liabilities = createQuery(() => lists.accounts("liabilities"));
const budgets = createQuery(() => ({
...lists.budgets(),
enabled: p.type === "budget" || p.type === "default",
}));
const categories = createQuery(() => ({
...lists.categories(),
enabled: p.type === "category",
}));
const tags = createQuery(() => ({
...lists.tags(),
enabled: p.type === "tag",
}));
const expense = createQuery(() => ({
...lists.accounts("expense"),
enabled: p.type === "double",
}));
const revenue = createQuery(() => ({
...lists.accounts("revenue"),
enabled: p.type === "double",
}));
const tx = createQuery(() => ({
...reportQueries.transactions(p.start, p.end),
enabled: p.type !== "audit",
}));
const accounts = $derived([
...(assets.data ?? []),
...(liabilities.data ?? []),
]);
const accountsReady = $derived(!!(assets.data && liabilities.data));
const byId = $derived(new Map(accounts.map((a) => [a.id, a.attributes])));
const selected = $derived(
p.accounts.includes(ALL_ASSETS) ? allAssetIds(accounts) : p.accounts
);
const selectedSet = $derived(new Set(selected));
const items = $derived.by(() => {
const raw = p.items;
if (p.type === "budget") {
return raw.includes("allBudgets")
? (budgets.data ?? [])
.filter((b) => b.attributes.active)
.map((b) => b.id)
: raw;
}
if (p.type === "category") {
return raw.includes("allCategories")
? (categories.data ?? []).map((c) => c.id)
: raw;
}
if (p.type === "tag") {
const list = tags.data ?? [];
if (raw.includes("allTags")) {
return list.map((t) => t.id);
}
return raw.map(
(value) =>
list.find(
(t) =>
t.id === value ||
t.attributes.tag.toLowerCase() === value.toLowerCase()
)?.id ?? value
);
}
return raw;
});
const itemSet = $derived(new Set(items));
const itemNames = $derived.by(() => {
const names = new Map<string, string>();
for (const b of budgets.data ?? []) {
names.set(`budget:${b.id}`, b.attributes.name);
}
for (const c of categories.data ?? []) {
names.set(`category:${c.id}`, c.attributes.name);
}
for (const t of tags.data ?? []) {
names.set(`tag:${t.id}`, t.attributes.tag);
}
for (const a of [...(expense.data ?? []), ...(revenue.data ?? [])]) {
names.set(`double:${a.id}`, a.attributes.name);
}
return names;
});
const tagNames = $derived(
new Set(
(tags.data ?? [])
.filter((t) => itemSet.has(t.id))
.map((t) => t.attributes.tag)
)
);
const counterparts = $derived(
p.type === "double"
? twins(items, expense.data ?? [], revenue.data ?? [])
: new Set<string>()
);
const flows = $derived(
tx.data && accountsReady
? toFlows(tx.data, selectedSet, (id) => byId.get(id)?.currency_code)
: undefined
);
const swaps = $derived(
tx.data && accountsReady
? exchanges(tx.data, selectedSet, (id) => byId.get(id)?.currency_code)
: []
);
const listReady = $derived(
{
audit: true,
budget: !!budgets.data,
category: !!categories.data,
default: true,
double: !!(expense.data && revenue.data),
tag: !!tags.data,
}[p.type]
);
const scoped = $derived.by(() => {
if (!(flows && listReady)) {
return;
}
switch (p.type) {
case "budget":
return flows.filter(
(f) => f.dir === -1 && f.budget && itemSet.has(f.budget.id)
);
case "category":
return flows.filter((f) => f.category && itemSet.has(f.category.id));
case "tag":
return flows.filter((f) => f.tags.some((t) => tagNames.has(t)));
case "double":
return flows.filter((f) => counterparts.has(f.other.id));
default:
return flows;
}
});
const totals = $derived(scoped ? total(scoped) : undefined);
const codes = $derived.by(() => {
const primary = session.primary?.code;
const all = session.currencies.map((c) => c.attributes.code);
return primary ? [primary, ...all.filter((c) => c !== primary)] : all;
});
const available = $derived(totals ? modes(totals, codes) : []);
const mode = $derived.by(() => {
const wanted = page.url.searchParams.get("cur");
if (wanted && available.includes(wanted)) {
return wanted;
}
return available[0] ?? session.primary?.code ?? "";
});
const grain = $derived(grainOf(p.start, p.end));
const template = $derived(templateOf(p.start, p.end));
const drill = $derived(readDrill(page.url));
const delta = $derived(page.url.searchParams.get("delta"));
return {
accounts: () => accounts,
get accountsReady() {
return accountsReady;
},
get available() {
return available;
},
get budgets() {
return budgets.data;
},
byId: () => byId,
get categories() {
return categories.data;
},
get codes() {
return codes;
},
get counterparts() {
return counterparts;
},
get delta() {
return delta;
},
get drill() {
return drill;
},
get error() {
return tx.error ?? assets.error ?? null;
},
get exchanges() {
return swaps;
},
get expense() {
return expense.data;
},
get flows() {
return flows;
},
get grain() {
return grain;
},
get groups() {
return tx.data;
},
itemName: (kindKey: string, id: string) =>
itemNames.get(`${kindKey}:${id}`),
get items() {
return items;
},
get kind() {
return kind;
},
get mode() {
return mode;
},
get params() {
return p;
},
path: (changes: Partial<ReportParams>) => reportPath({ ...p, ...changes }),
get revenue() {
return revenue.data;
},
get scoped() {
return scoped;
},
get selected() {
return selected;
},
get tags() {
return tags.data;
},
get template() {
return template;
},
get totals() {
return totals;
},
};
}
export type Report = ReturnType<typeof createReport>;
const KEY = Symbol("report");
export const provideReport = (report: Report) => setContext(KEY, report);
export const useReport = () => getContext<Report>(KEY);
+75
View File
@@ -0,0 +1,75 @@
import type { StackKey, StackRow } from "#lib/components/charts/chart.js";
import { tones } from "#lib/components/charts/chart.js";
import { compactMoney, formatDate } from "#lib/format.js";
import { session } from "#lib/session.svelte.js";
import { bucketOf, type Flow, flowValue } from "./flows.js";
import type { Grain } from "./params.js";
import { currencyMeta, money } from "./report.svelte.js";
export function bucketLabel(key: string, grain: Grain, short = false): string {
if (grain === "year") {
return key;
}
if (grain === "month") {
const date = `${key}-01`;
return short
? new Intl.DateTimeFormat(session.locale, {
month: "short",
year: "2-digit",
}).format(
new Date(Number(key.slice(0, 4)), Number(key.slice(5, 7)) - 1, 1)
)
: formatDate(date, "month");
}
return short ? formatDate(key, "short") : formatDate(key, "day");
}
export const FLOW_KEYS: StackKey[] = [
{ color: tones.income, key: "in", label: "Earned" },
{ color: tones.expense, key: "out", label: "Spent" },
];
export function bucketRows(
flows: Iterable<Flow>,
keys: string[],
grain: Grain,
mode: string
): StackRow[] {
const sums = new Map(keys.map((key) => [key, { in: 0, out: 0 }]));
for (const flow of flows) {
const value = flowValue(flow, mode);
const bucket = sums.get(bucketOf(flow.date, grain));
if (value === null || !bucket) {
continue;
}
bucket[flow.dir === 1 ? "in" : "out"] += value;
}
return keys.map((key) => {
const sum = sums.get(key) ?? { in: 0, out: 0 };
return {
key,
label: bucketLabel(key, grain, true),
values: { in: sum.in, out: -sum.out },
};
});
}
export function modeFormat(mode: string) {
return (value: number) => money(mode, value);
}
export function modeAxis(mode: string) {
const symbol =
mode === "pc" ? (session.primary?.symbol ?? "") : currencyMeta(mode).symbol;
return (value: number) => compactMoney(value, symbol);
}
export function upright(rows: StackRow[]): StackRow[] {
if (rows.some((row) => (row.values.in ?? 0) !== 0)) {
return rows;
}
return rows.map((row) => ({
...row,
values: { out: Math.abs(row.values.out ?? 0) },
}));
}