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:
1 parent
8c4e72666b
commit
26903b0066
20 files changed
+2519
-593
No files matched your search
@@ -0,0 +1,182 @@
|
||||
<script lang="ts">
|
||||
import { scaleLinear } from "d3-scale";
|
||||
import type { Snippet } from "svelte";
|
||||
import { compactMoney } from "#lib/format.js";
|
||||
import { versus, versusText } from "#lib/reports/typical.js";
|
||||
import Columns from "./Columns.svelte";
|
||||
import {
|
||||
type Lane,
|
||||
type StackKey,
|
||||
type StackRow,
|
||||
stack,
|
||||
textWidth,
|
||||
tones,
|
||||
} from "./chart.js";
|
||||
import Legend from "./Legend.svelte";
|
||||
|
||||
const LANE_HEIGHT = 112;
|
||||
const AXIS_HEIGHT = 18;
|
||||
const TICK_GAP = 44;
|
||||
|
||||
let {
|
||||
lanes,
|
||||
cursor = null,
|
||||
hover = $bindable(null),
|
||||
partial = null,
|
||||
pinned = false,
|
||||
dotLabel = "in",
|
||||
dotColor = tones.income,
|
||||
title = (row: StackRow) => row.label,
|
||||
label = "Months by currency",
|
||||
legend = true,
|
||||
header,
|
||||
onselect,
|
||||
}: {
|
||||
lanes: Lane[];
|
||||
cursor?: string | null;
|
||||
hover?: string | null;
|
||||
partial?: string | null;
|
||||
pinned?: boolean;
|
||||
dotLabel?: string;
|
||||
dotColor?: string;
|
||||
title?: (row: StackRow) => string;
|
||||
label?: string;
|
||||
legend?: boolean;
|
||||
header?: Snippet<[Lane, StackRow | undefined]>;
|
||||
onselect?: (month: string, lane: Lane, segment?: string) => void;
|
||||
} = $props();
|
||||
|
||||
const read = $derived(pinned ? cursor : (hover ?? cursor));
|
||||
|
||||
const sharedKeys = $derived.by(() => {
|
||||
const seen = new Map<string, StackKey>();
|
||||
for (const lane of lanes) {
|
||||
for (const key of lane.keys) {
|
||||
if (!seen.has(key.key)) {
|
||||
seen.set(key.key, key);
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...seen.values()];
|
||||
});
|
||||
|
||||
function widest(lane: Lane) {
|
||||
const stacks = lane.rows.map((row) => stack(row, lane.keys));
|
||||
const top = Math.max(
|
||||
lane.typical ?? 0,
|
||||
...stacks.map((s) => s.positive),
|
||||
...Object.values(lane.dots ?? {})
|
||||
);
|
||||
const bottom = Math.min(0, ...stacks.map((s) => s.negative));
|
||||
const height = lane.height ?? LANE_HEIGHT;
|
||||
const count = Math.max(2, Math.floor((height - 60) / TICK_GAP));
|
||||
const format = lane.axisFormat ?? ((v: number) => compactMoney(v));
|
||||
const ticks = scaleLinear()
|
||||
.domain([bottom, top || 1])
|
||||
.nice(count)
|
||||
.ticks(count);
|
||||
return Math.max(...ticks.map((t) => textWidth(format(t))));
|
||||
}
|
||||
|
||||
const gutter = $derived(
|
||||
Math.ceil(Math.max(24, ...lanes.map((lane) => widest(lane)))) + 10
|
||||
);
|
||||
|
||||
function rowOf(lane: Lane) {
|
||||
return lane.rows.find((row) => row.key === read);
|
||||
}
|
||||
|
||||
function totalOf(row: StackRow | undefined) {
|
||||
return row ? Object.values(row.values).reduce((a, b) => a + b, 0) : 0;
|
||||
}
|
||||
|
||||
function tone(ratio: number | null) {
|
||||
if (ratio === null || Math.abs(ratio) < 0.03) {
|
||||
return "text-cladd-fg-soft";
|
||||
}
|
||||
return ratio > 0 ? "text-expense" : "text-income";
|
||||
}
|
||||
</script>
|
||||
|
||||
{#snippet defaultHeader(
|
||||
lane: Lane,
|
||||
row: StackRow | undefined
|
||||
)}
|
||||
{@const value = totalOf(row)}
|
||||
{@const sofar = row?.key === partial}
|
||||
{@const ratio =
|
||||
row && lane.typical && !sofar ? versus(value, lane.typical) : null}
|
||||
{@const dot = row ? lane.dots?.[row.key] : undefined}
|
||||
<span class="w-10 shrink-0 font-semibold text-cladd-fg">{lane.label}</span>
|
||||
{#if row}
|
||||
<span class="text-cladd-fg-soft">{title(row)}</span>
|
||||
<span class="num font-semibold text-cladd-fg">{lane.format(value)}</span>
|
||||
{#if sofar}
|
||||
<span class="text-cladd-fg-soft">so far</span>
|
||||
{:else if ratio !== null}
|
||||
<span class={tone(ratio)}>{versusText(ratio)}</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}
|
||||
{#if dot}
|
||||
<span class="flex items-center gap-1.5 text-cladd-fg-soft">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="size-2 rounded-full"
|
||||
style:background={dotColor}
|
||||
></span>
|
||||
{dotLabel}
|
||||
<span class="num text-cladd-fg">{lane.format(dot)}</span>
|
||||
</span>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
<figure aria-label={label} class="m-0 flex min-w-0 flex-col">
|
||||
{#if legend && sharedKeys.length > 1}
|
||||
<Legend
|
||||
class="mb-2"
|
||||
items={sharedKeys.map((k) => ({ ...k, shape: "rect" as const }))}
|
||||
/>
|
||||
{/if}
|
||||
{#each lanes as lane, i (lane.key)}
|
||||
{@const last = i === lanes.length - 1}
|
||||
<div class="min-w-0 {i > 0 ? "border-cladd-outline border-t pt-1.5" : ""}">
|
||||
<div
|
||||
class="flex min-h-6 flex-wrap items-baseline gap-x-3 gap-y-0.5 text-[12px]"
|
||||
>
|
||||
{@render (header ?? defaultHeader)(lane, rowOf(lane))}
|
||||
</div>
|
||||
<Columns
|
||||
axis={last}
|
||||
axisFormat={lane.axisFormat}
|
||||
{dotColor}
|
||||
dots={lane.dots}
|
||||
format={lane.format}
|
||||
{gutter}
|
||||
height={(lane.height ?? LANE_HEIGHT) + (last ? AXIS_HEIGHT : 0)}
|
||||
keyboard={false}
|
||||
keys={lane.keys}
|
||||
label="{label}: {lane.label}"
|
||||
legend={false}
|
||||
onselect={onselect
|
||||
? (row, segment) => onselect(row.key, lane, segment)
|
||||
: undefined}
|
||||
{partial}
|
||||
reference={lane.typical}
|
||||
rows={lane.rows}
|
||||
selected={cursor}
|
||||
tilt={false}
|
||||
bind:active={hover}
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
</figure>
|
||||
Reference in new issue
Block a user