feat: one page for a category, tag, budget or merchant

The page answers how much and whether that is normal: this month per
currency with count, median and pace against a typical month, thirteen
months as lanes per currency, and what is inside (merchants, cards,
budgets, tags, subtags) on 1 to 4 with Enter narrowing the transactions
below. Budgets keep spent, left and overspent with the limit line;
expense and revenue accounts use the same page with visits and cadence.
The twelve period cards and per-day charts are gone.
This commit is contained in:
hh committed 2026-10-16 15:51:00 +00:00
1 parent 349a6a0b32
commit 722fdc2a20
19 files changed
+1667 -794

No files matched your search

@@ -19,6 +19,7 @@
setPrimary,
span,
} from "#lib/components/dashboard/data.js";
import Merchant from "#lib/components/dimension/Merchant.svelte";
import PageHeader from "#lib/components/shell/PageHeader.svelte";
import Pager from "#lib/components/transactions/Pager.svelte";
import TransactionTable from "#lib/components/transactions/TransactionTable.svelte";
@@ -75,9 +76,10 @@
const statement = $derived(
!everything && (type === "asset" || type === "liabilities")
);
const merchant = $derived(type === "expense" || type === "revenue");
const first = createQuery(() => ({
enabled: everything,
enabled: everything && !merchant,
queryFn: async () => {
const head = await api<Page<TransactionGroup>>(
`/v1/accounts/${id}/transactions`,
@@ -116,7 +118,7 @@
return days <= 3000 ? "1M" : "1Y";
});
const chart = createQuery(() => ({
enabled: !statement && (!everything || first.isSuccess),
enabled: !(statement || merchant) && (!everything || first.isSuccess),
placeholderData: keepPreviousData,
queryFn: () =>
api<ChartSet[]>("/v1/chart/account/overview", {
@@ -147,7 +149,7 @@
Number(page.url.searchParams.get("page") ?? "1") || 1
);
const dated = createQuery(() => ({
enabled: !(everything || statement),
enabled: !(everything || statement || merchant),
placeholderData: keepPreviousData,
queryFn: () =>
all<TransactionGroup>(
@@ -161,7 +163,7 @@
queryKey: ["account-tx-all", id, period.start, period.end],
}));
const paged = createQuery(() => ({
enabled: everything,
enabled: everything && !merchant,
placeholderData: keepPreviousData,
queryFn: () =>
api<Page<TransactionGroup>>(`/v1/accounts/${id}/transactions`, {
@@ -310,6 +312,8 @@
{#if item}
<Statement {item} />
{/if}
{:else if merchant}
<Merchant {everything} {id} />
{:else}
<div class="flex flex-col gap-3">
<div
+84 -289
View File
@@ -1,41 +1,23 @@
<script lang="ts">
import { createQuery } from "@tanstack/svelte-query";
import { untrack } from "svelte";
import { api } from "#lib/api/client.js";
import { all, invalidate } from "#lib/api/lists.js";
import type {
Budget,
BudgetLimit,
One,
TransactionGroup,
} from "#lib/api/types.js";
import Box from "#lib/components/Box.svelte";
import type { Budget, BudgetLimit, One } from "#lib/api/types.js";
import ConfirmButton from "#lib/components/ConfirmButton.svelte";
import BreakdownBox from "#lib/components/categories/BreakdownBox.svelte";
import CurrencyTabs from "#lib/components/categories/CurrencyTabs.svelte";
import GroupsBox from "#lib/components/categories/GroupsBox.svelte";
import {
breakdown,
curOfSplit,
dayOf,
firstDay,
fromEntries,
inRange,
periodRanges,
rows as splitRows,
stepLabel,
} from "#lib/components/categories/sums.js";
import { tones, wash } from "#lib/components/charts/chart.js";
import LineChart from "#lib/components/charts/LineChart.svelte";
import Meter from "#lib/components/charts/Meter.svelte";
import StackedBars from "#lib/components/charts/StackedBars.svelte";
import type { Lane, StackRow } from "#lib/components/charts/chart.js";
import Dimension from "#lib/components/dimension/Dimension.svelte";
import LimitLine from "#lib/components/dimension/LimitLine.svelte";
import LimitStrip from "#lib/components/dimension/LimitStrip.svelte";
import PageHeader from "#lib/components/shell/PageHeader.svelte";
import { Button } from "#lib/components/ui/button/index.js";
import { addDays, contains, today } from "#lib/dates.js";
import { formatMoney, formatRange, toNumber } from "#lib/format.js";
import { DimensionModel } from "#lib/dimension/model.svelte.js";
import { budgetSpec } from "#lib/dimension/spec.js";
import { limitFacts, stripsOf, withLimits } from "#lib/dimension/stats.js";
import { useKeys } from "#lib/keys.svelte.js";
import { period } from "#lib/period.svelte.js";
import { longMonth } from "#lib/reports/board.js";
import { goto } from "$app/navigation";
import { page } from "$app/state";
import { limitRange } from "./data.js";
let { id, limitId }: { id: string; limitId?: string } = $props();
@@ -48,166 +30,42 @@
queryFn: () => all<BudgetLimit>(`/v1/budgets/${id}/limits`),
queryKey: ["budget-limits", id],
}));
const groups = createQuery(() => ({
queryFn: () => all<TransactionGroup>(`/v1/budgets/${id}/transactions`),
queryKey: ["budget-transactions", id],
}));
const model = new DimensionModel(() => ({ spec: budgetSpec(id) }));
const name = $derived(budget.data?.data.attributes.name ?? "");
const sortedLimits = $derived(
[...(limits.data ?? [])].sort((a, b) =>
(b.attributes.start ?? "").localeCompare(a.attributes.start ?? "")
)
);
const limit = $derived(sortedLimits.find((l) => l.id === limitId));
const range = $derived(limit ? limitRange(limit) : null);
const allRows = $derived(
splitRows(groups.data ?? [], (s) => s.budget_id === id)
);
const scoped = $derived(
limit && range
? inRange(allRows, range).filter(
(r) => r.split.currency_code === limit.attributes.currency_code
const list = $derived(limits.data ?? []);
const limit = $derived(list.find((l) => l.id === limitId));
const facts = $derived(
model.range
? limitFacts(list, model.main, model.range, model.now).sort(
(a, b) =>
Number(b.code === limit?.attributes.currency_code) -
Number(a.code === limit?.attributes.currency_code)
)
: allRows
: []
);
const spentRows = $derived(
scoped.filter((r) => r.split.type === "withdrawal")
);
const codes = $derived(
[...new Set(spentRows.map((r) => r.split.currency_code ?? ""))].filter(
Boolean
)
);
const code = $derived(
limit?.attributes.currency_code ??
(codes.includes(page.url.searchParams.get("currency") ?? "")
? page.url.searchParams.get("currency")
: codes[0]) ??
""
);
const inCur = $derived(
spentRows.filter((r) => r.split.currency_code === code)
);
const cur = $derived(
inCur[0]
? curOfSplit(inCur[0].split)
: {
code,
decimals: limit?.attributes.currency_decimal_places ?? 2,
symbol: limit?.attributes.currency_symbol ?? code,
}
);
const fmt = (value: number) =>
formatMoney({ amount: value, decimals: cur.decimals, symbol: cur.symbol });
const strips = $derived(stripsOf(list, model.main, model.months));
const visibleGroups = $derived.by(() => {
if (!range) {
return groups.data ?? [];
$effect(() => {
if (limit) {
const range = limitRange(limit);
untrack(() => period.set(range));
}
const ids = new Set(scoped.map((r) => r.group.id));
return (groups.data ?? []).filter((g) => ids.has(g.id));
});
const view = $derived(period.view);
const history = $derived.by(() => {
const first = firstDay(inCur) ?? period.start;
const end = period.end > first ? period.end : today();
const ranges = periodRanges(end, view, 400)
.filter((r) => r.end >= first)
.reverse();
return ranges.map((r) => {
const spent = inRange(inCur, r).reduce(
(sum, row) => sum + toNumber(row.split.amount),
0
);
const own = sortedLimits.find(
(l) =>
l.attributes.currency_code === code &&
contains(r, l.attributes.start ?? "")
);
const amount = own ? toNumber(own.attributes.amount) : 0;
return {
format: fmt,
href: own ? `/budgets/show/${id}/${own.id}` : undefined,
key: r.start,
label: stepLabel(r, view),
values: amount
? {
left: Math.max(0, amount - spent),
over: Math.max(0, spent - amount),
spent: Math.min(spent, amount),
}
: { left: 0, over: 0, spent },
};
$effect(() => {
const { start, end } = period;
untrack(() => {
const range = limit && limitRange(limit);
if (range && (range.start !== start || range.end !== end)) {
setTimeout(() =>
goto(`/budgets/show/${id}?start=${start}&end=${end}`, {
replace: true,
reset: false,
})
);
}
});
});
const historyKeys = [
{ color: "var(--ff-brand)", key: "spent", label: "Spent" },
{ color: wash("var(--ff-brand)", 28), key: "left", label: "Left" },
{ color: tones.expense, key: "over", label: "Overspent" },
];
const burn = $derived.by(() => {
if (!(limit && range)) {
return [];
}
const amount = toNumber(limit.attributes.amount);
const last = range.end < today() ? range.end : today();
const byDay = new Map<string, number>();
for (const row of inCur) {
byDay.set(
dayOf(row),
(byDay.get(dayOf(row)) ?? 0) + toNumber(row.split.amount)
);
}
const points: { x: string; y: number }[] = [];
let left = amount;
for (let d = range.start; d <= last; d = addDays(d, 1)) {
left -= byDay.get(d) ?? 0;
points.push({ x: d, y: left });
}
return [
{
area: true,
format: fmt,
key: "left",
label: "Left",
points,
strong: true,
},
{
color: "var(--cladd-fg-softer)",
format: fmt,
key: "pace",
label: "Even pace",
points: [
{ x: range.start, y: amount },
{ x: range.end, y: 0 },
],
},
];
});
const byCategory = $derived(
breakdown(inCur, (s) => ({
href: s.category_id ? `/categories/show/${s.category_id}` : undefined,
label: s.category_name ?? "(no category)",
}))
);
const bySource = $derived(
breakdown(inCur, (s) => ({
href: s.source_id ? `/accounts/show/${s.source_id}` : undefined,
label: s.source_name ?? "",
}))
);
const byDestination = $derived(
breakdown(inCur, (s) => ({
href: s.destination_id ? `/accounts/show/${s.destination_id}` : undefined,
label: s.destination_name ?? "",
}))
);
async function remove() {
await api(`/v1/budgets/${id}`, { method: "DELETE" });
@@ -227,16 +85,14 @@
<PageHeader
crumbs={[{ href: "/budgets", label: "Budgets" }, { label: name || "Budget" }]}
subtitle={limit && range
? `${formatRange(range.start, range.end)} in ${limit.attributes.currency_code}`
: undefined}
subtitle={period.label}
title={name || "Budget"}
>
{#snippet actions()}
<Button
href="/budgets/edit/{id}"
size="sm"
title="Edit budget (E)"
title="Shift+E"
variant="outline"
>Edit</Button
>
@@ -244,111 +100,50 @@
{/snippet}
</PageHeader>
<div class="flex flex-col gap-4">
{#if limit && range}
<Box
title="Chart for all transactions for budget &quot;{name}&quot; between {formatRange(
range.start,
range.end
)} in {cur.code}"
{#snippet header(
lane: Lane,
row: StackRow | undefined
)}
<span class="w-10 shrink-0 font-semibold text-cladd-fg">{lane.label}</span>
{#if row}
<span class="text-cladd-fg-soft">{longMonth(row.key)}</span>
<span class="num font-semibold text-cladd-fg"
>{lane.format((row.values.spent ?? 0) + (row.values.over ?? 0))}</span
>
{#if burn[0]?.points.length}
<LineChart
label="Amount left in this budget"
marker={contains(range, today()) ? today() : undefined}
series={burn}
zero
/>
{:else}
<p class="text-[12px] text-cladd-fg-soft">Nothing spent yet.</p>
{/if}
</Box>
{:else}
<Box title="Chart of all transactions for budget &quot;{name}&quot;">
{#snippet tools()}
<CurrencyTabs {codes} current={code} />
{/snippet}
{#if history.length > 0}
<StackedBars
format={fmt}
keys={historyKeys}
label="Spent per period against the budgeted amount"
rows={history}
/>
{:else}
<p class="text-[12px] text-cladd-fg-soft">No expenses yet.</p>
{/if}
</Box>
{/if}
<div class="grid gap-4 lg:grid-cols-3">
<BreakdownBox items={byCategory} title="Expenses by category" />
<BreakdownBox items={bySource} title="Expenses by asset account" />
<BreakdownBox
items={byDestination}
title="Expenses by destination account"
/>
</div>
<div class="grid items-start gap-4 2xl:grid-cols-[minmax(0,1fr)_16rem]">
<GroupsBox
groups={visibleGroups}
loading={groups.isPending}
title="Transactions"
>
{#snippet links()}
{#if limit}
<a class="text-brand hover:underline" href="/budgets/show/{id}"
>Show everything</a
>
{/if}
{/snippet}
</GroupsBox>
<nav
aria-label="Budgeted amounts"
class="grid grid-cols-[repeat(auto-fill,minmax(12rem,1fr))] gap-2 2xl:grid-cols-1"
>
{#each sortedLimits as item (item.id)}
{@const r = limitRange(item)}
{@const amount = toNumber(item.attributes.amount)}
{@const spent = -(
fromEntries(item.attributes.spent).find(
(s) => s.code === item.attributes.currency_code
)?.amount ?? 0
)}
{@const money = (value: number) =>
formatMoney({
amount: value,
decimals: item.attributes.currency_decimal_places,
symbol: item.attributes.currency_symbol,
})}
<a
aria-current={item.id === limitId ? "page" : undefined}
class="cladd-surface cladd-outline flex flex-col gap-1 rounded-cladd-md px-3 py-2 text-[12px] {item.id ===
limitId
? "bg-row-cursor outline-2 outline-brand"
: "hover:bg-row-hover"}"
href="/budgets/show/{id}/{item.id}"
{#if row.values.left !== undefined}
<span class="text-cladd-fg-soft"
>of
<span class="num"
>{lane.format((row.values.spent ?? 0) + row.values.left)}</span
>
<span class="font-semibold text-cladd-fg"
>{formatRange(r.start, r.end)}</span
>
<span class="flex justify-between gap-2">
<span class="num text-expense">{money(spent)}</span>
<span class="num text-cladd-fg-soft">of {money(amount)}</span>
</span>
<Meter
height={6}
max={amount}
segments={spent > amount
? [
{ color: tones.warn, value: amount },
{ color: tones.expense, value: spent - amount },
]
: [{ color: "var(--ff-brand)", value: spent }]}
/>
</a>
{/each}
</nav>
</div>
</div>
limit</span
>
{/if}
{/if}
{#if lane.typical}
<span class="flex items-center gap-1.5 text-cladd-fg-soft">
<span
aria-hidden="true"
class="w-3 border-cladd-fg-soft border-t-[1.5px] border-dotted"
></span>
typical
<span class="num">{lane.format(lane.typical)}</span>
</span>
{/if}
{/snippet}
<Dimension
{header}
{model}
noun="purchases"
shape={(lanes) => withLimits(lanes, list)}
>
{#snippet extra()}
{#if !limits.isPending}
<LimitLine {facts} />
{/if}
{/snippet}
{#snippet below()}
<LimitStrip budgetId={id} cursor={model.focus} {strips} />
{/snippet}
</Dimension>
@@ -1,64 +0,0 @@
<script lang="ts">
import Box from "#lib/components/Box.svelte";
import BarChart from "#lib/components/charts/BarChart.svelte";
import { formatMoney } from "#lib/format.js";
import type { Breakdown } from "./sums.js";
const TOP = 8;
let {
title,
items,
color = "var(--ff-expense)",
}: { title: string; items: Breakdown[]; color?: string } = $props();
function bars(item: Breakdown) {
const top = item.bars.slice(0, TOP);
const rest = item.bars.slice(TOP).reduce((sum, bar) => sum + bar.amount, 0);
const format = (value: number) =>
formatMoney({
amount: value,
decimals: item.cur.decimals,
symbol: item.cur.symbol,
});
const list = top.map((bar) => ({
format,
href: bar.href,
key: bar.label,
label: bar.label,
value: bar.amount,
}));
if (rest > 0) {
list.push({
format,
href: undefined,
key: "\u0000other",
label: `${item.bars.length - TOP} more`,
value: rest,
});
}
return list;
}
</script>
<Box {title}>
{#if items.length === 0}
<p class="text-[12px] text-cladd-fg-soft">Nothing in this range.</p>
{/if}
<div class="flex flex-col gap-3">
{#each items as item (item.cur.code)}
{#if items.length > 1}
<h3 class="font-medium text-[12px] text-cladd-fg-soft">
{item.cur.code}
</h3>
{/if}
<BarChart
bars={bars(item)}
{color}
label={title}
labelWidth="minmax(5rem, 10rem)"
orientation="horizontal"
/>
{/each}
</div>
</Box>
@@ -1,28 +0,0 @@
<script lang="ts">
import { page } from "$app/state";
let { codes, current }: { codes: string[]; current: string } = $props();
function href(code: string) {
const url = new URL(page.url.href);
url.searchParams.set("currency", code);
return url.pathname + url.search;
}
</script>
{#if codes.length > 1}
<nav aria-label="Currency" class="flex gap-0.5 text-[12px]">
{#each codes as code (code)}
<a
aria-current={code === current ? "true" : undefined}
class="num rounded-cladd-sm px-1.5 py-0.5 {code === current
? "bg-row-selected font-semibold text-cladd-fg"
: "text-cladd-fg-soft hover:bg-row-hover"}"
data-sveltekit-noscroll
data-sveltekit-replacestate
href={href(code)}
>{code}</a
>
{/each}
</nav>
{/if}
+29 -125
View File
@@ -1,32 +1,21 @@
<script lang="ts">
import { createQuery } from "@tanstack/svelte-query";
import { api } from "#lib/api/client.js";
import { all, invalidate } from "#lib/api/lists.js";
import type { Category, One, TransactionGroup } from "#lib/api/types.js";
import Box from "#lib/components/Box.svelte";
import { invalidate } from "#lib/api/lists.js";
import type { Category, One } from "#lib/api/types.js";
import {
periodPath,
usePeriodPath,
} from "#lib/components/budgets/period-path.svelte.js";
import ConfirmButton from "#lib/components/ConfirmButton.svelte";
import StackedBars from "#lib/components/charts/StackedBars.svelte";
import Dimension from "#lib/components/dimension/Dimension.svelte";
import PageHeader from "#lib/components/shell/PageHeader.svelte";
import { Button } from "#lib/components/ui/button/index.js";
import { formatDate, formatMoney } from "#lib/format.js";
import { DimensionModel } from "#lib/dimension/model.svelte.js";
import { categorySpec } from "#lib/dimension/spec.js";
import { useKeys } from "#lib/keys.svelte.js";
import { period } from "#lib/period.svelte.js";
import { goto } from "$app/navigation";
import { page } from "$app/state";
import CurrencyTabs from "./CurrencyTabs.svelte";
import { currencies, FLOW_KEYS, flowRows } from "./flow.js";
import PeriodList from "./PeriodList.svelte";
import {
firstDay,
periodRanges,
rows as splitRows,
stepLabel,
stepRanges,
} from "./sums.js";
let { id, everything = false }: { id: string; everything?: boolean } =
$props();
@@ -38,49 +27,12 @@
queryFn: () => api<One<Category>>(`/v1/categories/${id}`),
queryKey: ["category", id],
}));
const groups = createQuery(() => ({
queryFn: () => all<TransactionGroup>(`/v1/categories/${id}/transactions`),
queryKey: ["category-transactions", id],
}));
const name = $derived(category.data?.data.attributes.name ?? "");
const own = $derived(
splitRows(groups.data ?? [], (s) => s.category_id === id)
);
const curs = $derived(currencies(own));
const cur = $derived(
curs.find((c) => c.code === page.url.searchParams.get("currency")) ??
curs[0]
);
const fmt = $derived((value: number) =>
formatMoney({ amount: value, decimals: cur?.decimals, symbol: cur?.symbol })
);
const periodRows = $derived(
cur
? flowRows(own, stepRanges(period.range), cur, (r) =>
r.start === r.end
? String(Number(r.start.slice(8)))
: formatDate(r.start, "short")
)
: []
);
const historyRows = $derived.by(() => {
if (!cur) {
return [];
}
const first = firstDay(own) ?? period.start;
const ranges = periodRanges(period.end, period.view, 400)
.filter((r) => r.end >= first)
.reverse();
return flowRows(
own,
ranges,
cur,
(r) => stepLabel(r, period.view),
(r) => periodPath(base, r.start, r.end)
);
});
const model = new DimensionModel(() => ({
everything,
spec: categorySpec(id),
}));
const other = $derived(everything ? base : `${base}/all`);
async function remove() {
await api(`/v1/categories/${id}`, { method: "DELETE" });
@@ -95,6 +47,12 @@
label: "Edit category",
run: () => goto(`/categories/edit/${id}`),
},
{
group: "Category",
keys: "A",
label: everything ? "Show the current period" : "Show all time",
run: () => goto(other),
},
]);
</script>
@@ -103,16 +61,17 @@
{ href: "/categories", label: "Categories" },
{ label: name || "Category" },
]}
subtitle={everything
? "All transactions"
: `Between ${formatDate(period.start)} and ${formatDate(period.end)}`}
subtitle={everything ? "All time" : period.label}
title={name || "Category"}
>
{#snippet actions()}
<Button href={other} size="sm" title="Shift+A" variant="outline"
>{everything ? "This period" : "All time"}</Button
>
<Button
href="/categories/edit/{id}"
size="sm"
title="Edit category (E)"
title="Shift+E"
variant="outline"
>Edit</Button
>
@@ -120,66 +79,11 @@
{/snippet}
</PageHeader>
<div class="flex flex-col gap-4">
<div class="grid gap-4 {everything ? "" : "lg:grid-cols-2"}">
{#if !everything}
<Box
title="Chart for all transactions for category &quot;{name}&quot; between {formatDate(
period.start
)} and {formatDate(period.end)}"
>
{#snippet tools()}
<CurrencyTabs
codes={curs.map((c) => c.code)}
current={cur?.code ?? ""}
/>
{/snippet}
<StackedBars
format={fmt}
height={220}
keys={FLOW_KEYS}
label="Spent and earned in this period"
rows={periodRows}
/>
</Box>
{/if}
<Box title="Chart for all transactions for category &quot;{name}&quot;">
{#snippet tools()}
{#if everything}
<CurrencyTabs
codes={curs.map((c) => c.code)}
current={cur?.code ?? ""}
/>
{/if}
{/snippet}
<StackedBars
format={fmt}
height={220}
keys={FLOW_KEYS}
label="Spent and earned per period"
rows={historyRows}
/>
</Box>
</div>
<PeriodList
groups={groups.data ?? []}
href={(r) => periodPath(base, r.start, r.end)}
keep={(s) => s.category_id === id}
loading={groups.isPending}
sidebar={!everything}
title="Transactions"
>
{#snippet links()}
{#if everything}
<a class="text-brand hover:underline" href={base}
>Show the current period and overview</a
>
{:else}
<a class="text-brand hover:underline" href="{base}/all"
>Show all transactions without grouping them by date</a
>
{/if}
{/snippet}
</PeriodList>
</div>
<Dimension
{model}
noun={model.dir === -1 ? "purchases" : "payments"}
onmonth={(range) =>
everything
? goto(periodPath(base, range.start, range.end))
: period.set(range)}
/>
-56
View File
@@ -1,56 +0,0 @@
import {
type StackKey,
type StackRow,
tones,
} from "#lib/components/charts/chart.js";
import type { Range } from "#lib/dates.js";
import { formatMoney, toNumber } from "#lib/format.js";
import { type Cur, curOfSplit, inRange, type Row } from "./sums.js";
export const FLOW_KEYS: StackKey[] = [
{ color: tones.income, key: "earned", label: "Earned" },
{ color: tones.expense, key: "spent", label: "Spent" },
];
export function currencies(items: Row[]): Cur[] {
const found = new Map<string, Cur>();
const counts = new Map<string, number>();
for (const { split } of items) {
const cur = curOfSplit(split);
found.set(cur.code, cur);
counts.set(cur.code, (counts.get(cur.code) ?? 0) + 1);
}
return [...found.values()].sort(
(a, b) => (counts.get(b.code) ?? 0) - (counts.get(a.code) ?? 0)
);
}
export function flowRows(
items: Row[],
ranges: Range[],
cur: Cur,
label: (range: Range) => string,
href?: (range: Range) => string
): StackRow[] {
const own = items.filter((row) => row.split.currency_code === cur.code);
const format = (value: number) =>
formatMoney({ amount: value, decimals: cur.decimals, symbol: cur.symbol });
return ranges.map((range) => {
let spent = 0;
let earned = 0;
for (const { split } of inRange(own, range)) {
if (split.type === "withdrawal") {
spent -= toNumber(split.amount);
} else if (split.type === "deposit") {
earned += toNumber(split.amount);
}
}
return {
format,
href: href?.(range),
key: range.start,
label: label(range),
values: { earned, spent },
};
});
}
+3 -1
View File
@@ -29,6 +29,7 @@
title = (row: StackRow) => row.label,
label = "Months by currency",
legend = true,
moreIsGood = false,
header,
onselect,
}: {
@@ -38,6 +39,7 @@
partial?: string | null;
pinned?: boolean;
dotLabel?: string;
moreIsGood?: boolean;
dotColor?: string;
title?: (row: StackRow) => string;
label?: string;
@@ -94,7 +96,7 @@
if (ratio === null || Math.abs(ratio) < 0.03) {
return "text-cladd-fg-soft";
}
return ratio > 0 ? "text-expense" : "text-income";
return ratio > 0 === moreIsGood ? "text-income" : "text-expense";
}
</script>
@@ -0,0 +1,161 @@
<script lang="ts">
import type { Snippet } from "svelte";
import Box from "#lib/components/Box.svelte";
import { setParam } from "#lib/components/categories/url.js";
import {
type Lane,
type StackRow,
tones,
} from "#lib/components/charts/chart.js";
import Lanes from "#lib/components/charts/Lanes.svelte";
import type { Range } from "#lib/dates.js";
import type { DimensionModel } from "#lib/dimension/model.svelte.js";
import type { Facet } from "#lib/dimension/spec.js";
import { lanesOf } from "#lib/dimension/stats.js";
import { useKeys } from "#lib/keys.svelte.js";
import { period } from "#lib/period.svelte.js";
import { longMonth, monthRange } from "#lib/reports/board.js";
import { page } from "$app/state";
import Headline from "./Headline.svelte";
import Inside from "./Inside.svelte";
import Ledger from "./Ledger.svelte";
const LANE_HEIGHTS = [180, 112, 88];
let {
model,
noun,
accountId,
shape = (list: Lane[]) => list,
onmonth = (month: Range) => period.set(month),
lead,
extra,
below,
header,
}: {
model: DimensionModel;
noun: string;
accountId?: string;
shape?: (lanes: Lane[]) => Lane[];
onmonth?: (range: Range) => void;
lead?: Snippet;
extra?: Snippet;
below?: Snippet;
header?: Snippet<[Lane, StackRow | undefined]>;
} = $props();
const height = $derived(
LANE_HEIGHTS[Math.min(model.codes.length, LANE_HEIGHTS.length) - 1] ?? 88
);
const lanes = $derived(
shape(
lanesOf(
model.flows,
model.months,
model.codes,
model.dir,
model.norms,
height
)
)
);
const facets = $derived(model.spec.facets);
const facet = $derived(
facets.find((f) => f.id === page.url.searchParams.get("facet")) ?? facets[0]
);
const only = $derived.by(() => {
const value = page.url.searchParams.get("only") ?? "";
const cut = value.indexOf(":");
const found = facets.find((f) => f.id === value.slice(0, cut));
return found && cut > 0
? { facet: found, key: value.slice(cut + 1) }
: null;
});
const narrowed = $derived.by(() => {
if (!only) {
return null;
}
const ids = new Set<string>();
let label = only.key;
for (const flow of model.periodFlows) {
const key = only.facet.of(flow).find((k) => k.key === only.key);
if (key) {
ids.add(flow.group);
({ label } = key);
}
}
return { groups: model.groups.filter((g) => ids.has(g.id)), label };
});
const pick = (f: Facet) =>
setParam("facet", f.id === facets[0]?.id ? "" : f.id);
const clear = () => setParam("only", "");
useKeys(() =>
only
? [
{
group: "Inside",
keys: "Escape",
label: "Show all transactions again",
run: clear,
},
]
: []
);
</script>
<Headline {lead} {model} {noun} />
{@render extra?.()}
<div class="grid gap-3 lg:grid-cols-[minmax(0,7fr)_minmax(0,5fr)]">
<Box title={model.everything ? "All months" : "13 months"}>
{#if lanes.length > 0}
<Lanes
cursor={model.focus}
dotColor={model.dir === -1 ? tones.income : tones.expense}
dotLabel={model.dir === -1 ? "in" : "out"}
{header}
label="Months by currency"
{lanes}
moreIsGood={model.dir === 1}
onselect={(month) => onmonth(monthRange(month))}
partial={model.partial}
title={(row) => longMonth(row.key)}
/>
{:else if !model.loading}
<p class="py-10 text-center text-[12px] text-cladd-fg-soft">
No spending or income here in these months.
</p>
{/if}
{@render below?.()}
</Box>
<Inside
class="lg:[contain:size]"
current={only?.facet.id === facet.id ? only.key : null}
{facet}
{model}
onfacet={pick}
onnarrow={(f, row) =>
setParam(
"only",
only?.facet.id === f.id && only.key === row.key
? ""
: `${f.id}:${row.key}`
)}
/>
</div>
<div class="mt-3">
<Ledger
{accountId}
empty={model.everything
? "No transactions."
: "No transactions in this period."}
groups={model.groups}
loading={model.loading}
{narrowed}
onclear={clear}
/>
</div>
@@ -0,0 +1,144 @@
<script lang="ts">
import ArrowUpRight from "@lucide/svelte/icons/arrow-up-right";
import { untrack } from "svelte";
import Sparkline from "#lib/components/charts/Sparkline.svelte";
import { type FacetRow, fmt } from "#lib/dimension/stats.js";
import { percent } from "#lib/format.js";
import { useKeys } from "#lib/keys.svelte.js";
import { ListNav } from "#lib/list-nav.svelte.js";
import { goto } from "$app/navigation";
const GROUP = "Inside";
let {
rows,
loading,
active,
current,
marker,
label,
onnarrow,
onleave,
}: {
rows: FacetRow[];
loading: boolean;
active: boolean;
current: string | null;
marker: number;
label: string;
onnarrow: (row: FacetRow) => void;
onleave: () => void;
} = $props();
function tint(i: number, row: FacetRow) {
if (active && nav.cursor === i) {
return "bg-row-cursor shadow-[inset_2px_0_0_var(--ff-brand)]";
}
return row.key === current ? "bg-row-selected" : "hover:bg-row-hover";
}
const top = $derived(Math.max(...rows.map((row) => row.share), 0) || 1);
const nav: ListNav<FacetRow> = new ListNav<FacetRow>({
extra: () => [
{
group: GROUP,
keys: "Enter",
label: "Narrow the transactions to this row",
run: () => nav.current && onnarrow(nav.current),
},
{
group: GROUP,
keys: "o",
label: "Open the page of this row",
run: () => nav.current?.href && goto(nav.current.href),
},
{
group: GROUP,
keys: "Escape",
label: "Back to the transactions",
run: onleave,
},
],
id: "facet",
items: () => rows,
key: (row) => row.key,
});
useKeys(() => (active ? nav.bindings() : []));
$effect(() => {
if (active) {
untrack(() =>
nav.move(
Math.max(
0,
rows.findIndex((row) => row.key === current)
)
)
);
}
});
</script>
{#if rows.length === 0 && !loading}
<p class="px-3 py-6 text-center text-[12px] text-cladd-fg-soft">
Nothing in this period.
</p>
{:else if rows.length > 0}
<ul
aria-label={label}
class="grid min-h-0 flex-1 auto-rows-min grid-cols-[minmax(0,1fr)_auto_auto_auto] overflow-y-auto py-1 text-[12px] sm:grid-cols-[minmax(6rem,1fr)_minmax(2rem,7rem)_auto_auto_auto_auto]"
>
{#each rows as row, i (row.key)}
<li
class="col-span-full grid h-7 grid-cols-subgrid items-center gap-x-2.5 px-3 {tint(
i,
row
)}"
id={nav.rowId(i)}
>
<button
class="truncate text-left text-cladd-fg hover:underline"
onclick={() => onnarrow(row)}
title={row.label}
type="button"
>
{row.label}
</button>
<span class="relative h-2.5 max-sm:hidden">
<span
class="absolute inset-y-0 left-0 rounded-r-sm bg-brand"
style:width="max(1px, {(row.share / top) * 100}%)"
></span>
</span>
<span class="num truncate text-right text-cladd-fg">
{row.sums.map((sum) => fmt(sum.code, sum.amount, 0)).join(" + ")}
</span>
<span class="num w-9 text-right text-cladd-fg-soft"
>{percent(row.share)}</span
>
<span class="max-sm:hidden">
<Sparkline
color="var(--ff-brand)"
height={18}
{marker}
values={row.spark}
width={56}
/>
</span>
{#if row.href}
<a
aria-label="Open {row.label}"
class="text-cladd-fg-softer hover:text-cladd-fg"
href={row.href}
>
<ArrowUpRight class="size-3.5" />
</a>
{:else}
<span></span>
{/if}
</li>
{/each}
</ul>
{/if}
@@ -0,0 +1,147 @@
<script lang="ts">
import type { Snippet } from "svelte";
import { daysBetween } from "#lib/dates.js";
import type { DimensionModel } from "#lib/dimension/model.svelte.js";
import { counted, fmt, type Money } from "#lib/dimension/stats.js";
import { formatDate, formatNumber } from "#lib/format.js";
import { period } from "#lib/period.svelte.js";
import { monthOf } from "#lib/reports/board.js";
import { versusText } from "#lib/reports/typical.js";
const WEEK = 7;
const SAME = 0.03;
let {
model,
noun,
lead,
}: { model: DimensionModel; noun: string; lead?: Snippet } = $props();
const facts = $derived(model.facts);
const when = $derived.by(() => {
if (!model.range) {
return "all time";
}
if (model.focus === monthOf(model.now) && period.isCurrent) {
return "this month";
}
return `in ${period.label}`;
});
const days = $derived.by(() => {
const start = model.range?.start ?? model.history.first;
const end =
model.range && model.range.end < model.now ? model.range.end : model.now;
return start ? daysBetween(start, end) + 1 : 0;
});
const perWeek = $derived(days >= WEEK ? (facts.count * WEEK) / days : null);
const main = $derived(model.norms[0]);
const typicals = $derived(model.norms.filter((n) => n.typical > 0));
const active = $derived(
model.typicalMonths.filter((month) =>
model.main.some((flow) => monthOf(flow.date) === month)
).length
);
const good = $derived(model.dir === 1);
function tone(ratio: number | null) {
if (ratio === null || Math.abs(ratio) < SAME) {
return "text-cladd-fg-soft";
}
return ratio > 0 === good ? "text-income" : "text-expense";
}
</script>
{#snippet amounts(
sums: Money[],
big: boolean
)}
{#each sums as sum, i (sum.code)}
{#if i > 0}
<span class="text-cladd-fg-soft">+</span>
{/if}
<span class="num font-semibold text-cladd-fg {big ? "text-[22px]" : ""}"
>{fmt(sum.code, sum.amount, 0)}</span
>
{/each}
{/snippet}
<div class="flex flex-col gap-1 pb-3">
{#if lead}
{@render lead()}
{:else if !model.loading}
<p
class="flex flex-wrap items-baseline gap-x-1.5 gap-y-0.5 text-[14px] text-cladd-fg-soft"
>
{#if facts.count > 0}
{@render amounts(facts.sums, true)}
<span>{when}</span>
<span>· {counted(facts.count, noun)}</span>
{#if facts.median}
<span
>· median
<span class="num text-cladd-fg"
>{fmt(facts.median.code, facts.median.amount, 0)}</span
></span
>
{/if}
{#if perWeek !== null}
<span
>·
<span class="num text-cladd-fg"
>{formatNumber(perWeek, perWeek < 10 ? 1 : 0)}</span
>
per week</span
>
{/if}
{:else}
<span class="font-semibold text-[22px] text-cladd-fg"
>Nothing {when}</span
>
{#if model.history.last}
<span>· last {formatDate(model.history.last)}</span>
{/if}
{/if}
</p>
{/if}
{#if !model.loading && main}
<p
class="flex flex-wrap items-baseline gap-x-1.5 gap-y-0.5 text-[13px] text-cladd-fg-soft"
>
{#if lead}
{#if facts.count > 0}
{@render amounts(facts.sums, false)}
<span>{when} ·</span>
{:else}
<span>nothing {when} ·</span>
{/if}
{/if}
{#if typicals.length > 0}
<span>typical month</span>
{@render amounts(
typicals.map((n) => ({ amount: n.typical, code: n.code })),
false
)}
{#if main.projected !== null}
<span
>· on pace for ≈
<span class="num font-semibold text-cladd-fg"
>{fmt(main.code, main.projected, 0)}</span
>,</span
>
{:else if main.ratio !== null}
<span>·</span>
{/if}
{#if main.ratio !== null}
<span class="font-semibold {tone(main.ratio)}"
>{versusText(main.ratio)}</span
>
{/if}
{:else if model.typicalMonths.length > 0}
<span
>in {active} of the last {model.typicalMonths.length}
{model.typicalMonths.length === 1 ? "month" : "months"}</span
>
{/if}
</p>
{/if}
</div>
+104
View File
@@ -0,0 +1,104 @@
<script lang="ts">
import type { DimensionModel } from "#lib/dimension/model.svelte.js";
import type { Facet } from "#lib/dimension/spec.js";
import { type FacetRow, facetRows } from "#lib/dimension/stats.js";
import { useKeys } from "#lib/keys.svelte.js";
import FacetRows from "./FacetRows.svelte";
let {
model,
facet,
current,
onfacet,
onnarrow,
class: className = "",
}: {
model: DimensionModel;
facet: Facet;
current: string | null;
onfacet: (facet: Facet) => void;
onnarrow: (facet: Facet, row: FacetRow) => void;
class?: string;
} = $props();
let focused = $state(false);
let section = $state<HTMLElement>();
const labelOf = (f: Facet) =>
model.dir === 1 && f.incomeLabel ? f.incomeLabel : f.label;
const rows = $derived(
facetRows(
facet,
model.periodMain,
model.main,
model.months,
model.codes,
model.weight
)
);
const marker = $derived(model.months.indexOf(model.focus ?? ""));
useKeys(() =>
model.spec.facets.map((f, i) => ({
group: "Inside",
keys: String(i + 1),
label: `Inside: ${labelOf(f)}`,
run: () => {
onfacet(f);
focused = true;
},
}))
);
</script>
<svelte:window
onpointerdown={(event) => {
if (focused && !section?.contains(event.target as Node)) {
focused = false;
}
}}
/>
<section
aria-label="Inside"
class="cladd-surface cladd-outline flex min-w-0 flex-col rounded-cladd-lg {focused
? "outline-2 outline-brand"
: ""} {className}"
bind:this={section}
>
<header
class="flex min-h-10 shrink-0 items-center gap-1 overflow-x-auto border-cladd-outline border-b px-2"
role="tablist"
>
{#each model.spec.facets as f, i (f.id)}
<button
aria-selected={f.id === facet.id}
class="flex h-7 shrink-0 items-center gap-1.5 rounded-cladd-sm px-2 text-[13px] {f.id ===
facet.id
? "bg-cladd-surface-next font-semibold text-cladd-fg"
: "text-cladd-fg-soft hover:text-cladd-fg"}"
onclick={() => onfacet(f)}
role="tab"
type="button"
>
<span class="kbd">{i + 1}</span>
{labelOf(f)}
</button>
{/each}
</header>
{#key facet.id}
<FacetRows
active={focused}
{current}
label={labelOf(facet)}
loading={model.loading}
{marker}
onleave={() => (focused = false)}
onnarrow={(row) => {
focused = false;
onnarrow(facet, row);
}}
{rows}
/>
{/key}
</section>
@@ -0,0 +1,70 @@
<script lang="ts">
import Box from "#lib/components/Box.svelte";
import Pager from "#lib/components/transactions/Pager.svelte";
import TransactionTable from "#lib/components/transactions/TransactionTable.svelte";
import { session } from "#lib/session.svelte.js";
import type { Group } from "#lib/transactions/split.js";
import { page } from "$app/state";
let {
groups,
narrowed,
onclear,
loading = false,
accountId,
empty,
}: {
groups: Group[];
narrowed: { groups: Group[]; label: string } | null;
onclear: () => void;
loading?: boolean;
accountId?: string;
empty: string;
} = $props();
const shown = $derived(narrowed?.groups ?? groups);
const size = $derived(session.pageSize);
const pages = $derived(Math.max(1, Math.ceil(shown.length / size)));
const current = $derived(
Math.min(pages, Math.max(1, Number(page.url.searchParams.get("page")) || 1))
);
const slice = $derived(shown.slice((current - 1) * size, current * size));
function href(n: number) {
const url = new URL(page.url.href);
url.searchParams.set("page", String(n));
return url.pathname + url.search;
}
</script>
<Box body="p-0" title="Transactions">
{#snippet tools()}
{#if narrowed}
<span class="text-[12px] text-cladd-fg-soft"
><span class="font-semibold text-cladd-fg">{narrowed.label}</span>
<span class="num"
>{narrowed.groups.length}
of {groups.length}</span
></span
>
<button
aria-label="Show all {groups.length}"
class="kbd hover:text-cladd-fg"
onclick={onclear}
type="button"
>
Esc
</button>
{:else}
<span class="num text-[12px] text-cladd-fg-soft">{groups.length}</span>
{/if}
<Pager {href} keys page={current} {pages} />
{/snippet}
<TransactionTable
context={{ accountId }}
{empty}
groups={slice}
id="dimension-tx"
{loading}
/>
</Box>
@@ -0,0 +1,56 @@
<script lang="ts">
import { fmt, type LimitFact } from "#lib/dimension/stats.js";
import { percent } from "#lib/format.js";
let { facts }: { facts: LimitFact[] } = $props();
</script>
<div class="-mt-1 flex flex-col gap-0.5 pb-3 text-[13px] text-cladd-fg-soft">
{#each facts as fact (fact.id)}
{@const expected =
fact.day === null ? null : (fact.amount * fact.day) / fact.days}
{@const left = fact.amount - fact.spent}
<p class="flex flex-wrap items-baseline gap-x-1.5">
<span
>limit
<span class="num font-semibold text-cladd-fg"
>{fmt(fact.code, fact.amount, 0)}</span
></span
>
<span
>· spent
<span class="num text-cladd-fg"
>{percent(fact.amount > 0 ? fact.spent / fact.amount : 0)}</span
></span
>
{#if fact.day !== null && expected !== null}
<span>· day {fact.day} of {fact.days} →</span>
{#if fact.spent > expected}
<span class="font-semibold text-expense"
>{fmt(fact.code, fact.spent - expected, 0)}
over pace</span
>
{:else}
<span class="font-semibold text-income">under pace</span>
{/if}
{/if}
{#if left >= 0}
<span
>·
<span class="num text-cladd-fg">{fmt(fact.code, left, 0)}</span>
left</span
>
{:else}
<span
>·
<span class="num font-semibold text-expense"
>{fmt(fact.code, -left, 0)}</span
>
overspent</span
>
{/if}
</p>
{:else}
<p>no limit for this period</p>
{/each}
</div>
@@ -0,0 +1,76 @@
<script lang="ts">
import { fmt, type StripCell } from "#lib/dimension/stats.js";
import { longMonth, shortMonth } from "#lib/reports/board.js";
let {
strips,
budgetId,
cursor,
}: {
strips: Map<string, StripCell[]>;
budgetId: string;
cursor: string | null;
} = $props();
</script>
{#snippet meter(
cell: StripCell,
code: string
)}
{@const over = cell.spent > cell.amount}
<span
class="text-[11px] max-sm:hidden {cell.month === cursor
? "font-semibold text-cladd-fg"
: "text-cladd-fg-soft"}"
>{shortMonth(cell.month)}</span
>
<span class="relative h-1 w-full rounded-full bg-cladd-surface-next">
{#if cell.id}
<span
class="absolute inset-y-0 left-0 rounded-full {over
? "bg-expense"
: "bg-brand"}"
style:width="{Math.min(1, cell.spent / (cell.amount || 1)) * 100}%"
></span>
{/if}
</span>
<span class="sr-only"
>{cell.id
? `${fmt(code, cell.spent, 0)} of ${fmt(code, cell.amount, 0)}`
: "no limit"}</span
>
{/snippet}
{#each [...strips] as [code, cells] (code)}
<nav
aria-label="Limits in {code}"
class="mt-2 grid gap-1 border-cladd-outline border-t pt-2"
style:grid-template-columns="2.5rem repeat({cells.length}, minmax(0, 1fr))"
>
<span class="self-center font-semibold text-[12px] text-cladd-fg"
>{code}</span
>
{#each cells as cell (cell.month)}
{#if cell.id}
<a
class="flex flex-col items-center gap-1 rounded-cladd-sm px-1 py-0.5 hover:bg-row-hover {cell.month ===
cursor
? "bg-row-cursor"
: ""}"
href="/budgets/show/{budgetId}/{cell.id}"
title="{longMonth(cell.month)}: {fmt(code, cell.spent, 0)} of {fmt(
code,
cell.amount,
0
)}"
>
{@render meter(cell, code)}
</a>
{:else}
<span class="flex flex-col items-center gap-1 px-1 py-0.5">
{@render meter(cell, code)}
</span>
{/if}
{/each}
</nav>
{/each}
@@ -0,0 +1,90 @@
<script lang="ts">
import { daysBetween } from "#lib/dates.js";
import { DimensionModel } from "#lib/dimension/model.svelte.js";
import { merchantSpec } from "#lib/dimension/spec.js";
import { counted, factsOf, fmt } from "#lib/dimension/stats.js";
import { formatDate, formatNumber } from "#lib/format.js";
import Dimension from "./Dimension.svelte";
let { id, everything = false }: { id: string; everything?: boolean } =
$props();
const model = new DimensionModel(() => ({
allTime: true,
everything,
spec: merchantSpec(id),
}));
const history = $derived(
factsOf(
model.main.filter((flow) => flow.date <= model.now),
model.codes
)
);
const noun = $derived(model.dir === -1 ? "visits" : "payments");
const every = $derived(
history.count > 1
? daysBetween(history.first, history.last) / (history.count - 1)
: null
);
const bill = $derived(model.main.find((flow) => flow.bill)?.bill ?? null);
</script>
{#snippet lead()}
{#if !model.loading}
<p
class="flex flex-wrap items-baseline gap-x-1.5 gap-y-0.5 text-[14px] text-cladd-fg-soft"
>
{#if history.count > 0}
{#each history.sums as sum, i (sum.code)}
{#if i > 0}
<span>+</span>
{/if}
<span class="num font-semibold text-[22px] text-cladd-fg"
>{fmt(sum.code, sum.amount, 0)}</span
>
{/each}
<span>all time · {counted(history.count, noun)}</span>
{#if history.median}
<span
>· median
<span class="num text-cladd-fg"
>{fmt(history.median.code, history.median.amount, 0)}</span
></span
>
{/if}
{#if every !== null}
<span
>· every
<span class="num text-cladd-fg"
>{formatNumber(every, every < 10 ? 1 : 0)}</span
>
days</span
>
{/if}
<span
>· last
<span class="text-cladd-fg"
>{formatDate(history.last, "short")}</span
></span
>
{#if bill}
<span
>· subscription
<a
class="text-brand hover:underline"
href="/subscriptions/show/{bill.id}"
>{bill.name}</a
></span
>
{/if}
{:else}
<span class="font-semibold text-[22px] text-cladd-fg"
>No {noun} yet</span
>
{/if}
</p>
{/if}
{/snippet}
<Dimension accountId={id} {lead} {model} {noun} />
+67 -227
View File
@@ -1,39 +1,20 @@
<script lang="ts">
import { createQuery } from "@tanstack/svelte-query";
import { api } from "#lib/api/client.js";
import { all, invalidate, lists } from "#lib/api/lists.js";
import type { One, Tag, TransactionGroup } from "#lib/api/types.js";
import Box from "#lib/components/Box.svelte";
import { invalidate, lists } from "#lib/api/lists.js";
import {
periodPath,
usePeriodPath,
} from "#lib/components/budgets/period-path.svelte.js";
import ConfirmButton from "#lib/components/ConfirmButton.svelte";
import CurrencyTabs from "#lib/components/categories/CurrencyTabs.svelte";
import {
currencies,
FLOW_KEYS,
flowRows,
} from "#lib/components/categories/flow.js";
import MoneyList from "#lib/components/categories/MoneyList.svelte";
import PeriodList from "#lib/components/categories/PeriodList.svelte";
import {
firstDay,
inRange,
list,
periodRanges,
rows as splitRows,
stepLabel,
totals,
} from "#lib/components/categories/sums.js";
import StackedBars from "#lib/components/charts/StackedBars.svelte";
import Dimension from "#lib/components/dimension/Dimension.svelte";
import PageHeader from "#lib/components/shell/PageHeader.svelte";
import { Button } from "#lib/components/ui/button/index.js";
import { formatDate, formatMoney } from "#lib/format.js";
import { DimensionModel } from "#lib/dimension/model.svelte.js";
import { tagSpec } from "#lib/dimension/spec.js";
import { useKeys } from "#lib/keys.svelte.js";
import { period } from "#lib/period.svelte.js";
import { goto } from "$app/navigation";
import { page } from "$app/state";
const ID = /^\d+$/;
@@ -41,94 +22,57 @@
$props();
const tags = createQuery(() => lists.tags());
const id = $derived(
ID.test(param)
? param
: tags.data?.find((t) => t.attributes.tag === param)?.id
const tag = $derived(
tags.data?.find((t) =>
ID.test(param) ? t.id === param : t.attributes.tag === param
)
);
const base = $derived(`/tags/show/${id ?? encodeURIComponent(param)}`);
const name = $derived(tag?.attributes.tag ?? (ID.test(param) ? "" : param));
const base = $derived(`/tags/show/${tag?.id ?? encodeURIComponent(param)}`);
usePeriodPath(() => base);
const tag = createQuery(() => ({
enabled: Boolean(id),
queryFn: () => api<One<Tag>>(`/v1/tags/${id}`),
queryKey: ["tag", id],
const model = new DimensionModel(() => ({
everything,
spec: tagSpec(name, tags.data ?? []),
}));
const groups = createQuery(() => ({
enabled: Boolean(id),
queryFn: () =>
all<TransactionGroup>(`/v1/tags/${id}/transactions`, { type: "all" }),
queryKey: ["tag-transactions", id],
}));
const attributes = $derived(tag.data?.data.attributes);
const name = $derived(attributes?.tag ?? param);
const keep = $derived((s: { tags?: string[] | null }) =>
(s.tags ?? []).includes(name)
const other = $derived(everything ? base : `${base}/all`);
const ancestors = $derived(
name
.split("/")
.slice(0, -1)
.map((_, i, parts) => parts.slice(0, i + 1).join("/"))
);
const own = $derived(splitRows(groups.data ?? [], keep));
const scoped = $derived(everything ? own : inRange(own, period.range));
const sums = $derived(totals(scoped));
const net = $derived.by(() => {
const result = { ...sums.earned };
for (const [code, sum] of Object.entries(sums.spent)) {
result[code] = {
...sum,
amount: (result[code]?.amount ?? 0) + sum.amount,
};
}
return list(result);
});
const children = $derived(
(tags.data ?? []).filter((t) => t.attributes.tag.startsWith(`${name}/`))
const subtitle = $derived(
[everything ? "All time" : period.label, tag?.attributes.description]
.filter(Boolean)
.join(" · ")
);
const parentPath = $derived(
name.includes("/") ? name.slice(0, name.lastIndexOf("/")) : ""
);
const parent = $derived(
tags.data?.find((t) => t.attributes.tag === parentPath)
);
const curs = $derived(currencies(own));
const cur = $derived(
curs.find((c) => c.code === page.url.searchParams.get("currency")) ??
curs[0]
);
const fmt = $derived((value: number) =>
formatMoney({ amount: value, decimals: cur?.decimals, symbol: cur?.symbol })
);
const history = $derived.by(() => {
if (!cur) {
return [];
}
const first = firstDay(own) ?? period.start;
const ranges = periodRanges(period.end, period.view, 400)
.filter((r) => r.end >= first)
.reverse();
return flowRows(
own,
ranges,
cur,
(r) => stepLabel(r, period.view),
(r) => periodPath(base, r.start, r.end)
);
});
async function remove() {
if (!id) {
if (!tag) {
return;
}
await api(`/v1/tags/${id}`, { method: "DELETE" });
await api(`/v1/tags/${tag.id}`, { method: "DELETE" });
await invalidate();
await goto("/tags");
}
useKeys(() => [
...(tag
? [
{
group: "Tag",
keys: "E",
label: "Edit tag",
run: () => goto(`/tags/edit/${tag.id}`),
},
]
: []),
{
group: "Tag",
keys: "E",
label: "Edit tag",
run: () => id && goto(`/tags/edit/${id}`),
keys: "A",
label: everything ? "Show the current period" : "Show all time",
run: () => goto(other),
},
]);
</script>
@@ -136,145 +80,41 @@
<PageHeader
crumbs={[
{ href: "/tags", label: "Tags" },
...(parent
? [{ href: `/tags/show/${parent.id}`, label: parent.attributes.tag }]
: []),
{ label: name },
...ancestors.map((path) => ({
href: `/tags/show/${encodeURIComponent(path)}`,
label: path.slice(path.lastIndexOf("/") + 1),
})),
{ label: name.slice(name.lastIndexOf("/") + 1) || "Tag" },
]}
subtitle={everything
? "All transactions"
: `Between ${formatDate(period.start)} and ${formatDate(period.end)}`}
title={name}
{subtitle}
title={name || "Tag"}
>
{#snippet actions()}
<Button
href="/tags/edit/{id}"
size="sm"
title="Edit tag (E)"
variant="outline"
>Edit</Button
<Button href={other} size="sm" title="Shift+A" variant="outline"
>{everything ? "This period" : "All time"}</Button
>
<ConfirmButton onconfirm={remove}>Delete</ConfirmButton>
{#if tag}
<Button
href="/tags/edit/{tag.id}"
size="sm"
title="Shift+E"
variant="outline"
>Edit</Button
>
<ConfirmButton onconfirm={remove}>Delete</ConfirmButton>
{/if}
{/snippet}
</PageHeader>
{#if tag.isError}
<p class="pb-3 text-expense">{tag.error.message}</p>
{#if tags.isError}
<p class="pb-3 text-expense">{tags.error.message}</p>
{/if}
<div class="flex flex-col gap-4">
<div class="grid items-start gap-4 lg:grid-cols-2">
<Box body="p-0" title="Meta data">
<table class="w-full text-[13px]">
<tbody>
{#if attributes?.description}
<tr class="border-cladd-outline border-b">
<th
class="w-1/2 px-3 py-1.5 text-left font-normal text-cladd-fg-soft"
>
Description
</th>
<td class="px-3 py-1.5">{attributes.description}</td>
</tr>
{/if}
{#if attributes?.date}
<tr class="border-cladd-outline border-b">
<th class="px-3 py-1.5 text-left font-normal text-cladd-fg-soft">
Date
</th>
<td class="num px-3 py-1.5">{formatDate(attributes.date)}</td>
</tr>
{/if}
{#each [
{ label: "Sum (excluding transfers)", sums: net },
{ label: "Sum of withdrawals", sums: list(sums.spent) },
{ label: "Sum of deposits", sums: list(sums.earned) },
{ label: "Sum of transfers", sums: list(sums.transferred) },
] as row (row.label)}
{#if row.sums.length > 0}
<tr class="border-cladd-outline border-b">
<th
class="px-3 py-1.5 text-left font-normal text-cladd-fg-soft"
>
{row.label}
</th>
<td class="px-3 py-1.5">
<MoneyList
class="!justify-start"
sums={row.sums}
tone={row.label === "Sum of transfers"
? "transfer"
: "auto"}
/>
</td>
</tr>
{/if}
{/each}
{#if children.length > 0}
<tr class="border-cladd-outline border-b">
<th class="px-3 py-1.5 text-left font-normal text-cladd-fg-soft">
Sub-tags
</th>
<td class="px-3 py-1.5">
{#each children as child, i (child.id)}
<a
class="text-brand hover:underline"
href="/tags/show/{child.id}"
>{child.attributes.tag.slice(name.length + 1)}</a
>{i < children.length - 1 ? ", " : ""}
{/each}
</td>
</tr>
{/if}
{#if scoped.length === 0 && !groups.isPending}
<tr>
<td class="px-3 py-1.5 text-cladd-fg-soft" colspan="2">
No transactions in this range.
</td>
</tr>
{/if}
</tbody>
</table>
</Box>
<Box title="Chart for all transactions for tag &quot;{name}&quot;">
{#snippet tools()}
<CurrencyTabs
codes={curs.map((c) => c.code)}
current={cur?.code ?? ""}
/>
{/snippet}
{#if history.length > 0}
<StackedBars
format={fmt}
height={200}
keys={FLOW_KEYS}
label="Spent and earned per period"
rows={history}
/>
{:else}
<p class="text-[12px] text-cladd-fg-soft">No transactions yet.</p>
{/if}
</Box>
</div>
<PeriodList
groups={groups.data ?? []}
href={(r) => periodPath(base, r.start, r.end)}
{keep}
loading={groups.isPending}
sidebar={!everything}
title="Transactions"
>
{#snippet links()}
{#if everything}
<a class="text-brand hover:underline" href={base}
>Show the current period and overview</a
>
{:else}
<a class="text-brand hover:underline" href="{base}/all"
>Show all transactions without grouping them by date</a
>
{/if}
{/snippet}
</PeriodList>
</div>
<Dimension
{model}
noun={model.dir === -1 ? "purchases" : "payments"}
onmonth={(range) =>
everything
? goto(periodPath(base, range.start, range.end))
: period.set(range)}
/>
+163
View File
@@ -0,0 +1,163 @@
import { type CreateQueryResult, createQuery } from "@tanstack/svelte-query";
import { lists } from "#lib/api/lists.js";
import type { Account, Item, Split, TransactionGroup } from "#lib/api/types.js";
import { contains, type Range, today } from "#lib/dates.js";
import { period } from "#lib/period.svelte.js";
import { monthOf } from "#lib/reports/board.js";
import { buckets, type Flow } from "#lib/reports/flows.js";
import { ledgerQuery, ledgerWindow, ownFlows } from "#lib/reports/ledger.js";
import { pages } from "#lib/reports/queries.js";
import { impliedRates } from "#lib/reports/rates.js";
import { typicalMonths } from "#lib/reports/typical.js";
import { session } from "#lib/session.svelte.js";
import type { Group } from "#lib/transactions/split.js";
import type { Spec } from "./spec.js";
import { codesOf, dirOf, factsOf, normsOf, weigh } from "./stats.js";
export interface Args {
allTime?: boolean;
everything?: boolean;
spec: Spec;
}
interface Kept {
groups: Group[];
journals: Set<string>;
}
function keptOf(
groups: Group[],
keep: (split: Split) => boolean,
range: Range | null
): Kept {
const journals = new Set<string>();
const kept: Group[] = [];
for (const group of groups) {
let hit = false;
for (const split of group.attributes.transactions) {
if (keep(split) && (!range || contains(range, split.date.slice(0, 10)))) {
journals.add(String(split.transaction_journal_id));
hit = true;
}
}
if (hit) {
kept.push(group);
}
}
return { groups: kept, journals };
}
const flowsOf = (kept: Kept, accounts: Item<Account>[]): Flow[] =>
ownFlows(kept.groups, accounts).filter((flow) =>
kept.journals.has(flow.journal)
);
export class DimensionModel {
readonly now = today();
readonly args: () => Args;
readonly ledger: CreateQueryResult<Group[]>;
readonly accounts: CreateQueryResult<Item<Account>[]>;
readonly source: CreateQueryResult<Group[]>;
spec = $derived.by(() => this.args().spec);
everything = $derived.by(() => Boolean(this.args().everything));
allTime = $derived.by(() => this.everything || Boolean(this.args().allTime));
range = $derived<Range | null>(this.everything ? null : period.range);
anchor = $derived(
this.range && this.range.end < ledgerWindow(this.now).start
? this.range.end
: this.now
);
window = $derived(ledgerWindow(this.anchor));
inside = $derived(
this.range !== null &&
this.range.start >= this.window.start &&
this.range.end <= this.window.end
);
sourceRange = $derived(this.allTime ? null : this.range);
raw = $derived.by(
() =>
(this.allTime ? this.source.data : this.ledger.data) ?? ([] as Group[])
);
loading = $derived.by(
() =>
this.accounts.isPending ||
(this.allTime ? this.source.isPending : this.ledger.isPending) ||
(!(this.allTime || this.inside) && this.source.isPending)
);
base = $derived(keptOf(this.raw, this.spec.keep, null));
inPeriod = $derived.by(() => {
if (this.everything) {
return this.base;
}
const from =
this.allTime || this.inside ? this.raw : (this.source.data ?? []);
return keptOf(from, this.spec.keep, this.range);
});
groups = $derived(this.inPeriod.groups);
flows = $derived.by(() => flowsOf(this.base, this.accounts.data ?? []));
periodFlows = $derived.by(() =>
flowsOf(this.inPeriod, this.accounts.data ?? [])
);
dir = $derived(dirOf(this.flows.length > 0 ? this.flows : this.periodFlows));
main = $derived(this.flows.filter((flow) => flow.dir === this.dir));
periodMain = $derived(
this.periodFlows.filter((flow) => flow.dir === this.dir)
);
codes = $derived(codesOf([...this.main, ...this.periodMain]));
history = $derived(factsOf(this.main, this.codes));
months = $derived(
this.everything && this.history.first
? buckets(this.history.first, this.now, "month")
: this.window.months
);
typicalMonths = $derived(
typicalMonths(this.months, this.now, period.first ?? this.history.first)
);
focus = $derived(
this.range && monthOf(this.range.start) === monthOf(this.range.end)
? monthOf(this.range.start)
: null
);
partial = $derived(
this.months.includes(monthOf(this.now)) ? monthOf(this.now) : null
);
norms = $derived(
normsOf(this.main, this.codes, this.typicalMonths, this.focus, this.now)
);
facts = $derived(factsOf(this.periodMain, this.codes));
weight = $derived(
weigh(
[...this.main, ...this.periodMain],
session.primary?.code ?? "",
impliedRates(this.raw)
)
);
constructor(args: () => Args) {
this.args = args;
this.ledger = createQuery(() => ({
...ledgerQuery(this.anchor),
enabled: !this.allTime,
}));
this.accounts = createQuery(() => lists.accounts("all"));
this.source = createQuery(() => {
const range = this.sourceRange;
return {
enabled: this.allTime || !this.inside,
queryFn: () =>
pages<TransactionGroup>(this.spec.source, {
...(range ?? {}),
type: "default",
}),
queryKey: [
"dimension",
this.spec.source,
range?.start ?? "all",
range?.end ?? "all",
],
};
});
}
}
+109
View File
@@ -0,0 +1,109 @@
import type { Item, Split, Tag } from "#lib/api/types.js";
import { buildTree, flatten } from "#lib/components/tags/tree.js";
import { type Flow, type Key, keys, NONE } from "#lib/reports/flows.js";
export interface Facet {
id: string;
incomeLabel?: string;
label: string;
of: (flow: Flow) => Key[];
}
export interface Spec {
facets: Facet[];
keep: (split: Split) => boolean;
source: string;
}
const listOf = (pick: (flow: Flow) => Key | null) => (flow: Flow) => {
const key = pick(flow);
return key ? [key] : [];
};
const facets = {
budgets: { id: "budgets", label: "Budgets", of: listOf(keys.budget) },
cards: { id: "cards", label: "Cards", of: listOf(keys.asset) },
categories: {
id: "categories",
label: "Categories",
of: listOf(keys.category),
},
merchants: {
id: "merchants",
incomeLabel: "Payers",
label: "Merchants",
of: listOf(keys.other),
},
tags: { id: "tags", label: "Tags", of: keys.tag },
} satisfies Record<string, Facet>;
export const categorySpec = (id: string): Spec => ({
facets: [facets.merchants, facets.cards, facets.budgets, facets.tags],
keep: (split) => String(split.category_id ?? "") === id,
source: `/v1/categories/${id}/transactions`,
});
export const budgetSpec = (id: string): Spec => ({
facets: [facets.merchants, facets.cards, facets.categories, facets.tags],
keep: (split) => String(split.budget_id ?? "") === id,
source: `/v1/budgets/${id}/transactions`,
});
export const merchantSpec = (id: string): Spec => ({
facets: [facets.categories, facets.cards, facets.budgets, facets.tags],
keep: (split) =>
String(split.source_id) === id || String(split.destination_id) === id,
source: `/v1/accounts/${id}/transactions`,
});
export const underTag = (name: string, tag: string) =>
tag === name || tag.startsWith(`${name}/`);
function subtagFacet(name: string, tags: Item<Tag>[]): Facet | null {
const node = flatten(buildTree(tags)).find((n) => n.path === name);
const children = node?.children ?? [];
if (children.length === 0) {
return null;
}
const childOf = (tag: string): Key | null => {
if (tag === name) {
return { key: NONE, label: "(no subtag)" };
}
const child = children.find((c) => underTag(c.path, tag));
return child
? {
href: `/tags/show/${encodeURIComponent(child.path)}`,
key: child.path,
label: child.name,
}
: null;
};
return {
id: "subtags",
label: "Subtags",
of: (flow) => {
const found = new Map<string, Key>();
for (const tag of flow.tags) {
const key = childOf(tag);
if (key) {
found.set(key.key, key);
}
}
return [...found.values()];
},
};
}
export function tagSpec(name: string, tags: Item<Tag>[]): Spec {
const subtags = subtagFacet(name, tags);
return {
facets: [
...(subtags ? [subtags] : []),
facets.merchants,
facets.cards,
facets.categories,
],
keep: (split) => (split.tags ?? []).some((tag) => underTag(name, tag)),
source: "/v1/transactions",
};
}
+356
View File
@@ -0,0 +1,356 @@
import type { BudgetLimit, Item } from "#lib/api/types.js";
import {
type Lane,
type StackKey,
tones,
wash,
} from "#lib/components/charts/chart.js";
import { contains, daysBetween, type Range } from "#lib/dates.js";
import { monthOf, shortMonth } from "#lib/reports/board.js";
import { type Flow, group, type Totals } from "#lib/reports/flows.js";
import type { Rate } from "#lib/reports/rates.js";
import { money } from "#lib/reports/report.svelte.js";
import { median, pace, series, typical, versus } from "#lib/reports/typical.js";
import { modeAxis } from "#lib/reports/view.js";
import type { Facet } from "./spec.js";
export type Dir = 1 | -1;
export interface Money {
amount: number;
code: string;
}
export interface Facts {
count: number;
first: string;
last: string;
median: Money | null;
sums: Money[];
}
export interface Norm {
code: string;
projected: number | null;
ratio: number | null;
typical: number;
}
export interface FacetRow {
href?: string;
key: string;
label: string;
share: number;
spark: number[];
sums: Money[];
}
export interface LimitFact {
amount: number;
code: string;
day: number | null;
days: number;
id: string;
spent: number;
}
export interface StripCell {
amount: number;
id: string | null;
month: string;
spent: number;
}
const BRAND = "var(--ff-brand)";
const PLURAL = /s$/;
const LEFT_WASH = 28;
export const counted = (count: number, noun: string) =>
`${count} ${count === 1 ? noun.replace(PLURAL, "") : noun}`;
export const fmt = (code: string, amount: number, decimals?: number) =>
money(code, amount, false, decimals);
export function dirOf(flows: Flow[]): Dir {
const out = flows.filter((flow) => flow.dir === -1).length;
return out * 2 >= flows.length ? -1 : 1;
}
function tally(flows: Flow[]) {
const by = new Map<string, { count: number; sum: number }>();
for (const flow of flows) {
const t = by.get(flow.currency) ?? { count: 0, sum: 0 };
t.count += 1;
t.sum += flow.amount;
by.set(flow.currency, t);
}
return by;
}
export function codesOf(flows: Flow[]): string[] {
const by = tally(flows);
return [...by.keys()].sort(
(a, b) => (by.get(b)?.count ?? 0) - (by.get(a)?.count ?? 0)
);
}
export function sumsOf(flows: Flow[], codes: string[]): Money[] {
const by = tally(flows);
return codes
.filter((code) => by.has(code))
.map((code) => ({ amount: by.get(code)?.sum ?? 0, code }));
}
export function factsOf(flows: Flow[], codes: string[]): Facts {
const main = codes.find((code) => flows.some((f) => f.currency === code));
let first = "";
let last = "";
for (const flow of flows) {
first = !first || flow.date < first ? flow.date : first;
last = flow.date > last ? flow.date : last;
}
const amounts = flows.filter((f) => f.currency === main).map((f) => f.amount);
return {
count: new Set(flows.map((flow) => flow.group)).size,
first,
last,
median: main ? { amount: median(amounts), code: main } : null,
sums: sumsOf(flows, codes),
};
}
export function normsOf(
flows: Flow[],
codes: string[],
months: string[],
focus: string | null,
now: string
): Norm[] {
const by = series(flows, (flow) => flow.currency);
return codes.map((code) => {
const typ = typical(by.get(code), months);
if (focus === monthOf(now)) {
const p = pace(
flows.filter((flow) => flow.currency === code),
months,
now
);
const projected = p.sofar + typ - p.typical;
return { code, projected, ratio: versus(projected, typ), typical: typ };
}
const value = focus ? (by.get(code)?.get(focus) ?? 0) : 0;
return {
code,
projected: null,
ratio: focus ? versus(value, typ) : null,
typical: typ,
};
});
}
export function lanesOf(
flows: Flow[],
months: string[],
codes: string[],
dir: Dir,
norms: Norm[],
height: number
): Lane[] {
const key: StackKey = {
color: BRAND,
key: "value",
label: dir === -1 ? "Spent" : "Earned",
};
const main = series(
flows.filter((flow) => flow.dir === dir),
(flow) => flow.currency
);
const back = series(
flows.filter((flow) => flow.dir !== dir),
(flow) => flow.currency
);
return codes.map((code) => ({
axisFormat: modeAxis(code),
dots: back.has(code)
? Object.fromEntries(
months.map((month) => [month, back.get(code)?.get(month) ?? 0])
)
: undefined,
format: (value: number) => fmt(code, value, 0),
height,
key: code,
keys: [key],
label: code,
rows: months.map((month) => ({
key: month,
label: shortMonth(month),
values: { value: main.get(code)?.get(month) ?? 0 },
})),
typical: norms.find((n) => n.code === code)?.typical || null,
}));
}
const LIMIT_KEYS: StackKey[] = [
{ color: BRAND, key: "spent", label: "Spent" },
{ color: wash(BRAND, LEFT_WASH), key: "left", label: "Left" },
{ color: tones.expense, key: "over", label: "Overspent" },
];
const limitKey = (code: string, month: string) => `${code} ${month}`;
function limitsByMonth(limits: Item<BudgetLimit>[]) {
const by = new Map<string, number>();
for (const limit of limits) {
const k = limitKey(
limit.attributes.currency_code ?? "",
monthOf(limit.attributes.start ?? "")
);
by.set(k, (by.get(k) ?? 0) + Number(limit.attributes.amount));
}
return by;
}
export function withLimits(lanes: Lane[], limits: Item<BudgetLimit>[]): Lane[] {
const by = limitsByMonth(limits);
return lanes.map((lane) => ({
...lane,
keys: LIMIT_KEYS,
rows: lane.rows.map((row) => {
const spent = row.values.value ?? 0;
const limit = by.get(limitKey(lane.key, row.key));
const values: Record<string, number> = limit
? {
left: Math.max(0, limit - spent),
over: Math.max(0, spent - limit),
spent: Math.min(spent, limit),
}
: { spent };
return { ...row, values };
}),
}));
}
const limitRangeOf = (limit: Item<BudgetLimit>): Range => ({
end: (limit.attributes.end ?? "").slice(0, 10),
start: (limit.attributes.start ?? "").slice(0, 10),
});
const spentIn = (flows: Flow[], code: string, range: Range) =>
flows
.filter((flow) => flow.currency === code && contains(range, flow.date))
.reduce((sum, flow) => sum + flow.amount, 0);
export function limitFacts(
limits: Item<BudgetLimit>[],
flows: Flow[],
range: Range,
now: string
): LimitFact[] {
return limits
.filter((limit) => {
const r = limitRangeOf(limit);
return r.start <= range.end && r.end >= range.start;
})
.map((limit) => {
const r = limitRangeOf(limit);
const code = limit.attributes.currency_code ?? "";
return {
amount: Number(limit.attributes.amount),
code,
day: contains(r, now) ? daysBetween(r.start, now) + 1 : null,
days: daysBetween(r.start, r.end) + 1,
id: limit.id,
spent: spentIn(flows, code, r),
};
});
}
export function stripsOf(
limits: Item<BudgetLimit>[],
flows: Flow[],
months: string[]
): Map<string, StripCell[]> {
const spent = series(flows, (flow) => flow.currency);
const strips = new Map<string, StripCell[]>();
const codes = new Set(limits.map((l) => l.attributes.currency_code ?? ""));
for (const code of codes) {
const own = limits.filter((l) => l.attributes.currency_code === code);
const cells = months.map((month) => {
const limit = own.find(
(l) => monthOf(l.attributes.start ?? "") === month
);
return {
amount: limit ? Number(limit.attributes.amount) : 0,
id: limit?.id ?? null,
month,
spent: spent.get(code)?.get(month) ?? 0,
};
});
if (cells.some((cell) => cell.id)) {
strips.set(code, cells);
}
}
return strips;
}
export function weigh(
flows: Flow[],
primary: string,
rate: Rate
): (code: string) => number {
const codes = [...new Set(flows.map((flow) => flow.currency))];
const rates = new Map(
codes.map((code) => [code, code === primary ? 1 : rate(code, primary)])
);
if ([...rates.values()].every((r) => r !== null)) {
return (code) => rates.get(code) ?? 0;
}
const by = tally(flows);
return (code) => {
const t = by.get(code);
return t && t.sum > 0 ? t.count / t.sum : 0;
};
}
export function facetRows(
facet: Facet,
period: Flow[],
window: Flow[],
months: string[],
codes: string[],
weight: (code: string) => number
): FacetRow[] {
const worth = (totals: Totals | undefined) => {
let sum = 0;
for (const [code, m] of totals?.cur ?? []) {
sum += (m.in + m.out) * weight(code);
}
return sum;
};
const sparks = new Map(
group(window, facet.of, (flow) => monthOf(flow.date)).map((row) => [
row.key,
months.map((month) => worth(row.cells.get(month))),
])
);
const total =
period.reduce(
(sum, flow) => sum + flow.amount * weight(flow.currency),
0
) || 1;
return group(period, facet.of)
.map((row) => ({
href: row.href,
key: row.key,
label: row.label,
share: worth(row.totals) / total,
spark: sparks.get(row.key) ?? [],
sums: codes
.filter((code) => row.totals.cur.has(code))
.map((code) => {
const m = row.totals.cur.get(code);
return { amount: (m?.in ?? 0) + (m?.out ?? 0), code };
}),
}))
.sort((a, b) => b.share - a.share || a.label.localeCompare(b.label));
}