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,
|
type StackRow,
|
||||||
stack,
|
stack,
|
||||||
textWidth,
|
textWidth,
|
||||||
|
tones,
|
||||||
} from "./chart.js";
|
} from "./chart.js";
|
||||||
import Legend from "./Legend.svelte";
|
import Legend from "./Legend.svelte";
|
||||||
|
|
||||||
@@ -24,6 +25,9 @@
|
|||||||
const TILTED_CHARS = 16;
|
const TILTED_CHARS = 16;
|
||||||
const TILTED_CHAR_WIDTH = 4.9;
|
const TILTED_CHAR_WIDTH = 4.9;
|
||||||
const LABEL_GAP = 13;
|
const LABEL_GAP = 13;
|
||||||
|
const BARE_BOTTOM = 4;
|
||||||
|
const DOT_ROOM = 1.5;
|
||||||
|
const DOT_R = 3.5;
|
||||||
|
|
||||||
let {
|
let {
|
||||||
keys,
|
keys,
|
||||||
@@ -33,6 +37,16 @@
|
|||||||
axisFormat = (value: number) => compactMoney(value),
|
axisFormat = (value: number) => compactMoney(value),
|
||||||
label = "Bar chart",
|
label = "Bar chart",
|
||||||
active = $bindable(null),
|
active = $bindable(null),
|
||||||
|
selected = null,
|
||||||
|
reference = null,
|
||||||
|
partial = null,
|
||||||
|
dots,
|
||||||
|
dotColor = tones.income,
|
||||||
|
gutter,
|
||||||
|
legend: showLegend = true,
|
||||||
|
axis = true,
|
||||||
|
tilt = true,
|
||||||
|
keyboard = true,
|
||||||
onselect,
|
onselect,
|
||||||
}: {
|
}: {
|
||||||
keys: StackKey[];
|
keys: StackKey[];
|
||||||
@@ -42,30 +56,63 @@
|
|||||||
axisFormat?: (value: number) => string;
|
axisFormat?: (value: number) => string;
|
||||||
label?: string;
|
label?: string;
|
||||||
active?: string | null;
|
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();
|
} = $props();
|
||||||
|
|
||||||
|
const uid = $props.id();
|
||||||
let width = $state(0);
|
let width = $state(0);
|
||||||
|
|
||||||
const stacks = $derived(rows.map((row) => ({ row, ...stack(row, keys) })));
|
const stacks = $derived(rows.map((row) => ({ row, ...stack(row, keys) })));
|
||||||
const yCount = $derived(Math.max(2, Math.floor((height - 60) / GAP_Y)));
|
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(
|
const yBase = $derived(
|
||||||
scaleLinear()
|
scaleLinear()
|
||||||
.domain([
|
.domain([
|
||||||
Math.min(0, ...stacks.map((s) => s.negative)),
|
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)
|
.nice(yCount)
|
||||||
);
|
);
|
||||||
const yTicks = $derived(yBase.ticks(yCount));
|
const yTicks = $derived(yBase.ticks(yCount));
|
||||||
const left = $derived(
|
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 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
|
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 labelEvery = $derived(Math.max(1, Math.ceil(LABEL_GAP / step)));
|
||||||
const y = $derived(yBase.copy().range([height - bottom, TOP]));
|
const y = $derived(yBase.copy().range([height - bottom, TOP]));
|
||||||
const x = $derived(
|
const x = $derived(
|
||||||
@@ -88,7 +135,13 @@
|
|||||||
const activeIndex = $derived(
|
const activeIndex = $derived(
|
||||||
active ? rows.findIndex((r) => r.key === active) : -1
|
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 fmt = (row: StackRow, value: number) => (row.format ?? format)(value);
|
||||||
const legend = $derived<LegendItem[]>(
|
const legend = $derived<LegendItem[]>(
|
||||||
readRow
|
readRow
|
||||||
@@ -113,17 +166,31 @@
|
|||||||
: text;
|
: text;
|
||||||
}
|
}
|
||||||
|
|
||||||
function select(row: StackRow | undefined) {
|
function select(row: StackRow | undefined, segment?: string) {
|
||||||
if (!row) {
|
if (!row) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (onselect) {
|
if (onselect) {
|
||||||
onselect(row);
|
onselect(row, segment);
|
||||||
} else if (row.href) {
|
} else if (row.href) {
|
||||||
goto(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));
|
const clickable = $derived(Boolean(onselect) || rows.some((r) => r.href));
|
||||||
|
|
||||||
function pick(event: PointerEvent) {
|
function pick(event: PointerEvent) {
|
||||||
@@ -136,6 +203,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function onkeydown(event: KeyboardEvent) {
|
function onkeydown(event: KeyboardEvent) {
|
||||||
|
if (!keyboard) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (event.key === "Enter") {
|
if (event.key === "Enter") {
|
||||||
select(rows[activeIndex]);
|
select(rows[activeIndex]);
|
||||||
return;
|
return;
|
||||||
@@ -179,14 +249,16 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex min-w-0 flex-col gap-2">
|
<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 showLegend}
|
||||||
{#if keys.length > 1 && readRow}
|
<div class="flex min-h-5 flex-wrap items-baseline gap-x-4 gap-y-1">
|
||||||
<span class="max-w-[16rem] truncate text-[12px] text-cladd-fg"
|
{#if keys.length > 1 && readRow}
|
||||||
>{readRow.label}</span
|
<span class="max-w-[16rem] truncate text-[12px] text-cladd-fg"
|
||||||
>
|
>{readRow.label}</span
|
||||||
{/if}
|
>
|
||||||
<Legend class="min-w-0 flex-1" items={legend} />
|
{/if}
|
||||||
</div>
|
<Legend class="min-w-0 flex-1" items={legend} />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
<div
|
<div
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
aria-valuemax={Math.max(0, rows.length - 1)}
|
aria-valuemax={Math.max(0, rows.length - 1)}
|
||||||
@@ -194,19 +266,45 @@
|
|||||||
aria-valuenow={Math.max(0, activeIndex)}
|
aria-valuenow={Math.max(0, activeIndex)}
|
||||||
aria-valuetext={valueText}
|
aria-valuetext={valueText}
|
||||||
class="relative rounded-sm {clickable ? "cursor-pointer" : ""}"
|
class="relative rounded-sm {clickable ? "cursor-pointer" : ""}"
|
||||||
onblur={() => (active = null)}
|
onblur={() => keyboard && (active = null)}
|
||||||
onclick={() => select(rows[activeIndex])}
|
onclick={(event) =>
|
||||||
onfocus={() => (active ??= rows[0]?.key ?? null)}
|
select(rows[activeIndex], segmentAt(event, activeIndex))}
|
||||||
|
onfocus={() => keyboard && (active ??= rows[0]?.key ?? null)}
|
||||||
{onkeydown}
|
{onkeydown}
|
||||||
onpointerleave={() => (active = null)}
|
onpointerleave={() => (active = null)}
|
||||||
onpointermove={pick}
|
onpointermove={pick}
|
||||||
role="slider"
|
role="slider"
|
||||||
tabindex="0"
|
tabindex={keyboard ? 0 : -1}
|
||||||
style:height="{height}px"
|
style:height="{height}px"
|
||||||
bind:clientWidth={width}
|
bind:clientWidth={width}
|
||||||
>
|
>
|
||||||
{#if width > 0 && rows.length > 0}
|
{#if width > 0 && rows.length > 0}
|
||||||
<svg aria-hidden="true" class="block" {height} {width}>
|
<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)}
|
{#each yTicks as tick (tick)}
|
||||||
<line
|
<line
|
||||||
stroke="var(--cladd-outline)"
|
stroke="var(--cladd-outline)"
|
||||||
@@ -239,16 +337,17 @@
|
|||||||
{@const dim = activeIndex >= 0 && activeIndex !== i}
|
{@const dim = activeIndex >= 0 && activeIndex !== i}
|
||||||
<g class="transition-opacity duration-100" opacity={dim ? 0.45 : 1}>
|
<g class="transition-opacity duration-100" opacity={dim ? 0.45 : 1}>
|
||||||
{#each s.segments as seg, j (seg.key)}
|
{#each s.segments as seg, j (seg.key)}
|
||||||
<path
|
{@const d = segmentPath(
|
||||||
d={segmentPath(
|
cx,
|
||||||
cx,
|
seg.from,
|
||||||
seg.from,
|
seg.to,
|
||||||
seg.to,
|
seg.outer,
|
||||||
seg.outer,
|
j === 0 || s.segments[j - 1].value > 0 !== seg.value > 0
|
||||||
j === 0 || s.segments[j - 1].value > 0 !== seg.value > 0
|
)}
|
||||||
)}
|
<path {d} style:fill={seg.color} />
|
||||||
style:fill={seg.color}
|
{#if partial === s.row.key}
|
||||||
/>
|
<path {d} fill="url(#{uid}-hatch)" />
|
||||||
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
</g>
|
</g>
|
||||||
{#if capLabels && s.segments[0]}
|
{#if capLabels && s.segments[0]}
|
||||||
@@ -268,7 +367,7 @@
|
|||||||
</text>
|
</text>
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
{#if tilted && i % labelEvery === 0}
|
{#if axis && tilted && i % labelEvery === 0}
|
||||||
<text
|
<text
|
||||||
fill={activeIndex === i
|
fill={activeIndex === i
|
||||||
? "var(--cladd-fg)"
|
? "var(--cladd-fg)"
|
||||||
@@ -282,20 +381,52 @@
|
|||||||
Math.min(TILTED_CHARS, Math.floor(cx / TILTED_CHAR_WIDTH))
|
Math.min(TILTED_CHARS, Math.floor(cx / TILTED_CHAR_WIDTH))
|
||||||
)}
|
)}
|
||||||
</text>
|
</text>
|
||||||
{:else if !tilted}
|
{:else if axis && !tilted && i % flatEvery === 0}
|
||||||
<text
|
<text
|
||||||
fill={activeIndex === i
|
fill={activeIndex === i || selectedIndex === i
|
||||||
? "var(--cladd-fg)"
|
? "var(--cladd-fg)"
|
||||||
: "var(--cladd-fg-soft)"}
|
: "var(--cladd-fg-soft)"}
|
||||||
font-size="11"
|
font-size="11"
|
||||||
|
font-weight={selectedIndex === i ? 600 : 400}
|
||||||
text-anchor="middle"
|
text-anchor="middle"
|
||||||
x={cx}
|
x={cx}
|
||||||
y={height - 6}
|
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>
|
</text>
|
||||||
{/if}
|
{/if}
|
||||||
{/each}
|
{/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>
|
</svg>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</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>;
|
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 {
|
export interface MeterSegment {
|
||||||
color?: string;
|
color?: string;
|
||||||
label?: 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,
|
navigate,
|
||||||
useReport,
|
useReport,
|
||||||
} from "#lib/reports/report.svelte.js";
|
} from "#lib/reports/report.svelte.js";
|
||||||
|
import CurrencySwitch from "./CurrencySwitch.svelte";
|
||||||
import ReportBox from "./ReportBox.svelte";
|
import ReportBox from "./ReportBox.svelte";
|
||||||
|
|
||||||
|
let { switcher = false }: { switcher?: boolean } = $props();
|
||||||
|
|
||||||
const LEFT_MAX = 5;
|
const LEFT_MAX = 5;
|
||||||
const RIGHT_MAX = 9;
|
const RIGHT_MAX = 9;
|
||||||
const SLOT = 34;
|
const SLOT = 34;
|
||||||
@@ -87,6 +90,7 @@
|
|||||||
function swaps(dir: 1 | -1): Node[] {
|
function swaps(dir: 1 | -1): Node[] {
|
||||||
return net
|
return net
|
||||||
.filter(([, value]) => Math.sign(value) === dir)
|
.filter(([, value]) => Math.sign(value) === dir)
|
||||||
|
.sort((a, b) => Math.abs(b[1]) - Math.abs(a[1]))
|
||||||
.map(([code, value]) => ({
|
.map(([code, value]) => ({
|
||||||
color: "var(--ff-transfer)",
|
color: "var(--ff-transfer)",
|
||||||
dir,
|
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 model = $derived.by(() => {
|
||||||
const flows = report.flows ?? [];
|
const flows = report.flows ?? [];
|
||||||
const left = side(flows, 1, LEFT_MAX);
|
const left = side(flows, 1, LEFT_MAX);
|
||||||
const right = side(flows, -1, RIGHT_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 totalIn = left.reduce((sum, n) => sum + n.value, 0);
|
||||||
const totalOut = right.reduce((sum, n) => sum + n.value, 0);
|
const totalOut = right.reduce((sum, n) => sum + n.value, 0);
|
||||||
if (totalIn - totalOut >= CENT) {
|
const exchange = mode !== "pc";
|
||||||
right.push({
|
if (totalOut - totalIn >= CENT) {
|
||||||
color: "var(--ff-series-1)",
|
const [used, rest] = exchange
|
||||||
dir: 1,
|
? cover(swaps(1), totalOut - totalIn)
|
||||||
drillable: false,
|
: [[], totalOut - totalIn];
|
||||||
key: "kept",
|
left.push(...used);
|
||||||
label: "Left over",
|
if (rest >= CENT) {
|
||||||
value: totalIn - totalOut,
|
left.push({
|
||||||
});
|
color: "var(--ff-warn)",
|
||||||
} else if (totalOut - totalIn >= CENT) {
|
dir: -1,
|
||||||
left.push({
|
drillable: false,
|
||||||
color: "var(--ff-warn)",
|
key: "deficit",
|
||||||
dir: -1,
|
label: "From balance",
|
||||||
drillable: false,
|
value: rest,
|
||||||
key: "deficit",
|
});
|
||||||
label: "From balance",
|
}
|
||||||
value: totalOut - totalIn,
|
} 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) };
|
return { left, right, total: Math.max(totalIn, totalOut) };
|
||||||
});
|
});
|
||||||
@@ -205,6 +231,11 @@
|
|||||||
loading={!report.flows}
|
loading={!report.flows}
|
||||||
title="Where the money went ({modeLabel(mode)})"
|
title="Where the money went ({modeLabel(mode)})"
|
||||||
>
|
>
|
||||||
|
{#snippet tools()}
|
||||||
|
{#if switcher}
|
||||||
|
<CurrencySwitch />
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
<div bind:clientWidth={width}>
|
<div bind:clientWidth={width}>
|
||||||
{#if width > 0 && narrow}
|
{#if width > 0 && narrow}
|
||||||
<div class="grid gap-4 sm:grid-cols-2">
|
<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 { createQuery } from "@tanstack/svelte-query";
|
||||||
import { getContext, setContext } from "svelte";
|
import { getContext, setContext } from "svelte";
|
||||||
import { lists } from "#lib/api/lists.js";
|
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 { formatMoney } from "#lib/format.js";
|
||||||
import { session } from "#lib/session.svelte.js";
|
import { session } from "#lib/session.svelte.js";
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
@@ -136,6 +136,7 @@ export interface Drill {
|
|||||||
asset?: string;
|
asset?: string;
|
||||||
at?: string;
|
at?: string;
|
||||||
box: string;
|
box: string;
|
||||||
|
currency?: string;
|
||||||
dim?: Dimension | "day" | "flow";
|
dim?: Dimension | "day" | "flow";
|
||||||
dir?: 1 | -1;
|
dir?: 1 | -1;
|
||||||
key?: string;
|
key?: string;
|
||||||
@@ -172,6 +173,7 @@ function readDrill(url: {
|
|||||||
asset: url.searchParams.get("acc") ?? undefined,
|
asset: url.searchParams.get("acc") ?? undefined,
|
||||||
at: url.searchParams.get("at") ?? undefined,
|
at: url.searchParams.get("at") ?? undefined,
|
||||||
box,
|
box,
|
||||||
|
currency: url.searchParams.get("dc") ?? undefined,
|
||||||
dim: DRILL_DIMS.has(dim) ? (dim as Drill["dim"]) : undefined,
|
dim: DRILL_DIMS.has(dim) ? (dim as Drill["dim"]) : undefined,
|
||||||
dir: DIRS[dir ?? ""],
|
dir: DIRS[dir ?? ""],
|
||||||
key: split > 0 ? raw.slice(split + 1) : undefined,
|
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> {
|
export function drillQuery(drill: Drill | null): Record<string, string | null> {
|
||||||
if (!drill) {
|
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 {
|
return {
|
||||||
acc: drill.asset ?? null,
|
acc: drill.asset ?? null,
|
||||||
at: drill.at ?? null,
|
at: drill.at ?? null,
|
||||||
box: drill.box,
|
box: drill.box,
|
||||||
|
dc: drill.currency ?? null,
|
||||||
dir: drill.dir ? DIR_NAMES[drill.dir] : null,
|
dir: drill.dir ? DIR_NAMES[drill.dir] : null,
|
||||||
dl: drill.label || null,
|
dl: drill.label || null,
|
||||||
drill:
|
drill:
|
||||||
@@ -208,6 +219,9 @@ export function drillFilter(
|
|||||||
if (drill.asset && flow.asset.id !== drill.asset) {
|
if (drill.asset && flow.asset.id !== drill.asset) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
if (drill.currency && flow.currency !== drill.currency) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
if (!drill.dim || drill.key === undefined) {
|
if (!drill.dim || drill.key === undefined) {
|
||||||
return true;
|
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 p = $derived(params());
|
||||||
const kind = $derived(kindOf(p.type));
|
const kind = $derived(kindOf(p.type));
|
||||||
const assets = createQuery(() => lists.accounts("asset"));
|
const assets = createQuery(() => lists.accounts("asset"));
|
||||||
@@ -273,8 +295,13 @@ export function createReport(params: () => ReportParams) {
|
|||||||
}));
|
}));
|
||||||
const tx = createQuery(() => ({
|
const tx = createQuery(() => ({
|
||||||
...reportQueries.transactions(p.start, p.end),
|
...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([
|
const accounts = $derived([
|
||||||
...(assets.data ?? []),
|
...(assets.data ?? []),
|
||||||
@@ -347,14 +374,14 @@ export function createReport(params: () => ReportParams) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const flows = $derived(
|
const flows = $derived(
|
||||||
tx.data && accountsReady
|
groups && accountsReady
|
||||||
? toFlows(tx.data, selectedSet, (id) => byId.get(id)?.currency_code)
|
? toFlows(groups, selectedSet, (id) => byId.get(id)?.currency_code)
|
||||||
: undefined
|
: undefined
|
||||||
);
|
);
|
||||||
|
|
||||||
const swaps = $derived(
|
const swaps = $derived(
|
||||||
tx.data && accountsReady
|
groups && accountsReady
|
||||||
? exchanges(tx.data, selectedSet, (id) => byId.get(id)?.currency_code)
|
? exchanges(groups, selectedSet, (id) => byId.get(id)?.currency_code)
|
||||||
: []
|
: []
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -451,7 +478,7 @@ export function createReport(params: () => ReportParams) {
|
|||||||
return grain;
|
return grain;
|
||||||
},
|
},
|
||||||
get groups() {
|
get groups() {
|
||||||
return tx.data;
|
return groups;
|
||||||
},
|
},
|
||||||
itemName: (kindKey: string, id: string) =>
|
itemName: (kindKey: string, id: string) =>
|
||||||
itemNames.get(`${kindKey}:${id}`),
|
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}` : ""}`;
|
||||||
|
}
|
||||||
@@ -1,384 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { createQuery } from "@tanstack/svelte-query";
|
import Board from "#lib/components/reports/Board.svelte";
|
||||||
import { lists } from "#lib/api/lists.js";
|
|
||||||
import Box from "#lib/components/Box.svelte";
|
|
||||||
import MultiSelect from "#lib/components/reports/MultiSelect.svelte";
|
|
||||||
import PageHeader from "#lib/components/shell/PageHeader.svelte";
|
|
||||||
import { today } from "#lib/dates.js";
|
|
||||||
import { prettyKeys, useKeys } from "#lib/keys.svelte.js";
|
|
||||||
import { period } from "#lib/period.svelte.js";
|
|
||||||
import { recall, remember } from "#lib/reports/memory.js";
|
|
||||||
import { listOptions, listReady } from "#lib/reports/options.js";
|
|
||||||
import {
|
|
||||||
fiscalYear,
|
|
||||||
LIST_LABELS,
|
|
||||||
type ListKind,
|
|
||||||
missing,
|
|
||||||
REPORT_KINDS,
|
|
||||||
type ReportParams,
|
|
||||||
type ReportType,
|
|
||||||
rangePresets,
|
|
||||||
reportPath,
|
|
||||||
} from "#lib/reports/params.js";
|
|
||||||
import { reportQueries } from "#lib/reports/queries.js";
|
|
||||||
import { accountOptions } from "#lib/reports/report.svelte.js";
|
|
||||||
import { session } from "#lib/session.svelte.js";
|
|
||||||
import { goto } from "$app/navigation";
|
|
||||||
|
|
||||||
const memory = recall();
|
|
||||||
let type = $state<ReportType>(memory.type);
|
|
||||||
let accounts = $state<string[]>(memory.accounts);
|
|
||||||
let items = $state<Partial<Record<ListKind, string[]>>>(memory.items);
|
|
||||||
let start = $derived(period.start);
|
|
||||||
let end = $derived(period.end);
|
|
||||||
let problem = $state("");
|
|
||||||
|
|
||||||
const kind = $derived(REPORT_KINDS.find((k) => k.type === type));
|
|
||||||
const list = $derived(kind?.list);
|
|
||||||
|
|
||||||
const assets = createQuery(() => lists.accounts("asset"));
|
|
||||||
const liabilities = createQuery(() => lists.accounts("liabilities"));
|
|
||||||
const budgets = createQuery(() => ({
|
|
||||||
...lists.budgets(),
|
|
||||||
enabled: list === "budget",
|
|
||||||
}));
|
|
||||||
const categories = createQuery(() => ({
|
|
||||||
...lists.categories(),
|
|
||||||
enabled: list === "category",
|
|
||||||
}));
|
|
||||||
const tags = createQuery(() => ({
|
|
||||||
...lists.tags(),
|
|
||||||
enabled: list === "tag",
|
|
||||||
}));
|
|
||||||
const expense = createQuery(() => ({
|
|
||||||
...lists.accounts("expense"),
|
|
||||||
enabled: list === "double",
|
|
||||||
}));
|
|
||||||
const revenue = createQuery(() => ({
|
|
||||||
...lists.accounts("revenue"),
|
|
||||||
enabled: list === "double",
|
|
||||||
}));
|
|
||||||
const first = createQuery(() => reportQueries.first());
|
|
||||||
|
|
||||||
const all = $derived([...(assets.data ?? []), ...(liabilities.data ?? [])]);
|
|
||||||
const accountReady = $derived(!!(assets.data && liabilities.data));
|
|
||||||
const options = $derived(accountOptions(all));
|
|
||||||
const sources = $derived({
|
|
||||||
budgets: budgets.data,
|
|
||||||
categories: categories.data,
|
|
||||||
expense: expense.data,
|
|
||||||
revenue: revenue.data,
|
|
||||||
tags: tags.data,
|
|
||||||
});
|
|
||||||
const itemOptions = $derived(list ? listOptions(list, sources) : []);
|
|
||||||
const allIds = $derived(all.map((a) => a.id).join(","));
|
|
||||||
const fiscal = $derived(
|
|
||||||
session.prefs.customFiscalYear
|
|
||||||
? String(session.prefs.fiscalYearStart ?? "01-01")
|
|
||||||
: null
|
|
||||||
);
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (accountReady && accounts.length === 0 && memory.accounts.length === 0) {
|
|
||||||
accounts = (assets.data ?? [])
|
|
||||||
.filter((a) => a.attributes.active)
|
|
||||||
.map((a) => a.id);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const params = $derived<ReportParams>({
|
|
||||||
accounts: accounts.filter(
|
|
||||||
(id) => all.length === 0 || all.some((a) => a.id === id)
|
|
||||||
),
|
|
||||||
end,
|
|
||||||
items: list ? (items[list] ?? []) : [],
|
|
||||||
start,
|
|
||||||
type,
|
|
||||||
});
|
|
||||||
|
|
||||||
const years = $derived.by(() => {
|
|
||||||
const now = Number(today().slice(0, 4));
|
|
||||||
const from = Number((first.data ?? today()).slice(0, 4));
|
|
||||||
return Array.from(
|
|
||||||
{ length: Math.max(1, now - from + 1) },
|
|
||||||
(_, i) => from + i
|
|
||||||
);
|
|
||||||
});
|
|
||||||
const monthName = $derived(
|
|
||||||
new Intl.DateTimeFormat(session.locale, { month: "short" })
|
|
||||||
);
|
|
||||||
|
|
||||||
function setRange(range: { start: string; end: string }) {
|
|
||||||
({ start, end } = range);
|
|
||||||
problem = "";
|
|
||||||
}
|
|
||||||
|
|
||||||
function monthRange(year: number, month: number) {
|
|
||||||
const last = new Date(year, month + 1, 0).getDate();
|
|
||||||
const mm = String(month + 1).padStart(2, "0");
|
|
||||||
return { end: `${year}-${mm}-${last}`, start: `${year}-${mm}-01` };
|
|
||||||
}
|
|
||||||
|
|
||||||
function quarterRange(year: number, quarter: number) {
|
|
||||||
const a = monthRange(year, quarter * 3);
|
|
||||||
const b = monthRange(year, quarter * 3 + 2);
|
|
||||||
return { end: b.end, start: a.start };
|
|
||||||
}
|
|
||||||
|
|
||||||
function submit() {
|
|
||||||
problem = missing(params) ?? "";
|
|
||||||
if (problem) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
remember(params);
|
|
||||||
goto(reportPath(params));
|
|
||||||
}
|
|
||||||
|
|
||||||
useKeys(() => [
|
|
||||||
{
|
|
||||||
group: "Report",
|
|
||||||
input: true,
|
|
||||||
keys: "mod+Enter",
|
|
||||||
label: "Show report",
|
|
||||||
run: submit,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
const quick = $derived([
|
|
||||||
{
|
|
||||||
label: "Current month, all accounts",
|
|
||||||
range: "currentMonthStart/currentMonthEnd",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Current year, all accounts",
|
|
||||||
range: "currentYearStart/currentYearEnd",
|
|
||||||
},
|
|
||||||
...(fiscal
|
|
||||||
? [
|
|
||||||
{
|
|
||||||
label: "Current fiscal year, all accounts",
|
|
||||||
range: "currentFiscalYearStart/currentFiscalYearEnd",
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: []),
|
|
||||||
{
|
|
||||||
label: "Last month, all accounts",
|
|
||||||
only: "default",
|
|
||||||
range: "previousMonthStart/previousMonthEnd",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "All-time, all accounts",
|
|
||||||
range: `${(first.data ?? today()).replaceAll("-", "")}/currentMonthEnd`,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<PageHeader crumbs={[{ label: "Reports" }]} title="Reports" />
|
<Board />
|
||||||
|
|
||||||
<div class="grid items-start gap-4 lg:grid-cols-2">
|
|
||||||
<form
|
|
||||||
class="flex min-w-0 flex-col gap-4"
|
|
||||||
onsubmit={(event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
submit();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Box body="p-4" title="Reports">
|
|
||||||
<div class="flex flex-col gap-4">
|
|
||||||
<label class="flex flex-col gap-1">
|
|
||||||
<span class="font-medium text-[12px] text-cladd-fg-soft"
|
|
||||||
>Report type</span
|
|
||||||
>
|
|
||||||
<select
|
|
||||||
class="cladd-cut cladd-focusable-input h-8 rounded-cladd-sm px-2 text-[13px] text-cladd-fg"
|
|
||||||
onchange={() => (problem = "")}
|
|
||||||
bind:value={type}
|
|
||||||
>
|
|
||||||
{#each REPORT_KINDS as option (option.type)}
|
|
||||||
<option value={option.type}>{option.label}</option>
|
|
||||||
{/each}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<div class="flex flex-col gap-1">
|
|
||||||
<label
|
|
||||||
class="font-medium text-[12px] text-cladd-fg-soft"
|
|
||||||
for="gen-accounts"
|
|
||||||
>Included accounts</label
|
|
||||||
>
|
|
||||||
<MultiSelect
|
|
||||||
id="gen-accounts"
|
|
||||||
label="Included accounts"
|
|
||||||
loading={!accountReady}
|
|
||||||
onchange={(next) => {
|
|
||||||
accounts = next;
|
|
||||||
problem = "";
|
|
||||||
}}
|
|
||||||
{options}
|
|
||||||
value={params.accounts}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-col gap-1">
|
|
||||||
<span class="font-medium text-[12px] text-cladd-fg-soft"
|
|
||||||
>Date range</span
|
|
||||||
>
|
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
|
||||||
<input
|
|
||||||
aria-label="Start date"
|
|
||||||
class="cladd-cut cladd-focusable-input num h-8 rounded-cladd-sm px-2 text-[13px] text-cladd-fg"
|
|
||||||
min={first.data ?? undefined}
|
|
||||||
type="date"
|
|
||||||
bind:value={start}
|
|
||||||
>
|
|
||||||
<span class="text-cladd-fg-soft">to</span>
|
|
||||||
<input
|
|
||||||
aria-label="End date"
|
|
||||||
class="cladd-cut cladd-focusable-input num h-8 rounded-cladd-sm px-2 text-[13px] text-cladd-fg"
|
|
||||||
type="date"
|
|
||||||
bind:value={end}
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<div class="mt-1 flex flex-wrap gap-x-3 gap-y-1 text-[12px]">
|
|
||||||
{#each rangePresets() as preset (preset.key)}
|
|
||||||
<button
|
|
||||||
class="text-brand dark:text-[var(--ff-series-1)] hover:underline {preset
|
|
||||||
.range.start === start && preset.range.end === end
|
|
||||||
? "font-semibold"
|
|
||||||
: ""}"
|
|
||||||
onclick={() => setRange(preset.range)}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
{preset.label}
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-col gap-1">
|
|
||||||
<span class="font-medium text-[12px] text-cladd-fg-soft"
|
|
||||||
>Pre-set ranges</span
|
|
||||||
>
|
|
||||||
<ul class="flex flex-col gap-1 text-[12px]">
|
|
||||||
{#each years as year (year)}
|
|
||||||
<li class="flex flex-wrap items-baseline gap-x-2 gap-y-0.5">
|
|
||||||
<button
|
|
||||||
class="num font-semibold text-brand dark:text-[var(--ff-series-1)] hover:underline"
|
|
||||||
onclick={() =>
|
|
||||||
setRange(
|
|
||||||
fiscal
|
|
||||||
? fiscalYear(`${year}-12-31`, fiscal)
|
|
||||||
: { end: `${year}-12-31`, start: `${year}-01-01` }
|
|
||||||
)}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
{year}{fiscal ? " (fiscal year)" : ""}
|
|
||||||
</button>
|
|
||||||
{#each [0, 1, 2, 3] as q (q)}
|
|
||||||
<button
|
|
||||||
class="text-brand dark:text-[var(--ff-series-1)] hover:underline"
|
|
||||||
onclick={() => setRange(quarterRange(year, q))}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
Q{q + 1}
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
<span class="text-cladd-fg-softer">·</span>
|
|
||||||
{#each Array.from({ length: 12 }, (_, m) => m) as m (m)}
|
|
||||||
{#if `${year}-${String(m + 1).padStart(2, "0")}` <=
|
|
||||||
today().slice(0, 7)}
|
|
||||||
<button
|
|
||||||
class="text-brand dark:text-[var(--ff-series-1)] hover:underline"
|
|
||||||
onclick={() => setRange(monthRange(year, m))}
|
|
||||||
title="{monthName.format(new Date(year, m, 1))} {year}"
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
{monthName.format(new Date(year, m, 1))}
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
{/each}
|
|
||||||
</li>
|
|
||||||
{/each}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Box body="p-4" title="Extra options">
|
|
||||||
{#if list}
|
|
||||||
<div class="flex flex-col gap-1">
|
|
||||||
<label
|
|
||||||
class="font-medium text-[12px] text-cladd-fg-soft"
|
|
||||||
for="gen-items"
|
|
||||||
>Select {LIST_LABELS[list].many.toLowerCase()}</label
|
|
||||||
>
|
|
||||||
<MultiSelect
|
|
||||||
id="gen-items"
|
|
||||||
label={LIST_LABELS[list].many}
|
|
||||||
loading={!listReady(list, sources)}
|
|
||||||
onchange={(next) => {
|
|
||||||
items = { ...items, [list]: next };
|
|
||||||
problem = "";
|
|
||||||
}}
|
|
||||||
options={itemOptions}
|
|
||||||
value={params.items}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<p class="text-cladd-fg-soft">This report has no extra options</p>
|
|
||||||
{/if}
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Box body="p-4" title="View report">
|
|
||||||
<div class="flex flex-wrap items-center gap-3">
|
|
||||||
<button
|
|
||||||
class="cladd-surface cladd-gradient cladd-outline cladd-hoverable cladd-color-brand h-8 rounded-cladd-sm px-4 font-semibold text-[13px] text-cladd-primary"
|
|
||||||
type="submit"
|
|
||||||
>
|
|
||||||
Show report
|
|
||||||
</button>
|
|
||||||
<span class="flex items-center gap-1 text-[12px] text-cladd-fg-soft">
|
|
||||||
{#each prettyKeys("mod+Enter") as part (part)}
|
|
||||||
<kbd class="kbd">{part}</kbd>
|
|
||||||
{/each}
|
|
||||||
</span>
|
|
||||||
{#if problem}
|
|
||||||
<span class="text-expense" role="alert">{problem}</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</Box>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<Box body="p-4" title="Quick links">
|
|
||||||
{#if accountReady && all.length === 0}
|
|
||||||
<p class="text-expense">
|
|
||||||
In order to generate reports, you need to add at least one asset account
|
|
||||||
to Firefly III.
|
|
||||||
</p>
|
|
||||||
{:else}
|
|
||||||
<div class="flex flex-col gap-4">
|
|
||||||
{#each [
|
|
||||||
{ label: "Default financial report", type: "default" },
|
|
||||||
{ label: "Transaction history overview", type: "audit" },
|
|
||||||
] as section (section.type)}
|
|
||||||
<section>
|
|
||||||
<h3 class="mb-1 font-semibold text-[13px]">{section.label}</h3>
|
|
||||||
<ul class="flex flex-col gap-0.5">
|
|
||||||
{#each quick.filter(
|
|
||||||
(q) => !q.only || q.only === section.type
|
|
||||||
) as link (link.label)}
|
|
||||||
<li>
|
|
||||||
<a
|
|
||||||
class="text-brand dark:text-[var(--ff-series-1)] hover:underline"
|
|
||||||
href="/reports/{section.type}/{allIds}/{link.range}"
|
|
||||||
>{link.label}</a
|
|
||||||
>
|
|
||||||
</li>
|
|
||||||
{/each}
|
|
||||||
</ul>
|
|
||||||
</section>
|
|
||||||
{/each}
|
|
||||||
<p class="text-[12px] text-cladd-fg-soft">
|
|
||||||
Remember that reports can be bookmarked.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</Box>
|
|
||||||
</div>
|
|
||||||
@@ -28,7 +28,7 @@
|
|||||||
<a
|
<a
|
||||||
class="text-brand dark:text-[var(--ff-series-1)] hover:underline"
|
class="text-brand dark:text-[var(--ff-series-1)] hover:underline"
|
||||||
href="/reports"
|
href="/reports"
|
||||||
>Back to the report generator</a
|
>Back to reports</a
|
||||||
>
|
>
|
||||||
</Box>
|
</Box>
|
||||||
{/if}
|
{/if}
|
||||||
Reference in new issue
Block a user