feat(reports): live firefly reports with drill-down, money flow and spending calendar

This commit is contained in:
hh committed 2026-10-16 03:45:43 +00:00
1 parent 60caee10dc
commit 62cd17ecde
39 files changed
+6596

No files matched your search

@@ -0,0 +1,135 @@
<script lang="ts">
import { createQuery } from "@tanstack/svelte-query";
import { reportQueries } from "#lib/reports/queries.js";
import {
drillQuery,
money,
navigate,
useReport,
} from "#lib/reports/report.svelte.js";
import ReportBox from "./ReportBox.svelte";
const report = useReport();
const p = $derived(report.params);
const balances = createQuery(() => reportQueries.balances(p.start, p.end));
const rows = $derived.by(() => {
const { data } = balances;
if (!data) {
return [];
}
return report.selected
.map((id) => {
const account = report.byId().get(id);
const start = data.before.get(id) ?? 0;
const end = data.after.get(id) ?? 0;
return {
code: account?.currency_code ?? "",
end,
id,
name: account?.name ?? `#${id}`,
start,
};
})
.sort((a, b) => a.name.localeCompare(b.name));
});
const sums = $derived.by(() => {
const map = new Map<string, { start: number; end: number }>();
for (const row of rows) {
const sum = map.get(row.code) ?? { end: 0, start: 0 };
sum.start += row.start;
sum.end += row.end;
map.set(row.code, sum);
}
const order = report.codes;
return [...map.entries()].sort(
(a, b) => order.indexOf(a[0]) - order.indexOf(b[0])
);
});
function tone(value: number) {
if (value === 0) {
return "text-cladd-fg-soft";
}
return value < 0 ? "text-expense" : "text-income";
}
function open(row: { id: string; name: string }) {
const current = report.drill;
const same = current?.box === "balances" && current.key === row.id;
navigate(
drillQuery(
same
? null
: { box: "balances", dim: "asset", key: row.id, label: row.name }
)
);
}
</script>
<ReportBox
id="balances"
loading={!(balances.data && report.accountsReady)}
title="Account balances"
>
<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">Name</th>
<th class="h-7 px-3 text-right font-medium">
Balance at start of period
</th>
<th class="h-7 px-3 text-right font-medium">
Balance at end of period
</th>
<th class="h-7 px-3 text-right font-medium">Difference</th>
</tr>
</thead>
<tbody>
{#each rows as row (row.id)}
{@const active =
report.drill?.box === "balances" && report.drill.key === row.id}
<tr
class="h-7 border-cladd-outline border-t hover:bg-row-hover {active
? "bg-row-selected"
: ""}"
>
<td class="max-w-[16rem] px-3">
<button
aria-pressed={active}
class="block max-w-full truncate text-left hover:text-brand dark:hover:text-[var(--ff-series-1)] hover:underline"
onclick={() => open(row)}
title="Show transactions"
type="button"
>
{row.name}
</button>
</td>
<td class="num px-3 text-right">{money(row.code, row.start)}</td>
<td class="num px-3 text-right">{money(row.code, row.end)}</td>
<td class="num px-3 text-right {tone(row.end - row.start)}">
{money(row.code, row.end - row.start, true)}
</td>
</tr>
{/each}
</tbody>
<tfoot>
{#each sums as [code, sum], i (code)}
<tr
class="h-7 font-semibold {i === 0
? "border-cladd-outline border-t-2"
: ""}"
>
<td class="px-3">Sum ({code})</td>
<td class="num px-3 text-right">{money(code, sum.start)}</td>
<td class="num px-3 text-right">{money(code, sum.end)}</td>
<td class="num px-3 text-right {tone(sum.end - sum.start)}">
{money(code, sum.end - sum.start, true)}
</td>
</tr>
{/each}
</tfoot>
</table>
</div>
</ReportBox>