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>
|
||||
Reference in new issue
Block a user