feat(reports): live firefly reports with drill-down, money flow and spending calendar
This commit is contained in:
1 parent
60caee10dc
commit
62cd17ecde
39 files changed
+6596
No files matched your search
@@ -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,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -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"]);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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]);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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),
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -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],
|
||||
}),
|
||||
};
|
||||
@@ -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);
|
||||
@@ -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) },
|
||||
}));
|
||||
}
|
||||
Reference in new issue
Block a user