feat(reports): a year board per currency instead of the generator

/reports opens the year: per currency spent so far, a typical month and
this month against typical by today, one lane of category columns per
currency with a dotted typical line and income as dots, what grew next
to the month under the cursor, an honest by-currency table and net worth
lanes weighted by size. Arrows move the month, Enter opens its
transactions, [ ] change the year, m shows the old tables, ?view=month
pins a month with the money flow. Old report URLs still work.

Shared pieces for other pages: Lanes, ledgerQuery for twelve months,
typical-month helpers.
This commit is contained in:
hh committed 2026-10-16 15:35:37 +00:00
1 parent 8c4e72666b
commit 26903b0066
20 files changed
+2519 -593

No files matched your search

+165 -34
View File
@@ -11,6 +11,7 @@
type StackRow,
stack,
textWidth,
tones,
} from "./chart.js";
import Legend from "./Legend.svelte";
@@ -24,6 +25,9 @@
const TILTED_CHARS = 16;
const TILTED_CHAR_WIDTH = 4.9;
const LABEL_GAP = 13;
const BARE_BOTTOM = 4;
const DOT_ROOM = 1.5;
const DOT_R = 3.5;
let {
keys,
@@ -33,6 +37,16 @@
axisFormat = (value: number) => compactMoney(value),
label = "Bar chart",
active = $bindable(null),
selected = null,
reference = null,
partial = null,
dots,
dotColor = tones.income,
gutter,
legend: showLegend = true,
axis = true,
tilt = true,
keyboard = true,
onselect,
}: {
keys: StackKey[];
@@ -42,30 +56,63 @@
axisFormat?: (value: number) => string;
label?: string;
active?: string | null;
onselect?: (row: StackRow) => void;
selected?: string | null;
reference?: number | null;
partial?: string | null;
dots?: Record<string, number>;
dotColor?: string;
gutter?: number;
legend?: boolean;
axis?: boolean;
tilt?: boolean;
keyboard?: boolean;
onselect?: (row: StackRow, segment?: string) => void;
} = $props();
const uid = $props.id();
let width = $state(0);
const stacks = $derived(rows.map((row) => ({ row, ...stack(row, keys) })));
const yCount = $derived(Math.max(2, Math.floor((height - 60) / GAP_Y)));
const stackTop = $derived(Math.max(0, ...stacks.map((s) => s.positive)));
const dotValues = $derived(
Object.values(dots ?? {}).filter(
(v) => v > 0 && (stackTop === 0 || v <= stackTop * DOT_ROOM)
)
);
const yBase = $derived(
scaleLinear()
.domain([
Math.min(0, ...stacks.map((s) => s.negative)),
Math.max(0, ...stacks.map((s) => s.positive)) || 1,
Math.max(stackTop, reference ?? 0, ...dotValues) || 1,
])
.nice(yCount)
);
const yTicks = $derived(yBase.ticks(yCount));
const left = $derived(
Math.ceil(Math.max(24, ...yTicks.map((t) => textWidth(axisFormat(t)))) + 10)
gutter ??
Math.ceil(
Math.max(24, ...yTicks.map((t) => textWidth(axisFormat(t)))) + 10
)
);
const step = $derived((width - left - RIGHT) / Math.max(1, rows.length));
const tilted = $derived(
const crowded = $derived(
rows.some((r) => textWidth(r.label, 6.2) > step - 6) && step < 70
);
const bottom = $derived(tilted ? TILTED_BOTTOM : FLAT_BOTTOM);
const tilted = $derived(tilt && crowded);
const flatEvery = $derived(
crowded && !tilt
? Math.ceil(
(Math.max(...rows.map((r) => textWidth(r.label, 6.2))) + 6) / step
)
: 1
);
const bottom = $derived.by(() => {
if (!axis) {
return BARE_BOTTOM;
}
return tilted ? TILTED_BOTTOM : FLAT_BOTTOM;
});
const labelEvery = $derived(Math.max(1, Math.ceil(LABEL_GAP / step)));
const y = $derived(yBase.copy().range([height - bottom, TOP]));
const x = $derived(
@@ -88,7 +135,13 @@
const activeIndex = $derived(
active ? rows.findIndex((r) => r.key === active) : -1
);
const readRow = $derived(rows[activeIndex >= 0 ? activeIndex : 0]);
const selectedIndex = $derived(
selected ? rows.findIndex((r) => r.key === selected) : -1
);
const readRow = $derived(
rows[Math.max(0, activeIndex >= 0 ? activeIndex : selectedIndex)]
);
const yTop = $derived(y.domain()[1]);
const fmt = (row: StackRow, value: number) => (row.format ?? format)(value);
const legend = $derived<LegendItem[]>(
readRow
@@ -113,17 +166,31 @@
: text;
}
function select(row: StackRow | undefined) {
function select(row: StackRow | undefined, segment?: string) {
if (!row) {
return;
}
if (onselect) {
onselect(row);
onselect(row, segment);
} else if (row.href) {
goto(row.href);
}
}
function segmentAt(event: MouseEvent, index: number) {
const found = stacks[index];
if (!found) {
return;
}
const box = (event.currentTarget as HTMLElement).getBoundingClientRect();
const value = y.invert(event.clientY - box.top);
return found.segments.find(
(seg) =>
value >= Math.min(seg.from, seg.to) &&
value <= Math.max(seg.from, seg.to)
)?.key;
}
const clickable = $derived(Boolean(onselect) || rows.some((r) => r.href));
function pick(event: PointerEvent) {
@@ -136,6 +203,9 @@
}
function onkeydown(event: KeyboardEvent) {
if (!keyboard) {
return;
}
if (event.key === "Enter") {
select(rows[activeIndex]);
return;
@@ -179,14 +249,16 @@
</script>
<div class="flex min-w-0 flex-col gap-2">
<div class="flex min-h-5 flex-wrap items-baseline gap-x-4 gap-y-1">
{#if keys.length > 1 && readRow}
<span class="max-w-[16rem] truncate text-[12px] text-cladd-fg"
>{readRow.label}</span
>
{/if}
<Legend class="min-w-0 flex-1" items={legend} />
</div>
{#if showLegend}
<div class="flex min-h-5 flex-wrap items-baseline gap-x-4 gap-y-1">
{#if keys.length > 1 && readRow}
<span class="max-w-[16rem] truncate text-[12px] text-cladd-fg"
>{readRow.label}</span
>
{/if}
<Legend class="min-w-0 flex-1" items={legend} />
</div>
{/if}
<div
aria-label={label}
aria-valuemax={Math.max(0, rows.length - 1)}
@@ -194,19 +266,45 @@
aria-valuenow={Math.max(0, activeIndex)}
aria-valuetext={valueText}
class="relative rounded-sm {clickable ? "cursor-pointer" : ""}"
onblur={() => (active = null)}
onclick={() => select(rows[activeIndex])}
onfocus={() => (active ??= rows[0]?.key ?? null)}
onblur={() => keyboard && (active = null)}
onclick={(event) =>
select(rows[activeIndex], segmentAt(event, activeIndex))}
onfocus={() => keyboard && (active ??= rows[0]?.key ?? null)}
{onkeydown}
onpointerleave={() => (active = null)}
onpointermove={pick}
role="slider"
tabindex="0"
tabindex={keyboard ? 0 : -1}
style:height="{height}px"
bind:clientWidth={width}
>
{#if width > 0 && rows.length > 0}
<svg aria-hidden="true" class="block" {height} {width}>
{#if partial}
<defs>
<pattern
height="5"
id="{uid}-hatch"
patternTransform="rotate(45)"
patternUnits="userSpaceOnUse"
width="5"
>
<rect fill="var(--cladd-surface)" height="5" width="2" />
</pattern>
</defs>
{/if}
{#if selectedIndex >= 0}
<rect
fill="var(--ff-row-cursor)"
fill-opacity="0.6"
height={height - bottom - TOP + 6}
rx="3"
width={x.step()}
x={(x(rows[selectedIndex].key) ?? 0) -
(x.step() - x.bandwidth()) / 2}
y={TOP - 6}
/>
{/if}
{#each yTicks as tick (tick)}
<line
stroke="var(--cladd-outline)"
@@ -239,16 +337,17 @@
{@const dim = activeIndex >= 0 && activeIndex !== i}
<g class="transition-opacity duration-100" opacity={dim ? 0.45 : 1}>
{#each s.segments as seg, j (seg.key)}
<path
d={segmentPath(
cx,
seg.from,
seg.to,
seg.outer,
j === 0 || s.segments[j - 1].value > 0 !== seg.value > 0
)}
style:fill={seg.color}
/>
{@const d = segmentPath(
cx,
seg.from,
seg.to,
seg.outer,
j === 0 || s.segments[j - 1].value > 0 !== seg.value > 0
)}
<path {d} style:fill={seg.color} />
{#if partial === s.row.key}
<path {d} fill="url(#{uid}-hatch)" />
{/if}
{/each}
</g>
{#if capLabels && s.segments[0]}
@@ -268,7 +367,7 @@
</text>
{/if}
{/if}
{#if tilted && i % labelEvery === 0}
{#if axis && tilted && i % labelEvery === 0}
<text
fill={activeIndex === i
? "var(--cladd-fg)"
@@ -282,20 +381,52 @@
Math.min(TILTED_CHARS, Math.floor(cx / TILTED_CHAR_WIDTH))
)}
</text>
{:else if !tilted}
{:else if axis && !tilted && i % flatEvery === 0}
<text
fill={activeIndex === i
fill={activeIndex === i || selectedIndex === i
? "var(--cladd-fg)"
: "var(--cladd-fg-soft)"}
font-size="11"
font-weight={selectedIndex === i ? 600 : 400}
text-anchor="middle"
x={cx}
y={height - 6}
>
{short(s.row.label, Math.floor(x.step() / 6.4))}
{flatEvery > 1
? s.row.label
: short(s.row.label, Math.floor(x.step() / 6.4))}
</text>
{/if}
{/each}
{#if reference}
<line
stroke="var(--cladd-fg-soft)"
stroke-dasharray="2 3"
stroke-width="1.25"
x1={left}
x2={width - RIGHT}
y1={crisp(y(reference))}
y2={crisp(y(reference))}
/>
{/if}
{#each rows as row (row.key)}
{@const value = dots?.[row.key] ?? 0}
{#if value > 0}
{@const cx = (x(row.key) ?? 0) + x.bandwidth() / 2}
{#if value > yTop}
<path d="M{cx - 4},{TOP - 1}l4,-6l4,6Z" style:fill={dotColor} />
{:else}
<circle
{cx}
cy={y(value)}
r={DOT_R}
stroke="var(--cladd-surface)"
stroke-width="1.5"
style:fill={dotColor}
/>
{/if}
{/if}
{/each}
</svg>
{/if}
</div>