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

@@ -0,0 +1,135 @@
<script lang="ts">
import { createQuery } from "@tanstack/svelte-query";
import { reportQueries } from "#lib/reports/queries.js";
import {
drillQuery,
money,
navigate,
useReport,
} from "#lib/reports/report.svelte.js";
import ReportBox from "./ReportBox.svelte";
const report = useReport();
const p = $derived(report.params);
const balances = createQuery(() => reportQueries.balances(p.start, p.end));
const rows = $derived.by(() => {
const { data } = balances;
if (!data) {
return [];
}
return report.selected
.map((id) => {
const account = report.byId().get(id);
const start = data.before.get(id) ?? 0;
const end = data.after.get(id) ?? 0;
return {
code: account?.currency_code ?? "",
end,
id,
name: account?.name ?? `#${id}`,
start,
};
})
.sort((a, b) => a.name.localeCompare(b.name));
});
const sums = $derived.by(() => {
const map = new Map<string, { start: number; end: number }>();
for (const row of rows) {
const sum = map.get(row.code) ?? { end: 0, start: 0 };
sum.start += row.start;
sum.end += row.end;
map.set(row.code, sum);
}
const order = report.codes;
return [...map.entries()].sort(
(a, b) => order.indexOf(a[0]) - order.indexOf(b[0])
);
});
function tone(value: number) {
if (value === 0) {
return "text-cladd-fg-soft";
}
return value < 0 ? "text-expense" : "text-income";
}
function open(row: { id: string; name: string }) {
const current = report.drill;
const same = current?.box === "balances" && current.key === row.id;
navigate(
drillQuery(
same
? null
: { box: "balances", dim: "asset", key: row.id, label: row.name }
)
);
}
</script>
<ReportBox
id="balances"
loading={!(balances.data && report.accountsReady)}
title="Account balances"
>
<div class="overflow-x-auto">
<table class="w-full border-collapse text-[13px]">
<thead>
<tr class="text-[12px] text-cladd-fg-soft">
<th class="h-7 px-3 text-left font-medium">Name</th>
<th class="h-7 px-3 text-right font-medium">
Balance at start of period
</th>
<th class="h-7 px-3 text-right font-medium">
Balance at end of period
</th>
<th class="h-7 px-3 text-right font-medium">Difference</th>
</tr>
</thead>
<tbody>
{#each rows as row (row.id)}
{@const active =
report.drill?.box === "balances" && report.drill.key === row.id}
<tr
class="h-7 border-cladd-outline border-t hover:bg-row-hover {active
? "bg-row-selected"
: ""}"
>
<td class="max-w-[16rem] px-3">
<button
aria-pressed={active}
class="block max-w-full truncate text-left hover:text-brand dark:hover:text-[var(--ff-series-1)] hover:underline"
onclick={() => open(row)}
title="Show transactions"
type="button"
>
{row.name}
</button>
</td>
<td class="num px-3 text-right">{money(row.code, row.start)}</td>
<td class="num px-3 text-right">{money(row.code, row.end)}</td>
<td class="num px-3 text-right {tone(row.end - row.start)}">
{money(row.code, row.end - row.start, true)}
</td>
</tr>
{/each}
</tbody>
<tfoot>
{#each sums as [code, sum], i (code)}
<tr
class="h-7 font-semibold {i === 0
? "border-cladd-outline border-t-2"
: ""}"
>
<td class="px-3">Sum ({code})</td>
<td class="num px-3 text-right">{money(code, sum.start)}</td>
<td class="num px-3 text-right">{money(code, sum.end)}</td>
<td class="num px-3 text-right {tone(sum.end - sum.start)}">
{money(code, sum.end - sum.start, true)}
</td>
</tr>
{/each}
</tfoot>
</table>
</div>
</ReportBox>
@@ -0,0 +1,246 @@
<script lang="ts">
import ArrowLeft from "@lucide/svelte/icons/arrow-left";
import ArrowLeftRight from "@lucide/svelte/icons/arrow-left-right";
import ArrowRight from "@lucide/svelte/icons/arrow-right";
import Calculator from "@lucide/svelte/icons/calculator";
import Star from "@lucide/svelte/icons/star";
import { createQuery } from "@tanstack/svelte-query";
import type { Account } from "#lib/api/types.js";
import Box from "#lib/components/Box.svelte";
import { addDays } from "#lib/dates.js";
import { formatDate, formatDateTime } from "#lib/format.js";
import { type AuditColumn, ledger } from "#lib/reports/audit.js";
import { reportQueries } from "#lib/reports/queries.js";
import { money, useReport } from "#lib/reports/report.svelte.js";
let {
id,
account,
before,
after,
columns,
}: {
id: string;
account: Account | undefined;
before: number | undefined;
after: number | undefined;
columns: AuditColumn[];
} = $props();
const report = useReport();
const p = $derived(report.params);
const code = $derived(account?.currency_code ?? "");
const query = createQuery(() => reportQueries.ledger(id, p.start, p.end));
const rows = $derived(
query.data && before !== undefined
? ledger(query.data, id, code, before)
: undefined
);
const show = (key: AuditColumn) => columns.includes(key);
const ICONS = {
deposit: ArrowRight,
"opening balance": Star,
reconciliation: Calculator,
transfer: ArrowLeftRight,
withdrawal: ArrowLeft,
} as const;
const META_DATES = [
"interest_date",
"book_date",
"process_date",
"due_date",
"payment_date",
"invoice_date",
] as const;
const dateColumns = $derived(META_DATES.filter((key) => show(key)));
</script>
<Box body="p-0" href="/accounts/show/{id}" title={account?.name ?? `#${id}`}>
{#if !rows}
<p class="px-3 py-3 text-cladd-fg-soft">Loading</p>
{:else if rows.length === 0}
<p class="px-3 py-3">
No activity was recorded on account
<a
class="text-brand dark:text-[var(--ff-series-1)] hover:underline"
href="/accounts/show/{id}"
>{account?.name}</a
>
between {formatDate(p.start, "long")} and {formatDate(p.end, "long")}.
</p>
{:else}
<p class="px-3 py-2 text-[13px]">
Account balance of <strong>{account?.name}</strong> at the end of
{formatDate(addDays(p.start, -1), "long")}
was:
<span class="num font-semibold">{money(code, before ?? 0)}</span>
</p>
<div class="overflow-x-auto">
<table class="w-full border-collapse text-[13px]">
<thead>
<tr class="text-left text-[12px] text-cladd-fg-soft">
{#if show("icon")}
<th class="h-7 w-8 px-2"><span class="sr-only">Type</span></th>
{/if}
{#if show("description")}
<th class="h-7 px-3 font-medium">Description</th>
{/if}
{#if show("balance_before")}
<th class="h-7 px-3 text-right font-medium">Balance before</th>
{/if}
{#if show("amount")}
<th class="h-7 px-3 text-right font-medium">Amount</th>
{/if}
{#if show("balance_after")}
<th class="h-7 px-3 text-right font-medium">Balance after</th>
{/if}
{#if show("date")}
<th class="h-7 px-3 font-medium">Date</th>
{/if}
{#if show("from")}
<th class="h-7 px-3 font-medium">From</th>
{/if}
{#if show("to")}
<th class="h-7 px-3 font-medium">To</th>
{/if}
{#if show("budget")}
<th class="h-7 px-3 font-medium">Budget</th>
{/if}
{#if show("category")}
<th class="h-7 px-3 font-medium">Category</th>
{/if}
{#if show("bill")}
<th class="h-7 px-3 font-medium">Subscription</th>
{/if}
{#if show("created_at")}
<th class="h-7 px-3 font-medium">Created at</th>
{/if}
{#if show("updated_at")}
<th class="h-7 px-3 font-medium">Updated at</th>
{/if}
{#if show("notes")}
<th class="h-7 px-3 font-medium">Notes</th>
{/if}
{#each dateColumns as key (key)}
<th class="h-7 px-3 font-medium">
{key.replace("_date", "").replace("process", "processing")}
date
</th>
{/each}
</tr>
</thead>
<tbody>
{#each rows as row (row.split.transaction_journal_id)}
{@const s = row.split}
{@const Icon =
ICONS[s.type as keyof typeof ICONS] ?? ArrowLeftRight}
<tr
class="h-7 border-cladd-outline border-t align-top hover:bg-row-hover"
>
{#if show("icon")}
<td class="px-2 py-1.5 text-cladd-fg-soft">
<Icon aria-label={s.type} class="size-3.5" />
</td>
{/if}
{#if show("description")}
<td class="max-w-[22rem] px-3 py-1">
<a
class="hover:underline"
href="/transactions/show/{row.group}"
>
{#if row.title}
{row.title}
({s.description})
{:else}
{s.description}
{/if}
</a>
</td>
{/if}
{#if show("balance_before")}
<td
class="num whitespace-nowrap px-3 py-1 text-right text-cladd-fg-soft"
>
{money(code, row.before)}
</td>
{/if}
{#if show("amount")}
<td
class="num whitespace-nowrap px-3 py-1 text-right {row.amount <
0
? "text-expense"
: "text-income"}"
>
{money(code, row.amount)}
</td>
{/if}
{#if show("balance_after")}
<td class="num whitespace-nowrap px-3 py-1 text-right">
{money(code, row.after)}
</td>
{/if}
{#if show("date")}
<td class="num whitespace-nowrap px-3 py-1">
{formatDate(s.date, "medium")}
</td>
{/if}
{#if show("from")}
<td class="max-w-[12rem] truncate px-3 py-1">
<a class="hover:underline" href="/accounts/show/{s.source_id}"
>{s.source_name}</a
>
</td>
{/if}
{#if show("to")}
<td class="max-w-[12rem] truncate px-3 py-1">
<a
class="hover:underline"
href="/accounts/show/{s.destination_id}"
>{s.destination_name}</a
>
</td>
{/if}
{#if show("budget")}
<td class="px-3 py-1">{s.budget_name ?? ""}</td>
{/if}
{#if show("category")}
<td class="px-3 py-1">{s.category_name ?? ""}</td>
{/if}
{#if show("bill")}
<td class="px-3 py-1">{s.bill_name ?? ""}</td>
{/if}
{#if show("created_at")}
<td class="num whitespace-nowrap px-3 py-1 text-cladd-fg-soft">
{formatDateTime(row.created)}
</td>
{/if}
{#if show("updated_at")}
<td class="num whitespace-nowrap px-3 py-1 text-cladd-fg-soft">
{formatDateTime(row.updated)}
</td>
{/if}
{#if show("notes")}
<td
class="max-w-[24rem] whitespace-pre-line px-3 py-1 text-[12px] text-cladd-fg-soft"
>
{s.notes ?? ""}
</td>
{/if}
{#each dateColumns as key (key)}
<td class="num whitespace-nowrap px-3 py-1">
{formatDate(s[key] ?? null, "medium")}
</td>
{/each}
</tr>
{/each}
</tbody>
</table>
</div>
<p class="border-cladd-outline border-t px-3 py-2 text-[13px]">
Account balance of <strong>{account?.name}</strong> at the end of
{formatDate(p.end, "long")}
was:
<span class="num font-semibold">{money(code, after ?? 0)}</span>
</p>
{/if}
</Box>
@@ -0,0 +1,73 @@
<script lang="ts">
import { createQuery } from "@tanstack/svelte-query";
import Box from "#lib/components/Box.svelte";
import {
AUDIT_COLUMNS,
type AuditColumn,
DEFAULT_AUDIT,
} from "#lib/reports/audit.js";
import { reportQueries } from "#lib/reports/queries.js";
import { navigate, useReport } from "#lib/reports/report.svelte.js";
import { page } from "$app/state";
import AuditAccount from "./AuditAccount.svelte";
const report = useReport();
const p = $derived(report.params);
const balances = createQuery(() => reportQueries.balances(p.start, p.end));
const columns = $derived.by(() => {
const raw = page.url.searchParams.get("cols");
if (raw === null) {
return DEFAULT_AUDIT;
}
const keys = new Set(raw.split(","));
return AUDIT_COLUMNS.map((c) => c.key).filter((key) => keys.has(key));
});
const accounts = $derived(
report.selected
.map((id) => ({ account: report.byId().get(id), id }))
.filter((a) => a.account)
.sort((a, b) =>
(a.account?.name ?? "").localeCompare(b.account?.name ?? "")
)
);
function toggle(key: AuditColumn, on: boolean) {
const next = AUDIT_COLUMNS.map((c) => c.key).filter((k) =>
k === key ? on : columns.includes(k)
);
navigate({ cols: next.join(",") });
}
</script>
<div class="flex flex-col gap-4">
<Box body="px-3 py-2" title="Options">
<fieldset class="flex flex-wrap gap-x-4 gap-y-1">
<legend class="sr-only">Visible columns</legend>
{#each AUDIT_COLUMNS as column (column.key)}
<label class="flex h-7 items-center gap-1.5 text-[13px]">
<input
checked={columns.includes(column.key)}
class="size-3.5 accent-[var(--ff-brand)]"
onchange={(event) =>
toggle(column.key, event.currentTarget.checked)}
type="checkbox"
>
{column.label}
</label>
{/each}
</fieldset>
</Box>
{#if !report.accountsReady}
<p class="text-cladd-fg-soft">Loading</p>
{/if}
{#each accounts as item (item.id)}
<AuditAccount
account={item.account}
after={balances.data?.after.get(item.id)}
before={balances.data?.before.get(item.id)}
{columns}
id={item.id}
/>
{/each}
</div>
@@ -0,0 +1,96 @@
<script lang="ts">
import { createQuery } from "@tanstack/svelte-query";
import type { LineSeries } from "#lib/components/charts/chart.js";
import LineChart from "#lib/components/charts/LineChart.svelte";
import { contains, today } from "#lib/dates.js";
import { formatDate } from "#lib/format.js";
import { reportQueries } from "#lib/reports/queries.js";
import { money, useReport } from "#lib/reports/report.svelte.js";
import { modeAxis } from "#lib/reports/view.js";
import ReportBox from "./ReportBox.svelte";
let { net = false }: { net?: boolean } = $props();
const report = useReport();
const p = $derived(report.params);
const ids = $derived(
report.selected.filter(
(id) => !net || report.byId().get(id)?.include_net_worth !== false
)
);
const period = $derived.by(() => {
if (!net) {
return "1D";
}
return report.template === "multi-year" ? "1M" : "1W";
});
const chart = createQuery(() => ({
...reportQueries.chart(p.start, p.end, period, ids),
enabled: report.accountsReady && ids.length > 0,
}));
const charts = $derived.by(() => {
const byCode = new Map<string, LineSeries[]>();
for (const set of chart.data ?? []) {
const code = set.currency_code ?? "";
const points = Object.entries(set.entries ?? {}).map(([x, y]) => ({
x: x.slice(0, 10),
y: Number(y),
}));
const list = byCode.get(code) ?? [];
if (net && list.length > 0) {
const [sum] = list;
sum.points = sum.points.map((point, i) => ({
x: point.x,
y: point.y + (points[i]?.y ?? 0),
}));
} else {
list.push({
format: (value: number) => money(code, value),
key: net ? code : (set.label ?? code),
label: net ? `Net worth in ${code}` : (set.label ?? code),
points,
});
}
byCode.set(code, list);
}
return [...byCode.entries()].sort(
(a, b) => report.codes.indexOf(a[0]) - report.codes.indexOf(b[0])
);
});
const marker = $derived(contains(p, today()) ? today() : undefined);
const xFormat = $derived(
report.template === "multi-year"
? (x: string) => formatDate(x, "month")
: (x: string) => formatDate(x, "short")
);
</script>
<ReportBox
body="p-3"
empty={charts.length === 0}
emptyText="No accounts to chart."
id={net ? "net-worth" : "balance-chart"}
loading={chart.isPending && ids.length > 0}
title={net ? "Net worth" : "Account balances"}
>
<div class="grid gap-x-6 gap-y-4 {charts.length > 1 ? "xl:grid-cols-2" : ""}">
{#each charts as [code, series] (code)}
<div class="min-w-0">
{#if charts.length > 1 || !net}
<h3 class="mb-1 font-semibold text-[12px] text-cladd-fg-soft">
{code}
</h3>
{/if}
<LineChart
axisFormat={modeAxis(code)}
height={charts.length > 1 ? 200 : 240}
label="{net ? "Net worth" : "Account balances"} in {code}"
{marker}
{series}
{xFormat}
/>
</div>
{/each}
</div>
</ReportBox>
@@ -0,0 +1,226 @@
<script lang="ts">
import { createQuery } from "@tanstack/svelte-query";
import { formatRange, percent } from "#lib/format.js";
import { NONE } from "#lib/reports/flows.js";
import { reportQueries } from "#lib/reports/queries.js";
import {
drillQuery,
money,
navigate,
useReport,
} from "#lib/reports/report.svelte.js";
import ReportBox from "./ReportBox.svelte";
let { class: className = "" }: { class?: string } = $props();
const report = useReport();
const p = $derived(report.params);
const limits = createQuery(() => reportQueries.limits(p.start, p.end));
interface Line {
budgeted: number | null;
budgetId: string;
code: string;
end: string;
key: string;
name: string;
spent: number;
start: string;
}
const lines = $derived.by(() => {
const { flows } = report;
if (!(flows && limits.data)) {
return [];
}
const withdrawals = flows.filter(
(f) => f.dir === -1 && f.type === "withdrawal"
);
const result: Line[] = limits.data.map((item) => {
const a = item.attributes;
const start = (a.start ?? p.start).slice(0, 10);
const end = (a.end ?? p.end).slice(0, 10);
const code = a.currency_code ?? "";
const spent = withdrawals
.filter(
(f) =>
f.budget?.id === String(a.budget_id) &&
f.currency === code &&
f.date >= start &&
f.date <= end
)
.reduce((sum, f) => sum + f.amount, 0);
return {
budgeted: Number(a.amount ?? 0),
budgetId: String(a.budget_id),
code,
end,
key: item.id,
name:
report.itemName("budget", String(a.budget_id)) ?? `#${a.budget_id}`,
spent,
start,
};
});
result.sort(
(a, b) => a.name.localeCompare(b.name) || a.start.localeCompare(b.start)
);
const none = new Map<string, number>();
for (const f of withdrawals) {
if (!f.budget) {
none.set(f.currency, (none.get(f.currency) ?? 0) + f.amount);
}
}
for (const [code, spent] of none) {
result.push({
budgeted: null,
budgetId: NONE,
code,
end: p.end,
key: `none-${code}`,
name: `(no budget) (${code})`,
spent,
start: p.start,
});
}
return result;
});
const sums = $derived.by(() => {
const map = new Map<string, { budgeted: number; spent: number }>();
for (const line of lines) {
const sum = map.get(line.code) ?? { budgeted: 0, spent: 0 };
sum.budgeted += line.budgeted ?? 0;
sum.spent += line.spent;
map.set(line.code, sum);
}
return [...map.entries()].sort(
(a, b) => report.codes.indexOf(a[0]) - report.codes.indexOf(b[0])
);
});
const sumOf = (code: string) => sums.find(([c]) => c === code)?.[1];
function open(line: Line) {
const current = report.drill;
const same = current?.box === "budgets" && current.key === line.budgetId;
navigate(
drillQuery(
same
? null
: {
box: "budgets",
dim: "budget",
dir: -1,
key: line.budgetId,
label: line.name,
}
)
);
}
</script>
<ReportBox
class={className}
empty={lines.length === 0}
emptyText="No budgets or spending in this period."
id="budgets"
loading={!(report.flows && limits.data && report.budgets)}
title="Budgets"
>
<div class="overflow-x-auto">
<table class="w-full border-collapse text-[13px]">
<thead>
<tr class="text-[12px] text-cladd-fg-soft">
<th class="h-7 px-3 text-left font-medium">Budget</th>
<th class="h-7 px-3 text-left font-medium max-2xl:hidden">Date</th>
<th class="h-7 px-3 text-right font-medium">Budgeted</th>
<th class="h-7 px-2 text-right font-medium max-md:hidden">pct.</th>
<th class="h-7 px-3 text-right font-medium">Spent</th>
<th class="h-7 px-2 text-right font-medium max-md:hidden">pct.</th>
<th class="h-7 px-3 text-right font-medium">Left</th>
<th class="h-7 px-3 text-right font-medium">Overspent</th>
</tr>
</thead>
<tbody>
{#each lines as line (line.key)}
{@const sum = sumOf(line.code)}
{@const left = (line.budgeted ?? 0) - line.spent}
{@const active =
report.drill?.box === "budgets" &&
report.drill.key === line.budgetId}
<tr
class="h-7 border-cladd-outline border-t hover:bg-row-hover {active
? "bg-row-selected"
: ""}"
>
<td class="max-w-[14rem] px-3">
<button
aria-pressed={active}
class="block max-w-full truncate text-left hover:text-brand dark:hover:text-[var(--ff-series-1)] hover:underline"
onclick={() => open(line)}
title="Show transactions"
type="button"
>
{line.name}
</button>
</td>
<td
class="num whitespace-nowrap px-3 text-cladd-fg-soft max-2xl:hidden"
>
{#if line.budgeted !== null}
{formatRange(line.start, line.end)}
{/if}
</td>
<td class="num px-3 text-right">
{#if line.budgeted !== null}
{money(line.code, line.budgeted)}
{/if}
</td>
<td class="num px-2 text-right text-cladd-fg-soft max-md:hidden">
{#if line.budgeted && sum?.budgeted}
{percent(line.budgeted / sum.budgeted)}
{/if}
</td>
<td class="num px-3 text-right text-expense">
{line.spent ? money(line.code, -line.spent) : ""}
</td>
<td class="num px-2 text-right text-cladd-fg-soft max-md:hidden">
{#if line.spent && sum?.spent}
{percent(line.spent / sum.spent)}
{/if}
</td>
<td class="num px-3 text-right text-income">
{#if line.budgeted !== null && left > 0}
{money(line.code, left)}
{/if}
</td>
<td class="num px-3 text-right text-expense">
{#if line.budgeted !== null && left < 0}
{money(line.code, -left)}
{/if}
</td>
</tr>
{/each}
</tbody>
<tfoot>
{#each sums as [code, sum], i (code)}
<tr
class="h-7 font-semibold {i === 0
? "border-cladd-outline border-t-2"
: ""}"
>
<td class="px-3">Sum ({code})</td>
<td class="max-2xl:hidden"></td>
<td class="num px-3 text-right">{money(code, sum.budgeted)}</td>
<td class="max-md:hidden"></td>
<td class="num px-3 text-right text-expense">
{money(code, -sum.spent)}
</td>
<td class="max-md:hidden"></td>
<td colspan="2"></td>
</tr>
{/each}
</tfoot>
</table>
</div>
</ReportBox>
@@ -0,0 +1,264 @@
<script lang="ts">
import { bucketOf, buckets, group, keys, total } from "#lib/reports/flows.js";
import { modeLabel, useReport } from "#lib/reports/report.svelte.js";
import { bucketLabel } from "#lib/reports/view.js";
import AccountBalances from "./AccountBalances.svelte";
import BalanceLines from "./BalanceLines.svelte";
import BudgetLimits from "./BudgetLimits.svelte";
import DeltaToggle from "./DeltaToggle.svelte";
import IncomeVsExpenses from "./IncomeVsExpenses.svelte";
import Matrix from "./Matrix.svelte";
import MoneyFlow from "./MoneyFlow.svelte";
import PeriodChart from "./PeriodChart.svelte";
import ReportBox from "./ReportBox.svelte";
import ReportRow from "./ReportRow.svelte";
import SpendingCalendar from "./SpendingCalendar.svelte";
import Subscriptions from "./Subscriptions.svelte";
import SumTable from "./SumTable.svelte";
const report = useReport();
const flows = $derived(report.flows);
const month = $derived(report.template === "month");
const income = $derived(flows?.filter((f) => f.dir === 1) ?? []);
const expenses = $derived(flows?.filter((f) => f.dir === -1) ?? []);
const withdrawals = $derived(expenses.filter((f) => f.type === "withdrawal"));
const deposits = $derived(income.filter((f) => f.type === "deposit"));
const bucket = (date: string) => bucketOf(date, report.grain);
const periodCols = $derived(
buckets(report.params.start, report.params.end, report.grain).map(
(key) => ({
key,
label: bucketLabel(key, report.grain, true),
title: bucketLabel(key, report.grain),
})
)
);
const accountCols = $derived(
report.selected
.filter((id) => withdrawals.some((f) => f.asset.id === id))
.map((id) => ({
key: id,
label: report.byId().get(id)?.name ?? `#${id}`,
}))
);
</script>
<div class="flex flex-col gap-4">
{#if month}
<BalanceLines />
{:else}
<PeriodChart {flows} />
{/if}
<ReportRow class="lg:grid-cols-2">
<AccountBalances />
<IncomeVsExpenses />
</ReportRow>
<MoneyFlow />
<ReportRow class="lg:grid-cols-2">
<ReportBox
empty={income.length === 0}
id="income"
loading={!flows}
title="Revenue / income"
>
<SumTable
box="income"
columns={[
{ kind: "in", label: "Total" },
{ kind: "avg-in", label: "Average" },
]}
counts
dim="other"
dir={1}
footer={total(income)}
limit={10}
rows={group(income, keys.other)}
sort="in"
/>
</ReportBox>
<ReportBox
empty={expenses.length === 0}
id="expenses"
loading={!flows}
title="Expenses"
>
<SumTable
box="expenses"
columns={[
{ kind: "out", label: "Total" },
{ kind: "avg-out", label: "Average" },
]}
counts
dim="other"
dir={-1}
footer={total(expenses)}
limit={10}
rows={group(expenses, keys.other)}
sort="out"
/>
</ReportBox>
</ReportRow>
{#if month}
<ReportRow class="lg:grid-cols-12">
<BudgetLimits class="lg:col-span-7" />
<ReportBox
class="lg:col-span-5"
empty={(flows?.length ?? 0) === 0}
id="categories"
loading={!flows}
title="Categories"
>
<SumTable
box="categories"
columns={[
{ kind: "out", label: "Spent" },
{ kind: "in", label: "Earned" },
{ kind: "net", label: "Sum" },
]}
dim="category"
footer={report.totals}
limit={10}
nameLabel="Category"
rows={group(flows ?? [], keys.category)}
/>
</ReportBox>
</ReportRow>
<ReportBox
empty={accountCols.length === 0}
id="budget-accounts"
loading={!flows}
title="Budgets (split by account)"
>
<Matrix
box="budget-accounts"
colIs="asset"
cols={accountCols}
dim="budget"
dir={-1}
multi
nameLabel="Budget"
rows={group(withdrawals, keys.budget, (f) => f.asset.id)}
side="out"
/>
</ReportBox>
<ReportRow class="lg:grid-cols-2">
<ReportBox
empty={(flows?.length ?? 0) === 0}
id="tags"
loading={!flows}
title="Tags"
>
<SumTable
box="tags"
columns={[
{ kind: "out", label: "Spent" },
{ kind: "in", label: "Earned" },
]}
dim="tag"
footer={report.totals}
limit={10}
nameLabel="Tag"
rows={group(flows ?? [], keys.tag)}
/>
</ReportBox>
<SpendingCalendar />
</ReportRow>
<Subscriptions />
{:else}
<BalanceLines net />
<ReportBox
empty={withdrawals.length === 0}
id="budget-periods"
loading={!flows}
title="Budgets ({modeLabel(report.mode)})"
>
{#snippet tools()}
<DeltaToggle box="budget-periods" />
{/snippet}
<Matrix
average
box="budget-periods"
cols={periodCols}
delta={report.delta === "budget-periods"}
dim="budget"
dir={-1}
nameLabel="Budget"
rows={group(withdrawals, keys.budget, (f) => bucket(f.date))}
side="out"
/>
</ReportBox>
<ReportBox
empty={expenses.length === 0}
id="category-expenses"
loading={!flows}
title="Categories (Expenses) ({modeLabel(report.mode)})"
>
{#snippet tools()}
<DeltaToggle box="category-expenses" />
{/snippet}
<Matrix
box="category-expenses"
cols={periodCols}
delta={report.delta === "category-expenses"}
dim="category"
dir={-1}
nameLabel="Category"
rows={group(withdrawals, keys.category, (f) => bucket(f.date))}
side="out"
/>
</ReportBox>
<ReportBox
empty={deposits.length === 0}
id="category-income"
loading={!flows}
title="Categories (Revenue / income) ({modeLabel(report.mode)})"
>
{#snippet tools()}
<DeltaToggle box="category-income" />
{/snippet}
<Matrix
box="category-income"
cols={periodCols}
delta={report.delta === "category-income"}
dim="category"
dir={1}
nameLabel="Category"
rows={group(deposits, keys.category, (f) => bucket(f.date))}
side="in"
/>
</ReportBox>
<ReportBox
empty={(flows?.length ?? 0) === 0}
id="tag-periods"
loading={!flows}
title="Tags (Expenses) ({modeLabel(report.mode)})"
>
{#snippet tools()}
<DeltaToggle box="tag-periods" />
{/snippet}
<Matrix
box="tag-periods"
cols={periodCols}
delta={report.delta === "tag-periods"}
dim="tag"
dir={-1}
nameLabel="Tag"
rows={group(expenses, keys.tag, (f) => bucket(f.date))}
side="out"
/>
</ReportBox>
<SpendingCalendar />
{/if}
</div>
+34
View File
@@ -0,0 +1,34 @@
<script lang="ts">
import ArrowDown from "@lucide/svelte/icons/arrow-down";
import ArrowUp from "@lucide/svelte/icons/arrow-up";
import { percent } from "#lib/format.js";
let {
now,
before,
upIsGood,
title,
}: {
now: number;
before: number | undefined;
upIsGood: boolean;
title?: string;
} = $props();
const ratio = $derived(
before ? (Math.abs(now) - Math.abs(before)) / Math.abs(before) : null
);
const up = $derived((ratio ?? 0) > 0);
const tone = $derived(up === upIsGood ? "text-income" : "text-expense");
</script>
{#if ratio !== null && Math.abs(ratio) >= 0.005}
<span class="num inline-flex items-center gap-0.5 text-[11px] {tone}" {title}>
{#if up}
<ArrowUp aria-label="up" class="size-3" />
{:else}
<ArrowDown aria-label="down" class="size-3" />
{/if}
{percent(Math.abs(ratio))}
</span>
{/if}
@@ -0,0 +1,20 @@
<script lang="ts">
import { navigate, useReport } from "#lib/reports/report.svelte.js";
let { box }: { box: string } = $props();
const report = useReport();
const on = $derived(report.delta === box);
</script>
<button
aria-pressed={on}
class="h-6 rounded-cladd-sm px-2 text-[12px] {on
? "cladd-cut font-semibold text-cladd-fg"
: "text-cladd-fg-soft hover:text-cladd-fg"}"
onclick={() => navigate({ delta: on ? null : box })}
title={on ? "Show amounts" : "Show change against the previous period"}
type="button"
>
{on ? "Amounts" : "Change"}
</button>
@@ -0,0 +1,495 @@
<script lang="ts">
import {
bucketOf,
buckets,
type Dimension,
type Flow,
group,
type Key,
keys,
topFlows,
total,
} from "#lib/reports/flows.js";
import {
LIST_LENGTH,
modeLabel,
useReport,
} from "#lib/reports/report.svelte.js";
import { bucketLabel } from "#lib/reports/view.js";
import DeltaToggle from "./DeltaToggle.svelte";
import FlowTable from "./FlowTable.svelte";
import Matrix from "./Matrix.svelte";
import PeriodChart from "./PeriodChart.svelte";
import ReportBox from "./ReportBox.svelte";
import ReportRow from "./ReportRow.svelte";
import ShareBars from "./ShareBars.svelte";
import SumTable from "./SumTable.svelte";
const report = useReport();
const type = $derived(report.params.type);
const flows = $derived(report.scoped);
const loading = $derived(!flows);
const out = $derived(flows?.filter((f) => f.dir === -1) ?? []);
const inn = $derived(flows?.filter((f) => f.dir === 1) ?? []);
const hasIncome = $derived(type !== "budget" && inn.length > 0);
const tagNames = $derived(
new Set(
report.items
.map((id) => report.itemName("tag", id))
.filter((name): name is string => !!name)
)
);
const itemDim = $derived<Dimension>(
(
{
budget: "budget",
category: "category",
double: "name",
tag: "tag",
} as const
)[type as "budget" | "category" | "double" | "tag"]
);
const itemKey = $derived.by(() => {
if (type === "tag") {
return (flow: Flow) => keys.tag(flow).filter((k) => tagNames.has(k.key));
}
return keys[itemDim] as (flow: Flow) => Key | Key[] | null;
});
const NAMES = {
budget: { many: "Budgets", one: "Budget" },
category: { many: "Categories", one: "Category" },
double: { many: "Expense/revenue accounts", one: "Account" },
tag: { many: "Tags", one: "Tag" },
} as const;
const names = $derived(NAMES[type as keyof typeof NAMES]);
const items = $derived.by(() => {
if (type === "double") {
const seen = new Map<string, string>();
for (const flow of flows ?? []) {
const key = keys.name(flow);
seen.set(key.key, key.label);
}
return [...seen.entries()].map(([key, label]) => ({ key, label }));
}
return report.items.map((id) => {
const kind = type === "tag" ? "tag" : type;
const label = report.itemName(kind, id) ?? `#${id}`;
return { key: type === "tag" ? label : id, label };
});
});
const itemRows = $derived(group(flows ?? [], itemKey));
const accountRows = $derived(group(flows ?? [], keys.asset));
const accountMatrix = $derived(
group(flows ?? [], keys.asset, (f) => {
const found = itemKey(f);
const first = Array.isArray(found) ? found[0] : found;
return first?.key ?? null;
})
);
const periodCols = $derived(
buckets(report.params.start, report.params.end, report.grain).map(
(key) => ({
key,
label: bucketLabel(key, report.grain, true),
title: bucketLabel(key, report.grain),
})
)
);
const top = $derived((dir: 1 | -1) =>
topFlows(flows ?? [], report.mode, dir, LIST_LENGTH)
);
const sumColumns = $derived(
hasIncome
? [
{ kind: "out" as const, label: "Spent" },
{ kind: "in" as const, label: "Earned" },
{ kind: "net" as const, label: "Sum" },
]
: [{ kind: "out" as const, label: "Spent" }]
);
const itemColumns = $derived(
hasIncome
? sumColumns
: [
{ kind: "out" as const, label: "Spent" },
{ kind: "pct-out" as const, label: "pct." },
]
);
const flowColumns = $derived(
type === "double"
? ["date", "description", "account", "other"]
: [
"date",
"description",
"account",
"other",
type === "budget" ? "budget" : "category",
]
);
const mode = $derived(modeLabel(report.mode));
</script>
<div class="flex flex-col gap-4">
<ReportRow class="lg:grid-cols-2">
<ReportBox
empty={accountRows.length === 0}
id="accounts"
{loading}
title={type === "double"
? "Earned and spent (per asset account)"
: "Accounts"}
>
<SumTable
box="accounts"
columns={sumColumns}
dim="asset"
footer={report.totals}
rows={accountRows}
/>
</ReportBox>
<ReportBox
empty={itemRows.length === 0}
id="items"
{loading}
title={type === "double"
? "Earned and spent per combination"
: names.many}
>
<SumTable
box="items"
columns={itemColumns}
dim={itemDim}
footer={report.totals}
nameLabel={names.one}
rows={itemRows}
/>
</ReportBox>
</ReportRow>
{#if type !== "double"}
<ReportBox
empty={accountMatrix.length === 0}
id="account-matrix"
{loading}
title="Account per {names.one.toLowerCase()}"
>
<Matrix
box="account-matrix"
colIs="item"
cols={items}
dim="asset"
{itemDim}
multi
nameLabel="Account"
rows={accountMatrix}
side={hasIncome ? "net" : "out"}
/>
</ReportBox>
{/if}
<ReportBox
empty={itemRows.length === 0}
id="item-periods"
{loading}
title="{names.many} per period ({mode})"
>
{#snippet tools()}
<DeltaToggle box="item-periods" />
{/snippet}
<Matrix
average
box="item-periods"
cols={periodCols}
delta={report.delta === "item-periods"}
dim={itemDim}
dir={-1}
nameLabel={names.one}
rows={group(out, itemKey, (f) => bucketOf(f.date, report.grain))}
side="out"
/>
</ReportBox>
<ReportRow class={type === "budget" ? "lg:grid-cols-2" : "lg:grid-cols-3"}>
{#if type === "tag"}
<ReportBox
empty={out.length === 0}
id="share-item-out"
{loading}
title="Expense per tag ({mode})"
>
<ShareBars
box="share-item-out"
dim="tag"
rows={group(out, itemKey)}
side="out"
/>
</ReportBox>
<ReportBox
empty={inn.length === 0}
id="share-item-in"
{loading}
title="Income per tag ({mode})"
>
<ShareBars
box="share-item-in"
dim="tag"
rows={group(inn, itemKey)}
side="in"
/>
</ReportBox>
<div class="max-lg:hidden"></div>
{/if}
{#if type === "budget"}
<ReportBox
empty={out.length === 0}
id="share-budget"
{loading}
title="Expense per budget ({mode})"
>
<ShareBars
box="share-budget"
dim="budget"
rows={group(out, keys.budget)}
side="out"
/>
</ReportBox>
{/if}
<ReportBox
empty={out.length === 0}
id="share-category-out"
{loading}
title="Expense per category ({mode})"
>
<ShareBars
box="share-category-out"
dim="category"
rows={group(out, keys.category)}
side="out"
/>
</ReportBox>
{#if hasIncome}
<ReportBox
empty={inn.length === 0}
id="share-category-in"
{loading}
title="Income per category ({mode})"
>
<ShareBars
box="share-category-in"
dim="category"
rows={group(inn, keys.category)}
side="in"
/>
</ReportBox>
<ReportBox
empty={out.length === 0}
id="share-budget"
{loading}
title="Expense per budget ({mode})"
>
<ShareBars
box="share-budget"
dim="budget"
rows={group(out, keys.budget)}
side="out"
/>
</ReportBox>
{/if}
</ReportRow>
{#if type === "double"}
<ReportRow class="lg:grid-cols-2">
<ReportBox
empty={out.length === 0}
id="share-tag-out"
{loading}
title="Expense per tag ({mode})"
>
<ShareBars
box="share-tag-out"
dim="tag"
rows={group(out, keys.tag)}
side="out"
/>
</ReportBox>
<ReportBox
empty={inn.length === 0}
id="share-tag-in"
{loading}
title="Income per tag ({mode})"
>
<ShareBars
box="share-tag-in"
dim="tag"
rows={group(inn, keys.tag)}
side="in"
/>
</ReportBox>
</ReportRow>
{:else}
<ReportRow class="lg:grid-cols-2">
<ReportBox
empty={out.length === 0}
id="share-source"
{loading}
title="Expenses per source account ({mode})"
>
<ShareBars
box="share-source"
dim="asset"
rows={group(out, keys.asset)}
side="out"
/>
</ReportBox>
{#if hasIncome}
<ReportBox
empty={inn.length === 0}
id="share-income-source"
{loading}
title="Income per source account ({mode})"
>
<ShareBars
box="share-income-source"
dim="other"
rows={group(inn, keys.other)}
side="in"
/>
</ReportBox>
{/if}
<ReportBox
empty={out.length === 0}
id="share-destination"
{loading}
title="Expenses per destination account ({mode})"
>
<ShareBars
box="share-destination"
dim="other"
rows={group(out, keys.other)}
side="out"
/>
</ReportBox>
{#if hasIncome}
<ReportBox
empty={inn.length === 0}
id="share-income-destination"
{loading}
title="Income per destination account ({mode})"
>
<ShareBars
box="share-income-destination"
dim="asset"
rows={group(inn, keys.asset)}
side="in"
/>
</ReportBox>
{/if}
</ReportRow>
{/if}
{#each items as item (item.key)}
{@const subset = flows?.filter((f) => {
const found = itemKey(f);
return Array.isArray(found)
? found.some((k) => k.key === item.key)
: found?.key === item.key;
})}
{#if !subset || subset.length > 0}
<PeriodChart
dim={itemDim}
flows={subset}
id="item-{item.key}"
itemKey={item.key}
title={type === "budget"
? `Expenses (${item.label}),`
: `Income and expenses (${item.label}),`}
/>
{/if}
{/each}
<ReportRow class="lg:grid-cols-2">
<ReportBox
empty={out.length === 0}
id="avg-out"
{loading}
title={type === "double"
? "Average expense per source account"
: "Average expense per destination account"}
>
<SumTable
averageAll
box="avg-out"
columns={[
{ kind: "avg-out", label: "Spent (average)" },
{ kind: "out", label: "Total" },
{ kind: "count", label: "Transaction count" },
]}
dim={type === "double" ? "asset" : "other"}
dir={-1}
limit={LIST_LENGTH}
nameLabel="Account"
rows={group(out, type === "double" ? keys.asset : keys.other)}
/>
</ReportBox>
{#if hasIncome}
<ReportBox
empty={inn.length === 0}
id="avg-in"
{loading}
title={type === "double"
? "Average earning per destination account"
: "Average earning per source account"}
>
<SumTable
averageAll
box="avg-in"
columns={[
{ kind: "avg-in", label: "Income (average)" },
{ kind: "in", label: "Total" },
{ kind: "count", label: "Transaction count" },
]}
dim={type === "double" ? "asset" : "other"}
dir={1}
limit={LIST_LENGTH}
nameLabel="Account"
rows={group(inn, type === "double" ? keys.asset : keys.other)}
sort="in"
/>
</ReportBox>
{:else}
<ReportBox
empty={top(-1).length === 0}
id="top-out"
{loading}
title="Expenses (top {LIST_LENGTH}, {mode})"
>
<FlowTable columns={flowColumns} flows={top(-1)} />
</ReportBox>
{/if}
</ReportRow>
{#if hasIncome}
<ReportRow class="lg:grid-cols-2">
<ReportBox
empty={top(-1).length === 0}
id="top-out"
{loading}
title="Expenses (top {LIST_LENGTH}, {mode})"
>
<FlowTable columns={flowColumns} flows={top(-1)} />
</ReportBox>
<ReportBox
empty={top(1).length === 0}
id="top-in"
{loading}
title="Revenue / income (top {LIST_LENGTH}, {mode})"
>
<FlowTable columns={flowColumns} flows={top(1)} />
</ReportBox>
</ReportRow>
{/if}
</div>
@@ -0,0 +1,135 @@
<script lang="ts">
import X from "@lucide/svelte/icons/x";
import Columns from "#lib/components/charts/Columns.svelte";
import TransactionTable from "#lib/components/transactions/TransactionTable.svelte";
import { useKeys } from "#lib/keys.svelte.js";
import { bucketOf, buckets, total } from "#lib/reports/flows.js";
import {
type Drill,
drillFilter,
drillQuery,
navigate,
useReport,
} from "#lib/reports/report.svelte.js";
import {
bucketLabel,
bucketRows,
FLOW_KEYS,
modeAxis,
modeFormat,
upright,
} from "#lib/reports/view.js";
import Money from "./Money.svelte";
let { drill }: { drill: Drill } = $props();
const report = useReport();
const subset = $derived(
(report.scoped ?? [])
.filter(drillFilter(drill, (date) => bucketOf(date, report.grain)))
.sort((a, b) => b.date.localeCompare(a.date) || b.amount - a.amount)
);
const totals = $derived(total(subset));
const groups = $derived.by(() => {
const ids = new Set(subset.map((flow) => flow.group));
return (report.groups ?? []).filter((group) => ids.has(group.id));
});
const showChart = $derived(
!drill.at &&
drill.dim !== "day" &&
drill.dim !== "flow" &&
report.grain !== "day" &&
subset.length > 0
);
const rows = $derived(
showChart
? upright(
bucketRows(
subset,
buckets(report.params.start, report.params.end, report.grain),
report.grain,
report.mode
)
)
: []
);
const keys = $derived(
FLOW_KEYS.filter((key) =>
rows.some((row) => (row.values[key.key] ?? 0) !== 0)
)
);
const title = $derived(
[drill.label, drill.at ? bucketLabel(drill.at, report.grain) : ""]
.filter(Boolean)
.join(", ")
);
function close() {
navigate(drillQuery(null));
}
useKeys(() => [
{
group: "Report",
keys: "Escape",
label: "Close transactions list",
run: close,
},
]);
</script>
<section
aria-label="Transactions for {title}"
class="border-cladd-outline border-t bg-cladd-surface-next/50"
>
<header class="flex flex-wrap items-start gap-x-4 gap-y-1 px-3 pt-2.5 pb-2">
<div class="min-w-0 flex-1">
<h3 class="truncate font-semibold text-[13px]">{title}</h3>
<p class="text-[12px] text-cladd-fg-soft">
{subset.length}
{subset.length === 1 ? "transaction" : "transactions"}
</p>
</div>
<div class="flex gap-4 text-right text-[12px]">
{#each [
{ label: "In", side: "in" as const },
{ label: "Out", side: "out" as const },
] as column (column.side)}
{#if [...totals.cur.values()].some((m) => m[column.side] > 0)}
<div>
<div class="text-cladd-fg-soft">{column.label}</div>
<Money codes={report.codes} side={column.side} {totals} />
</div>
{/if}
{/each}
</div>
<button
aria-label="Close"
class="grid size-7 place-items-center rounded-cladd-sm text-cladd-fg-soft hover:bg-row-hover hover:text-cladd-fg"
onclick={close}
title="Close Esc"
type="button"
>
<X class="size-4" />
</button>
</header>
{#if showChart && keys.length > 0}
<div class="px-3 pb-2">
<Columns
axisFormat={modeAxis(report.mode)}
format={modeFormat(report.mode)}
height={180}
{keys}
label="{title} per period"
onselect={(row) => navigate(drillQuery({ ...drill, at: row.key }))}
{rows}
/>
</div>
{/if}
{#if subset.length > 0}
<TransactionTable {groups} id="drill" />
{:else}
<p class="px-3 pb-3 text-cladd-fg-soft">No transactions match.</p>
{/if}
</section>
@@ -0,0 +1,92 @@
<script lang="ts">
import { formatDate } from "#lib/format.js";
import type { Flow } from "#lib/reports/flows.js";
import { money } from "#lib/reports/report.svelte.js";
let {
flows,
columns = ["date", "description", "account", "other", "category", "budget"],
}: {
flows: Flow[];
columns?: string[];
} = $props();
const has = (name: string) => columns.includes(name);
</script>
<div class="overflow-x-auto">
<table class="w-full border-collapse text-[13px]">
<thead>
<tr class="text-left text-[12px] text-cladd-fg-soft">
{#if has("date")}
<th class="h-7 px-3 font-medium">Date</th>
{/if}
<th class="h-7 px-3 font-medium">Description</th>
{#if has("account")}
<th class="h-7 px-3 font-medium max-md:hidden">Account</th>
{/if}
{#if has("other")}
<th class="h-7 px-3 font-medium max-md:hidden">Counterparty</th>
{/if}
{#if has("category")}
<th class="h-7 px-3 font-medium max-lg:hidden">Category</th>
{/if}
{#if has("budget")}
<th class="h-7 px-3 font-medium max-lg:hidden">Budget</th>
{/if}
<th class="h-7 px-3 text-right font-medium">Amount</th>
</tr>
</thead>
<tbody>
{#each flows as flow (`${flow.journal}:${flow.dir}`)}
<tr class="h-7 border-cladd-outline border-t hover:bg-row-hover">
{#if has("date")}
<td class="num whitespace-nowrap px-3 text-cladd-fg-soft">
{formatDate(flow.date, "short")}
</td>
{/if}
<td class="max-w-[22rem] truncate px-3">
<a class="hover:underline" href="/transactions/show/{flow.group}"
>{flow.description}</a
>
</td>
{#if has("account")}
<td
class="max-w-[12rem] truncate px-3 text-cladd-fg-soft max-md:hidden"
>
{flow.asset.name}
</td>
{/if}
{#if has("other")}
<td class="max-w-[14rem] truncate px-3 max-md:hidden">
<a class="hover:underline" href="/accounts/show/{flow.other.id}"
>{flow.other.name}</a
>
</td>
{/if}
{#if has("category")}
<td
class="max-w-[10rem] truncate px-3 text-cladd-fg-soft max-lg:hidden"
>
{flow.category?.name ?? ""}
</td>
{/if}
{#if has("budget")}
<td
class="max-w-[10rem] truncate px-3 text-cladd-fg-soft max-lg:hidden"
>
{flow.budget?.name ?? ""}
</td>
{/if}
<td
class="num whitespace-nowrap px-3 text-right {flow.dir === 1
? "text-income"
: "text-expense"}"
>
{money(flow.currency, flow.dir * flow.amount)}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
@@ -0,0 +1,154 @@
<script lang="ts">
import { createQuery } from "@tanstack/svelte-query";
import { formatRange } from "#lib/format.js";
import {
currencies,
measureOf,
type Totals,
toFlows,
total,
} from "#lib/reports/flows.js";
import { shiftRange } from "#lib/reports/params.js";
import { reportQueries } from "#lib/reports/queries.js";
import {
drillQuery,
modeLabel,
money,
navigate,
useReport,
} from "#lib/reports/report.svelte.js";
import Delta from "./Delta.svelte";
import ReportBox from "./ReportBox.svelte";
const report = useReport();
const previous = $derived(shiftRange(report.params, -1));
const earlier = createQuery(() =>
reportQueries.transactions(previous.start, previous.end)
);
const before = $derived(
earlier.data && report.accountsReady
? total(
toFlows(
earlier.data,
new Set(report.selected),
(id) => report.byId().get(id)?.currency_code
)
)
: undefined
);
const was = (code: string) =>
before && (code !== "pc" || before.pcOk)
? measureOf(before, code)
: undefined;
const rows = $derived.by(() => {
const totals: Totals | undefined = report.totals;
if (!totals) {
return [];
}
const list = currencies(totals, report.codes).map((code) => {
const m = totals.cur.get(code) ?? { in: 0, n: 0, out: 0 };
return { code, in: m.in, label: code, out: m.out };
});
if (totals.pcOk && list.length > 1) {
list.push({
code: "pc",
in: totals.pc.in,
label: modeLabel("pc"),
out: totals.pc.out,
});
}
return list;
});
function open(dir: 1 | -1) {
const label = dir === 1 ? "Money in" : "Money out";
const current = report.drill;
const same = current?.box === "operations" && current.dir === dir;
navigate(drillQuery(same ? null : { box: "operations", dir, label }));
}
</script>
<ReportBox
empty={rows.length === 0}
id="operations"
loading={!report.totals}
title="Income vs. expenses"
>
{#snippet tools()}
{#if before && before.n > 0}
<span class="text-[12px] text-cladd-fg-soft"
>Change against {formatRange(previous.start, previous.end)}</span
>
{/if}
{/snippet}
<div class="overflow-x-auto">
<table class="w-full border-collapse text-[13px]">
<thead>
<tr class="text-[12px] text-cladd-fg-soft">
<th class="h-7 px-3 text-left font-medium">Currency</th>
<th class="h-7 px-3 text-right font-medium">In</th>
<th class="h-7 px-3 text-right font-medium">Out</th>
<th class="h-7 px-3 text-right font-medium">Difference</th>
</tr>
</thead>
<tbody>
{#each rows as row (row.code)}
<tr
class="h-7 border-cladd-outline border-t hover:bg-row-hover {row.code ===
"pc"
? "font-semibold"
: ""}"
>
<td class="px-3">{row.label}</td>
<td class="p-0 text-right">
<button
class="num h-7 w-full px-3 text-right text-income hover:bg-row-cursor"
onclick={() => open(1)}
title="Show money in"
type="button"
>
<Delta
before={was(row.code)?.in}
now={row.in}
title="Previous period: {money(
row.code,
was(row.code)?.in ?? 0
)}"
upIsGood
/>
{money(row.code, row.in)}
</button>
</td>
<td class="p-0 text-right">
<button
class="num h-7 w-full px-3 text-right text-expense hover:bg-row-cursor"
onclick={() => open(-1)}
title="Show money out"
type="button"
>
<Delta
before={was(row.code)?.out}
now={row.out}
title="Previous period: {money(
row.code,
-(was(row.code)?.out ?? 0)
)}"
upIsGood={false}
/>
{money(row.code, -row.out)}
</button>
</td>
<td
class="num px-3 text-right {row.in - row.out < 0
? "text-expense"
: "text-income"}"
>
{money(row.code, row.in - row.out, true)}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</ReportBox>
+312
View File
@@ -0,0 +1,312 @@
<script lang="ts">
import ArrowDown from "@lucide/svelte/icons/arrow-down";
import ArrowUp from "@lucide/svelte/icons/arrow-up";
import { formatNumber, percent } from "#lib/format.js";
import {
amountOf,
type Dimension,
merge,
type Row,
rank,
type Side,
} from "#lib/reports/flows.js";
import {
type Drill,
drillQuery,
money,
navigate,
useReport,
} from "#lib/reports/report.svelte.js";
import Money from "./Money.svelte";
interface Col {
key: string;
label: string;
title?: string;
}
let {
box,
rows,
cols,
side,
dim,
dir,
colIs = "at",
itemDim,
delta = false,
average = false,
nameLabel = "Name",
multi = false,
}: {
box: string;
rows: Row[];
cols: Col[];
side: Side;
dim: Dimension;
dir?: 1 | -1;
colIs?: "at" | "asset" | "item";
itemDim?: Dimension;
delta?: boolean;
average?: boolean;
nameLabel?: string;
multi?: boolean;
} = $props();
const report = useReport();
const mode = $derived(report.mode);
const sorted = $derived(
multi
? rank(rows, mode, side)
: rank(rows, mode, side).filter(
(row) => amountOf(row.totals, mode, side) !== 0
)
);
const colMerged = $derived(
multi
? cols.map((col) => merge(sorted.map((row) => row.cells.get(col.key))))
: []
);
const sign = $derived(side === "out" ? -1 : 1);
const compact = $derived(cols.length > 6);
const short = (value: number) =>
compact ? formatNumber(value, 0) : money(mode, value);
const colTotals = $derived(
cols.map((col) =>
sorted.reduce(
(sum, row) => sum + amountOf(row.cells.get(col.key), mode, side),
0
)
)
);
const grand = $derived(colTotals.reduce((a, b) => a + b, 0));
function cellDrill(row: Row | null, col: Col | null): Drill {
const label = [row?.label, col?.title ?? col?.label]
.filter(Boolean)
.join(", ");
if (colIs === "item") {
return {
asset: row && col ? row.key : undefined,
box,
dim: col ? itemDim : dim,
dir,
key: col ? col.key : row?.key,
label,
};
}
return {
asset: colIs === "asset" ? col?.key : undefined,
at: colIs === "at" ? col?.key : undefined,
box,
dim: row ? dim : undefined,
dir,
key: row?.key,
label,
};
}
function isActive(row: Row | null, col: Col | null) {
const current = report.drill;
if (current?.box !== box) {
return false;
}
const next = cellDrill(row, col);
return (
current.key === next.key &&
current.at === next.at &&
current.asset === next.asset
);
}
function open(row: Row | null, col: Col | null) {
navigate(drillQuery(isActive(row, col) ? null : cellDrill(row, col)));
}
function change(row: Row, index: number) {
if (index === 0) {
return null;
}
const now = amountOf(row.cells.get(cols[index].key), mode, side);
const before = amountOf(row.cells.get(cols[index - 1].key), mode, side);
if (!(before || now)) {
return null;
}
const ratio = before ? (now - before) / Math.abs(before) : null;
const up = now > before;
const good = side === "out" ? !up : up;
return { good, ratio, up };
}
</script>
<div class="mx-px mb-px overflow-x-auto">
<table class="w-full border-collapse text-[13px]">
<thead>
<tr class="text-[12px] text-cladd-fg-soft">
<th
class="sticky left-0 z-[1] h-7 bg-cladd-surface px-3 text-left font-medium"
>
{nameLabel}
</th>
{#each cols as col (col.key)}
<th
class="h-7 whitespace-nowrap px-2 text-right font-medium"
title={col.title}
>
{col.label}
</th>
{/each}
{#if average}
<th class="h-7 whitespace-nowrap px-2 text-right font-medium">
Average
</th>
{/if}
<th class="h-7 whitespace-nowrap px-3 text-right font-medium">Sum</th>
</tr>
</thead>
<tbody>
{#each sorted as row (row.key)}
{@const rowTotal = amountOf(row.totals, mode, side)}
<tr class="h-7 border-cladd-outline border-t hover:bg-row-hover">
<th
class="sticky left-0 z-[1] max-w-[16rem] bg-cladd-surface px-3 text-left font-normal"
scope="row"
>
<button
aria-pressed={isActive(row, null)}
class="block max-w-full truncate text-left hover:text-brand dark:hover:text-[var(--ff-series-1)] hover:underline {isActive(
row,
null
)
? "font-semibold text-brand dark:text-[var(--ff-series-1)]"
: ""}"
onclick={() => open(row, null)}
title="Show transactions for {row.label}"
type="button"
>
{row.label}
</button>
</th>
{#each cols as col, i (col.key)}
{@const value = amountOf(row.cells.get(col.key), mode, side)}
{@const diff = delta ? change(row, i) : null}
{@const cell = row.cells.get(col.key)}
<td class="p-0 text-right">
{#if multi && cell}
<button
class="w-full px-2 py-1 text-right hover:bg-row-cursor {isActive(
row,
col
)
? "bg-row-selected font-semibold"
: ""}"
onclick={() => open(row, col)}
tabindex="-1"
title="{row.label}, {col.title ?? col.label}"
type="button"
>
<Money codes={report.codes} {side} totals={cell} />
</button>
{:else if !multi && (value || diff)}
<button
class="num h-7 w-full whitespace-nowrap px-2 text-right hover:bg-row-cursor {isActive(
row,
col
)
? "bg-row-selected font-semibold"
: ""}"
onclick={() => open(row, col)}
tabindex="-1"
title="{row.label}, {col.title ?? col.label}: {money(
mode,
sign * value
)}"
type="button"
>
{#if diff}
<span
class="inline-flex items-center gap-0.5 {diff.good
? "text-income"
: "text-expense"}"
>
{#if diff.up}
<ArrowUp aria-label="up" class="size-3" />
{:else}
<ArrowDown aria-label="down" class="size-3" />
{/if}
{diff.ratio === null
? "new"
: percent(Math.abs(diff.ratio))}
</span>
{:else}
{short(sign * value)}
{/if}
</button>
{/if}
</td>
{/each}
{#if average}
<td
class="num whitespace-nowrap px-2 text-right text-cladd-fg-soft"
>
{short((sign * rowTotal) / Math.max(1, cols.length))}
</td>
{/if}
<td class="num whitespace-nowrap px-3 text-right font-semibold">
{#if multi}
<Money codes={report.codes} {side} totals={row.totals} />
{:else}
{short(sign * rowTotal)}
{/if}
</td>
</tr>
{/each}
</tbody>
<tfoot>
<tr class="h-7 border-cladd-outline border-t-2 font-semibold">
<th
class="sticky left-0 z-[1] bg-cladd-surface px-3 text-left"
scope="row"
>
Sum
</th>
{#each cols as col, i (col.key)}
<td class="p-0 text-right">
{#if multi}
<div class="px-2 py-1">
<Money codes={report.codes} {side} totals={colMerged[i]} />
</div>
{:else if colTotals[i]}
<button
class="num h-7 w-full whitespace-nowrap px-2 text-right hover:bg-row-cursor {isActive(
null,
col
)
? "bg-row-selected"
: ""}"
onclick={() => open(null, col)}
tabindex="-1"
type="button"
>
{short(sign * colTotals[i])}
</button>
{/if}
</td>
{/each}
{#if average}
<td class="num whitespace-nowrap px-2 text-right">
{short((sign * grand) / Math.max(1, cols.length))}
</td>
{/if}
<td class="num whitespace-nowrap px-3 text-right">
{#if multi}
<Money codes={report.codes} {side} totals={merge(colMerged)} />
{:else}
{short(sign * grand)}
{/if}
</td>
</tr>
</tfoot>
</table>
</div>
+47
View File
@@ -0,0 +1,47 @@
<script lang="ts">
import {
amountOf,
currencies,
type Side,
type Totals,
} from "#lib/reports/flows.js";
import { money } from "#lib/reports/report.svelte.js";
let {
totals,
side,
codes,
mode,
}: {
totals: Totals | undefined;
side: Side;
codes: string[];
mode?: string;
} = $props();
const lines = $derived.by(() => {
if (!totals) {
return [];
}
const list = mode === "pc" ? ["pc"] : currencies(totals, codes);
return list
.map((code) => ({ code, value: amountOf(totals, code, side) }))
.filter((line) => Math.abs(line.value) > 0.000_001);
});
function tone(value: number) {
if (side === "out") {
return "text-expense";
}
if (side === "in") {
return "text-income";
}
return value < 0 ? "text-expense" : "text-income";
}
</script>
{#each lines as line (line.code)}
<div class="num {tone(line.value)}">
{money(line.code, side === "out" ? -line.value : line.value)}
</div>
{/each}
+323
View File
@@ -0,0 +1,323 @@
<script lang="ts">
import { percent } from "#lib/format.js";
import { type Flow, flowValue, keys } from "#lib/reports/flows.js";
import {
drillQuery,
modeLabel,
money,
navigate,
useReport,
} from "#lib/reports/report.svelte.js";
import ReportBox from "./ReportBox.svelte";
const LEFT_MAX = 5;
const RIGHT_MAX = 9;
const SLOT = 34;
const BAR = 10;
const CORE = 14;
const CORE_HEIGHT = 260;
const GAP = 4;
const CENT = 0.01;
interface Node {
color: string;
dir: 1 | -1;
drillable: boolean;
key: string;
label: string;
value: number;
}
const report = useReport();
const mode = $derived(report.mode);
let width = $state(0);
function side(flows: Flow[], dir: 1 | -1, max: number): Node[] {
const sums = new Map<string, { label: string; value: number }>();
for (const flow of flows) {
const value = flowValue(flow, mode);
if (flow.dir !== dir || value === null) {
continue;
}
const key = keys.category(flow);
const entry = sums.get(key.key) ?? { label: key.label, value: 0 };
entry.value += value;
sums.set(key.key, entry);
}
const sorted = [...sums.entries()]
.map(([key, entry]) => ({ key, ...entry }))
.filter((entry) => entry.value > 0)
.sort((a, b) => b.value - a.value);
const color = dir === 1 ? "var(--ff-income)" : "var(--ff-expense)";
const head: Node[] = sorted.slice(0, max).map((entry) => ({
color,
dir,
drillable: true,
key: entry.key,
label: entry.label,
value: entry.value,
}));
const rest = sorted.slice(max);
if (rest.length > 0) {
head.push({
color: "var(--cladd-fg-softest)",
dir,
drillable: false,
key: `other-${dir}`,
label: `Other (${rest.length})`,
value: rest.reduce((sum, entry) => sum + entry.value, 0),
});
}
return head;
}
const net = $derived.by(() => {
const sums = new Map<string, number>();
for (const swap of report.exchanges) {
if (swap.to === mode) {
sums.set(swap.from, (sums.get(swap.from) ?? 0) + swap.toAmount);
}
if (swap.from === mode) {
sums.set(swap.to, (sums.get(swap.to) ?? 0) - swap.fromAmount);
}
}
return [...sums.entries()].filter(([, value]) => value !== 0);
});
function swaps(dir: 1 | -1): Node[] {
return net
.filter(([, value]) => Math.sign(value) === dir)
.map(([code, value]) => ({
color: "var(--ff-transfer)",
dir,
drillable: false,
key: `swap-${dir}-${code}`,
label: dir === 1 ? `Exchanged from ${code}` : `Exchanged to ${code}`,
value: Math.abs(value),
}));
}
const model = $derived.by(() => {
const flows = report.flows ?? [];
const left = side(flows, 1, LEFT_MAX);
const right = side(flows, -1, RIGHT_MAX);
if (mode !== "pc") {
left.push(...swaps(1));
right.push(...swaps(-1));
}
const totalIn = left.reduce((sum, n) => sum + n.value, 0);
const totalOut = right.reduce((sum, n) => sum + n.value, 0);
if (totalIn - totalOut >= CENT) {
right.push({
color: "var(--ff-series-1)",
dir: 1,
drillable: false,
key: "kept",
label: "Left over",
value: totalIn - totalOut,
});
} else if (totalOut - totalIn >= CENT) {
left.push({
color: "var(--ff-warn)",
dir: -1,
drillable: false,
key: "deficit",
label: "From balance",
value: totalOut - totalIn,
});
}
return { left, right, total: Math.max(totalIn, totalOut) };
});
const narrow = $derived(width < 640);
const labelWidth = $derived(Math.min(220, Math.max(110, width * 0.27)));
const scale = $derived(model.total > 0 ? CORE_HEIGHT / model.total : 0);
const slotOf = (n: Node) => Math.max(SLOT, n.value * scale + GAP * 2);
const height = $derived(
Math.max(
CORE_HEIGHT + 20,
model.left.reduce((s, n) => s + slotOf(n), 0),
model.right.reduce((s, n) => s + slotOf(n), 0)
)
);
const leftX = $derived(labelWidth);
const rightX = $derived(width - labelWidth - BAR);
const coreX = $derived((leftX + BAR + rightX) / 2 - CORE / 2);
const coreTop = $derived((height - model.total * scale) / 2);
function layout(nodes: Node[]) {
const used = nodes.reduce((s, n) => s + slotOf(n), 0);
let y = (height - used) / 2;
let core = coreTop;
return nodes.map((node) => {
const slot = slotOf(node);
const h = Math.max(1.5, node.value * scale);
const top = y + (slot - h) / 2;
const placed = { center: y + slot / 2, core, h, node, top };
y += slot;
core += node.value * scale;
return placed;
});
}
const left = $derived(layout(model.left));
const right = $derived(layout(model.right));
function ribbon(x0: number, y0: number, x1: number, y1: number, h: number) {
const mid = (x0 + x1) / 2;
return `M${x0},${y0}C${mid},${y0} ${mid},${y1} ${x1},${y1}V${y1 + h}C${mid},${y1 + h} ${mid},${y0 + h} ${x0},${y0 + h}Z`;
}
function open(node: Node) {
if (!node.drillable) {
return;
}
const { key } = node;
const current = report.drill;
const same =
current?.box === "flow" &&
current.key === key &&
current.dir === node.dir;
navigate(
drillQuery(
same
? null
: {
box: "flow",
dim: "category",
dir: node.dir,
key,
label: `${node.dir === 1 ? "Income" : "Expenses"}: ${node.label}`,
}
)
);
}
const share = (n: Node) =>
model.total ? percent(n.value / model.total) : "";
</script>
<ReportBox
body="p-3"
empty={model.total === 0}
emptyText="No money moved in {modeLabel(mode)} in this period."
id="flow"
loading={!report.flows}
title="Where the money went ({modeLabel(mode)})"
>
<div bind:clientWidth={width}>
{#if width > 0 && narrow}
<div class="grid gap-4 sm:grid-cols-2">
{#each [
{ list: model.left, title: "In" },
{ list: model.right, title: "Out" },
] as column (column.title)}
<div>
<h3 class="mb-1 font-semibold text-[12px] text-cladd-fg-soft">
{column.title}
</h3>
{#each column.list as node (node.key)}
<button
class="grid h-7 w-full grid-cols-[0.5rem_1fr_auto_2.5rem] items-center gap-2 rounded-cladd-sm px-1 text-left hover:bg-row-hover disabled:hover:bg-transparent"
disabled={!node.drillable}
onclick={() => open(node)}
type="button"
>
<span
class="size-2 rounded-full"
style:background={node.color}
></span>
<span class="truncate">{node.label}</span>
<span class="num">{money(mode, node.value)}</span>
<span class="num text-right text-cladd-fg-soft"
>{share(node)}</span
>
</button>
{/each}
</div>
{/each}
</div>
{:else if width > 0}
<div class="relative" style:height="{height}px">
<svg aria-hidden="true" class="absolute inset-0" {height} {width}>
{#each left as item (item.node.key)}
<path
d={ribbon(leftX + BAR, item.top, coreX, item.core, item.h)}
fill="color-mix(in oklch, {item.node.color} 22%, transparent)"
/>
<rect
fill={item.node.color}
height={item.h}
rx="2"
width={BAR}
x={leftX}
y={item.top}
/>
{/each}
{#each right as item (item.node.key)}
<path
d={ribbon(coreX + CORE, item.core, rightX, item.top, item.h)}
fill="color-mix(in oklch, {item.node.color} 22%, transparent)"
/>
<rect
fill={item.node.color}
height={item.h}
rx="2"
width={BAR}
x={rightX}
y={item.top}
/>
{/each}
<rect
fill="var(--cladd-fg-soft)"
height={model.total * scale}
rx="3"
width={CORE}
x={coreX}
y={coreTop}
/>
</svg>
<span
class="num absolute -translate-x-1/2 font-semibold text-[12px]"
style:left="{coreX + CORE / 2}px"
style:top="{coreTop - 22}px"
>{money(mode, model.total)}</span
>
{#each [
...left.map((item) => ({ ...item, isLeft: true })),
...right.map((item) => ({ ...item, isLeft: false })),
] as item (`${item.isLeft}-${item.node.key}`)}
{@const active =
report.drill?.box === "flow" &&
report.drill.key === item.node.key &&
report.drill.dir === item.node.dir}
<button
class="absolute flex h-8 flex-col justify-center rounded-cladd-sm px-1 leading-tight hover:bg-row-hover disabled:hover:bg-transparent {item.isLeft
? "items-end text-right"
: "items-start text-left"} {active ? "bg-row-selected" : ""}"
disabled={!item.node.drillable}
onclick={() => open(item.node)}
title={item.node.drillable
? `Show transactions for ${item.node.label}`
: undefined}
type="button"
style:left="{item.isLeft ? 0 : rightX + BAR + 4}px"
style:top="{item.center - 16}px"
style:width="{labelWidth - 6}px"
>
<span
class="max-w-full truncate text-[12px] {active
? "font-semibold"
: ""}"
>{item.node.label}</span
>
<span class="num text-[11px] text-cladd-fg-soft"
>{money(mode, item.node.value)}
· {share(item.node)}</span
>
</button>
{/each}
</div>
{/if}
</div>
</ReportBox>
@@ -0,0 +1,205 @@
<script lang="ts">
import ChevronDown from "@lucide/svelte/icons/chevron-down";
import { Popover } from "bits-ui";
import type { Option } from "#lib/reports/report.svelte.js";
let {
options,
value,
onchange,
label,
id,
open = $bindable(false),
loading = false,
class: className = "",
}: {
options: Option[];
value: string[];
onchange: (next: string[]) => void;
label: string;
id: string;
open?: boolean;
loading?: boolean;
class?: string;
} = $props();
let query = $state("");
let active = $state(0);
const chosen = $derived(new Set(value));
const visible = $derived(
query
? options.filter((o) =>
`${o.label} ${o.group ?? ""}`
.toLowerCase()
.includes(query.toLowerCase())
)
: options
);
const groups = $derived.by(() => {
const map = new Map<string, Option[]>();
for (const option of visible) {
const key = option.group ?? "";
map.set(key, [...(map.get(key) ?? []), option]);
}
return [...map.entries()];
});
const summary = $derived.by(() => {
const picked = options.filter((o) => chosen.has(o.id));
if (picked.length === 0) {
return loading ? "Loading" : "None selected";
}
if (picked.length === options.length && options.length > 2) {
return `All selected (${options.length})`;
}
if (picked.length <= 2) {
return picked.map((o) => o.label).join(", ");
}
return `${picked.length} selected: ${picked.map((o) => o.label).join(", ")}`;
});
function toggle(ids: string[], on: boolean) {
const next = new Set(chosen);
for (const optionId of ids) {
if (on) {
next.add(optionId);
} else {
next.delete(optionId);
}
}
onchange(options.filter((o) => next.has(o.id)).map((o) => o.id));
}
function onkeydown(event: KeyboardEvent) {
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
event.preventDefault();
const step = event.key === "ArrowDown" ? 1 : -1;
active = Math.max(0, Math.min(visible.length - 1, active + step));
document
.getElementById(`${id}-opt-${active}`)
?.scrollIntoView({ block: "nearest" });
}
if (event.key === "Enter" && !(event.metaKey || event.ctrlKey)) {
event.preventDefault();
const option = visible[active];
if (option) {
toggle([option.id], !chosen.has(option.id));
}
}
}
</script>
<Popover.Root
onOpenChange={(next) => {
if (!next) {
query = "";
active = 0;
}
}}
bind:open
>
<Popover.Trigger
aria-label="{label}: {summary}"
class="cladd-cut cladd-focusable-input flex h-8 min-w-0 items-center gap-2 rounded-cladd-sm px-2.5 text-left text-[13px] text-cladd-fg {className}"
{id}
>
<span class="min-w-0 flex-1 truncate">{summary}</span>
<ChevronDown class="size-3.5 shrink-0 text-cladd-fg-soft" />
</Popover.Trigger>
<Popover.Portal>
<Popover.Content
align="start"
class="cladd-surface cladd-gradient cladd-outline cladd-level-1 cladd-anim-popover z-50 flex max-h-[min(70vh,32rem)] w-80 flex-col rounded-cladd-lg text-cladd-fg shadow-cladd-popover outline-none"
sideOffset={6}
>
<div class="flex flex-col gap-2 border-cladd-outline border-b p-2">
<input
aria-label="Find {label.toLowerCase()}"
class="cladd-cut cladd-focusable-input h-8 rounded-cladd-sm px-2 text-[13px]"
{onkeydown}
placeholder="Find.."
bind:value={query}
>
<div class="flex items-center gap-3 text-[12px]">
<button
class="text-brand dark:text-[var(--ff-series-1)] hover:underline"
onclick={() =>
toggle(
visible.map((o) => o.id),
true
)}
type="button"
>
Select all
</button>
<button
class="text-brand dark:text-[var(--ff-series-1)] hover:underline"
onclick={() =>
toggle(
visible.map((o) => o.id),
false
)}
type="button"
>
None
</button>
<span class="num ml-auto text-cladd-fg-soft"
>{chosen.size}
of {options.length}</span
>
</div>
</div>
<div class="min-h-0 flex-1 overflow-y-auto p-1">
{#each groups as [group, items] (group)}
{#if group}
{@const all = items.every((o) => chosen.has(o.id))}
<button
class="flex h-7 w-full items-center px-2 text-left font-semibold text-[11px] text-cladd-fg-soft uppercase tracking-wide hover:text-cladd-fg"
onclick={() =>
toggle(
items.map((o) => o.id),
!all
)}
title={all ? `Deselect ${group}` : `Select ${group}`}
type="button"
>
{group}
</button>
{/if}
{#each items as option (option.id)}
{@const index = visible.indexOf(option)}
<label
class="flex h-7 cursor-pointer items-center gap-2 rounded-cladd-sm px-2 text-[13px] hover:bg-row-hover {index ===
active
? "bg-row-cursor"
: ""}"
id="{id}-opt-{index}"
>
<input
checked={chosen.has(option.id)}
class="size-3.5 accent-[var(--ff-brand)]"
onchange={(event) =>
toggle([option.id], event.currentTarget.checked)}
type="checkbox"
>
<span class="min-w-0 flex-1 truncate">{option.label}</span>
{#if option.note}
<span class="shrink-0 text-[11px] text-cladd-fg-soft"
>{option.note}</span
>
{/if}
</label>
{/each}
{:else}
<p class="px-2 py-1.5 text-cladd-fg-soft">
{#if query}
Nothing matches
{:else}
{loading ? "Loading" : "Nothing to choose from"}
{/if}
</p>
{/each}
</div>
</Popover.Content>
</Popover.Portal>
</Popover.Root>
+292
View File
@@ -0,0 +1,292 @@
<script lang="ts">
import ChevronLeft from "@lucide/svelte/icons/chevron-left";
import ChevronRight from "@lucide/svelte/icons/chevron-right";
import { useKeys } from "#lib/keys.svelte.js";
import { recall, remember } from "#lib/reports/memory.js";
import { listOptions, listReady } from "#lib/reports/options.js";
import {
LIST_LABELS,
REPORT_KINDS,
type ReportParams,
type ReportType,
rangePresets,
shiftRange,
} from "#lib/reports/params.js";
import {
accountOptions,
modeLabel,
navigate,
useReport,
} from "#lib/reports/report.svelte.js";
import MultiSelect from "./MultiSelect.svelte";
const report = useReport();
let accountsOpen = $state(false);
let itemsOpen = $state(false);
let typeSelect = $state<HTMLSelectElement>();
let startInput = $state<HTMLInputElement>();
const p = $derived(report.params);
const presets = rangePresets();
const preset = $derived(
presets.find((x) => x.range.start === p.start && x.range.end === p.end)
?.key ?? "custom"
);
const options = $derived(accountOptions(report.accounts()));
const list = $derived(report.kind?.list);
const sources = $derived({
budgets: report.budgets,
categories: report.categories,
expense: report.expense,
revenue: report.revenue,
tags: report.tags,
});
const itemOptions = $derived(list ? listOptions(list, sources) : []);
const accountValue = $derived(report.selected);
function go(changes: Partial<ReportParams>) {
const next = { ...p, ...changes };
remember(next);
navigate({}, report.path(changes));
}
function defaultItems(type: ReportType): string[] {
const kind = REPORT_KINDS.find((k) => k.type === type)?.list;
if (!kind) {
return [];
}
const stored = recall().items[kind];
if (stored && stored.length > 0) {
return stored;
}
if (kind === "double") {
const top = new Map<string, number>();
for (const flow of report.flows ?? []) {
if (flow.type === "withdrawal") {
top.set(flow.other.id, (top.get(flow.other.id) ?? 0) + 1);
}
}
const ids = [...top.entries()]
.sort((a, b) => b[1] - a[1])
.slice(0, 3)
.map(([id]) => id);
return ids.length > 0 ? ids : ["0"];
}
return [
{ budget: "allBudgets", category: "allCategories", tag: "allTags" }[kind],
];
}
function setType(type: ReportType) {
go({ items: defaultItems(type), type });
}
function setRange(start: string, end: string) {
if (start && end && start <= end) {
go({ end, start });
}
}
function shift(dir: 1 | -1) {
const range = shiftRange(p, dir);
go({ end: range.end, start: range.start });
}
function cycleMode() {
const { available } = report;
if (available.length > 1) {
const next =
available[(available.indexOf(report.mode) + 1) % available.length];
navigate({ cur: next });
}
}
useKeys(() => [
{
group: "Report",
keys: "[",
label: "Shift report back one period",
run: () => shift(-1),
},
{
group: "Report",
keys: "]",
label: "Shift report forward one period",
run: () => shift(1),
},
{
group: "Report",
keys: "t",
label: "Change report type",
run: () => typeSelect?.focus(),
},
{
group: "Report",
keys: "a",
label: "Choose accounts",
run: () => (accountsOpen = true),
},
...(list
? [
{
group: "Report",
keys: "f",
label: `Choose ${LIST_LABELS[list].many.toLowerCase()}`,
run: () => (itemsOpen = true),
},
]
: []),
{
group: "Report",
keys: "d",
label: "Change dates",
run: () => startInput?.focus(),
},
...(report.available.length > 1
? [
{
group: "Report",
keys: "c",
label: "Next currency in charts",
run: cycleMode,
},
]
: []),
]);
</script>
<div
class="cladd-surface cladd-outline z-20 mb-4 lg:sticky lg:top-14 flex flex-wrap items-end justify-between gap-x-4 gap-y-2 rounded-cladd-lg px-3 py-2"
>
<div class="flex min-w-0 flex-1 flex-wrap items-end gap-x-3 gap-y-2">
<label class="flex flex-col gap-0.5">
<span class="text-[11px] text-cladd-fg-soft">Report type</span>
<select
class="cladd-cut cladd-focusable-input h-8 max-w-[14rem] rounded-cladd-sm px-2 text-[13px] text-cladd-fg"
onchange={(event) => setType(event.currentTarget.value as ReportType)}
value={p.type}
bind:this={typeSelect}
>
{#each REPORT_KINDS as kind (kind.type)}
<option value={kind.type}>{kind.label}</option>
{/each}
</select>
</label>
<div class="flex min-w-0 flex-col gap-0.5">
<label class="text-[11px] text-cladd-fg-soft" for="report-accounts"
>Accounts</label
>
<MultiSelect
class="w-48"
id="report-accounts"
label="Accounts"
loading={!report.accountsReady}
onchange={(accounts) => go({ accounts })}
{options}
value={accountValue}
bind:open={accountsOpen}
/>
</div>
{#if list}
<div class="flex min-w-0 flex-col gap-0.5">
<label class="text-[11px] text-cladd-fg-soft" for="report-items"
>{LIST_LABELS[list].many}</label
>
<MultiSelect
class="w-48"
id="report-items"
label={LIST_LABELS[list].many}
loading={!listReady(list, sources)}
onchange={(items) => go({ items })}
options={itemOptions}
value={report.items}
bind:open={itemsOpen}
/>
</div>
{/if}
<div class="flex flex-col gap-0.5">
<span class="text-[11px] text-cladd-fg-soft">Date range</span>
<div class="flex flex-wrap items-center gap-x-2 gap-y-1">
<div class="flex items-center gap-1">
<button
aria-label="Previous period"
class="grid size-8 place-items-center rounded-cladd-sm text-cladd-fg-soft hover:bg-row-hover hover:text-cladd-fg"
onclick={() => shift(-1)}
title="Previous period ["
type="button"
>
<ChevronLeft class="size-4" />
</button>
<input
aria-label="Start date"
class="cladd-cut cladd-focusable-input num h-8 w-[7.4rem] rounded-cladd-sm px-1.5 text-[13px] sm:w-[8.2rem] sm:px-2 text-cladd-fg"
onchange={(event) => setRange(event.currentTarget.value, p.end)}
type="date"
value={p.start}
bind:this={startInput}
>
<span class="text-cladd-fg-soft">to</span>
<input
aria-label="End date"
class="cladd-cut cladd-focusable-input num h-8 w-[7.4rem] rounded-cladd-sm px-1.5 text-[13px] sm:w-[8.2rem] sm:px-2 text-cladd-fg"
onchange={(event) => setRange(p.start, event.currentTarget.value)}
type="date"
value={p.end}
>
<button
aria-label="Next period"
class="grid size-8 place-items-center rounded-cladd-sm text-cladd-fg-soft hover:bg-row-hover hover:text-cladd-fg"
onclick={() => shift(1)}
title="Next period ]"
type="button"
>
<ChevronRight class="size-4" />
</button>
</div>
<select
aria-label="Preset range"
class="cladd-cut cladd-focusable-input h-8 rounded-cladd-sm px-2 text-[13px] text-cladd-fg"
onchange={(event) => {
const found = presets.find(
(x) => x.key === event.currentTarget.value
);
if (found) {
setRange(found.range.start, found.range.end);
}
}}
value={preset}
>
{#each presets as item (item.key)}
<option value={item.key}>{item.label}</option>
{/each}
<option disabled value="custom">Custom</option>
</select>
</div>
</div>
</div>
{#if report.available.length > 1}
<fieldset class="shrink-0">
<legend class="float-left mb-0.5 w-full text-[11px] text-cladd-fg-soft">
Charts in
</legend>
<div
class="cladd-cut clear-both flex h-8 items-center gap-0.5 rounded-cladd-sm p-0.5"
>
{#each report.available as mode (mode)}
<button
aria-pressed={report.mode === mode}
class="num h-7 rounded-[5px] px-2 text-[12px] {report.mode === mode
? "cladd-surface cladd-outline font-semibold text-cladd-fg"
: "text-cladd-fg-soft hover:text-cladd-fg"}"
onclick={() => navigate({ cur: mode })}
title="Show charts in {modeLabel(mode)} c"
type="button"
>
{modeLabel(mode)}
</button>
{/each}
</div>
</fieldset>
{/if}
</div>
@@ -0,0 +1,88 @@
<script lang="ts">
import Columns from "#lib/components/charts/Columns.svelte";
import { buckets, type Dimension, type Flow } from "#lib/reports/flows.js";
import {
drillQuery,
modeLabel,
navigate,
useReport,
} from "#lib/reports/report.svelte.js";
import {
bucketLabel,
bucketRows,
FLOW_KEYS,
modeAxis,
modeFormat,
upright,
} from "#lib/reports/view.js";
import ReportBox from "./ReportBox.svelte";
let {
id = "period-chart",
title = "Income vs. expenses",
flows,
dim,
itemKey,
}: {
id?: string;
title?: string;
flows: Flow[] | undefined;
dim?: Dimension;
itemKey?: string;
} = $props();
const report = useReport();
const keys = $derived(
buckets(report.params.start, report.params.end, report.grain)
);
const rows = $derived(
flows ? upright(bucketRows(flows, keys, report.grain, report.mode)) : []
);
const shownKeys = $derived(
FLOW_KEYS.filter((key) =>
rows.some((row) => (row.values[key.key] ?? 0) !== 0)
)
);
const active = $derived(
report.drill?.box === id ? (report.drill.at ?? null) : null
);
</script>
<ReportBox
body="p-3"
empty={shownKeys.length === 0}
{id}
loading={!flows}
title={title.endsWith(",")
? `${title} ${modeLabel(report.mode)}`
: `${title} (${modeLabel(report.mode)})`}
>
<Columns
{active}
axisFormat={modeAxis(report.mode)}
format={modeFormat(report.mode)}
height={260}
keys={shownKeys}
label="{title} per period"
onselect={(row) =>
navigate(
drillQuery(
active === row.key
? null
: {
at: row.key,
box: id,
dim: itemKey === undefined ? undefined : dim,
key: itemKey,
label: [
itemKey === undefined ? "" : title,
bucketLabel(row.key, report.grain),
]
.filter(Boolean)
.join(", "),
}
)
)}
{rows}
/>
</ReportBox>
@@ -0,0 +1,54 @@
<script lang="ts">
import { getContext, type Snippet } from "svelte";
import type { SvelteSet } from "svelte/reactivity";
import Box from "#lib/components/Box.svelte";
import { useReport } from "#lib/reports/report.svelte.js";
import DrillPanel from "./DrillPanel.svelte";
import { ROW } from "./ReportRow.svelte";
let {
id,
title,
loading = false,
empty = false,
emptyText = "Nothing in this period.",
tools,
footer,
children,
body = "p-0",
class: className = "",
}: {
id: string;
title: string;
loading?: boolean;
empty?: boolean;
emptyText?: string;
tools?: Snippet;
footer?: Snippet;
children: Snippet;
body?: string;
class?: string;
} = $props();
const report = useReport();
const row = getContext<SvelteSet<string> | undefined>(ROW);
$effect(() => {
row?.add(id);
return () => row?.delete(id);
});
</script>
<Box {body} class={className} {footer} {title} {tools}>
<div class="scroll-mt-24" {id}>
{#if loading}
<p class="px-3 py-3 text-cladd-fg-soft">Loading</p>
{:else if empty}
<p class="px-3 py-3 text-cladd-fg-soft">{emptyText}</p>
{:else}
{@render children()}
{/if}
{#if !row && report.drill?.box === id && !loading}
<DrillPanel drill={report.drill} />
{/if}
</div>
</Box>
@@ -0,0 +1,52 @@
<script lang="ts">
import PageHeader from "#lib/components/shell/PageHeader.svelte";
import { formatRange } from "#lib/format.js";
import type { ReportParams } from "#lib/reports/params.js";
import { createReport, provideReport } from "#lib/reports/report.svelte.js";
import AuditReport from "./AuditReport.svelte";
import DefaultReport from "./DefaultReport.svelte";
import DimensionReport from "./DimensionReport.svelte";
import ParamBar from "./ParamBar.svelte";
let { params }: { params: ReportParams } = $props();
const report = createReport(() => params);
provideReport(report);
const SUBTITLES: Record<string, string> = {
audit: "Transaction history account overview",
budget: "Budget report",
category: "Category report",
default: "Default financial report",
double: "Expense/revenue account report",
tag: "Tag report",
};
const subtitle = $derived(
`${SUBTITLES[params.type]}, ${formatRange(params.start, params.end)}`
);
</script>
<PageHeader
crumbs={[
{ href: "/reports", label: "Reports" },
{ label: report.kind?.label ?? "" },
]}
{subtitle}
title="Reports"
/>
<ParamBar />
{#if report.error}
<p class="mb-4 text-expense">
Firefly III did not return the data for this report: {report.error.message}
</p>
{/if}
{#if params.type === "default"}
<DefaultReport />
{:else if params.type === "audit"}
<AuditReport />
{:else}
{#key params.type}
<DimensionReport />
{/key}
{/if}
@@ -0,0 +1,35 @@
<script lang="ts" module>
export const ROW = Symbol("report-row");
</script>
<script lang="ts">
import { type Snippet, setContext } from "svelte";
import { SvelteSet } from "svelte/reactivity";
import { useReport } from "#lib/reports/report.svelte.js";
import DrillPanel from "./DrillPanel.svelte";
let {
children,
class: className = "",
}: { children: Snippet; class?: string } = $props();
const boxes = new SvelteSet<string>();
setContext(ROW, boxes);
const report = useReport();
const drill = $derived(
report.drill && boxes.has(report.drill.box) ? report.drill : null
);
</script>
<div class="grid gap-4 {className}">
{@render children()}
</div>
{#if drill}
<div
class="cladd-surface cladd-outline scroll-mt-24 overflow-hidden rounded-cladd-lg"
id="drill-{drill.box}"
>
<DrillPanel {drill} />
</div>
{/if}
+116
View File
@@ -0,0 +1,116 @@
<script lang="ts">
import { percent } from "#lib/format.js";
import {
amountOf,
type Dimension,
type Row,
rank,
} from "#lib/reports/flows.js";
import {
drillQuery,
money,
navigate,
useReport,
} from "#lib/reports/report.svelte.js";
let {
box,
rows,
side,
dim,
limit = 8,
}: {
box: string;
rows: Row[];
side: "in" | "out";
dim: Dimension;
limit?: number;
} = $props();
const report = useReport();
const mode = $derived(report.mode);
const dir = $derived(side === "in" ? 1 : -1);
const ranked = $derived(
rank(rows, mode, side)
.map((row) => ({ row, value: amountOf(row.totals, mode, side) }))
.filter((item) => item.value > 0)
);
const head = $derived(ranked.slice(0, limit));
const rest = $derived(ranked.slice(limit));
const restValue = $derived(rest.reduce((acc, item) => acc + item.value, 0));
const sum = $derived(ranked.reduce((acc, item) => acc + item.value, 0));
const max = $derived(
Math.max(restValue, ...head.map((item) => item.value), 1)
);
const color = $derived(
side === "in" ? "var(--ff-income)" : "var(--ff-expense)"
);
function open(row: Row) {
const current = report.drill;
const same =
current?.box === box && current.key === row.key && current.dim === dim;
navigate(
drillQuery(
same ? null : { box, dim, dir, key: row.key, label: row.label }
)
);
}
</script>
{#snippet line(
label: string,
value: number,
fill: string
)}
<span class="flex w-full items-baseline gap-3">
<span class="min-w-0 flex-1 truncate">{label}</span>
<span class="num">{money(mode, value)}</span>
<span class="num w-9 text-right text-cladd-fg-soft"
>{percent(value / sum)}</span
>
</span>
<span class="block h-1.5 w-full rounded-full bg-cladd-surface-next">
<span
class="block h-full rounded-full"
style:background={fill}
style:width="{(value / max) * 100}%"
></span>
</span>
{/snippet}
{#if ranked.length === 0}
<p class="px-3 py-3 text-cladd-fg-soft">
Nothing in {mode === "pc" ? "this period" : mode}.
</p>
{:else}
<ul class="flex flex-col py-1.5">
{#each head as item (item.row.key)}
{@const active =
report.drill?.box === box && report.drill.key === item.row.key}
<li>
<button
aria-pressed={active}
class="flex w-full flex-col gap-1 px-3 py-1 text-left hover:bg-row-hover {active
? "bg-row-selected"
: ""}"
onclick={() => open(item.row)}
title="Show transactions for {item.row.label}"
type="button"
>
{@render line(item.row.label, item.value, color)}
</button>
</li>
{/each}
{#if rest.length > 0}
<li class="flex flex-col gap-1 px-3 py-1 text-cladd-fg-soft">
{@render line(
`Other (${rest.length})`,
restValue,
"var(--cladd-fg-softest)"
)}
</li>
{/if}
</ul>
{/if}
@@ -0,0 +1,336 @@
<script lang="ts">
import { addDays, daysBetween, eachDay, parse } from "#lib/dates.js";
import { compactMoney, formatDate, formatMoney } from "#lib/format.js";
import { daily } from "#lib/reports/flows.js";
import {
currencyMeta,
drillQuery,
modeLabel,
money,
navigate,
useReport,
} from "#lib/reports/report.svelte.js";
import { session } from "#lib/session.svelte.js";
import ReportBox from "./ReportBox.svelte";
const STEPS = [14, 30, 48, 68, 88];
const WEEKDAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
const BIG_CELL_DAYS = 62;
const TOP_DAYS = 5;
const report = useReport();
const p = $derived(report.params);
const mode = $derived(report.mode);
let width = $state(0);
let cursor = $state<string | null>(null);
const spending = $derived(
report.flows ? daily(report.flows, mode, -1) : null
);
const days = $derived(eachDay(p));
const big = $derived(days.length <= BIG_CELL_DAYS);
const values = $derived(
[...(spending?.values() ?? [])].map((d) => d.value).sort((a, b) => a - b)
);
const thresholds = $derived(
STEPS.slice(1).map(
(_, i) =>
values[Math.floor(((i + 1) / STEPS.length) * values.length)] ?? 0
)
);
const top = $derived(
[...(spending?.entries() ?? [])]
.sort((a, b) => b[1].value - a[1].value)
.slice(0, TOP_DAYS)
);
const quiet = $derived(
days.filter((d) => !spending?.has(d) && d <= p.end).length
);
const meta = $derived(
mode === "pc"
? {
decimals: 0,
symbol: session.primary?.symbol ?? "",
}
: { decimals: 0, symbol: currencyMeta(mode).symbol }
);
function level(value: number) {
let index = 0;
while (index < thresholds.length && value > thresholds[index]) {
index += 1;
}
return index;
}
const fill = (step: number) =>
`color-mix(in oklch, var(--ff-expense) ${STEPS[step]}%, var(--cladd-surface))`;
const monthShort = $derived(
new Intl.DateTimeFormat(session.locale, { month: "short" })
);
const weekday = (day: string) => (parse(day).getDay() + 6) % 7;
const months = $derived.by(() => {
const list: { key: string; label: string; cells: (string | null)[] }[] = [];
for (const day of days) {
const key = day.slice(0, 7);
let month = list.at(-1);
if (month?.key !== key) {
month = {
cells: Array.from({ length: weekday(day) }, () => null),
key,
label: formatDate(`${key}-01`, "month"),
};
list.push(month);
}
month.cells.push(day);
}
return list;
});
const weeks = $derived.by(() => {
if (big) {
return [];
}
const first = addDays(p.start, -weekday(p.start));
const count = Math.ceil((daysBetween(first, p.end) + 1) / 7);
return Array.from({ length: count }, (_, w) =>
Array.from({ length: 7 }, (__, d) => {
const day = addDays(first, w * 7 + d);
return day < p.start || day > p.end ? null : day;
})
);
});
const cellSize = $derived(
big
? 0
: Math.max(
8,
Math.min(16, Math.floor((width - 40) / Math.max(1, weeks.length)) - 2)
)
);
function amount(value: number, compact: boolean) {
return compact
? compactMoney(value, meta.symbol)
: formatMoney({ amount: value, ...meta });
}
function open(day: string) {
const current = report.drill;
const same = current?.box === "calendar" && current.key === day;
navigate(
drillQuery(
same
? null
: {
box: "calendar",
dim: "day",
dir: -1,
key: day,
label: formatDate(day, "long"),
}
)
);
}
const focusDay = $derived(
cursor ?? days.find((d) => spending?.has(d)) ?? p.start
);
function onkeydown(event: KeyboardEvent) {
const moves: Record<string, number> = {
ArrowDown: big ? 7 : 1,
ArrowLeft: big ? -1 : -7,
ArrowRight: big ? 1 : 7,
ArrowUp: big ? -7 : -1,
};
if (event.key in moves) {
event.preventDefault();
const next = addDays(focusDay, moves[event.key]);
if (next >= p.start && next <= p.end) {
cursor = next;
document.getElementById(`cal-${next}`)?.focus();
}
}
}
const label = (day: string) => {
const value = spending?.get(day)?.value ?? 0;
return `${formatDate(day, "long")}: ${value ? money(mode, -value) : "no spending"}`;
};
</script>
<ReportBox
body="p-3"
empty={values.length === 0}
emptyText="No spending in {modeLabel(mode)} in this period."
id="calendar"
loading={!spending}
title="Daily spending ({modeLabel(mode)})"
>
<div bind:clientWidth={width}>
{#if big}
<div class="flex flex-col gap-3">
{#each months as month (month.key)}
<div>
{#if months.length > 1}
<h3 class="mb-1 font-semibold text-[12px] text-cladd-fg-soft">
{month.label}
</h3>
{/if}
<div class="grid grid-cols-7 gap-0.5">
{#each WEEKDAYS as name (name)}
<div class="px-1 text-[11px] text-cladd-fg-soft">{name}</div>
{/each}
{#each month.cells as day, i (day ?? `pad-${i}`)}
{#if day}
{@const value = spending?.get(day)?.value ?? 0}
{@const step = value ? level(value) : -1}
<button
aria-label={label(day)}
class="flex h-11 min-w-0 flex-col justify-between rounded-[4px] px-1.5 py-1 text-left {report
.drill?.box === "calendar" && report.drill.key === day
? "outline-2 outline-cladd-fg"
: ""}"
id="cal-{day}"
onclick={() => open(day)}
onfocus={() => (cursor = day)}
{onkeydown}
tabindex={day === focusDay ? 0 : -1}
type="button"
style:background={step >= 0
? fill(step)
: "var(--cladd-surface-next)"}
style:color={step >= 3 ? "white" : undefined}
>
<span class="num text-[11px] opacity-70"
>{Number(day.slice(8, 10))}</span
>
{#if value}
<span
class="num self-end truncate text-[12px] font-medium"
>{amount(value, width < 520)}</span
>
{/if}
</button>
{:else}
<div></div>
{/if}
{/each}
</div>
</div>
{/each}
</div>
{:else}
<div class="flex flex-wrap items-start gap-x-8 gap-y-4">
<div class="overflow-x-auto">
<div class="flex gap-[2px]">
<div class="mr-1 flex flex-col gap-[2px] pt-4">
{#each WEEKDAYS as name, i (name)}
<div
class="text-[10px] leading-none text-cladd-fg-soft"
style:height="{cellSize}px"
>
{i % 2 === 0 ? name : ""}
</div>
{/each}
</div>
{#each weeks as week, w (w)}
{@const firstDay = week.find((d) => d !== null)}
<div class="flex flex-col gap-[2px]">
<div
class="h-4 whitespace-nowrap text-[10px] text-cladd-fg-soft"
style:width="{cellSize}px"
>
{#if firstDay &&
(w === 0 || Number(firstDay.slice(8, 10)) <= 7)}
{monthShort.format(parse(firstDay))}
{/if}
</div>
{#each week as day, d (d)}
{#if day}
{@const value = spending?.get(day)?.value ?? 0}
{@const step = value ? level(value) : -1}
<button
aria-label={label(day)}
class="rounded-[3px]"
id="cal-{day}"
onclick={() => open(day)}
onfocus={() => (cursor = day)}
{onkeydown}
tabindex={day === focusDay ? 0 : -1}
title={label(day)}
type="button"
style:background={step >= 0
? fill(step)
: "var(--cladd-surface-next)"}
style:height="{cellSize}px"
style:width="{cellSize}px"
></button>
{:else}
<div
style:height="{cellSize}px"
style:width="{cellSize}px"
></div>
{/if}
{/each}
</div>
{/each}
</div>
</div>
<div class="min-w-[14rem] flex-1">
<h3 class="mb-1 font-semibold text-[12px] text-cladd-fg-soft">
Biggest days
</h3>
<ul>
{#each top as [day, info] (day)}
<li>
<button
class="flex h-7 w-full items-center justify-between gap-3 rounded-cladd-sm px-1 hover:bg-row-hover"
onclick={() => open(day)}
type="button"
>
<span class="num text-cladd-fg-soft"
>{formatDate(day, "medium")}</span
>
<span class="text-[11px] text-cladd-fg-soft"
>{info.n}
{info.n === 1 ? "transaction" : "transactions"}</span
>
<span class="num ml-auto">{money(mode, -info.value)}</span>
</button>
</li>
{/each}
</ul>
<p class="mt-2 text-[12px] text-cladd-fg-soft">
{quiet}
of {days.length} days without spending
</p>
</div>
</div>
{/if}
<div
class="mt-3 flex flex-wrap items-center gap-1.5 text-[11px] text-cladd-fg-soft"
>
<span>Less</span>
{#each STEPS as _, i (i)}
<span
class="inline-block size-3 rounded-[3px]"
title={i === 0
? `up to ${amount(thresholds[0] ?? 0, false)}`
: `over ${amount(thresholds[i - 1] ?? 0, false)}`}
style:background={fill(i)}
></span>
{/each}
<span>More</span>
{#if thresholds.length > 0}
<span class="num ml-2"
>over {amount(thresholds.at(-1) ?? 0, false)} is the darkest</span
>
{/if}
</div>
</div>
</ReportBox>
@@ -0,0 +1,94 @@
<script lang="ts">
import { createQuery } from "@tanstack/svelte-query";
import { contains } from "#lib/dates.js";
import { formatDate } from "#lib/format.js";
import { expectedDates } from "#lib/reports/bills.js";
import { reportQueries } from "#lib/reports/queries.js";
import { money, useReport } from "#lib/reports/report.svelte.js";
import ReportBox from "./ReportBox.svelte";
const report = useReport();
const p = $derived(report.params);
const bills = createQuery(() => reportQueries.bills(p.start, p.end));
const rows = $derived(
(bills.data ?? [])
.map((item) => {
const a = item.attributes;
const expected = expectedDates(a, p);
const paid = (a.paid_dates ?? []).filter(
(d) => d.date && contains(p, d.date)
);
return { a, expected, id: item.id, paid };
})
.filter(
(row) =>
row.a.active && (row.expected.length > 0 || row.paid.length > 0)
)
.sort((x, y) => (x.a.name ?? "").localeCompare(y.a.name ?? ""))
);
</script>
<ReportBox
empty={rows.length === 0}
emptyText="No subscriptions expected or paid in this period."
id="bills"
loading={!bills.data}
title="Subscriptions"
>
<div class="overflow-x-auto">
<table class="w-full border-collapse text-[13px]">
<thead>
<tr class="text-[12px] text-cladd-fg-soft">
<th class="h-7 px-3 text-left font-medium">Name</th>
<th class="h-7 px-3 text-right font-medium">Minimum amount</th>
<th class="h-7 px-3 text-right font-medium">Maximum amount</th>
<th class="h-7 px-3 text-left font-medium">Expected on</th>
<th class="h-7 px-3 text-left font-medium">Paid</th>
</tr>
</thead>
<tbody>
{#each rows as row (row.id)}
{@const code = row.a.currency_code ?? ""}
<tr
class="border-cladd-outline border-t align-top hover:bg-row-hover"
>
<td class="max-w-[16rem] truncate px-3 py-1">
<a class="hover:underline" href="/subscriptions/show/{row.id}"
>{row.a.name}</a
>
</td>
<td class="num px-3 py-1 text-right">
{money(code, Number(row.a.amount_min ?? 0))}
</td>
<td class="num px-3 py-1 text-right">
{money(code, Number(row.a.amount_max ?? 0))}
</td>
<td class="num px-3 py-1 text-cladd-fg-soft">
{row.expected.map((d) => formatDate(d, "short")).join(", ")}
</td>
<td class="px-3 py-1">
{#each row.paid as paid (paid.transaction_journal_id)}
<div class="flex gap-2">
<a
class="num hover:underline"
href="/transactions/show/{paid.transaction_group_id}"
>{formatDate(paid.date, "short")}</a
>
<span class="num text-expense"
>{money(
paid.currency_code ?? code,
-Number(paid.amount ?? 0)
)}</span
>
</div>
{:else}
<span class="text-cladd-fg-soft">Not charged (yet)</span>
{/each}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</ReportBox>
+201
View File
@@ -0,0 +1,201 @@
<script lang="ts" module>
export type ColumnKind =
| "in"
| "out"
| "net"
| "avg-in"
| "avg-out"
| "pct-in"
| "pct-out"
| "count";
export interface Column {
kind: ColumnKind;
label: string;
}
</script>
<script lang="ts">
import { formatNumber, percent } from "#lib/format.js";
import {
currencies,
type Dimension,
type Measure,
type Row,
rank,
type Side,
type Totals,
} from "#lib/reports/flows.js";
import {
drillQuery,
money,
navigate,
useReport,
} from "#lib/reports/report.svelte.js";
let {
box,
rows,
columns,
dim,
dir,
footer,
sort = "out",
limit,
nameLabel = "Name",
counts = false,
averageAll = false,
}: {
box: string;
rows: Row[];
columns: Column[];
dim: Dimension;
dir?: 1 | -1;
footer?: Totals;
sort?: Side;
limit?: number;
nameLabel?: string;
counts?: boolean;
averageAll?: boolean;
} = $props();
const report = useReport();
let expanded = $state(false);
const sorted = $derived(rank(rows, report.mode, sort));
const shown = $derived(limit && !expanded ? sorted.slice(0, limit) : sorted);
interface Line {
code: string;
text: string;
tone: string;
}
function cell(
kind: ColumnKind,
m: Measure,
whole: Measure | undefined,
code: string
): Line | null {
if (kind === "count") {
return { code, text: formatNumber(m.n, 0), tone: "" };
}
if (kind === "pct-in" || kind === "pct-out") {
const part = kind === "pct-in" ? m.in : m.out;
const all = (kind === "pct-in" ? whole?.in : whole?.out) ?? 0;
return part && all
? { code, text: percent(part / all, 1), tone: "" }
: null;
}
const avg = kind === "avg-in" || kind === "avg-out";
const base = { in: m.in, net: m.in - m.out, out: -m.out }[
kind.replace("avg-", "") as "in" | "net" | "out"
];
if (!base || (avg && m.n < (averageAll ? 1 : 2))) {
return null;
}
const value = avg ? base / m.n : base;
return {
code,
text: money(code, value),
tone: value < 0 ? "text-expense" : "text-income",
};
}
function lines(totals: Totals, kind: ColumnKind): Line[] {
return currencies(totals, report.codes).flatMap((code) => {
const m = totals.cur.get(code);
const line = m ? cell(kind, m, footer?.cur.get(code), code) : null;
return line ? [line] : [];
});
}
function drill(row: Row) {
const current = report.drill;
const same =
current?.box === box && current.key === row.key && current.dim === dim;
navigate(
drillQuery(
same ? null : { box, dim, dir, key: row.key, label: row.label }
)
);
}
</script>
<div class="overflow-x-auto">
<table class="w-full border-collapse text-[13px]">
<thead>
<tr class="text-[12px] text-cladd-fg-soft">
<th class="h-7 px-3 text-left font-medium">{nameLabel}</th>
{#each columns as column (column.kind)}
<th class="h-7 whitespace-nowrap px-3 text-right font-medium">
{column.label}
</th>
{/each}
</tr>
</thead>
<tbody>
{#each shown as row (row.key)}
{@const active =
report.drill?.box === box && report.drill.key === row.key}
<tr
class="border-cladd-outline border-t align-top hover:bg-row-hover {active
? "bg-row-selected"
: ""}"
>
<td class="max-w-[18rem] px-3 py-1">
<button
aria-pressed={active}
class="block max-w-full truncate text-left hover:text-brand dark:hover:text-[var(--ff-series-1)] hover:underline"
onclick={() => drill(row)}
title="Show transactions"
type="button"
>
{row.label}
</button>
{#if counts && row.totals.n > 1}
<span class="text-[11px] text-cladd-fg-soft"
>{row.totals.n}
transactions</span
>
{/if}
</td>
{#each columns as column (column.kind)}
<td class="whitespace-nowrap px-3 py-1 text-right">
{#each lines(row.totals, column.kind) as line (line.code)}
<div class="num {line.tone}">{line.text}</div>
{/each}
</td>
{/each}
</tr>
{/each}
</tbody>
{#if footer && footer.n > 0}
<tfoot>
<tr class="border-cladd-outline border-t-2 align-top font-semibold">
<td class="px-3 py-1">Sum</td>
{#each columns as column (column.kind)}
<td class="whitespace-nowrap px-3 py-1 text-right">
{#if !(
column.kind.startsWith("pct") || column.kind.startsWith("avg")
)}
{#each lines(footer, column.kind) as line (line.code)}
<div class="num {line.tone}">{line.text}</div>
{/each}
{/if}
</td>
{/each}
</tr>
</tfoot>
{/if}
</table>
</div>
{#if limit && sorted.length > limit}
<button
class="w-full border-cladd-outline border-t px-3 py-1.5 text-left text-[12px] text-brand dark:text-[var(--ff-series-1)] hover:underline"
onclick={() => (expanded = !expanded)}
type="button"
>
{expanded ? "Show top" : `Show entire list (${sorted.length})`}
</button>
{/if}
+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) },
}));
}