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

+2 -381
View File
@@ -1,384 +1,5 @@
<script lang="ts">
import { createQuery } from "@tanstack/svelte-query";
import { lists } from "#lib/api/lists.js";
import Box from "#lib/components/Box.svelte";
import MultiSelect from "#lib/components/reports/MultiSelect.svelte";
import PageHeader from "#lib/components/shell/PageHeader.svelte";
import { today } from "#lib/dates.js";
import { prettyKeys, useKeys } from "#lib/keys.svelte.js";
import { period } from "#lib/period.svelte.js";
import { recall, remember } from "#lib/reports/memory.js";
import { listOptions, listReady } from "#lib/reports/options.js";
import {
fiscalYear,
LIST_LABELS,
type ListKind,
missing,
REPORT_KINDS,
type ReportParams,
type ReportType,
rangePresets,
reportPath,
} from "#lib/reports/params.js";
import { reportQueries } from "#lib/reports/queries.js";
import { accountOptions } from "#lib/reports/report.svelte.js";
import { session } from "#lib/session.svelte.js";
import { goto } from "$app/navigation";
const memory = recall();
let type = $state<ReportType>(memory.type);
let accounts = $state<string[]>(memory.accounts);
let items = $state<Partial<Record<ListKind, string[]>>>(memory.items);
let start = $derived(period.start);
let end = $derived(period.end);
let problem = $state("");
const kind = $derived(REPORT_KINDS.find((k) => k.type === type));
const list = $derived(kind?.list);
const assets = createQuery(() => lists.accounts("asset"));
const liabilities = createQuery(() => lists.accounts("liabilities"));
const budgets = createQuery(() => ({
...lists.budgets(),
enabled: list === "budget",
}));
const categories = createQuery(() => ({
...lists.categories(),
enabled: list === "category",
}));
const tags = createQuery(() => ({
...lists.tags(),
enabled: list === "tag",
}));
const expense = createQuery(() => ({
...lists.accounts("expense"),
enabled: list === "double",
}));
const revenue = createQuery(() => ({
...lists.accounts("revenue"),
enabled: list === "double",
}));
const first = createQuery(() => reportQueries.first());
const all = $derived([...(assets.data ?? []), ...(liabilities.data ?? [])]);
const accountReady = $derived(!!(assets.data && liabilities.data));
const options = $derived(accountOptions(all));
const sources = $derived({
budgets: budgets.data,
categories: categories.data,
expense: expense.data,
revenue: revenue.data,
tags: tags.data,
});
const itemOptions = $derived(list ? listOptions(list, sources) : []);
const allIds = $derived(all.map((a) => a.id).join(","));
const fiscal = $derived(
session.prefs.customFiscalYear
? String(session.prefs.fiscalYearStart ?? "01-01")
: null
);
$effect(() => {
if (accountReady && accounts.length === 0 && memory.accounts.length === 0) {
accounts = (assets.data ?? [])
.filter((a) => a.attributes.active)
.map((a) => a.id);
}
});
const params = $derived<ReportParams>({
accounts: accounts.filter(
(id) => all.length === 0 || all.some((a) => a.id === id)
),
end,
items: list ? (items[list] ?? []) : [],
start,
type,
});
const years = $derived.by(() => {
const now = Number(today().slice(0, 4));
const from = Number((first.data ?? today()).slice(0, 4));
return Array.from(
{ length: Math.max(1, now - from + 1) },
(_, i) => from + i
);
});
const monthName = $derived(
new Intl.DateTimeFormat(session.locale, { month: "short" })
);
function setRange(range: { start: string; end: string }) {
({ start, end } = range);
problem = "";
}
function monthRange(year: number, month: number) {
const last = new Date(year, month + 1, 0).getDate();
const mm = String(month + 1).padStart(2, "0");
return { end: `${year}-${mm}-${last}`, start: `${year}-${mm}-01` };
}
function quarterRange(year: number, quarter: number) {
const a = monthRange(year, quarter * 3);
const b = monthRange(year, quarter * 3 + 2);
return { end: b.end, start: a.start };
}
function submit() {
problem = missing(params) ?? "";
if (problem) {
return;
}
remember(params);
goto(reportPath(params));
}
useKeys(() => [
{
group: "Report",
input: true,
keys: "mod+Enter",
label: "Show report",
run: submit,
},
]);
const quick = $derived([
{
label: "Current month, all accounts",
range: "currentMonthStart/currentMonthEnd",
},
{
label: "Current year, all accounts",
range: "currentYearStart/currentYearEnd",
},
...(fiscal
? [
{
label: "Current fiscal year, all accounts",
range: "currentFiscalYearStart/currentFiscalYearEnd",
},
]
: []),
{
label: "Last month, all accounts",
only: "default",
range: "previousMonthStart/previousMonthEnd",
},
{
label: "All-time, all accounts",
range: `${(first.data ?? today()).replaceAll("-", "")}/currentMonthEnd`,
},
]);
import Board from "#lib/components/reports/Board.svelte";
</script>
<PageHeader crumbs={[{ label: "Reports" }]} title="Reports" />
<div class="grid items-start gap-4 lg:grid-cols-2">
<form
class="flex min-w-0 flex-col gap-4"
onsubmit={(event) => {
event.preventDefault();
submit();
}}
>
<Box body="p-4" title="Reports">
<div class="flex flex-col gap-4">
<label class="flex flex-col gap-1">
<span class="font-medium text-[12px] text-cladd-fg-soft"
>Report type</span
>
<select
class="cladd-cut cladd-focusable-input h-8 rounded-cladd-sm px-2 text-[13px] text-cladd-fg"
onchange={() => (problem = "")}
bind:value={type}
>
{#each REPORT_KINDS as option (option.type)}
<option value={option.type}>{option.label}</option>
{/each}
</select>
</label>
<div class="flex flex-col gap-1">
<label
class="font-medium text-[12px] text-cladd-fg-soft"
for="gen-accounts"
>Included accounts</label
>
<MultiSelect
id="gen-accounts"
label="Included accounts"
loading={!accountReady}
onchange={(next) => {
accounts = next;
problem = "";
}}
{options}
value={params.accounts}
/>
</div>
<div class="flex flex-col gap-1">
<span class="font-medium text-[12px] text-cladd-fg-soft"
>Date range</span
>
<div class="flex flex-wrap items-center gap-2">
<input
aria-label="Start date"
class="cladd-cut cladd-focusable-input num h-8 rounded-cladd-sm px-2 text-[13px] text-cladd-fg"
min={first.data ?? undefined}
type="date"
bind:value={start}
>
<span class="text-cladd-fg-soft">to</span>
<input
aria-label="End date"
class="cladd-cut cladd-focusable-input num h-8 rounded-cladd-sm px-2 text-[13px] text-cladd-fg"
type="date"
bind:value={end}
>
</div>
<div class="mt-1 flex flex-wrap gap-x-3 gap-y-1 text-[12px]">
{#each rangePresets() as preset (preset.key)}
<button
class="text-brand dark:text-[var(--ff-series-1)] hover:underline {preset
.range.start === start && preset.range.end === end
? "font-semibold"
: ""}"
onclick={() => setRange(preset.range)}
type="button"
>
{preset.label}
</button>
{/each}
</div>
</div>
<div class="flex flex-col gap-1">
<span class="font-medium text-[12px] text-cladd-fg-soft"
>Pre-set ranges</span
>
<ul class="flex flex-col gap-1 text-[12px]">
{#each years as year (year)}
<li class="flex flex-wrap items-baseline gap-x-2 gap-y-0.5">
<button
class="num font-semibold text-brand dark:text-[var(--ff-series-1)] hover:underline"
onclick={() =>
setRange(
fiscal
? fiscalYear(`${year}-12-31`, fiscal)
: { end: `${year}-12-31`, start: `${year}-01-01` }
)}
type="button"
>
{year}{fiscal ? " (fiscal year)" : ""}
</button>
{#each [0, 1, 2, 3] as q (q)}
<button
class="text-brand dark:text-[var(--ff-series-1)] hover:underline"
onclick={() => setRange(quarterRange(year, q))}
type="button"
>
Q{q + 1}
</button>
{/each}
<span class="text-cladd-fg-softer">·</span>
{#each Array.from({ length: 12 }, (_, m) => m) as m (m)}
{#if `${year}-${String(m + 1).padStart(2, "0")}` <=
today().slice(0, 7)}
<button
class="text-brand dark:text-[var(--ff-series-1)] hover:underline"
onclick={() => setRange(monthRange(year, m))}
title="{monthName.format(new Date(year, m, 1))} {year}"
type="button"
>
{monthName.format(new Date(year, m, 1))}
</button>
{/if}
{/each}
</li>
{/each}
</ul>
</div>
</div>
</Box>
<Box body="p-4" title="Extra options">
{#if list}
<div class="flex flex-col gap-1">
<label
class="font-medium text-[12px] text-cladd-fg-soft"
for="gen-items"
>Select {LIST_LABELS[list].many.toLowerCase()}</label
>
<MultiSelect
id="gen-items"
label={LIST_LABELS[list].many}
loading={!listReady(list, sources)}
onchange={(next) => {
items = { ...items, [list]: next };
problem = "";
}}
options={itemOptions}
value={params.items}
/>
</div>
{:else}
<p class="text-cladd-fg-soft">This report has no extra options</p>
{/if}
</Box>
<Box body="p-4" title="View report">
<div class="flex flex-wrap items-center gap-3">
<button
class="cladd-surface cladd-gradient cladd-outline cladd-hoverable cladd-color-brand h-8 rounded-cladd-sm px-4 font-semibold text-[13px] text-cladd-primary"
type="submit"
>
Show report
</button>
<span class="flex items-center gap-1 text-[12px] text-cladd-fg-soft">
{#each prettyKeys("mod+Enter") as part (part)}
<kbd class="kbd">{part}</kbd>
{/each}
</span>
{#if problem}
<span class="text-expense" role="alert">{problem}</span>
{/if}
</div>
</Box>
</form>
<Box body="p-4" title="Quick links">
{#if accountReady && all.length === 0}
<p class="text-expense">
In order to generate reports, you need to add at least one asset account
to Firefly III.
</p>
{:else}
<div class="flex flex-col gap-4">
{#each [
{ label: "Default financial report", type: "default" },
{ label: "Transaction history overview", type: "audit" },
] as section (section.type)}
<section>
<h3 class="mb-1 font-semibold text-[13px]">{section.label}</h3>
<ul class="flex flex-col gap-0.5">
{#each quick.filter(
(q) => !q.only || q.only === section.type
) as link (link.label)}
<li>
<a
class="text-brand dark:text-[var(--ff-series-1)] hover:underline"
href="/reports/{section.type}/{allIds}/{link.range}"
>{link.label}</a
>
</li>
{/each}
</ul>
</section>
{/each}
<p class="text-[12px] text-cladd-fg-soft">
Remember that reports can be bookmarked.
</p>
</div>
{/if}
</Box>
</div>
<Board />
@@ -28,7 +28,7 @@
<a
class="text-brand dark:text-[var(--ff-series-1)] hover:underline"
href="/reports"
>Back to the report generator</a
>Back to reports</a
>
</Box>
{/if}