Files
firefly-ui/src/lib/components/reports/Board.svelte
T
hh 617a4700e2 feat: every page follows the one-currency mode
Totals, info boxes, section headers, group subtotals, shares and the
per-currency charts on the dashboard, transactions, search, accounts,
merchants, statement, budgets, categories, tags, groups, piggy banks,
reports and the category, tag, budget and merchant pages sum into one
converted amount when the mode is on; charts split by currency merge
into one lane. Inputs, reconciliation and the Foreign column stay exact.

Money moved to or from a debt, loan or mortgage no longer counts as
income or spending in the transaction list boxes; it shows as From
debts and To debts. The running balance of an incoming foreign-currency
transfer shows in the account's currency.
2026-10-16 18:49:16 +00:00

462 lines
13 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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 { displayCode, displayFlows } from "#lib/reports/display.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 mine = $derived(
ledger.data && own ? ownFlows(ledger.data, own) : undefined
);
const into = $derived(
mine ? displayCode(new Set(mine.map((flow) => flow.currency))) : null
);
const report = createReport(
() => params,
() => ledger.data,
() => into
);
provideReport(report);
const flows = $derived(mine && displayFlows(mine, into));
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>