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

+201
View File
@@ -0,0 +1,201 @@
<script lang="ts" module>
export type ColumnKind =
| "in"
| "out"
| "net"
| "avg-in"
| "avg-out"
| "pct-in"
| "pct-out"
| "count";
export interface Column {
kind: ColumnKind;
label: string;
}
</script>
<script lang="ts">
import { formatNumber, percent } from "#lib/format.js";
import {
currencies,
type Dimension,
type Measure,
type Row,
rank,
type Side,
type Totals,
} from "#lib/reports/flows.js";
import {
drillQuery,
money,
navigate,
useReport,
} from "#lib/reports/report.svelte.js";
let {
box,
rows,
columns,
dim,
dir,
footer,
sort = "out",
limit,
nameLabel = "Name",
counts = false,
averageAll = false,
}: {
box: string;
rows: Row[];
columns: Column[];
dim: Dimension;
dir?: 1 | -1;
footer?: Totals;
sort?: Side;
limit?: number;
nameLabel?: string;
counts?: boolean;
averageAll?: boolean;
} = $props();
const report = useReport();
let expanded = $state(false);
const sorted = $derived(rank(rows, report.mode, sort));
const shown = $derived(limit && !expanded ? sorted.slice(0, limit) : sorted);
interface Line {
code: string;
text: string;
tone: string;
}
function cell(
kind: ColumnKind,
m: Measure,
whole: Measure | undefined,
code: string
): Line | null {
if (kind === "count") {
return { code, text: formatNumber(m.n, 0), tone: "" };
}
if (kind === "pct-in" || kind === "pct-out") {
const part = kind === "pct-in" ? m.in : m.out;
const all = (kind === "pct-in" ? whole?.in : whole?.out) ?? 0;
return part && all
? { code, text: percent(part / all, 1), tone: "" }
: null;
}
const avg = kind === "avg-in" || kind === "avg-out";
const base = { in: m.in, net: m.in - m.out, out: -m.out }[
kind.replace("avg-", "") as "in" | "net" | "out"
];
if (!base || (avg && m.n < (averageAll ? 1 : 2))) {
return null;
}
const value = avg ? base / m.n : base;
return {
code,
text: money(code, value),
tone: value < 0 ? "text-expense" : "text-income",
};
}
function lines(totals: Totals, kind: ColumnKind): Line[] {
return currencies(totals, report.codes).flatMap((code) => {
const m = totals.cur.get(code);
const line = m ? cell(kind, m, footer?.cur.get(code), code) : null;
return line ? [line] : [];
});
}
function drill(row: Row) {
const current = report.drill;
const same =
current?.box === box && current.key === row.key && current.dim === dim;
navigate(
drillQuery(
same ? null : { box, dim, dir, key: row.key, label: row.label }
)
);
}
</script>
<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">{nameLabel}</th>
{#each columns as column (column.kind)}
<th class="h-7 whitespace-nowrap px-3 text-right font-medium">
{column.label}
</th>
{/each}
</tr>
</thead>
<tbody>
{#each shown as row (row.key)}
{@const active =
report.drill?.box === box && report.drill.key === row.key}
<tr
class="border-cladd-outline border-t align-top hover:bg-row-hover {active
? "bg-row-selected"
: ""}"
>
<td class="max-w-[18rem] px-3 py-1">
<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={() => drill(row)}
title="Show transactions"
type="button"
>
{row.label}
</button>
{#if counts && row.totals.n > 1}
<span class="text-[11px] text-cladd-fg-soft"
>{row.totals.n}
transactions</span
>
{/if}
</td>
{#each columns as column (column.kind)}
<td class="whitespace-nowrap px-3 py-1 text-right">
{#each lines(row.totals, column.kind) as line (line.code)}
<div class="num {line.tone}">{line.text}</div>
{/each}
</td>
{/each}
</tr>
{/each}
</tbody>
{#if footer && footer.n > 0}
<tfoot>
<tr class="border-cladd-outline border-t-2 align-top font-semibold">
<td class="px-3 py-1">Sum</td>
{#each columns as column (column.kind)}
<td class="whitespace-nowrap px-3 py-1 text-right">
{#if !(
column.kind.startsWith("pct") || column.kind.startsWith("avg")
)}
{#each lines(footer, column.kind) as line (line.code)}
<div class="num {line.tone}">{line.text}</div>
{/each}
{/if}
</td>
{/each}
</tr>
</tfoot>
{/if}
</table>
</div>
{#if limit && sorted.length > limit}
<button
class="w-full border-cladd-outline border-t px-3 py-1.5 text-left text-[12px] text-brand dark:text-[var(--ff-series-1)] hover:underline"
onclick={() => (expanded = !expanded)}
type="button"
>
{expanded ? "Show top" : `Show entire list (${sorted.length})`}
</button>
{/if}