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