Files
firefly-ui/src/lib/components/reports/MonthPanel.svelte
T
hh 26903b0066 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.
2026-10-16 15:35:37 +00:00

196 lines
5.9 KiB
Svelte

<script lang="ts">
import { formatDate } from "#lib/format.js";
import { longMonth, type MonthFacts } from "#lib/reports/board.js";
import type { Exchange } from "#lib/reports/flows.js";
import {
type Drill,
drillQuery,
money,
navigate,
useReport,
} from "#lib/reports/report.svelte.js";
import { versusText } from "#lib/reports/typical.js";
import ReportBox from "./ReportBox.svelte";
let {
facts,
month,
swaps,
loading,
partial = false,
totals = true,
monthDrill,
class: className = "",
}: {
facts: MonthFacts[];
month: string;
swaps: Exchange[];
loading: boolean;
partial?: boolean;
totals?: boolean;
monthDrill: (extra: Partial<Drill>) => Drill;
class?: string;
} = $props();
const report = useReport();
const shown = $derived(facts.filter((f) => f.spent > 0 || f.earned > 0));
const moved = $derived.by(() => {
const pairs = new Map<
string,
{ from: string; to: string; a: number; b: number }
>();
for (const swap of swaps) {
const key = `${swap.from}>${swap.to}`;
const pair = pairs.get(key) ?? {
a: 0,
b: 0,
from: swap.from,
to: swap.to,
};
pair.a += swap.fromAmount;
pair.b += swap.toAmount;
pairs.set(key, pair);
}
return [...pairs.values()];
});
function tone(ratio: number | null) {
if (ratio === null || Math.abs(ratio) < 0.03) {
return "text-cladd-fg-soft";
}
return ratio > 0 ? "text-expense" : "text-income";
}
function open(drill: Drill) {
const current = report.drill;
const same =
current?.box === "month" &&
current.key === drill.key &&
current.dim === drill.dim &&
current.currency === drill.currency;
navigate(drillQuery(same ? null : drill));
}
const isOpen = (dim: string, key: string, code: string) =>
report.drill?.box === "month" &&
report.drill.dim === dim &&
report.drill.key === key &&
report.drill.currency === code;
</script>
<ReportBox
body="px-3 py-2"
class={className}
empty={shown.length === 0}
emptyText="Nothing spent or earned in {longMonth(month)}."
id="month"
{loading}
title={longMonth(month)}
>
<div class="flex flex-col gap-3">
{#each shown as fact (fact.code)}
<section class="flex flex-col gap-1">
<h3 class="flex flex-wrap items-baseline gap-x-3 text-[13px]">
<span class="w-10 font-semibold">{fact.code}</span>
{#if totals}
<span class="num font-semibold"
>{money(fact.code, fact.spent, false, 0)}</span
>
<span class="text-cladd-fg-soft"
>spent{partial ? " so far" : ""}</span
>
{#if fact.ratio !== null}
<span class={tone(fact.ratio)}>{versusText(fact.ratio)}</span>
{/if}
{#if fact.earned > 0}
<span class="num text-income"
>{money(fact.code, fact.earned, false, 0)}</span
>
<span class="-ml-2 text-cladd-fg-soft">earned</span>
{/if}
{/if}
</h3>
<ul class="flex flex-col pl-[3.25rem] text-[13px]">
{#each fact.places as place (place.id)}
<li>
<button
class="grid h-6 w-full grid-cols-[minmax(0,1fr)_auto_4.5rem] items-center gap-3 rounded-cladd-sm px-1 text-left hover:bg-row-hover {isOpen(
"other",
place.id,
fact.code
)
? "bg-row-selected"
: ""}"
onclick={() =>
open(
monthDrill({
currency: fact.code,
dim: "other",
key: place.id,
label: place.name,
})
)}
type="button"
>
<span class="truncate">{place.name}</span>
<span class="num text-right"
>{money(fact.code, place.value, false, 0)}</span
>
<span class="text-right text-[12px] text-cladd-fg-soft"
>{place.count === 1 ? "once" : `${place.count} times`}</span
>
</button>
</li>
{/each}
{#if fact.day}
{@const day = fact.day}
<li>
<button
class="grid h-6 w-full grid-cols-[minmax(0,1fr)_auto_4.5rem] items-center gap-3 rounded-cladd-sm px-1 text-left hover:bg-row-hover {isOpen(
"day",
day.date,
fact.code
)
? "bg-row-selected"
: ""}"
onclick={() =>
open({
box: "month",
currency: fact.code,
dim: "day",
dir: -1,
key: day.date,
label: formatDate(day.date, "long"),
})}
type="button"
>
<span class="truncate text-cladd-fg-soft"
>Biggest day, {formatDate(day.date, "day")}:
{day.where}{day.count > 1
? ` and ${day.count - 1} more`
: ""}</span
>
<span class="num text-right"
>{money(fact.code, day.value, false, 0)}</span
>
<span></span>
</button>
</li>
{/if}
</ul>
</section>
{/each}
{#if moved.length > 0}
<p class="flex flex-wrap gap-x-3 text-[12px] text-cladd-fg-soft">
<span>Exchanged</span>
{#each moved as pair (`${pair.from}${pair.to}`)}
<span class="num"
>{money(pair.from, pair.a, false, 0)}
into {money(pair.to, pair.b, false, 0)}</span
>
{/each}
</p>
{/if}
</div>
</ReportBox>