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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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}`),
|
||||
|
||||
@@ -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}` : ""}`;
|
||||
}
|
||||
Reference in new issue
Block a user