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>
+182
View File
@@ -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>
+12
View File
@@ -39,6 +39,18 @@ export interface StackRow {
values: Record<string, number>;
}
export interface Lane {
axisFormat?: (value: number) => string;
dots?: Record<string, number>;
format: (value: number) => string;
height?: number;
key: string;
keys: StackKey[];
label: string;
rows: StackRow[];
typical?: number | null;
}
export interface MeterSegment {
color?: string;
label?: string;
@@ -1,146 +0,0 @@
<script lang="ts">
import { createQuery, keepPreviousData } from "@tanstack/svelte-query";
import { api } from "#lib/api/client.js";
import { showUrl } from "#lib/components/accounts/links.js";
import Box from "#lib/components/Box.svelte";
import BarChart from "#lib/components/charts/BarChart.svelte";
import { type Bar, tones } from "#lib/components/charts/chart.js";
import { toNumber } from "#lib/format.js";
import { period } from "#lib/period.svelte.js";
import {
axis,
byCurrency,
currency,
type InsightEntry,
money,
proportional,
span,
} from "./data.js";
const MAX_BARS = 24;
const NARROW_BARS = 8;
let { kind }: { kind: "expense" | "revenue" } = $props();
const path = $derived(
kind === "expense"
? "/v1/insight/expense/expense"
: "/v1/insight/income/revenue"
);
const insight = createQuery(() => ({
placeholderData: keepPreviousData,
queryFn: () =>
api<InsightEntry[]>(path, {
query: span(period.start, period.end),
}),
queryKey: ["insight", kind, period.start, period.end],
}));
const title = $derived(
kind === "expense" ? "Expense accounts" : "Revenue accounts"
);
const verb = $derived(kind === "expense" ? "Spent" : "Earned");
function fold(bars: Bar[]): Bar[] {
if (bars.length <= MAX_BARS) {
return bars;
}
const rest = bars.slice(MAX_BARS - 1);
return [
...bars.slice(0, MAX_BARS - 1),
{
key: "other",
label: `Other (${rest.length})`,
value: rest.reduce((sum, b) => sum + b.value, 0),
},
];
}
const groups = $derived(
byCurrency(insight.data ?? [], (e) => e.currency_code).map(
([code, list]) => {
const cur = currency(code);
const bars = list
.map((e) => ({
href: e.id ? showUrl(e.id) : undefined,
key: e.id ?? e.name ?? "",
label: e.name ?? "",
value: Math.abs(toNumber(e.difference)),
}))
.filter((b) => b.value > 0)
.sort((a, b) => b.value - a.value);
return { bars: fold(bars), code, cur };
}
)
);
const visible = $derived(groups.filter((g) => g.bars.length > 0));
const wide = $derived(
visible.filter((g, i) => i === 0 || g.bars.length > NARROW_BARS)
);
const narrow = $derived(visible.filter((g) => !wide.includes(g)));
</script>
{#snippet chart(
group: (typeof groups)[number],
height: number
)}
<div class="flex min-w-0 flex-col gap-1">
{#if visible.length > 1}
<span class="font-semibold text-[11px] text-cladd-fg-soft tracking-wide"
>{verb}
in {group.code}</span
>
{/if}
<BarChart
axisFormat={axis(group.cur)}
bars={group.bars}
color={kind === "expense" ? tones.expense : tones.income}
format={money(group.cur)}
{height}
label="{verb} per {kind} account in {group.code}"
name={verb}
/>
</div>
{/snippet}
<Box href="/accounts/{kind}" {title}>
<div
class="flex flex-col gap-5 transition-opacity {insight.isPlaceholderData
? "opacity-60"
: ""}"
>
{#if insight.isPending}
<div class="h-60"></div>
{:else if insight.isError}
<p class="text-expense">{insight.error.message}</p>
{:else if visible.length === 0}
<p class="py-8 text-center text-cladd-fg-soft">
{kind === "expense"
? "Nothing spent in this period."
: "Nothing earned in this period."}
</p>
{:else}
{#each [wide, narrow] as set, i (i)}
{#if set.length > 0}
<div
class="grid gap-x-6 gap-y-5 {i === 1
? "md:grid-cols-(--cols)"
: ""}"
style:--cols={proportional(set.map((g) => g.bars.length))}
>
{#each set as group (group.code)}
{@render chart(group, i === 0 ? 240 : 180)}
{/each}
</div>
{/if}
{/each}
{/if}
</div>
{#snippet footer()}
<a
class="text-brand dark:text-(--ff-series-1) hover:underline"
href="/accounts/{kind}"
>See your {kind} accounts</a
>
{/snippet}
</Box>
+455
View File
@@ -0,0 +1,455 @@
<script lang="ts">
import { createQuery } from "@tanstack/svelte-query";
import { lists } from "#lib/api/lists.js";
import Lanes from "#lib/components/charts/Lanes.svelte";
import PageHeader from "#lib/components/shell/PageHeader.svelte";
import { today } from "#lib/dates.js";
import { useKeys } from "#lib/keys.svelte.js";
import { period } from "#lib/period.svelte.js";
import {
buildBoard,
cursorOf,
grew,
longMonth,
mergedLane,
monthFacts,
monthOf,
monthRange,
shiftMonth,
spendingLanes,
} from "#lib/reports/board.js";
import { exchanges } from "#lib/reports/flows.js";
import { ledgerQuery, ledgerWindow, ownFlows } from "#lib/reports/ledger.js";
import { ALL_ASSETS, type ReportParams, ymd } from "#lib/reports/params.js";
import {
createReport,
type Drill,
drillQuery,
navigate,
provideReport,
} from "#lib/reports/report.svelte.js";
import { page } from "$app/state";
import BoardTables from "./BoardTables.svelte";
import CurrencyTable from "./CurrencyTable.svelte";
import Headline from "./Headline.svelte";
import MoneyFlow from "./MoneyFlow.svelte";
import MonthPanel from "./MonthPanel.svelte";
import NetWorthLanes from "./NetWorthLanes.svelte";
import ReportBox from "./ReportBox.svelte";
import ReportRow from "./ReportRow.svelte";
import WhatGrew from "./WhatGrew.svelte";
const now = today();
const thisYear = now.slice(0, 4);
const thisMonth = monthOf(now);
const query = $derived(page.url.searchParams);
const view = $derived(query.get("view") === "month" ? "month" : "year");
const tables = $derived(query.has("tables"));
const merged = $derived(query.has("merged"));
const tab = $derived(query.get("tab") === "month" ? "month" : "grew");
const cursor = $derived(cursorOf(period.range, now));
const year = $derived(cursor.slice(0, 4));
const anchor = $derived(year === thisYear ? now : `${year}-12-31`);
const span = $derived(ledgerWindow(anchor));
const first = $derived(monthOf(period.first ?? "0000-01"));
const ledger = createQuery(() => ledgerQuery(anchor));
const assets = createQuery(() => lists.accounts("asset"));
const liabilities = createQuery(() => lists.accounts("liabilities"));
const own = $derived(
assets.data && liabilities.data
? [...assets.data, ...liabilities.data]
: undefined
);
const ownIds = $derived(own?.map((a) => a.id) ?? []);
const range = $derived(
view === "month"
? monthRange(cursor)
: { end: `${year}-12-31`, start: `${year}-01-01` }
);
const params = $derived<ReportParams>({
accounts: ownIds,
end: range.end,
items: [],
start: range.start,
type: "default",
});
const report = createReport(
() => params,
() => ledger.data
);
provideReport(report);
const flows = $derived(
ledger.data && own ? ownFlows(ledger.data, own) : undefined
);
const board = $derived(
flows ? buildBoard(flows, span.months, year, report.codes, now) : undefined
);
const pcOk = $derived(
!!flows &&
flows.length > 0 &&
flows.every((flow) => flow.pc !== null) &&
(board?.codes.length ?? 0) > 1
);
const lanes = $derived.by(() => {
if (!board) {
return [];
}
return pcOk && merged ? [mergedLane(board)] : spendingLanes(board);
});
const swaps = $derived.by(() => {
if (!(ledger.data && own)) {
return [];
}
const currency = new Map(
own.map((a) => [a.id, a.attributes.currency_code])
);
return exchanges(
ledger.data,
new Set(ownIds),
(id) => currency.get(id) ?? undefined
);
});
const grewRows = $derived(board ? grew(board, cursor) : []);
const facts = $derived(board ? monthFacts(board, cursor) : []);
const loading = $derived(!board);
let hover = $state<string | null>(null);
function go(month: string, drill?: Drill | null) {
const target = monthRange(month);
navigate({
end: target.end,
start: target.start,
...(drill === undefined ? {} : drillQuery(drill)),
});
}
function clamp(month: string) {
if (month > thisMonth) {
return thisMonth;
}
return month < first ? first : month;
}
function move(delta: number) {
const next = clamp(shiftMonth(cursor, delta));
if (next !== cursor) {
go(next);
}
}
function labelFor(month: string, item = "Spent") {
return view === "year" ? item : `${item}, ${longMonth(month)}`;
}
function drillFor(
month: string,
box: string,
extra: Partial<Drill> = {}
): Drill {
return {
at: view === "year" ? month : undefined,
box,
dir: -1,
...extra,
label: labelFor(month, extra.label),
};
}
function openMonth(month: string, segment?: string) {
const found = board?.catKeys.find(
(k) => k.key === segment && k.key !== "other"
);
const drill = drillFor(month, "months", {
dim: found ? "category" : undefined,
key: found?.key,
label: found?.label,
});
const same =
report.drill?.box === "months" &&
report.drill.key === drill.key &&
month === cursor;
go(month, same ? null : drill);
}
function setView(next: "year" | "month") {
navigate({ ...drillQuery(null), view: next === "month" ? "month" : null });
}
function flag(name: string, on: boolean) {
navigate({ [name]: on ? "1" : null });
}
const yearLinks = $derived.by(() => {
const dates = `${ymd(range.start)}/${ymd(range.end)}`;
const top = [...(board?.spending ?? [])]
.filter((flow) => flow.other.type === "Expense account")
.reduce((counts, flow) => {
counts.set(flow.other.id, (counts.get(flow.other.id) ?? 0) + 1);
return counts;
}, new Map<string, number>());
const busiest = [...top.entries()]
.sort((a, b) => b[1] - a[1])
.slice(0, 3)
.map(([id]) => id);
return [
{
href: `/reports/budget/${ALL_ASSETS}/allBudgets/${dates}`,
label: "Budget report",
},
{
href: `/reports/category/${ALL_ASSETS}/allCategories/${dates}`,
label: "Category report",
},
{
href: `/reports/tag/${ALL_ASSETS}/allTags/${dates}`,
label: "Tag report",
},
...(busiest.length > 0
? [
{
href: `/reports/double/${ALL_ASSETS}/${busiest.join(",")}/${dates}`,
label: "Expense and revenue account report",
},
]
: []),
{
href: `/reports/audit/${ALL_ASSETS}/${dates}`,
label: "Transaction history",
},
{
href: `/reports/default/${ALL_ASSETS}/${dates}`,
label: "Default financial report",
},
];
});
useKeys(() => [
{
group: "Report",
keys: "ArrowLeft",
label: "Previous month",
run: () => move(-1),
},
{
group: "Report",
keys: "ArrowRight",
label: "Next month",
run: () => move(1),
},
{
group: "Report",
keys: "[",
label: "Previous year",
run: () => move(-12),
},
{ group: "Report", keys: "]", label: "Next year", run: () => move(12) },
{
group: "Report",
keys: "Enter",
label: "Transactions of the month",
run: () => openMonth(cursor),
},
{
group: "Report",
keys: "m",
label: tables ? "Hide tables by month" : "Show tables by month",
run: () => flag("tables", !tables),
},
{
group: "Report",
keys: "g M",
label: "Month view",
run: () => setView("month"),
},
{
group: "Report",
keys: "g Y",
label: "Year view",
run: () => setView("year"),
},
...(pcOk
? [
{
group: "Report",
keys: "c",
label: merged ? "One lane per currency" : "All in one currency",
run: () => flag("merged", !merged),
},
]
: []),
]);
</script>
<PageHeader
crumbs={[{ label: "Reports" }]}
subtitle={view === "month" ? longMonth(cursor) : year}
title="Reports"
>
{#snippet actions()}
<nav aria-label="Report period" class="flex items-center gap-3 text-[13px]">
{#if `${Number(year) - 1}-12` >= first}
<button
class="text-brand hover:underline dark:text-[var(--ff-series-1)]"
onclick={() => move(-12)}
title="Previous year ["
type="button"
>
‹ {Number(year) - 1}
</button>
{/if}
{#if year < thisYear}
<button
class="text-brand hover:underline dark:text-[var(--ff-series-1)]"
onclick={() => move(12)}
title="Next year ]"
type="button"
>
{Number(year) + 1}
›
</button>
{/if}
<span aria-hidden="true" class="text-cladd-fg-softest">|</span>
{#each [
{ key: "year" as const, label: "Year", shortcut: "g Y" },
{ key: "month" as const, label: "Month", shortcut: "g M" },
] as item (item.key)}
<button
aria-pressed={view === item.key}
class={view === item.key
? "font-semibold text-cladd-fg"
: "text-brand hover:underline dark:text-[var(--ff-series-1)]"}
onclick={() => setView(item.key)}
title="{item.label} view {item.shortcut}"
type="button"
>
{item.label}
</button>
{/each}
</nav>
{/snippet}
</PageHeader>
{#if ledger.error}
<p class="mb-4 text-expense">
Firefly III did not return the transactions: {ledger.error.message}
</p>
{/if}
<div class="flex flex-col gap-4">
<Headline {board} month={view === "month" ? cursor : null} />
<ReportBox
body="px-3 pt-2 pb-3"
empty={lanes.length === 0}
emptyText="No spending or income in {year}."
id="months"
{loading}
title={merged && pcOk
? "Spent each month, all in one currency"
: "Spent each month"}
>
{#snippet tools()}
{#if pcOk}
<button
aria-pressed={merged}
class="text-[12px] text-brand hover:underline dark:text-[var(--ff-series-1)]"
onclick={() => flag("merged", !merged)}
title="Switch c"
type="button"
>
{merged ? "One lane per currency" : "All in one currency"}
</button>
{/if}
{/snippet}
<Lanes
{cursor}
dotLabel="earned"
label="Spent each month by currency"
{lanes}
onselect={(month, _lane, segment) => openMonth(month, segment)}
partial={board?.partial}
pinned={view === "month"}
title={(row) => longMonth(row.key)}
bind:hover
/>
</ReportBox>
<div class="flex gap-4 text-[13px] lg:hidden" role="tablist">
{#each [
{ key: "grew", label: "What grew" },
{ key: "month", label: longMonth(cursor) },
] as item (item.key)}
<button
aria-selected={tab === item.key}
class={tab === item.key
? "font-semibold text-cladd-fg"
: "text-brand dark:text-[var(--ff-series-1)]"}
onclick={() => navigate({ tab: item.key === "month" ? "month" : null })}
role="tab"
type="button"
>
{item.label}
</button>
{/each}
</div>
<ReportRow class="lg:grid-cols-2">
<WhatGrew
class={tab === "grew" ? "" : "max-lg:hidden"}
codes={board?.codes.length ?? 0}
colors={board?.catKeys ?? []}
{cursor}
{loading}
monthDrill={(extra) => drillFor(cursor, "grew", extra)}
partial={cursor === board?.partial}
rows={grewRows}
/>
<MonthPanel
class={tab === "month" ? "" : "max-lg:hidden"}
{facts}
{loading}
month={cursor}
monthDrill={(extra) => drillFor(cursor, "month", extra)}
partial={cursor === board?.partial}
swaps={swaps.filter((swap) => monthOf(swap.date) === cursor)}
totals={view === "year"}
/>
</ReportRow>
{#if view === "month"}
<MoneyFlow switcher />
{:else if board}
<CurrencyTable {board} {swaps} />
<NetWorthLanes {cursor} groups={ledger.data ?? []} {own} {year} />
{/if}
{#if tables}
<BoardTables {view} />
{/if}
<nav
aria-label="More reports"
class="flex flex-wrap items-baseline gap-x-4 gap-y-1 text-[12px] text-cladd-fg-soft"
>
<button
class="text-brand hover:underline dark:text-[var(--ff-series-1)]"
onclick={() => flag("tables", !tables)}
title="Tables by month m"
type="button"
>
{tables ? "Hide tables by month" : "Tables by month"}
</button>
{#each yearLinks as link (link.href)}
<a
class="text-brand hover:underline dark:text-[var(--ff-series-1)]"
href={link.href}
>{link.label}</a
>
{/each}
</nav>
</div>
@@ -0,0 +1,145 @@
<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 BudgetLimits from "./BudgetLimits.svelte";
import CurrencySwitch from "./CurrencySwitch.svelte";
import Matrix from "./Matrix.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";
let { view }: { view: "year" | "month" } = $props();
const report = useReport();
const flows = $derived(report.flows);
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 periodCols = $derived(
buckets(report.params.start, report.params.end, "month").map((key) => ({
key,
label: bucketLabel(key, "month", true),
title: bucketLabel(key, "month"),
}))
);
const month = (date: string) => bucketOf(date, "month");
const NAMES = { budget: "Budget", category: "Category", tag: "Tag" };
</script>
{#snippet matrix(
id: string,
title: string,
dim: "budget" | "category" | "tag",
dir: 1 | -1,
list: typeof expenses
)}
<ReportBox
empty={list.length === 0}
{id}
loading={!flows}
title="{title} in {modeLabel(report.mode)}"
>
{#snippet tools()}
<CurrencySwitch />
{/snippet}
<Matrix
average={dim === "budget"}
box={id}
cols={periodCols}
{dim}
{dir}
nameLabel={NAMES[dim]}
rows={group(list, keys[dim], (f) => month(f.date))}
side={dir === 1 ? "in" : "out"}
/>
</ReportBox>
{/snippet}
<div class="flex flex-col gap-4">
{#if view === "year"}
{@render matrix("budget-periods", "Budgets", "budget", -1, withdrawals)}
{@render matrix(
"category-expenses",
"Categories, spent",
"category",
-1,
withdrawals
)}
{@render matrix(
"category-income",
"Categories, earned",
"category",
1,
deposits
)}
{@render matrix("tag-periods", "Tags, spent", "tag", -1, expenses)}
{:else}
<ReportRow class="lg:grid-cols-2">
<ReportBox
empty={expenses.length === 0}
id="expenses"
loading={!flows}
title="Expenses"
>
<SumTable
box="expenses"
columns={[{ kind: "out", label: "Total" }]}
counts
dim="other"
dir={-1}
footer={total(expenses)}
limit={10}
rows={group(expenses, keys.other)}
sort="out"
/>
</ReportBox>
<ReportBox
empty={income.length === 0}
id="income"
loading={!flows}
title="Revenue / income"
>
<SumTable
box="income"
columns={[{ kind: "in", label: "Total" }]}
counts
dim="other"
dir={1}
footer={total(income)}
limit={10}
rows={group(income, keys.other)}
sort="in"
/>
</ReportBox>
</ReportRow>
<ReportRow class="lg:grid-cols-12">
<BudgetLimits class="lg:col-span-7" />
<ReportBox
class="lg:col-span-5"
empty={expenses.length === 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>
</ReportRow>
<Subscriptions />
{/if}
<SpendingCalendar />
</div>
@@ -0,0 +1,26 @@
<script lang="ts">
import {
modeLabel,
navigate,
useReport,
} from "#lib/reports/report.svelte.js";
const report = useReport();
</script>
{#if report.available.length > 1}
<div class="flex items-center gap-3 text-[12px]">
{#each report.available as mode (mode)}
<button
aria-pressed={report.mode === mode}
class={report.mode === mode
? "font-semibold text-cladd-fg"
: "text-brand hover:underline dark:text-[var(--ff-series-1)]"}
onclick={() => navigate({ cur: mode })}
type="button"
>
{modeLabel(mode)}
</button>
{/each}
</div>
{/if}
@@ -0,0 +1,199 @@
<script lang="ts">
import { formatNumber } from "#lib/format.js";
import {
type Board,
longMonth,
monthOf,
shortMonth,
} from "#lib/reports/board.js";
import type { Exchange } from "#lib/reports/flows.js";
import {
drillQuery,
navigate,
useReport,
} from "#lib/reports/report.svelte.js";
import { typical } from "#lib/reports/typical.js";
import ReportBox from "./ReportBox.svelte";
let { board, swaps }: { board: Board; swaps: Exchange[] } = $props();
const report = useReport();
type Kind = "spent" | "earned" | "moved";
const LABELS: Record<Kind, string> = {
earned: "Earned",
moved: "Exchanged",
spent: "Spent",
};
const moved = $derived.by(() => {
const sums = new Map<string, Map<string, number>>();
const add = (code: string, month: string, value: number) => {
const byMonth = sums.get(code) ?? new Map<string, number>();
byMonth.set(month, (byMonth.get(month) ?? 0) + value);
sums.set(code, byMonth);
};
for (const swap of swaps) {
add(swap.to, monthOf(swap.date), swap.toAmount);
add(swap.from, monthOf(swap.date), -swap.fromAmount);
}
return sums;
});
const sources = $derived({
earned: board.earned,
moved,
spent: board.spent,
});
const rows = $derived(
board.codes.flatMap((code) =>
(["spent", "earned", "moved"] as Kind[])
.map((kind) => {
const byMonth = sources[kind].get(code);
return {
byMonth,
code,
kind,
total: board.months.reduce(
(sum, m) => sum + (byMonth?.get(m) ?? 0),
0
),
typical:
kind === "moved" ? null : typical(byMonth, board.typicalMonths),
};
})
.filter((row) => board.months.some((m) => row.byMonth?.get(m)))
)
);
const toneOf = (kind: Kind, value: number) => {
if (kind === "spent") {
return "text-expense";
}
if (kind === "earned") {
return "text-income";
}
return value < 0 ? "text-cladd-fg-soft" : "text-transfer";
};
const cell = (kind: Kind, value: number) =>
kind === "moved" ? formatNumber(value, 0, true) : formatNumber(value, 0);
function open(code: string, kind: Kind, month: string) {
const dir = kind === "spent" ? -1 : 1;
const current = report.drill;
const same =
current?.box === "currencies" &&
current.at === month &&
current.currency === code &&
current.dir === dir;
navigate(
drillQuery(
same
? null
: {
at: month,
box: "currencies",
currency: code,
dir,
label: `${LABELS[kind]} in ${code}, ${longMonth(month)}`,
}
)
);
}
</script>
<ReportBox
empty={rows.length === 0}
id="currencies"
loading={false}
title="By currency"
>
<div class="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"
></th>
{#each board.months as month (month)}
<th
class="h-7 px-2 text-right font-medium {month === board.partial
? "italic"
: ""}"
title={longMonth(month)}
>
{shortMonth(month)}
</th>
{/each}
<th class="h-7 whitespace-nowrap px-2 text-right font-medium">
Typical
</th>
<th class="h-7 px-3 text-right font-medium">Total</th>
</tr>
</thead>
<tbody>
{#each rows as row, i (`${row.code}-${row.kind}`)}
{@const first = i === 0 || rows[i - 1].code !== row.code}
<tr
class="h-7 hover:bg-row-hover {first
? "border-cladd-outline border-t-2"
: "border-cladd-outline border-t"}"
>
<th
class="sticky left-0 z-[1] whitespace-nowrap bg-cladd-surface px-3 text-left font-normal"
scope="row"
>
<span class="inline-block w-10 font-semibold"
>{first ? row.code : ""}</span
>
<span class="text-cladd-fg-soft">{LABELS[row.kind]}</span>
</th>
{#each board.months as month (month)}
{@const value = row.byMonth?.get(month) ?? 0}
{@const active =
report.drill?.box === "currencies" &&
report.drill.at === month &&
report.drill.currency === row.code &&
report.drill.dir === (row.kind === "spent" ? -1 : 1)}
<td class="p-0 text-right">
{#if value && row.kind !== "moved"}
<button
class="num h-7 w-full whitespace-nowrap px-2 text-right hover:bg-row-cursor {toneOf(
row.kind,
value
)} {active ? "bg-row-selected font-semibold" : ""}"
onclick={() => open(row.code, row.kind, month)}
tabindex="-1"
title="{LABELS[row.kind]} in {row.code}, {longMonth(month)}"
type="button"
>
{cell(row.kind, value)}
</button>
{:else if value}
<span
class="num block whitespace-nowrap px-2 {toneOf(
row.kind,
value
)}"
>{cell(row.kind, value)}</span
>
{/if}
</td>
{/each}
<td
class="num whitespace-nowrap px-2 text-right text-cladd-fg-soft"
>
{row.typical === null ? "" : formatNumber(row.typical, 0)}
</td>
<td class="num whitespace-nowrap px-3 text-right font-semibold">
{cell(row.kind, row.total)}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</ReportBox>
+128
View File
@@ -0,0 +1,128 @@
<script lang="ts">
import Box from "#lib/components/Box.svelte";
import {
type Board,
headlines,
longMonth,
ordinal,
shortMonth,
} from "#lib/reports/board.js";
import { money } from "#lib/reports/report.svelte.js";
import { versus, versusText } from "#lib/reports/typical.js";
let {
board,
month = null,
}: { board: Board | undefined; month?: string | null } = $props();
const rows = $derived(
board ? headlines(board, month ? [month] : board.months) : []
);
const year = $derived(board?.months[0]?.slice(0, 4) ?? "");
const period = $derived(month ? longMonth(month) : year);
const showPace = $derived(rows.some((row) => row.pace));
const byDay = $derived(
`Typical by the ${ordinal(Number(board?.now.slice(8, 10) ?? 0))}`
);
const 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>
<Box body="px-3 py-2">
{#if !board}
<p class="py-2 text-cladd-fg-soft">Loading</p>
{:else if rows.length === 0}
<p class="py-2 text-cladd-fg-soft">No spending or income in {period}.</p>
{:else}
<div class="overflow-x-auto">
<table class="border-collapse text-[13px]">
<thead>
<tr
class="whitespace-nowrap text-left text-[12px] text-cladd-fg-soft"
>
<th class="w-12 pr-3 font-medium"></th>
<th class="pr-10 font-medium">Spent in {period}</th>
{#if showPace && !month}
<th class="pr-10 font-medium">
{shortMonth(board.partial ?? "")}
so far
</th>
{/if}
{#if showPace}
<th class="pr-10 font-medium">{byDay}</th>
{/if}
<th class="pr-10 font-medium">Typical month</th>
<th class="font-medium">Earned in {period}</th>
</tr>
</thead>
<tbody>
{#each rows as row (row.code)}
{@const pace = row.pace}
{@const paceRatio = pace ? versus(pace.sofar, pace.typical) : null}
<tr class="h-9">
<th class="pr-3 text-left font-semibold" scope="row">
{row.code}
</th>
<td class="pr-10 sm:whitespace-nowrap">
<span class="num font-semibold text-[18px] tracking-[-0.01em]"
>{money(
row.code,
month && pace ? pace.sofar : row.spent,
false,
0
)}</span
>
{#if month}
{@const ratio = pace
? paceRatio
: versus(row.spent, row.typical)}
<span
class="ml-2 whitespace-nowrap max-sm:ml-0 max-sm:block max-sm:text-[12px] {tone(
ratio
)}"
>{versusText(ratio)}</span
>
{/if}
</td>
{#if showPace && !month}
<td class="pr-10 sm:whitespace-nowrap">
{#if pace}
<span class="num"
>{money(row.code, pace.sofar, false, 0)}</span
>
<span
class="ml-2 whitespace-nowrap max-sm:ml-0 max-sm:block max-sm:text-[12px] {tone(
paceRatio
)}"
>{versusText(paceRatio)}</span
>
{/if}
</td>
{/if}
{#if showPace}
<td class="num whitespace-nowrap pr-10">
{#if pace}
{money(row.code, pace.typical, false, 0)}
{/if}
</td>
{/if}
<td class="num whitespace-nowrap pr-10">
{money(row.code, row.typical, false, 0)}
</td>
<td class="num whitespace-nowrap text-income">
{#if row.earned}
{money(row.code, row.earned, false, 0)}
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</Box>
+53 -22
View File
@@ -8,8 +8,11 @@
navigate,
useReport,
} from "#lib/reports/report.svelte.js";
import CurrencySwitch from "./CurrencySwitch.svelte";
import ReportBox from "./ReportBox.svelte";
let { switcher = false }: { switcher?: boolean } = $props();
const LEFT_MAX = 5;
const RIGHT_MAX = 9;
const SLOT = 34;
@@ -87,6 +90,7 @@
function swaps(dir: 1 | -1): Node[] {
return net
.filter(([, value]) => Math.sign(value) === dir)
.sort((a, b) => Math.abs(b[1]) - Math.abs(a[1]))
.map(([code, value]) => ({
color: "var(--ff-transfer)",
dir,
@@ -97,34 +101,56 @@
}));
}
function cover(nodes: Node[], gap: number): [Node[], number] {
let rest = gap;
const used: Node[] = [];
for (const node of nodes) {
const value = Math.min(node.value, rest);
if (value >= CENT) {
used.push({ ...node, value });
rest -= value;
}
}
return [used, rest];
}
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,
});
const exchange = mode !== "pc";
if (totalOut - totalIn >= CENT) {
const [used, rest] = exchange
? cover(swaps(1), totalOut - totalIn)
: [[], totalOut - totalIn];
left.push(...used);
if (rest >= CENT) {
left.push({
color: "var(--ff-warn)",
dir: -1,
drillable: false,
key: "deficit",
label: "From balance",
value: rest,
});
}
} else if (totalIn - totalOut >= CENT) {
const [used, rest] = exchange
? cover(swaps(-1), totalIn - totalOut)
: [[], totalIn - totalOut];
right.push(...used);
if (rest >= CENT) {
right.push({
color: "var(--ff-series-1)",
dir: 1,
drillable: false,
key: "kept",
label: "Left over",
value: rest,
});
}
}
return { left, right, total: Math.max(totalIn, totalOut) };
});
@@ -205,6 +231,11 @@
loading={!report.flows}
title="Where the money went ({modeLabel(mode)})"
>
{#snippet tools()}
{#if switcher}
<CurrencySwitch />
{/if}
{/snippet}
<div bind:clientWidth={width}>
{#if width > 0 && narrow}
<div class="grid gap-4 sm:grid-cols-2">
@@ -0,0 +1,195 @@
<script lang="ts">
import { formatDate } from "#lib/format.js";
import { longMonth, type MonthFacts } from "#lib/reports/board.js";
import type { Exchange } from "#lib/reports/flows.js";
import {
type Drill,
drillQuery,
money,
navigate,
useReport,
} from "#lib/reports/report.svelte.js";
import { versusText } from "#lib/reports/typical.js";
import ReportBox from "./ReportBox.svelte";
let {
facts,
month,
swaps,
loading,
partial = false,
totals = true,
monthDrill,
class: className = "",
}: {
facts: MonthFacts[];
month: string;
swaps: Exchange[];
loading: boolean;
partial?: boolean;
totals?: boolean;
monthDrill: (extra: Partial<Drill>) => Drill;
class?: string;
} = $props();
const report = useReport();
const shown = $derived(facts.filter((f) => f.spent > 0 || f.earned > 0));
const moved = $derived.by(() => {
const pairs = new Map<
string,
{ from: string; to: string; a: number; b: number }
>();
for (const swap of swaps) {
const key = `${swap.from}>${swap.to}`;
const pair = pairs.get(key) ?? {
a: 0,
b: 0,
from: swap.from,
to: swap.to,
};
pair.a += swap.fromAmount;
pair.b += swap.toAmount;
pairs.set(key, pair);
}
return [...pairs.values()];
});
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";
}
function open(drill: Drill) {
const current = report.drill;
const same =
current?.box === "month" &&
current.key === drill.key &&
current.dim === drill.dim &&
current.currency === drill.currency;
navigate(drillQuery(same ? null : drill));
}
const isOpen = (dim: string, key: string, code: string) =>
report.drill?.box === "month" &&
report.drill.dim === dim &&
report.drill.key === key &&
report.drill.currency === code;
</script>
<ReportBox
body="px-3 py-2"
class={className}
empty={shown.length === 0}
emptyText="Nothing spent or earned in {longMonth(month)}."
id="month"
{loading}
title={longMonth(month)}
>
<div class="flex flex-col gap-3">
{#each shown as fact (fact.code)}
<section class="flex flex-col gap-1">
<h3 class="flex flex-wrap items-baseline gap-x-3 text-[13px]">
<span class="w-10 font-semibold">{fact.code}</span>
{#if totals}
<span class="num font-semibold"
>{money(fact.code, fact.spent, false, 0)}</span
>
<span class="text-cladd-fg-soft"
>spent{partial ? " so far" : ""}</span
>
{#if fact.ratio !== null}
<span class={tone(fact.ratio)}>{versusText(fact.ratio)}</span>
{/if}
{#if fact.earned > 0}
<span class="num text-income"
>{money(fact.code, fact.earned, false, 0)}</span
>
<span class="-ml-2 text-cladd-fg-soft">earned</span>
{/if}
{/if}
</h3>
<ul class="flex flex-col pl-[3.25rem] text-[13px]">
{#each fact.places as place (place.id)}
<li>
<button
class="grid h-6 w-full grid-cols-[minmax(0,1fr)_auto_4.5rem] items-center gap-3 rounded-cladd-sm px-1 text-left hover:bg-row-hover {isOpen(
"other",
place.id,
fact.code
)
? "bg-row-selected"
: ""}"
onclick={() =>
open(
monthDrill({
currency: fact.code,
dim: "other",
key: place.id,
label: place.name,
})
)}
type="button"
>
<span class="truncate">{place.name}</span>
<span class="num text-right"
>{money(fact.code, place.value, false, 0)}</span
>
<span class="text-right text-[12px] text-cladd-fg-soft"
>{place.count === 1 ? "once" : `${place.count} times`}</span
>
</button>
</li>
{/each}
{#if fact.day}
{@const day = fact.day}
<li>
<button
class="grid h-6 w-full grid-cols-[minmax(0,1fr)_auto_4.5rem] items-center gap-3 rounded-cladd-sm px-1 text-left hover:bg-row-hover {isOpen(
"day",
day.date,
fact.code
)
? "bg-row-selected"
: ""}"
onclick={() =>
open({
box: "month",
currency: fact.code,
dim: "day",
dir: -1,
key: day.date,
label: formatDate(day.date, "long"),
})}
type="button"
>
<span class="truncate text-cladd-fg-soft"
>Biggest day, {formatDate(day.date, "day")}:
{day.where}{day.count > 1
? ` and ${day.count - 1} more`
: ""}</span
>
<span class="num text-right"
>{money(fact.code, day.value, false, 0)}</span
>
<span></span>
</button>
</li>
{/if}
</ul>
</section>
{/each}
{#if moved.length > 0}
<p class="flex flex-wrap gap-x-3 text-[12px] text-cladd-fg-soft">
<span>Exchanged</span>
{#each moved as pair (`${pair.from}${pair.to}`)}
<span class="num"
>{money(pair.from, pair.a, false, 0)}
into {money(pair.to, pair.b, false, 0)}</span
>
{/each}
</p>
{/if}
</div>
</ReportBox>
@@ -0,0 +1,150 @@
<script lang="ts">
import { createQuery } from "@tanstack/svelte-query";
import type { Account, Item, TransactionGroup } from "#lib/api/types.js";
import type { Lane } from "#lib/components/charts/chart.js";
import Lanes from "#lib/components/charts/Lanes.svelte";
import { addDays, periodOf, today } from "#lib/dates.js";
import { longMonth, monthOf, shortMonth } from "#lib/reports/board.js";
import { buckets } from "#lib/reports/flows.js";
import { reportQueries } from "#lib/reports/queries.js";
import { impliedRates } from "#lib/reports/rates.js";
import { money, useReport } from "#lib/reports/report.svelte.js";
import { modeAxis } from "#lib/reports/view.js";
import ReportBox from "./ReportBox.svelte";
const MIN_SHARE = 0.01;
const HEIGHT_BUDGET = 360;
const MIN_HEIGHT = 80;
let {
year,
cursor,
own,
groups,
}: {
year: string;
cursor: string;
own: Item<Account>[] | undefined;
groups: Item<TransactionGroup>[];
} = $props();
const report = useReport();
const now = today();
const start = $derived(addDays(`${year}-01-01`, -1));
const end = $derived(year === now.slice(0, 4) ? now : `${year}-12-31`);
const ids = $derived(
(own ?? [])
.filter((a) => a.attributes.include_net_worth !== false)
.map((a) => a.id)
);
const chart = createQuery(() => ({
...reportQueries.chart(start, end, "1D", ids),
enabled: ids.length > 0,
}));
const months = $derived(buckets(`${year}-01-01`, `${year}-12-31`, "month"));
const totals = $derived.by(() => {
const byCode = new Map<string, Map<string, number>>();
for (const set of chart.data ?? []) {
const code = set.currency_code ?? "";
const days = byCode.get(code) ?? new Map<string, number>();
for (const [date, value] of Object.entries(set.entries ?? {})) {
const day = date.slice(0, 10);
days.set(day, (days.get(day) ?? 0) + Number(value));
}
byCode.set(code, days);
}
return byCode;
});
function closing(days: Map<string, number>, month: string) {
const last = periodOf(`${month}-01`, "1M").end;
let found: number | undefined;
let at = "";
for (const [day, value] of days) {
if (day <= last && day <= end && day >= at) {
at = day;
found = value;
}
}
return month <= monthOf(end) ? found : undefined;
}
const rate = $derived(impliedRates(groups));
const primary = $derived(report.codes[0] ?? "");
const measured = $derived(
[...totals.entries()]
.sort((a, b) => report.codes.indexOf(a[0]) - report.codes.indexOf(b[0]))
.map(([code, days]) => {
const values = months.map((month) => closing(days, month));
const peak = Math.max(0, ...values.map((v) => Math.abs(v ?? 0)));
const r = rate(code, primary);
return {
code,
last: values.findLast((v) => v !== undefined) ?? 0,
values,
weight: r === null ? null : peak * r,
};
})
);
const total = $derived(measured.reduce((sum, m) => sum + (m.weight ?? 0), 0));
const share = (weight: number | null) =>
weight === null || total === 0 ? null : weight / total;
const lanes = $derived<Lane[]>(
measured
.filter((m) => (share(m.weight) ?? 1) >= MIN_SHARE)
.map((m) => ({
axisFormat: modeAxis(m.code),
format: (value: number) => money(m.code, value, false, 0),
height: Math.max(
MIN_HEIGHT,
Math.round((share(m.weight) ?? 1 / measured.length) * HEIGHT_BUDGET)
),
key: m.code,
keys: [
{ color: "var(--ff-series-1)", key: "value", label: "Net worth" },
],
label: m.code,
rows: months.map((month, i) => ({
key: month,
label: shortMonth(month),
values: Object.fromEntries(
m.values[i] === undefined ? [] : [["value", m.values[i]]]
),
})),
}))
);
const small = $derived(
measured.filter((m) => (share(m.weight) ?? 1) < MIN_SHARE)
);
</script>
<ReportBox
body="px-3 pt-2 pb-3"
empty={measured.length === 0}
emptyText="No accounts count towards net worth."
id="net-worth"
loading={chart.isPending && ids.length > 0}
title="Net worth"
>
<Lanes
{cursor}
label="Net worth by currency"
{lanes}
legend={false}
title={(row) =>
row.key === monthOf(now) ? "today" : `end of ${longMonth(row.key)}`}
/>
{#each small as m (m.code)}
<p
class="flex items-baseline gap-x-3 border-cladd-outline border-t pt-1.5 text-[12px]"
>
<span class="w-10 font-semibold text-cladd-fg">{m.code}</span>
<span class="num font-semibold text-cladd-fg"
>{money(m.code, m.last, false, 0)}</span
>
</p>
{/each}
</ReportBox>
+156
View File
@@ -0,0 +1,156 @@
<script lang="ts">
import type { StackKey } from "#lib/components/charts/chart.js";
import Sparkline from "#lib/components/charts/Sparkline.svelte";
import { type Grew, longMonth, shortMonth } from "#lib/reports/board.js";
import {
type Drill,
drillQuery,
money,
navigate,
useReport,
} from "#lib/reports/report.svelte.js";
import { versusText } from "#lib/reports/typical.js";
import ReportBox from "./ReportBox.svelte";
const SHOWN = 10;
let {
rows,
cursor,
codes,
colors,
loading,
partial = false,
monthDrill,
class: className = "",
}: {
rows: Grew[];
cursor: string;
codes: number;
colors: StackKey[];
loading: boolean;
partial?: boolean;
monthDrill: (extra: Partial<Drill>) => Drill;
class?: string;
} = $props();
const report = useReport();
let all = $state(false);
const shown = $derived(all ? rows : rows.slice(0, SHOWN));
const marker = $derived(Number(cursor.slice(5, 7)) - 1);
const colorOf = (cat: string) =>
colors.find((k) => k.key === cat)?.color ?? "var(--cladd-fg-softer)";
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";
}
function open(row: Grew) {
const drill = monthDrill({
currency: row.code,
dim: "category",
key: row.cat,
label: row.label,
});
const current = report.drill;
const same =
current?.box === "grew" &&
current.key === row.cat &&
current.currency === row.code;
navigate(drillQuery(same ? null : drill));
}
</script>
<ReportBox
class={className}
empty={rows.length === 0}
emptyText="No spending yet."
id="grew"
{loading}
title="What grew"
>
<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">Category</th>
<th class="h-7 px-2 text-left font-medium max-sm:hidden">
This year
</th>
<th class="h-7 px-2 text-right font-medium max-sm:hidden">Total</th>
<th class="h-7 whitespace-nowrap px-2 text-right font-medium">
{shortMonth(cursor)}{partial ? " so far" : ""}
</th>
<th class="h-7 px-3 text-right font-medium">Against typical</th>
</tr>
</thead>
<tbody>
{#each shown as row (row.key)}
{@const active =
report.drill?.box === "grew" &&
report.drill.key === row.cat &&
report.drill.currency === row.code}
<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
class="flex max-w-full items-center gap-2 text-left hover:underline"
onclick={() => open(row)}
title="Show {row.label} in {longMonth(cursor)}"
type="button"
>
<span
aria-hidden="true"
class="size-2.5 shrink-0 rounded-[3px]"
style:background={colorOf(row.cat)}
></span>
<span class="truncate">{row.label}</span>
{#if codes > 1}
<span class="text-[12px] text-cladd-fg-soft">{row.code}</span>
{/if}
</button>
</td>
<td class="px-2 max-sm:hidden">
<Sparkline
color={colorOf(row.cat)}
height={20}
{marker}
values={row.values}
width={96}
/>
</td>
<td class="num whitespace-nowrap px-2 text-right max-sm:hidden">
{money(row.code, row.year, false, 0)}
</td>
<td class="num whitespace-nowrap px-2 text-right">
{money(row.code, row.month, false, 0)}
</td>
<td class="whitespace-nowrap px-3 text-right {tone(row.ratio)}">
{#if row.ratio !== null}
{versusText(row.ratio, "")}
{:else if row.month > 0}
new
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{#if rows.length > SHOWN}
<button
class="mx-3 my-2 text-[12px] text-brand hover:underline dark:text-[var(--ff-series-1)]"
onclick={() => (all = !all)}
type="button"
>
{all ? "Show fewer" : `Show all ${rows.length}`}
</button>
{/if}
</ReportBox>
+403
View File
@@ -0,0 +1,403 @@
import {
type Lane,
OTHER_COLOR,
type StackKey,
seriesColor,
} from "#lib/components/charts/chart.js";
import { addMonths, periodOf, type Range, today } from "#lib/dates.js";
import { formatDate } from "#lib/format.js";
import { session } from "#lib/session.svelte.js";
import { buckets, type Flow, keys, NONE } from "./flows.js";
import { money } from "./report.svelte.js";
import {
type Pace,
pace,
series,
typical,
typicalMonths,
typicalToDay,
versus,
} from "./typical.js";
import { modeAxis } from "./view.js";
export const OTHER = "other";
const NO_CATEGORY_COLOR = "var(--cladd-fg-softer)";
const MAX_CATEGORIES = 6;
const TOP_PLACES = 3;
export const monthOf = (date: string) => date.slice(0, 7);
export const shiftMonth = (month: string, delta: number) =>
monthOf(addMonths(`${month}-01`, delta));
export const monthRange = (month: string): Range =>
periodOf(`${month}-01`, "1M");
export function cursorOf(range: Range, now = today()): string {
const start = monthOf(range.start);
if (start === monthOf(range.end)) {
return start;
}
if (range.start <= now && now <= range.end) {
return monthOf(now);
}
return monthOf(range.end) > monthOf(now) ? monthOf(now) : monthOf(range.end);
}
export const shortMonth = (month: string) =>
new Intl.DateTimeFormat(session.locale, { month: "short" }).format(
new Date(Number(month.slice(0, 4)), Number(month.slice(5, 7)) - 1, 1)
);
export const longMonth = (month: string) => formatDate(`${month}-01`, "month");
const ordinals = new Intl.PluralRules("en", { type: "ordinal" });
const SUFFIX: Record<string, string> = {
few: "rd",
one: "st",
other: "th",
two: "nd",
};
export const ordinal = (n: number) => `${n}${SUFFIX[ordinals.select(n)]}`;
const key = (code: string, part: string) => `${code} ${part}`;
export interface Board {
categories: Map<string, Map<string, number>>;
catKeys: StackKey[];
catLabel: Map<string, string>;
codes: string[];
earned: Map<string, Map<string, number>>;
months: string[];
now: string;
partial: string | null;
primary: string;
spending: Flow[];
spent: Map<string, Map<string, number>>;
stackOf: (flow: Flow) => string;
stacks: Map<string, Map<string, number>>;
typical: Map<string, number>;
typicalMonths: string[];
}
function rankCategories(spending: Flow[], months: Set<string>) {
const totals = new Map<string, number>();
const byCat = new Map<string, number>();
const labels = new Map<string, string>([
[NONE, "(no category)"],
[OTHER, "Other"],
]);
for (const flow of spending) {
if (!months.has(monthOf(flow.date))) {
continue;
}
const cat = keys.category(flow);
labels.set(cat.key, cat.label);
totals.set(flow.currency, (totals.get(flow.currency) ?? 0) + flow.amount);
const k = key(flow.currency, cat.key);
byCat.set(k, (byCat.get(k) ?? 0) + flow.amount);
}
const score = new Map<string, number>();
for (const [k, value] of byCat) {
const [code, cat] = [
k.slice(0, k.indexOf(" ")),
k.slice(k.indexOf(" ") + 1),
];
score.set(cat, (score.get(cat) ?? 0) + value / (totals.get(code) || 1));
}
const ranked = [...score.entries()]
.filter(([cat]) => cat !== NONE)
.sort((a, b) => b[1] - a[1])
.map(([cat]) => cat);
const top = new Set(ranked.slice(0, MAX_CATEGORIES));
const catKeys: StackKey[] = [...top].map((cat, i) => ({
color: seriesColor(i),
key: cat,
label: labels.get(cat) ?? cat,
}));
if (score.has(NONE)) {
catKeys.push({
color: NO_CATEGORY_COLOR,
key: NONE,
label: labels.get(NONE) ?? "",
});
}
if (ranked.length > top.size) {
catKeys.push({ color: OTHER_COLOR, key: OTHER, label: "Other" });
}
const stackOf = (flow: Flow) => {
const cat = flow.category?.id ?? NONE;
return cat === NONE || top.has(cat) ? cat : OTHER;
};
return { catKeys, labels, stackOf };
}
export function buildBoard(
flows: Flow[],
windowMonths: string[],
year: string,
order: string[],
now = today()
): Board {
const months = buckets(`${year}-01-01`, `${year}-12-31`, "month");
const closed = year < now.slice(0, 4) ? `${Number(year) + 1}-01-01` : now;
const first = flows.reduce(
(min, flow) => (flow.date < min ? flow.date : min),
"9999"
);
const typMonths = typicalMonths(windowMonths, closed, first);
const spending = flows.filter((flow) => flow.dir === -1);
const spent = series(spending, (flow) => flow.currency);
const earned = series(
flows.filter((flow) => flow.dir === 1),
(flow) => flow.currency
);
const shown = new Set([...months, ...typMonths]);
const { catKeys, labels, stackOf } = rankCategories(
spending,
new Set(months)
);
const counts = new Map<string, number>();
for (const flow of flows) {
if (shown.has(monthOf(flow.date))) {
counts.set(flow.currency, (counts.get(flow.currency) ?? 0) + 1);
}
}
const primary = order[0] ?? "";
const rank = (code: string) => (code === primary ? 0 : 1);
const codes = [...counts.keys()].sort(
(a, b) => rank(a) - rank(b) || (counts.get(b) ?? 0) - (counts.get(a) ?? 0)
);
return {
categories: series(spending, (flow) =>
key(flow.currency, keys.category(flow).key)
),
catKeys,
catLabel: labels,
codes,
earned,
months,
now,
partial: months.includes(monthOf(now)) ? monthOf(now) : null,
primary,
spending,
spent,
stackOf,
stacks: series(spending, (flow) => key(flow.currency, stackOf(flow))),
typical: new Map(
codes.map((code) => [code, typical(spent.get(code), typMonths)])
),
typicalMonths: typMonths,
};
}
const QUIET_HEIGHT = 56;
export function spendingLanes(board: Board): Lane[] {
return board.codes.map((code) => ({
axisFormat: modeAxis(code),
dots: Object.fromEntries(
board.months.map((month) => [
month,
board.earned.get(code)?.get(month) ?? 0,
])
),
format: (value: number) => money(code, value, false, 0),
height: board.typical.get(code) ? undefined : QUIET_HEIGHT,
key: code,
keys: board.catKeys,
label: code,
rows: board.months.map((month) => ({
key: month,
label: shortMonth(month),
values: Object.fromEntries(
board.catKeys.map((k) => [
k.key,
board.stacks.get(key(code, k.key))?.get(month) ?? 0,
])
),
})),
typical: board.typical.get(code) ?? null,
}));
}
export function mergedLane(board: Board): Lane {
const code = board.primary;
const pc = (flow: Flow) => flow.pc ?? 0;
const stacks = series(board.spending, board.stackOf, pc);
const spent = series(board.spending, () => code, pc).get(code);
return {
axisFormat: modeAxis(code),
format: (value: number) => money(code, value, false, 0),
key: "pc",
keys: board.catKeys,
label: `≈${code}`,
rows: board.months.map((month) => ({
key: month,
label: shortMonth(month),
values: Object.fromEntries(
board.catKeys.map((k) => [k.key, stacks.get(k.key)?.get(month) ?? 0])
),
})),
typical: typical(spent, board.typicalMonths),
};
}
const sumOf = (byMonth: Map<string, number> | undefined, months: string[]) =>
months.reduce((sum, month) => sum + (byMonth?.get(month) ?? 0), 0);
export interface Headline {
code: string;
earned: number;
pace: Pace | null;
spent: number;
typical: number;
}
export function headlines(board: Board, months = board.months): Headline[] {
return board.codes.map((code) => ({
code,
earned: sumOf(board.earned.get(code), months),
pace:
board.partial && months.includes(board.partial)
? pace(
board.spending.filter((flow) => flow.currency === code),
board.typicalMonths,
board.now
)
: null,
spent: sumOf(board.spent.get(code), months),
typical: board.typical.get(code) ?? 0,
}));
}
export interface Grew {
cat: string;
code: string;
impact: number;
key: string;
label: string;
month: number;
ratio: number | null;
typical: number;
values: number[];
year: number;
}
export function grew(board: Board, month: string): Grew[] {
const partial = month === board.partial;
const day = Number(board.now.slice(8, 10));
const lastMonth = board.partial ?? board.months.at(-1) ?? month;
const months = board.months.filter((m) => m <= lastMonth);
const byKey = new Map<string, Flow[]>();
if (partial) {
for (const flow of board.spending) {
const k = key(flow.currency, keys.category(flow).key);
const list = byKey.get(k) ?? [];
list.push(flow);
byKey.set(k, list);
}
}
const rows: Grew[] = [];
for (const [k, byMonth] of board.categories) {
const split = k.indexOf(" ");
const code = k.slice(0, split);
const cat = k.slice(split + 1);
const base = board.typical.get(code) || 1;
const now = partial
? (byKey.get(k) ?? [])
.filter((f) => monthOf(f.date) === month && f.date <= board.now)
.reduce((sum, f) => sum + f.amount, 0)
: (byMonth.get(month) ?? 0);
const typ = partial
? typicalToDay(byKey.get(k) ?? [], board.typicalMonths, day)
: typical(byMonth, board.typicalMonths);
const year = sumOf(byMonth, board.months);
if (now === 0 && typ === 0 && year === 0) {
continue;
}
rows.push({
cat,
code,
impact: (now - typ) / base,
key: k,
label: board.catLabel.get(cat) ?? cat,
month: now,
ratio: versus(now, typ),
typical: typ,
values: months.map((m) => byMonth.get(m) ?? 0),
year,
});
}
return rows.sort((a, b) => b.impact - a.impact);
}
export interface Place {
count: number;
id: string;
name: string;
value: number;
}
export interface MonthFacts {
code: string;
day: { date: string; value: number; where: string; count: number } | null;
earned: number;
places: Place[];
ratio: number | null;
spent: number;
}
export function monthFacts(board: Board, month: string): MonthFacts[] {
const partial = month === board.partial;
return board.codes.map((code) => {
const flows = board.spending.filter(
(flow) => flow.currency === code && monthOf(flow.date) === month
);
const places = new Map<string, Place>();
const days = new Map<string, Flow[]>();
for (const flow of flows) {
const place = places.get(flow.other.id) ?? {
count: 0,
id: flow.other.id,
name: flow.other.name,
value: 0,
};
place.count += 1;
place.value += flow.amount;
places.set(flow.other.id, place);
const list = days.get(flow.date) ?? [];
list.push(flow);
days.set(flow.date, list);
}
let day: MonthFacts["day"] = null;
for (const [date, list] of days) {
const value = list.reduce((sum, f) => sum + f.amount, 0);
if (!day || value > day.value) {
const top = list.reduce((a, b) => (b.amount > a.amount ? b : a));
day = { count: list.length, date, value, where: top.other.name };
}
}
const spent = board.spent.get(code)?.get(month) ?? 0;
const p = partial
? pace(
board.spending.filter((flow) => flow.currency === code),
board.typicalMonths,
board.now
)
: null;
return {
code,
day,
earned: board.earned.get(code)?.get(month) ?? 0,
places: [...places.values()]
.sort((a, b) => b.value - a.value)
.slice(0, TOP_PLACES),
ratio: p
? versus(p.sofar, p.typical)
: versus(spent, board.typical.get(code) ?? 0),
spent,
};
});
}
+50
View File
@@ -0,0 +1,50 @@
import type { Account, Item, TransactionGroup } from "#lib/api/types.js";
import { addDays, addMonths, today } from "#lib/dates.js";
import { buckets, type Flow, toFlows } from "./flows.js";
import { pages } from "./queries.js";
export const LEDGER_MONTHS = 13;
export interface LedgerWindow {
end: string;
months: string[];
start: string;
}
export function ledgerWindow(anchor = today()): LedgerWindow {
const month = `${anchor.slice(0, 7)}-01`;
const start = addMonths(month, 1 - LEDGER_MONTHS);
const end = addDays(addMonths(month, 1), -1);
return { end, months: buckets(start, end, "month"), start };
}
export function ledgerQuery(anchor = today()) {
const { start, end } = ledgerWindow(anchor);
return {
queryFn: () =>
pages<TransactionGroup>("/v1/transactions", {
end,
start,
type: "default",
}),
queryKey: ["ledger", start, end],
};
}
export function ownFlows(
groups: Item<TransactionGroup>[],
accounts: Item<Account>[]
): Flow[] {
const own = accounts.filter(
(a) =>
a.attributes.type === "asset" ||
a.attributes.type === "liabilities" ||
a.attributes.type === "liability"
);
const currency = new Map(own.map((a) => [a.id, a.attributes.currency_code]));
return toFlows(
groups,
new Set(own.map((a) => a.id)),
(id) => currency.get(id) ?? undefined
).filter((flow) => flow.type === "withdrawal" || flow.type === "deposit");
}
+48
View File
@@ -0,0 +1,48 @@
import type { Item, TransactionGroup } from "#lib/api/types.js";
export type Rate = (from: string, to: string) => number | null;
type Pairs = Map<string, Map<string, [number, number]>>;
function pairs(groups: Item<TransactionGroup>[]): Pairs {
const sums: Pairs = new Map();
const add = (a: string, b: string, x: number, y: number) => {
const row = sums.get(a) ?? new Map<string, [number, number]>();
const pair = row.get(b) ?? [0, 0];
row.set(b, [pair[0] + x, pair[1] + y]);
sums.set(a, row);
};
for (const group of groups) {
for (const split of group.attributes.transactions) {
const from = split.currency_code;
const to = split.foreign_currency_code;
const x = Math.abs(Number(split.amount));
const y = Math.abs(Number(split.foreign_amount));
if (from && to && from !== to && x > 0 && y > 0) {
add(from, to, x, y);
add(to, from, y, x);
}
}
}
return sums;
}
function walk(sums: Pairs, from: string, to: string): number | null {
const rates = new Map([[from, 1]]);
const queue = [from];
for (const code of queue) {
const rate = rates.get(code) ?? 1;
for (const [other, [x, y]] of sums.get(code) ?? []) {
if (!rates.has(other)) {
rates.set(other, (rate * y) / x);
queue.push(other);
}
}
}
return rates.get(to) ?? null;
}
export function impliedRates(groups: Item<TransactionGroup>[]): Rate {
const sums = pairs(groups);
return (from, to) => walk(sums, from, to);
}
+36 -9
View File
@@ -1,7 +1,7 @@
import { createQuery } from "@tanstack/svelte-query";
import { getContext, setContext } from "svelte";
import { lists } from "#lib/api/lists.js";
import type { Account, Item } from "#lib/api/types.js";
import type { Account, Item, TransactionGroup } from "#lib/api/types.js";
import { formatMoney } from "#lib/format.js";
import { session } from "#lib/session.svelte.js";
import { goto } from "$app/navigation";
@@ -136,6 +136,7 @@ export interface Drill {
asset?: string;
at?: string;
box: string;
currency?: string;
dim?: Dimension | "day" | "flow";
dir?: 1 | -1;
key?: string;
@@ -172,6 +173,7 @@ function readDrill(url: {
asset: url.searchParams.get("acc") ?? undefined,
at: url.searchParams.get("at") ?? undefined,
box,
currency: url.searchParams.get("dc") ?? undefined,
dim: DRILL_DIMS.has(dim) ? (dim as Drill["dim"]) : undefined,
dir: DIRS[dir ?? ""],
key: split > 0 ? raw.slice(split + 1) : undefined,
@@ -181,12 +183,21 @@ function readDrill(url: {
export function drillQuery(drill: Drill | null): Record<string, string | null> {
if (!drill) {
return { acc: null, at: null, box: null, dir: null, dl: null, drill: null };
return {
acc: null,
at: null,
box: null,
dc: null,
dir: null,
dl: null,
drill: null,
};
}
return {
acc: drill.asset ?? null,
at: drill.at ?? null,
box: drill.box,
dc: drill.currency ?? null,
dir: drill.dir ? DIR_NAMES[drill.dir] : null,
dl: drill.label || null,
drill:
@@ -208,6 +219,9 @@ export function drillFilter(
if (drill.asset && flow.asset.id !== drill.asset) {
return false;
}
if (drill.currency && flow.currency !== drill.currency) {
return false;
}
if (!drill.dim || drill.key === undefined) {
return true;
}
@@ -246,7 +260,15 @@ function twins(
);
}
export function createReport(params: () => ReportParams) {
const inRange = (group: Item<TransactionGroup>, start: string, end: string) => {
const date = group.attributes.transactions[0]?.date.slice(0, 10) ?? "";
return date >= start && date <= end;
};
export function createReport(
params: () => ReportParams,
source?: () => Item<TransactionGroup>[] | undefined
) {
const p = $derived(params());
const kind = $derived(kindOf(p.type));
const assets = createQuery(() => lists.accounts("asset"));
@@ -273,8 +295,13 @@ export function createReport(params: () => ReportParams) {
}));
const tx = createQuery(() => ({
...reportQueries.transactions(p.start, p.end),
enabled: p.type !== "audit",
enabled: !source && p.type !== "audit",
}));
const groups = $derived(
source
? source()?.filter((group) => inRange(group, p.start, p.end))
: tx.data
);
const accounts = $derived([
...(assets.data ?? []),
@@ -347,14 +374,14 @@ export function createReport(params: () => ReportParams) {
);
const flows = $derived(
tx.data && accountsReady
? toFlows(tx.data, selectedSet, (id) => byId.get(id)?.currency_code)
groups && accountsReady
? toFlows(groups, selectedSet, (id) => byId.get(id)?.currency_code)
: undefined
);
const swaps = $derived(
tx.data && accountsReady
? exchanges(tx.data, selectedSet, (id) => byId.get(id)?.currency_code)
groups && accountsReady
? exchanges(groups, selectedSet, (id) => byId.get(id)?.currency_code)
: []
);
@@ -451,7 +478,7 @@ export function createReport(params: () => ReportParams) {
return grain;
},
get groups() {
return tx.data;
return groups;
},
itemName: (kindKey: string, id: string) =>
itemNames.get(`${kindKey}:${id}`),
+113
View File
@@ -0,0 +1,113 @@
import { today } from "#lib/dates.js";
import { percent } from "#lib/format.js";
import type { Flow } from "./flows.js";
export const TYPICAL_MONTHS = 12;
const SAME = 0.03;
export function median(values: number[]): number {
if (values.length === 0) {
return 0;
}
const sorted = [...values].sort((a, b) => a - b);
const mid = Math.floor(sorted.length / 2);
return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
}
export function typicalMonths(
months: string[],
now = today(),
since = ""
): string[] {
const current = now.slice(0, 7);
const first = since.slice(0, 7);
return months
.filter((month) => month < current && month >= first)
.slice(-TYPICAL_MONTHS);
}
export function series(
flows: Iterable<Flow>,
keyOf: (flow: Flow) => string | null,
measure: (flow: Flow) => number = (flow) => flow.amount
): Map<string, Map<string, number>> {
const result = new Map<string, Map<string, number>>();
for (const flow of flows) {
const key = keyOf(flow);
if (key === null) {
continue;
}
let months = result.get(key);
if (!months) {
months = new Map();
result.set(key, months);
}
const month = flow.date.slice(0, 7);
months.set(month, (months.get(month) ?? 0) + measure(flow));
}
return result;
}
export function typical(
byMonth: ReadonlyMap<string, number> | undefined,
months: string[]
): number {
return median(months.map((month) => byMonth?.get(month) ?? 0));
}
export function typicalToDay(
flows: Iterable<Flow>,
months: string[],
day: number
): number {
const sums = new Map(months.map((month) => [month, 0]));
for (const flow of flows) {
const month = flow.date.slice(0, 7);
const sum = sums.get(month);
if (sum !== undefined && Number(flow.date.slice(8, 10)) <= day) {
sums.set(month, sum + flow.amount);
}
}
return median([...sums.values()]);
}
export interface Pace {
day: number;
month: string;
sofar: number;
typical: number;
}
export function pace(
flows: Iterable<Flow>,
months: string[],
now = today()
): Pace {
const month = now.slice(0, 7);
const day = Number(now.slice(8, 10));
let sofar = 0;
const past: Flow[] = [];
for (const flow of flows) {
if (flow.date.slice(0, 7) === month) {
sofar += flow.date <= now ? flow.amount : 0;
} else {
past.push(flow);
}
}
return { day, month, sofar, typical: typicalToDay(past, months, day) };
}
export function versus(value: number, base: number): number | null {
return base > 0 ? (value - base) / base : null;
}
export function versusText(ratio: number | null, noun = "typical"): string {
if (ratio === null) {
return "";
}
if (Math.abs(ratio) < SAME) {
return noun ? `about ${noun}` : "about the same";
}
const side = ratio > 0 ? "above" : "below";
return `${percent(Math.abs(ratio))} ${side}${noun ? ` ${noun}` : ""}`;
}