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}