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

@@ -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>