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

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

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

No files matched your search

+455
View File
@@ -0,0 +1,455 @@
<script lang="ts">
import { createQuery } from "@tanstack/svelte-query";
import { lists } from "#lib/api/lists.js";
import Lanes from "#lib/components/charts/Lanes.svelte";
import PageHeader from "#lib/components/shell/PageHeader.svelte";
import { today } from "#lib/dates.js";
import { useKeys } from "#lib/keys.svelte.js";
import { period } from "#lib/period.svelte.js";
import {
buildBoard,
cursorOf,
grew,
longMonth,
mergedLane,
monthFacts,
monthOf,
monthRange,
shiftMonth,
spendingLanes,
} from "#lib/reports/board.js";
import { exchanges } from "#lib/reports/flows.js";
import { ledgerQuery, ledgerWindow, ownFlows } from "#lib/reports/ledger.js";
import { ALL_ASSETS, type ReportParams, ymd } from "#lib/reports/params.js";
import {
createReport,
type Drill,
drillQuery,
navigate,
provideReport,
} from "#lib/reports/report.svelte.js";
import { page } from "$app/state";
import BoardTables from "./BoardTables.svelte";
import CurrencyTable from "./CurrencyTable.svelte";
import Headline from "./Headline.svelte";
import MoneyFlow from "./MoneyFlow.svelte";
import MonthPanel from "./MonthPanel.svelte";
import NetWorthLanes from "./NetWorthLanes.svelte";
import ReportBox from "./ReportBox.svelte";
import ReportRow from "./ReportRow.svelte";
import WhatGrew from "./WhatGrew.svelte";
const now = today();
const thisYear = now.slice(0, 4);
const thisMonth = monthOf(now);
const query = $derived(page.url.searchParams);
const view = $derived(query.get("view") === "month" ? "month" : "year");
const tables = $derived(query.has("tables"));
const merged = $derived(query.has("merged"));
const tab = $derived(query.get("tab") === "month" ? "month" : "grew");
const cursor = $derived(cursorOf(period.range, now));
const year = $derived(cursor.slice(0, 4));
const anchor = $derived(year === thisYear ? now : `${year}-12-31`);
const span = $derived(ledgerWindow(anchor));
const first = $derived(monthOf(period.first ?? "0000-01"));
const ledger = createQuery(() => ledgerQuery(anchor));
const assets = createQuery(() => lists.accounts("asset"));
const liabilities = createQuery(() => lists.accounts("liabilities"));
const own = $derived(
assets.data && liabilities.data
? [...assets.data, ...liabilities.data]
: undefined
);
const ownIds = $derived(own?.map((a) => a.id) ?? []);
const range = $derived(
view === "month"
? monthRange(cursor)
: { end: `${year}-12-31`, start: `${year}-01-01` }
);
const params = $derived<ReportParams>({
accounts: ownIds,
end: range.end,
items: [],
start: range.start,
type: "default",
});
const report = createReport(
() => params,
() => ledger.data
);
provideReport(report);
const flows = $derived(
ledger.data && own ? ownFlows(ledger.data, own) : undefined
);
const board = $derived(
flows ? buildBoard(flows, span.months, year, report.codes, now) : undefined
);
const pcOk = $derived(
!!flows &&
flows.length > 0 &&
flows.every((flow) => flow.pc !== null) &&
(board?.codes.length ?? 0) > 1
);
const lanes = $derived.by(() => {
if (!board) {
return [];
}
return pcOk && merged ? [mergedLane(board)] : spendingLanes(board);
});
const swaps = $derived.by(() => {
if (!(ledger.data && own)) {
return [];
}
const currency = new Map(
own.map((a) => [a.id, a.attributes.currency_code])
);
return exchanges(
ledger.data,
new Set(ownIds),
(id) => currency.get(id) ?? undefined
);
});
const grewRows = $derived(board ? grew(board, cursor) : []);
const facts = $derived(board ? monthFacts(board, cursor) : []);
const loading = $derived(!board);
let hover = $state<string | null>(null);
function go(month: string, drill?: Drill | null) {
const target = monthRange(month);
navigate({
end: target.end,
start: target.start,
...(drill === undefined ? {} : drillQuery(drill)),
});
}
function clamp(month: string) {
if (month > thisMonth) {
return thisMonth;
}
return month < first ? first : month;
}
function move(delta: number) {
const next = clamp(shiftMonth(cursor, delta));
if (next !== cursor) {
go(next);
}
}
function labelFor(month: string, item = "Spent") {
return view === "year" ? item : `${item}, ${longMonth(month)}`;
}
function drillFor(
month: string,
box: string,
extra: Partial<Drill> = {}
): Drill {
return {
at: view === "year" ? month : undefined,
box,
dir: -1,
...extra,
label: labelFor(month, extra.label),
};
}
function openMonth(month: string, segment?: string) {
const found = board?.catKeys.find(
(k) => k.key === segment && k.key !== "other"
);
const drill = drillFor(month, "months", {
dim: found ? "category" : undefined,
key: found?.key,
label: found?.label,
});
const same =
report.drill?.box === "months" &&
report.drill.key === drill.key &&
month === cursor;
go(month, same ? null : drill);
}
function setView(next: "year" | "month") {
navigate({ ...drillQuery(null), view: next === "month" ? "month" : null });
}
function flag(name: string, on: boolean) {
navigate({ [name]: on ? "1" : null });
}
const yearLinks = $derived.by(() => {
const dates = `${ymd(range.start)}/${ymd(range.end)}`;
const top = [...(board?.spending ?? [])]
.filter((flow) => flow.other.type === "Expense account")
.reduce((counts, flow) => {
counts.set(flow.other.id, (counts.get(flow.other.id) ?? 0) + 1);
return counts;
}, new Map<string, number>());
const busiest = [...top.entries()]
.sort((a, b) => b[1] - a[1])
.slice(0, 3)
.map(([id]) => id);
return [
{
href: `/reports/budget/${ALL_ASSETS}/allBudgets/${dates}`,
label: "Budget report",
},
{
href: `/reports/category/${ALL_ASSETS}/allCategories/${dates}`,
label: "Category report",
},
{
href: `/reports/tag/${ALL_ASSETS}/allTags/${dates}`,
label: "Tag report",
},
...(busiest.length > 0
? [
{
href: `/reports/double/${ALL_ASSETS}/${busiest.join(",")}/${dates}`,
label: "Expense and revenue account report",
},
]
: []),
{
href: `/reports/audit/${ALL_ASSETS}/${dates}`,
label: "Transaction history",
},
{
href: `/reports/default/${ALL_ASSETS}/${dates}`,
label: "Default financial report",
},
];
});
useKeys(() => [
{
group: "Report",
keys: "ArrowLeft",
label: "Previous month",
run: () => move(-1),
},
{
group: "Report",
keys: "ArrowRight",
label: "Next month",
run: () => move(1),
},
{
group: "Report",
keys: "[",
label: "Previous year",
run: () => move(-12),
},
{ group: "Report", keys: "]", label: "Next year", run: () => move(12) },
{
group: "Report",
keys: "Enter",
label: "Transactions of the month",
run: () => openMonth(cursor),
},
{
group: "Report",
keys: "m",
label: tables ? "Hide tables by month" : "Show tables by month",
run: () => flag("tables", !tables),
},
{
group: "Report",
keys: "g M",
label: "Month view",
run: () => setView("month"),
},
{
group: "Report",
keys: "g Y",
label: "Year view",
run: () => setView("year"),
},
...(pcOk
? [
{
group: "Report",
keys: "c",
label: merged ? "One lane per currency" : "All in one currency",
run: () => flag("merged", !merged),
},
]
: []),
]);
</script>
<PageHeader
crumbs={[{ label: "Reports" }]}
subtitle={view === "month" ? longMonth(cursor) : year}
title="Reports"
>
{#snippet actions()}
<nav aria-label="Report period" class="flex items-center gap-3 text-[13px]">
{#if `${Number(year) - 1}-12` >= first}
<button
class="text-brand hover:underline dark:text-[var(--ff-series-1)]"
onclick={() => move(-12)}
title="Previous year ["
type="button"
>
‹ {Number(year) - 1}
</button>
{/if}
{#if year < thisYear}
<button
class="text-brand hover:underline dark:text-[var(--ff-series-1)]"
onclick={() => move(12)}
title="Next year ]"
type="button"
>
{Number(year) + 1}
›
</button>
{/if}
<span aria-hidden="true" class="text-cladd-fg-softest">|</span>
{#each [
{ key: "year" as const, label: "Year", shortcut: "g Y" },
{ key: "month" as const, label: "Month", shortcut: "g M" },
] as item (item.key)}
<button
aria-pressed={view === item.key}
class={view === item.key
? "font-semibold text-cladd-fg"
: "text-brand hover:underline dark:text-[var(--ff-series-1)]"}
onclick={() => setView(item.key)}
title="{item.label} view {item.shortcut}"
type="button"
>
{item.label}
</button>
{/each}
</nav>
{/snippet}
</PageHeader>
{#if ledger.error}
<p class="mb-4 text-expense">
Firefly III did not return the transactions: {ledger.error.message}
</p>
{/if}
<div class="flex flex-col gap-4">
<Headline {board} month={view === "month" ? cursor : null} />
<ReportBox
body="px-3 pt-2 pb-3"
empty={lanes.length === 0}
emptyText="No spending or income in {year}."
id="months"
{loading}
title={merged && pcOk
? "Spent each month, all in one currency"
: "Spent each month"}
>
{#snippet tools()}
{#if pcOk}
<button
aria-pressed={merged}
class="text-[12px] text-brand hover:underline dark:text-[var(--ff-series-1)]"
onclick={() => flag("merged", !merged)}
title="Switch c"
type="button"
>
{merged ? "One lane per currency" : "All in one currency"}
</button>
{/if}
{/snippet}
<Lanes
{cursor}
dotLabel="earned"
label="Spent each month by currency"
{lanes}
onselect={(month, _lane, segment) => openMonth(month, segment)}
partial={board?.partial}
pinned={view === "month"}
title={(row) => longMonth(row.key)}
bind:hover
/>
</ReportBox>
<div class="flex gap-4 text-[13px] lg:hidden" role="tablist">
{#each [
{ key: "grew", label: "What grew" },
{ key: "month", label: longMonth(cursor) },
] as item (item.key)}
<button
aria-selected={tab === item.key}
class={tab === item.key
? "font-semibold text-cladd-fg"
: "text-brand dark:text-[var(--ff-series-1)]"}
onclick={() => navigate({ tab: item.key === "month" ? "month" : null })}
role="tab"
type="button"
>
{item.label}
</button>
{/each}
</div>
<ReportRow class="lg:grid-cols-2">
<WhatGrew
class={tab === "grew" ? "" : "max-lg:hidden"}
codes={board?.codes.length ?? 0}
colors={board?.catKeys ?? []}
{cursor}
{loading}
monthDrill={(extra) => drillFor(cursor, "grew", extra)}
partial={cursor === board?.partial}
rows={grewRows}
/>
<MonthPanel
class={tab === "month" ? "" : "max-lg:hidden"}
{facts}
{loading}
month={cursor}
monthDrill={(extra) => drillFor(cursor, "month", extra)}
partial={cursor === board?.partial}
swaps={swaps.filter((swap) => monthOf(swap.date) === cursor)}
totals={view === "year"}
/>
</ReportRow>
{#if view === "month"}
<MoneyFlow switcher />
{:else if board}
<CurrencyTable {board} {swaps} />
<NetWorthLanes {cursor} groups={ledger.data ?? []} {own} {year} />
{/if}
{#if tables}
<BoardTables {view} />
{/if}
<nav
aria-label="More reports"
class="flex flex-wrap items-baseline gap-x-4 gap-y-1 text-[12px] text-cladd-fg-soft"
>
<button
class="text-brand hover:underline dark:text-[var(--ff-series-1)]"
onclick={() => flag("tables", !tables)}
title="Tables by month m"
type="button"
>
{tables ? "Hide tables by month" : "Tables by month"}
</button>
{#each yearLinks as link (link.href)}
<a
class="text-brand hover:underline dark:text-[var(--ff-series-1)]"
href={link.href}
>{link.label}</a
>
{/each}
</nav>
</div>
@@ -0,0 +1,145 @@
<script lang="ts">
import { bucketOf, buckets, group, keys, total } from "#lib/reports/flows.js";
import { modeLabel, useReport } from "#lib/reports/report.svelte.js";
import { bucketLabel } from "#lib/reports/view.js";
import BudgetLimits from "./BudgetLimits.svelte";
import CurrencySwitch from "./CurrencySwitch.svelte";
import Matrix from "./Matrix.svelte";
import ReportBox from "./ReportBox.svelte";
import ReportRow from "./ReportRow.svelte";
import SpendingCalendar from "./SpendingCalendar.svelte";
import Subscriptions from "./Subscriptions.svelte";
import SumTable from "./SumTable.svelte";
let { view }: { view: "year" | "month" } = $props();
const report = useReport();
const flows = $derived(report.flows);
const income = $derived(flows?.filter((f) => f.dir === 1) ?? []);
const expenses = $derived(flows?.filter((f) => f.dir === -1) ?? []);
const withdrawals = $derived(expenses.filter((f) => f.type === "withdrawal"));
const deposits = $derived(income.filter((f) => f.type === "deposit"));
const periodCols = $derived(
buckets(report.params.start, report.params.end, "month").map((key) => ({
key,
label: bucketLabel(key, "month", true),
title: bucketLabel(key, "month"),
}))
);
const month = (date: string) => bucketOf(date, "month");
const NAMES = { budget: "Budget", category: "Category", tag: "Tag" };
</script>
{#snippet matrix(
id: string,
title: string,
dim: "budget" | "category" | "tag",
dir: 1 | -1,
list: typeof expenses
)}
<ReportBox
empty={list.length === 0}
{id}
loading={!flows}
title="{title} in {modeLabel(report.mode)}"
>
{#snippet tools()}
<CurrencySwitch />
{/snippet}
<Matrix
average={dim === "budget"}
box={id}
cols={periodCols}
{dim}
{dir}
nameLabel={NAMES[dim]}
rows={group(list, keys[dim], (f) => month(f.date))}
side={dir === 1 ? "in" : "out"}
/>
</ReportBox>
{/snippet}
<div class="flex flex-col gap-4">
{#if view === "year"}
{@render matrix("budget-periods", "Budgets", "budget", -1, withdrawals)}
{@render matrix(
"category-expenses",
"Categories, spent",
"category",
-1,
withdrawals
)}
{@render matrix(
"category-income",
"Categories, earned",
"category",
1,
deposits
)}
{@render matrix("tag-periods", "Tags, spent", "tag", -1, expenses)}
{:else}
<ReportRow class="lg:grid-cols-2">
<ReportBox
empty={expenses.length === 0}
id="expenses"
loading={!flows}
title="Expenses"
>
<SumTable
box="expenses"
columns={[{ kind: "out", label: "Total" }]}
counts
dim="other"
dir={-1}
footer={total(expenses)}
limit={10}
rows={group(expenses, keys.other)}
sort="out"
/>
</ReportBox>
<ReportBox
empty={income.length === 0}
id="income"
loading={!flows}
title="Revenue / income"
>
<SumTable
box="income"
columns={[{ kind: "in", label: "Total" }]}
counts
dim="other"
dir={1}
footer={total(income)}
limit={10}
rows={group(income, keys.other)}
sort="in"
/>
</ReportBox>
</ReportRow>
<ReportRow class="lg:grid-cols-12">
<BudgetLimits class="lg:col-span-7" />
<ReportBox
class="lg:col-span-5"
empty={expenses.length === 0}
id="tags"
loading={!flows}
title="Tags"
>
<SumTable
box="tags"
columns={[
{ kind: "out", label: "Spent" },
{ kind: "in", label: "Earned" },
]}
dim="tag"
footer={report.totals}
limit={10}
nameLabel="Tag"
rows={group(flows ?? [], keys.tag)}
/>
</ReportBox>
</ReportRow>
<Subscriptions />
{/if}
<SpendingCalendar />
</div>
@@ -0,0 +1,26 @@
<script lang="ts">
import {
modeLabel,
navigate,
useReport,
} from "#lib/reports/report.svelte.js";
const report = useReport();
</script>
{#if report.available.length > 1}
<div class="flex items-center gap-3 text-[12px]">
{#each report.available as mode (mode)}
<button
aria-pressed={report.mode === mode}
class={report.mode === mode
? "font-semibold text-cladd-fg"
: "text-brand hover:underline dark:text-[var(--ff-series-1)]"}
onclick={() => navigate({ cur: mode })}
type="button"
>
{modeLabel(mode)}
</button>
{/each}
</div>
{/if}
@@ -0,0 +1,199 @@
<script lang="ts">
import { formatNumber } from "#lib/format.js";
import {
type Board,
longMonth,
monthOf,
shortMonth,
} from "#lib/reports/board.js";
import type { Exchange } from "#lib/reports/flows.js";
import {
drillQuery,
navigate,
useReport,
} from "#lib/reports/report.svelte.js";
import { typical } from "#lib/reports/typical.js";
import ReportBox from "./ReportBox.svelte";
let { board, swaps }: { board: Board; swaps: Exchange[] } = $props();
const report = useReport();
type Kind = "spent" | "earned" | "moved";
const LABELS: Record<Kind, string> = {
earned: "Earned",
moved: "Exchanged",
spent: "Spent",
};
const moved = $derived.by(() => {
const sums = new Map<string, Map<string, number>>();
const add = (code: string, month: string, value: number) => {
const byMonth = sums.get(code) ?? new Map<string, number>();
byMonth.set(month, (byMonth.get(month) ?? 0) + value);
sums.set(code, byMonth);
};
for (const swap of swaps) {
add(swap.to, monthOf(swap.date), swap.toAmount);
add(swap.from, monthOf(swap.date), -swap.fromAmount);
}
return sums;
});
const sources = $derived({
earned: board.earned,
moved,
spent: board.spent,
});
const rows = $derived(
board.codes.flatMap((code) =>
(["spent", "earned", "moved"] as Kind[])
.map((kind) => {
const byMonth = sources[kind].get(code);
return {
byMonth,
code,
kind,
total: board.months.reduce(
(sum, m) => sum + (byMonth?.get(m) ?? 0),
0
),
typical:
kind === "moved" ? null : typical(byMonth, board.typicalMonths),
};
})
.filter((row) => board.months.some((m) => row.byMonth?.get(m)))
)
);
const toneOf = (kind: Kind, value: number) => {
if (kind === "spent") {
return "text-expense";
}
if (kind === "earned") {
return "text-income";
}
return value < 0 ? "text-cladd-fg-soft" : "text-transfer";
};
const cell = (kind: Kind, value: number) =>
kind === "moved" ? formatNumber(value, 0, true) : formatNumber(value, 0);
function open(code: string, kind: Kind, month: string) {
const dir = kind === "spent" ? -1 : 1;
const current = report.drill;
const same =
current?.box === "currencies" &&
current.at === month &&
current.currency === code &&
current.dir === dir;
navigate(
drillQuery(
same
? null
: {
at: month,
box: "currencies",
currency: code,
dir,
label: `${LABELS[kind]} in ${code}, ${longMonth(month)}`,
}
)
);
}
</script>
<ReportBox
empty={rows.length === 0}
id="currencies"
loading={false}
title="By currency"
>
<div class="overflow-x-auto">
<table class="w-full border-collapse text-[13px]">
<thead>
<tr class="text-[12px] text-cladd-fg-soft">
<th
class="sticky left-0 z-[1] h-7 bg-cladd-surface px-3 text-left font-medium"
></th>
{#each board.months as month (month)}
<th
class="h-7 px-2 text-right font-medium {month === board.partial
? "italic"
: ""}"
title={longMonth(month)}
>
{shortMonth(month)}
</th>
{/each}
<th class="h-7 whitespace-nowrap px-2 text-right font-medium">
Typical
</th>
<th class="h-7 px-3 text-right font-medium">Total</th>
</tr>
</thead>
<tbody>
{#each rows as row, i (`${row.code}-${row.kind}`)}
{@const first = i === 0 || rows[i - 1].code !== row.code}
<tr
class="h-7 hover:bg-row-hover {first
? "border-cladd-outline border-t-2"
: "border-cladd-outline border-t"}"
>
<th
class="sticky left-0 z-[1] whitespace-nowrap bg-cladd-surface px-3 text-left font-normal"
scope="row"
>
<span class="inline-block w-10 font-semibold"
>{first ? row.code : ""}</span
>
<span class="text-cladd-fg-soft">{LABELS[row.kind]}</span>
</th>
{#each board.months as month (month)}
{@const value = row.byMonth?.get(month) ?? 0}
{@const active =
report.drill?.box === "currencies" &&
report.drill.at === month &&
report.drill.currency === row.code &&
report.drill.dir === (row.kind === "spent" ? -1 : 1)}
<td class="p-0 text-right">
{#if value && row.kind !== "moved"}
<button
class="num h-7 w-full whitespace-nowrap px-2 text-right hover:bg-row-cursor {toneOf(
row.kind,
value
)} {active ? "bg-row-selected font-semibold" : ""}"
onclick={() => open(row.code, row.kind, month)}
tabindex="-1"
title="{LABELS[row.kind]} in {row.code}, {longMonth(month)}"
type="button"
>
{cell(row.kind, value)}
</button>
{:else if value}
<span
class="num block whitespace-nowrap px-2 {toneOf(
row.kind,
value
)}"
>{cell(row.kind, value)}</span
>
{/if}
</td>
{/each}
<td
class="num whitespace-nowrap px-2 text-right text-cladd-fg-soft"
>
{row.typical === null ? "" : formatNumber(row.typical, 0)}
</td>
<td class="num whitespace-nowrap px-3 text-right font-semibold">
{cell(row.kind, row.total)}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</ReportBox>
+128
View File
@@ -0,0 +1,128 @@
<script lang="ts">
import Box from "#lib/components/Box.svelte";
import {
type Board,
headlines,
longMonth,
ordinal,
shortMonth,
} from "#lib/reports/board.js";
import { money } from "#lib/reports/report.svelte.js";
import { versus, versusText } from "#lib/reports/typical.js";
let {
board,
month = null,
}: { board: Board | undefined; month?: string | null } = $props();
const rows = $derived(
board ? headlines(board, month ? [month] : board.months) : []
);
const year = $derived(board?.months[0]?.slice(0, 4) ?? "");
const period = $derived(month ? longMonth(month) : year);
const showPace = $derived(rows.some((row) => row.pace));
const byDay = $derived(
`Typical by the ${ordinal(Number(board?.now.slice(8, 10) ?? 0))}`
);
const tone = (ratio: number | null) => {
if (ratio === null || Math.abs(ratio) < 0.03) {
return "text-cladd-fg-soft";
}
return ratio > 0 ? "text-expense" : "text-income";
};
</script>
<Box body="px-3 py-2">
{#if !board}
<p class="py-2 text-cladd-fg-soft">Loading</p>
{:else if rows.length === 0}
<p class="py-2 text-cladd-fg-soft">No spending or income in {period}.</p>
{:else}
<div class="overflow-x-auto">
<table class="border-collapse text-[13px]">
<thead>
<tr
class="whitespace-nowrap text-left text-[12px] text-cladd-fg-soft"
>
<th class="w-12 pr-3 font-medium"></th>
<th class="pr-10 font-medium">Spent in {period}</th>
{#if showPace && !month}
<th class="pr-10 font-medium">
{shortMonth(board.partial ?? "")}
so far
</th>
{/if}
{#if showPace}
<th class="pr-10 font-medium">{byDay}</th>
{/if}
<th class="pr-10 font-medium">Typical month</th>
<th class="font-medium">Earned in {period}</th>
</tr>
</thead>
<tbody>
{#each rows as row (row.code)}
{@const pace = row.pace}
{@const paceRatio = pace ? versus(pace.sofar, pace.typical) : null}
<tr class="h-9">
<th class="pr-3 text-left font-semibold" scope="row">
{row.code}
</th>
<td class="pr-10 sm:whitespace-nowrap">
<span class="num font-semibold text-[18px] tracking-[-0.01em]"
>{money(
row.code,
month && pace ? pace.sofar : row.spent,
false,
0
)}</span
>
{#if month}
{@const ratio = pace
? paceRatio
: versus(row.spent, row.typical)}
<span
class="ml-2 whitespace-nowrap max-sm:ml-0 max-sm:block max-sm:text-[12px] {tone(
ratio
)}"
>{versusText(ratio)}</span
>
{/if}
</td>
{#if showPace && !month}
<td class="pr-10 sm:whitespace-nowrap">
{#if pace}
<span class="num"
>{money(row.code, pace.sofar, false, 0)}</span
>
<span
class="ml-2 whitespace-nowrap max-sm:ml-0 max-sm:block max-sm:text-[12px] {tone(
paceRatio
)}"
>{versusText(paceRatio)}</span
>
{/if}
</td>
{/if}
{#if showPace}
<td class="num whitespace-nowrap pr-10">
{#if pace}
{money(row.code, pace.typical, false, 0)}
{/if}
</td>
{/if}
<td class="num whitespace-nowrap pr-10">
{money(row.code, row.typical, false, 0)}
</td>
<td class="num whitespace-nowrap text-income">
{#if row.earned}
{money(row.code, row.earned, false, 0)}
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</Box>
+53 -22
View File
@@ -8,8 +8,11 @@
navigate,
useReport,
} from "#lib/reports/report.svelte.js";
import CurrencySwitch from "./CurrencySwitch.svelte";
import ReportBox from "./ReportBox.svelte";
let { switcher = false }: { switcher?: boolean } = $props();
const LEFT_MAX = 5;
const RIGHT_MAX = 9;
const SLOT = 34;
@@ -87,6 +90,7 @@
function swaps(dir: 1 | -1): Node[] {
return net
.filter(([, value]) => Math.sign(value) === dir)
.sort((a, b) => Math.abs(b[1]) - Math.abs(a[1]))
.map(([code, value]) => ({
color: "var(--ff-transfer)",
dir,
@@ -97,34 +101,56 @@
}));
}
function cover(nodes: Node[], gap: number): [Node[], number] {
let rest = gap;
const used: Node[] = [];
for (const node of nodes) {
const value = Math.min(node.value, rest);
if (value >= CENT) {
used.push({ ...node, value });
rest -= value;
}
}
return [used, rest];
}
const model = $derived.by(() => {
const flows = report.flows ?? [];
const left = side(flows, 1, LEFT_MAX);
const right = side(flows, -1, RIGHT_MAX);
if (mode !== "pc") {
left.push(...swaps(1));
right.push(...swaps(-1));
}
const totalIn = left.reduce((sum, n) => sum + n.value, 0);
const totalOut = right.reduce((sum, n) => sum + n.value, 0);
if (totalIn - totalOut >= CENT) {
right.push({
color: "var(--ff-series-1)",
dir: 1,
drillable: false,
key: "kept",
label: "Left over",
value: totalIn - totalOut,
});
} else if (totalOut - totalIn >= CENT) {
left.push({
color: "var(--ff-warn)",
dir: -1,
drillable: false,
key: "deficit",
label: "From balance",
value: totalOut - totalIn,
});
const exchange = mode !== "pc";
if (totalOut - totalIn >= CENT) {
const [used, rest] = exchange
? cover(swaps(1), totalOut - totalIn)
: [[], totalOut - totalIn];
left.push(...used);
if (rest >= CENT) {
left.push({
color: "var(--ff-warn)",
dir: -1,
drillable: false,
key: "deficit",
label: "From balance",
value: rest,
});
}
} else if (totalIn - totalOut >= CENT) {
const [used, rest] = exchange
? cover(swaps(-1), totalIn - totalOut)
: [[], totalIn - totalOut];
right.push(...used);
if (rest >= CENT) {
right.push({
color: "var(--ff-series-1)",
dir: 1,
drillable: false,
key: "kept",
label: "Left over",
value: rest,
});
}
}
return { left, right, total: Math.max(totalIn, totalOut) };
});
@@ -205,6 +231,11 @@
loading={!report.flows}
title="Where the money went ({modeLabel(mode)})"
>
{#snippet tools()}
{#if switcher}
<CurrencySwitch />
{/if}
{/snippet}
<div bind:clientWidth={width}>
{#if width > 0 && narrow}
<div class="grid gap-4 sm:grid-cols-2">
@@ -0,0 +1,195 @@
<script lang="ts">
import { formatDate } from "#lib/format.js";
import { longMonth, type MonthFacts } from "#lib/reports/board.js";
import type { Exchange } from "#lib/reports/flows.js";
import {
type Drill,
drillQuery,
money,
navigate,
useReport,
} from "#lib/reports/report.svelte.js";
import { versusText } from "#lib/reports/typical.js";
import ReportBox from "./ReportBox.svelte";
let {
facts,
month,
swaps,
loading,
partial = false,
totals = true,
monthDrill,
class: className = "",
}: {
facts: MonthFacts[];
month: string;
swaps: Exchange[];
loading: boolean;
partial?: boolean;
totals?: boolean;
monthDrill: (extra: Partial<Drill>) => Drill;
class?: string;
} = $props();
const report = useReport();
const shown = $derived(facts.filter((f) => f.spent > 0 || f.earned > 0));
const moved = $derived.by(() => {
const pairs = new Map<
string,
{ from: string; to: string; a: number; b: number }
>();
for (const swap of swaps) {
const key = `${swap.from}>${swap.to}`;
const pair = pairs.get(key) ?? {
a: 0,
b: 0,
from: swap.from,
to: swap.to,
};
pair.a += swap.fromAmount;
pair.b += swap.toAmount;
pairs.set(key, pair);
}
return [...pairs.values()];
});
function tone(ratio: number | null) {
if (ratio === null || Math.abs(ratio) < 0.03) {
return "text-cladd-fg-soft";
}
return ratio > 0 ? "text-expense" : "text-income";
}
function open(drill: Drill) {
const current = report.drill;
const same =
current?.box === "month" &&
current.key === drill.key &&
current.dim === drill.dim &&
current.currency === drill.currency;
navigate(drillQuery(same ? null : drill));
}
const isOpen = (dim: string, key: string, code: string) =>
report.drill?.box === "month" &&
report.drill.dim === dim &&
report.drill.key === key &&
report.drill.currency === code;
</script>
<ReportBox
body="px-3 py-2"
class={className}
empty={shown.length === 0}
emptyText="Nothing spent or earned in {longMonth(month)}."
id="month"
{loading}
title={longMonth(month)}
>
<div class="flex flex-col gap-3">
{#each shown as fact (fact.code)}
<section class="flex flex-col gap-1">
<h3 class="flex flex-wrap items-baseline gap-x-3 text-[13px]">
<span class="w-10 font-semibold">{fact.code}</span>
{#if totals}
<span class="num font-semibold"
>{money(fact.code, fact.spent, false, 0)}</span
>
<span class="text-cladd-fg-soft"
>spent{partial ? " so far" : ""}</span
>
{#if fact.ratio !== null}
<span class={tone(fact.ratio)}>{versusText(fact.ratio)}</span>
{/if}
{#if fact.earned > 0}
<span class="num text-income"
>{money(fact.code, fact.earned, false, 0)}</span
>
<span class="-ml-2 text-cladd-fg-soft">earned</span>
{/if}
{/if}
</h3>
<ul class="flex flex-col pl-[3.25rem] text-[13px]">
{#each fact.places as place (place.id)}
<li>
<button
class="grid h-6 w-full grid-cols-[minmax(0,1fr)_auto_4.5rem] items-center gap-3 rounded-cladd-sm px-1 text-left hover:bg-row-hover {isOpen(
"other",
place.id,
fact.code
)
? "bg-row-selected"
: ""}"
onclick={() =>
open(
monthDrill({
currency: fact.code,
dim: "other",
key: place.id,
label: place.name,
})
)}
type="button"
>
<span class="truncate">{place.name}</span>
<span class="num text-right"
>{money(fact.code, place.value, false, 0)}</span
>
<span class="text-right text-[12px] text-cladd-fg-soft"
>{place.count === 1 ? "once" : `${place.count} times`}</span
>
</button>
</li>
{/each}
{#if fact.day}
{@const day = fact.day}
<li>
<button
class="grid h-6 w-full grid-cols-[minmax(0,1fr)_auto_4.5rem] items-center gap-3 rounded-cladd-sm px-1 text-left hover:bg-row-hover {isOpen(
"day",
day.date,
fact.code
)
? "bg-row-selected"
: ""}"
onclick={() =>
open({
box: "month",
currency: fact.code,
dim: "day",
dir: -1,
key: day.date,
label: formatDate(day.date, "long"),
})}
type="button"
>
<span class="truncate text-cladd-fg-soft"
>Biggest day, {formatDate(day.date, "day")}:
{day.where}{day.count > 1
? ` and ${day.count - 1} more`
: ""}</span
>
<span class="num text-right"
>{money(fact.code, day.value, false, 0)}</span
>
<span></span>
</button>
</li>
{/if}
</ul>
</section>
{/each}
{#if moved.length > 0}
<p class="flex flex-wrap gap-x-3 text-[12px] text-cladd-fg-soft">
<span>Exchanged</span>
{#each moved as pair (`${pair.from}${pair.to}`)}
<span class="num"
>{money(pair.from, pair.a, false, 0)}
into {money(pair.to, pair.b, false, 0)}</span
>
{/each}
</p>
{/if}
</div>
</ReportBox>
@@ -0,0 +1,150 @@
<script lang="ts">
import { createQuery } from "@tanstack/svelte-query";
import type { Account, Item, TransactionGroup } from "#lib/api/types.js";
import type { Lane } from "#lib/components/charts/chart.js";
import Lanes from "#lib/components/charts/Lanes.svelte";
import { addDays, periodOf, today } from "#lib/dates.js";
import { longMonth, monthOf, shortMonth } from "#lib/reports/board.js";
import { buckets } from "#lib/reports/flows.js";
import { reportQueries } from "#lib/reports/queries.js";
import { impliedRates } from "#lib/reports/rates.js";
import { money, useReport } from "#lib/reports/report.svelte.js";
import { modeAxis } from "#lib/reports/view.js";
import ReportBox from "./ReportBox.svelte";
const MIN_SHARE = 0.01;
const HEIGHT_BUDGET = 360;
const MIN_HEIGHT = 80;
let {
year,
cursor,
own,
groups,
}: {
year: string;
cursor: string;
own: Item<Account>[] | undefined;
groups: Item<TransactionGroup>[];
} = $props();
const report = useReport();
const now = today();
const start = $derived(addDays(`${year}-01-01`, -1));
const end = $derived(year === now.slice(0, 4) ? now : `${year}-12-31`);
const ids = $derived(
(own ?? [])
.filter((a) => a.attributes.include_net_worth !== false)
.map((a) => a.id)
);
const chart = createQuery(() => ({
...reportQueries.chart(start, end, "1D", ids),
enabled: ids.length > 0,
}));
const months = $derived(buckets(`${year}-01-01`, `${year}-12-31`, "month"));
const totals = $derived.by(() => {
const byCode = new Map<string, Map<string, number>>();
for (const set of chart.data ?? []) {
const code = set.currency_code ?? "";
const days = byCode.get(code) ?? new Map<string, number>();
for (const [date, value] of Object.entries(set.entries ?? {})) {
const day = date.slice(0, 10);
days.set(day, (days.get(day) ?? 0) + Number(value));
}
byCode.set(code, days);
}
return byCode;
});
function closing(days: Map<string, number>, month: string) {
const last = periodOf(`${month}-01`, "1M").end;
let found: number | undefined;
let at = "";
for (const [day, value] of days) {
if (day <= last && day <= end && day >= at) {
at = day;
found = value;
}
}
return month <= monthOf(end) ? found : undefined;
}
const rate = $derived(impliedRates(groups));
const primary = $derived(report.codes[0] ?? "");
const measured = $derived(
[...totals.entries()]
.sort((a, b) => report.codes.indexOf(a[0]) - report.codes.indexOf(b[0]))
.map(([code, days]) => {
const values = months.map((month) => closing(days, month));
const peak = Math.max(0, ...values.map((v) => Math.abs(v ?? 0)));
const r = rate(code, primary);
return {
code,
last: values.findLast((v) => v !== undefined) ?? 0,
values,
weight: r === null ? null : peak * r,
};
})
);
const total = $derived(measured.reduce((sum, m) => sum + (m.weight ?? 0), 0));
const share = (weight: number | null) =>
weight === null || total === 0 ? null : weight / total;
const lanes = $derived<Lane[]>(
measured
.filter((m) => (share(m.weight) ?? 1) >= MIN_SHARE)
.map((m) => ({
axisFormat: modeAxis(m.code),
format: (value: number) => money(m.code, value, false, 0),
height: Math.max(
MIN_HEIGHT,
Math.round((share(m.weight) ?? 1 / measured.length) * HEIGHT_BUDGET)
),
key: m.code,
keys: [
{ color: "var(--ff-series-1)", key: "value", label: "Net worth" },
],
label: m.code,
rows: months.map((month, i) => ({
key: month,
label: shortMonth(month),
values: Object.fromEntries(
m.values[i] === undefined ? [] : [["value", m.values[i]]]
),
})),
}))
);
const small = $derived(
measured.filter((m) => (share(m.weight) ?? 1) < MIN_SHARE)
);
</script>
<ReportBox
body="px-3 pt-2 pb-3"
empty={measured.length === 0}
emptyText="No accounts count towards net worth."
id="net-worth"
loading={chart.isPending && ids.length > 0}
title="Net worth"
>
<Lanes
{cursor}
label="Net worth by currency"
{lanes}
legend={false}
title={(row) =>
row.key === monthOf(now) ? "today" : `end of ${longMonth(row.key)}`}
/>
{#each small as m (m.code)}
<p
class="flex items-baseline gap-x-3 border-cladd-outline border-t pt-1.5 text-[12px]"
>
<span class="w-10 font-semibold text-cladd-fg">{m.code}</span>
<span class="num font-semibold text-cladd-fg"
>{money(m.code, m.last, false, 0)}</span
>
</p>
{/each}
</ReportBox>
+156
View File
@@ -0,0 +1,156 @@
<script lang="ts">
import type { StackKey } from "#lib/components/charts/chart.js";
import Sparkline from "#lib/components/charts/Sparkline.svelte";
import { type Grew, longMonth, shortMonth } from "#lib/reports/board.js";
import {
type Drill,
drillQuery,
money,
navigate,
useReport,
} from "#lib/reports/report.svelte.js";
import { versusText } from "#lib/reports/typical.js";
import ReportBox from "./ReportBox.svelte";
const SHOWN = 10;
let {
rows,
cursor,
codes,
colors,
loading,
partial = false,
monthDrill,
class: className = "",
}: {
rows: Grew[];
cursor: string;
codes: number;
colors: StackKey[];
loading: boolean;
partial?: boolean;
monthDrill: (extra: Partial<Drill>) => Drill;
class?: string;
} = $props();
const report = useReport();
let all = $state(false);
const shown = $derived(all ? rows : rows.slice(0, SHOWN));
const marker = $derived(Number(cursor.slice(5, 7)) - 1);
const colorOf = (cat: string) =>
colors.find((k) => k.key === cat)?.color ?? "var(--cladd-fg-softer)";
function tone(ratio: number | null) {
if (ratio === null || Math.abs(ratio) < 0.03) {
return "text-cladd-fg-soft";
}
return ratio > 0 ? "text-expense" : "text-income";
}
function open(row: Grew) {
const drill = monthDrill({
currency: row.code,
dim: "category",
key: row.cat,
label: row.label,
});
const current = report.drill;
const same =
current?.box === "grew" &&
current.key === row.cat &&
current.currency === row.code;
navigate(drillQuery(same ? null : drill));
}
</script>
<ReportBox
class={className}
empty={rows.length === 0}
emptyText="No spending yet."
id="grew"
{loading}
title="What grew"
>
<div class="overflow-x-auto">
<table class="w-full border-collapse text-[13px]">
<thead>
<tr class="text-[12px] text-cladd-fg-soft">
<th class="h-7 px-3 text-left font-medium">Category</th>
<th class="h-7 px-2 text-left font-medium max-sm:hidden">
This year
</th>
<th class="h-7 px-2 text-right font-medium max-sm:hidden">Total</th>
<th class="h-7 whitespace-nowrap px-2 text-right font-medium">
{shortMonth(cursor)}{partial ? " so far" : ""}
</th>
<th class="h-7 px-3 text-right font-medium">Against typical</th>
</tr>
</thead>
<tbody>
{#each shown as row (row.key)}
{@const active =
report.drill?.box === "grew" &&
report.drill.key === row.cat &&
report.drill.currency === row.code}
<tr
class="h-7 border-cladd-outline border-t hover:bg-row-hover {active
? "bg-row-selected"
: ""}"
>
<td class="max-w-[14rem] px-3">
<button
class="flex max-w-full items-center gap-2 text-left hover:underline"
onclick={() => open(row)}
title="Show {row.label} in {longMonth(cursor)}"
type="button"
>
<span
aria-hidden="true"
class="size-2.5 shrink-0 rounded-[3px]"
style:background={colorOf(row.cat)}
></span>
<span class="truncate">{row.label}</span>
{#if codes > 1}
<span class="text-[12px] text-cladd-fg-soft">{row.code}</span>
{/if}
</button>
</td>
<td class="px-2 max-sm:hidden">
<Sparkline
color={colorOf(row.cat)}
height={20}
{marker}
values={row.values}
width={96}
/>
</td>
<td class="num whitespace-nowrap px-2 text-right max-sm:hidden">
{money(row.code, row.year, false, 0)}
</td>
<td class="num whitespace-nowrap px-2 text-right">
{money(row.code, row.month, false, 0)}
</td>
<td class="whitespace-nowrap px-3 text-right {tone(row.ratio)}">
{#if row.ratio !== null}
{versusText(row.ratio, "")}
{:else if row.month > 0}
new
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{#if rows.length > SHOWN}
<button
class="mx-3 my-2 text-[12px] text-brand hover:underline dark:text-[var(--ff-series-1)]"
onclick={() => (all = !all)}
type="button"
>
{all ? "Show fewer" : `Show all ${rows.length}`}
</button>
{/if}
</ReportBox>