From 63e980d89ed26917b7cbe930f00df235813c129c Mon Sep 17 00:00:00 2001 From: Max Moon Date: Mon, 7 Sep 2026 22:28:01 +0100 Subject: [PATCH 1/2] feat(reports): add interactive financial insights --- src/app/(app)/reports/page.tsx | 106 +++++---- src/features/reports/cash-flow-chart.tsx | 241 +++++++++++++++++++++ src/features/reports/expense-breakdown.tsx | 123 +++++++++++ src/features/reports/insights.test.ts | 114 ++++++++++ src/features/reports/insights.ts | 58 +++++ src/features/reports/queries.ts | 30 +++ src/features/reports/range.ts | 42 ++++ src/features/reports/report-breakdown.tsx | 4 +- src/features/reports/report-highlights.tsx | 59 +++++ 9 files changed, 737 insertions(+), 40 deletions(-) create mode 100644 src/features/reports/cash-flow-chart.tsx create mode 100644 src/features/reports/expense-breakdown.tsx create mode 100644 src/features/reports/insights.test.ts create mode 100644 src/features/reports/insights.ts create mode 100644 src/features/reports/report-highlights.tsx diff --git a/src/app/(app)/reports/page.tsx b/src/app/(app)/reports/page.tsx index 3c5d0f6..2a727c9 100644 --- a/src/app/(app)/reports/page.tsx +++ b/src/app/(app)/reports/page.tsx @@ -1,18 +1,24 @@ -import { ArrowDownToLine, CalendarRange } from "@/components/ui/icons"; +import { ArrowDownToLine, ArrowUpRight, CalendarRange } from "@/components/ui/icons"; import Link from "next/link"; import { PageHeader } from "@/components/folio/page-header"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { DatePicker } from "@/components/ui/date-picker"; +import { CreditCardIcon } from "@/components/ui/credit-card"; +import { File01Icon } from "@/components/ui/file-01"; import { Label } from "@/components/ui/label"; +import { Wallet01Icon } from "@/components/ui/wallet-01"; import { ReportBreakdown } from "@/features/reports/report-breakdown"; +import { ReportCashFlowChart } from "@/features/reports/cash-flow-chart"; +import { ExpenseBreakdown } from "@/features/reports/expense-breakdown"; +import { ReportHighlights } from "@/features/reports/report-highlights"; import { defaultReportRange, - getFinancialSummary, + getReportInsights, parseDateRange, } from "@/features/reports/queries"; -import { formatMoney, todayInTimeZone } from "@/lib/format"; +import { formatDate, formatMoney, todayInTimeZone } from "@/lib/format"; import { requireBusiness } from "@/lib/session"; interface ReportsPageProps { @@ -26,18 +32,20 @@ export default async function ReportsPage({ searchParams }: ReportsPageProps) { params.to, defaultReportRange(todayInTimeZone(business.timezone)), ); - const summary = await getFinancialSummary( + const insights = await getReportInsights( business.id, range, business.currency, ); + const { summary } = insights; const exportHref = `/api/reports/export?from=${encodeURIComponent(range.from)}&to=${encodeURIComponent(range.to)}`; return (
@@ -48,9 +56,9 @@ export default async function ReportsPage({ searchParams }: ReportsPageProps) { } /> - - -
+ + +
@@ -63,6 +71,7 @@ export default async function ReportsPage({ searchParams }: ReportsPageProps) {
@@ -80,44 +89,65 @@ export default async function ReportsPage({ searchParams }: ReportsPageProps) { -
-
-

Cash net income

-

{formatMoney(summary.netIncomeCents, business.currency)}

-

Payments received less expenses

-
-
-
Receipts
-
{formatMoney(summary.receiptsCents, business.currency)}
-
-
-
Expenses
-
{formatMoney(summary.expensesCents, business.currency)}
-
-
-
-
-

Invoice sales

-

{formatMoney(summary.salesCents, business.currency)}

-

Issued invoices dated inside this period

-
- Invoice sales use issue dates. Cash income uses payment and expense - dates. -
-
-
- {summary.missingConversionCount > 0 ? (

{summary.missingConversionCount}{" "} {summary.missingConversionCount === 1 ? "entry is" : "entries are"} - {" "}excluded from these totals because no conversion rate is stored. + {" "}excluded from the figures and charts because no conversion rate is stored.

) : null} - +
+ {[ + { label: "Invoice sales", value: summary.salesCents, count: insights.invoiceCount, singular: "issued invoice", plural: "issued invoices", icon: File01Icon, tone: "text-info", href: "/invoices" }, + { label: "Payments received", value: summary.receiptsCents, count: insights.receiptCount, singular: "payment", plural: "payments", icon: CreditCardIcon, tone: "text-success", href: "/payments" }, + { label: "Expenses", value: summary.expensesCents, count: insights.expenseCount, singular: "expense", plural: "expenses", icon: Wallet01Icon, tone: "text-warning", href: "/expenses" }, + ].map(({ label, value, count, singular, plural, icon: Icon, tone, href }, index) => ( + +
+

{label}

+
+

{formatMoney(value, business.currency)}

+
+ {count} {count === 1 ? singular : plural} in period + + View all + +
+ + ))} +
+ +
+
+
+
+

Cash net income

+

{formatDate(range.from)} – {formatDate(range.to)} · {business.currency}

+
+

{formatMoney(summary.netIncomeCents, business.currency)}

+

Payments received less expenses

+
+
+ +
+ +
+ +
+ + - How this report is calculated + Report breakdown +

Invoice sales use issue dates. Cash income uses payment and expense dates.

diff --git a/src/features/reports/cash-flow-chart.tsx b/src/features/reports/cash-flow-chart.tsx new file mode 100644 index 0000000..6852015 --- /dev/null +++ b/src/features/reports/cash-flow-chart.tsx @@ -0,0 +1,241 @@ +"use client"; + +import { type PointerEvent, useEffect, useId, useRef, useState } from "react"; + +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import type { ChartPoint } from "@/features/reports/range"; +import { formatDate, formatMoney } from "@/lib/format"; + +const series = [ + { key: "receiptsCents", label: "Payments received", className: "bg-success" }, + { key: "expensesCents", label: "Expenses", className: "bg-warning" }, +] as const; + +const granularityLabels = { day: "Daily", month: "Monthly", year: "Yearly" }; + +export function ReportCashFlowChart({ + currency, + points, + granularity, +}: { + currency: string; + points: ChartPoint[]; + granularity: "day" | "month" | "year"; +}) { + const titleId = useId(); + const [activeKey, setActiveKey] = useState(null); + const scrollRef = useRef(null); + const drag = useRef<{ pointerId: number; startX: number; scrollLeft: number; moved: boolean } | null>(null); + const suppressClick = useRef(false); + const [canScroll, setCanScroll] = useState(false); + const [isDragging, setIsDragging] = useState(false); + const maximum = points.reduce( + (highest, point) => Math.max(highest, point.receiptsCents, point.expensesCents), + 0, + ); + const roughStep = Math.max(1, maximum / 4); + const magnitude = 10 ** Math.floor(Math.log10(roughStep)); + const step = ([1, 1.5, 2, 2.5, 3, 4, 5, 7.5, 10].find((value) => value * magnitude >= roughStep) ?? 10) * magnitude; + const ceiling = step * 4; + const axisFormatter = new Intl.NumberFormat("en-GB", { + style: "currency", + currency, + notation: "compact", + minimumFractionDigits: 0, + maximumFractionDigits: step < 100 ? 2 : 1, + }); + + useEffect(() => { + const scroller = scrollRef.current; + if (!scroller) return; + + const observer = new ResizeObserver(() => { + setCanScroll(scroller.scrollWidth > scroller.clientWidth + 1); + }); + observer.observe(scroller); + return () => observer.disconnect(); + }, [points.length, granularity, maximum]); + + function startDrag(event: PointerEvent) { + suppressClick.current = false; + if (!canScroll || event.pointerType !== "mouse" || event.button !== 0) return; + drag.current = { + pointerId: event.pointerId, + startX: event.clientX, + scrollLeft: event.currentTarget.scrollLeft, + moved: false, + }; + } + + function moveDrag(event: PointerEvent) { + const current = drag.current; + if (!current || current.pointerId !== event.pointerId) return; + if ((event.buttons & 1) === 0) { + endDrag(event); + return; + } + const distance = event.clientX - current.startX; + if (!current.moved && Math.abs(distance) <= 4) return; + + if (!current.moved) { + current.moved = true; + event.currentTarget.setPointerCapture(event.pointerId); + setIsDragging(true); + setActiveKey(null); + } + event.preventDefault(); + event.stopPropagation(); + event.currentTarget.scrollLeft = current.scrollLeft - distance; + } + + function endDrag(event: PointerEvent) { + if (drag.current?.pointerId !== event.pointerId) return; + suppressClick.current = drag.current.moved; + drag.current = null; + setIsDragging(false); + if (event.currentTarget.hasPointerCapture(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId); + } + } + + return ( +
+
+
+

Cash flow

+

{granularityLabels[granularity]} totals

+
+
    + {series.map((item, index) => ( +
  • +
  • + ))} +
+
+ + {maximum === 0 ? ( +
+

No cash activity in this period

+

+ No payments received or expenses recorded. +

+
+ ) : ( +
+ + +
{ + if (!suppressClick.current || event.detail === 0) return; + event.preventDefault(); + event.stopPropagation(); + suppressClick.current = false; + }} + > +
+ +
+ {points.map((point, pointIndex) => { + const period = granularity === "day" + ? formatDate(point.key) + : granularity === "month" + ? new Intl.DateTimeFormat("en-GB", { month: "long", year: "numeric", timeZone: "UTC" }).format(new Date(`${point.key}-01T00:00:00Z`)) + : point.label; + const netIncome = point.receiptsCents - point.expensesCents; + const accessibleValues = series.map((item) => `${item.label}: ${formatMoney(point[item.key], currency)}`).join(", "); + + return ( + { + if (open && drag.current?.moved) return; + setActiveKey((current) => open ? point.key : current === point.key ? null : current); + }} + > + + + + +

{period}

+
+ {series.map((item) => ( +
+
+
+
{formatMoney(point[item.key], currency)}
+
+ ))} +
+
Cash net income
+
{formatMoney(netIncome, currency)}
+
+
+
+
+ ); + })} +
+
+
+
+
+ )} +
+ ); +} diff --git a/src/features/reports/expense-breakdown.tsx b/src/features/reports/expense-breakdown.tsx new file mode 100644 index 0000000..e6f407e --- /dev/null +++ b/src/features/reports/expense-breakdown.tsx @@ -0,0 +1,123 @@ +"use client"; + +import Link from "next/link"; +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { ArrowUpRight, ReceiptText } from "@/components/ui/icons"; +import { formatMoney } from "@/lib/format"; + +const categoryColours = [ + "text-info", + "text-success", + "text-warning", + "text-chart-2", + "text-chart-3", +]; + +export function ExpenseBreakdown({ + categories, + currency, + totalCents, +}: { + categories: { category: string; totalCents: number }[]; + currency: string; + totalCents: number; +}) { + const [selectedCategory, setSelectedCategory] = useState(null); + const [hoveredCategory, setHoveredCategory] = useState(null); + const [focusedCategory, setFocusedCategory] = useState(null); + const visibleCategories = categories.filter((category) => category.totalCents > 0); + const segments = visibleCategories.slice(0, 4); + if (visibleCategories.length > 4) { + segments.push({ + category: "Other categories", + totalCents: visibleCategories.slice(4).reduce((total, category) => total + category.totalCents, 0), + }); + } + const shares = segments.map((segment) => (segment.totalCents / totalCents) * 100); + const categoryKeys = segments.map((segment, index) => index === 4 ? "remainder" : `category:${segment.category}`); + const activeCategory = hoveredCategory ?? focusedCategory ?? selectedCategory; + const activeIndex = activeCategory === null ? -1 : categoryKeys.indexOf(activeCategory); + const activeSegment = segments[activeIndex]; + + return ( +
+
+

Expenses by category

+ +
+ + {totalCents > 0 ? ( + <> +
+ +
+

{activeSegment?.category ?? "Total spent"}

+

{formatMoney(activeSegment?.totalCents ?? totalCents, currency)}

+

{activeSegment ? `${shares[activeIndex].toFixed(1)}% of total` : ""}

+
+
+
    + {segments.map((segment, index) => ( +
  • + +
  • + ))} +
+ + ) : ( +
+ + +

No expenses to break down

+

Expenses in this period will appear here, grouped by category.

+
+ )} +
+ ); +} diff --git a/src/features/reports/insights.test.ts b/src/features/reports/insights.test.ts new file mode 100644 index 0000000..cd83bb1 --- /dev/null +++ b/src/features/reports/insights.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it } from "vitest"; + +import type { FinancialEntry, FinancialEntryKind } from "@/features/finance/ledger"; + +import { buildReportInsights } from "./insights"; +import { reportChartBuckets } from "./range"; + +let seed = 86291; +function syntheticNumber() { + seed = (seed * 48271) % 2147483647; + return seed; +} + +function syntheticEntry(kind: FinancialEntryKind, date: string): FinancialEntry { + return { + id: `synthetic-entry-${syntheticNumber()}`, + kind, + date, + reference: `synthetic-reference-${syntheticNumber()}`, + party: `Fictional party ${syntheticNumber()}`, + description: `Synthetic report entry ${syntheticNumber()}`, + amountCents: syntheticNumber() % 100_000, + currency: "GBP", + baseConversion: { status: "converted", amountCents: syntheticNumber() % 100_000 }, + rateDate: null, + rateSource: null, + }; +} + +describe("report chart buckets", () => { + it("includes both daily boundaries and leap days, then switches after 31 days", () => { + const daily = reportChartBuckets({ from: "2072-02-01", to: "2072-03-02" }); + expect(daily.granularity).toBe("day"); + expect(daily.points).toHaveLength(31); + expect(daily.points.at(0)?.key).toBe("2072-02-01"); + expect(daily.points.at(-1)?.key).toBe("2072-03-02"); + expect(daily.points.some(({ key }) => key === "2072-02-29")).toBe(true); + const monthly = reportChartBuckets({ from: "2072-02-01", to: "2072-03-03" }); + expect(monthly.granularity).toBe("month"); + expect(monthly.points.map(({ label }) => label)).toEqual(["Feb 2072", "Mar 2072"]); + }); + + it("uses up to 24 monthly buckets and yearly buckets for longer ranges", () => { + const monthly = reportChartBuckets({ from: "2083-01-15", to: "2084-12-12" }); + expect(monthly.granularity).toBe("month"); + expect(monthly.points).toHaveLength(24); + const yearly = reportChartBuckets({ from: "2083-01-15", to: "2085-01-12" }); + expect(yearly.granularity).toBe("year"); + expect(yearly.points.map(({ key }) => key)).toEqual(["2083", "2084", "2085"]); + }); + + it("preserves early years and bounds the widest supported range", () => { + const early = reportChartBuckets({ from: "0099-12-31", to: "0100-01-01" }); + expect(early.points.map(({ label }) => label)).toEqual(["31 Dec 0099", "1 Jan 0100"]); + const widest = reportChartBuckets({ from: "0000-01-01", to: "9999-12-31" }); + expect(widest.granularity).toBe("year"); + expect(widest.points).toHaveLength(10_000); + expect(widest.points.at(0)?.label).toBe("0000"); + expect(widest.points.at(-1)?.label).toBe("9999"); + }); +}); + +describe("report insights", () => { + it("uses converted amounts, keeps empty days and excludes missing rates from totals and counts", () => { + const sale = syntheticEntry("sale", "2072-02-02"); + const receipt = syntheticEntry("receipt", "2072-02-04"); + const expense = syntheticEntry("expense", "2072-02-08"); + const missing = syntheticEntry("expense", "2072-02-03"); + missing.baseConversion = { status: "missing-rate", reason: "missing-rate" }; + const category = `Synthetic category ${syntheticNumber()}`; + const result = buildReportInsights( + [sale, receipt, expense, missing, syntheticEntry("sale", "2072-02-01")], + { from: "2072-02-02", to: "2072-02-08" }, + new Map([[expense.id, category], [missing.id, "Excluded synthetic category"]]), + ); + expect(result.points).toHaveLength(7); + expect(result.points.at(0)?.salesCents).toBe( + sale.baseConversion.status === "converted" ? sale.baseConversion.amountCents : 0, + ); + expect(result.points[2].receiptsCents).toBe( + receipt.baseConversion.status === "converted" ? receipt.baseConversion.amountCents : 0, + ); + expect(result.points[1]).toMatchObject({ salesCents: 0, receiptsCents: 0, expensesCents: 0 }); + expect(result.expenseCategories).toEqual([{ + category, + totalCents: expense.baseConversion.status === "converted" ? expense.baseConversion.amountCents : 0, + }]); + expect(result).toMatchObject({ invoiceCount: 1, receiptCount: 1, expenseCount: 1 }); + }); + + it("combines category amounts and keeps uncategorised expenses in the breakdown", () => { + const entries = Array.from({ length: 3 }, () => syntheticEntry("expense", "2086-05-12")); + const category = `Synthetic category ${syntheticNumber()}`; + const result = buildReportInsights( + entries, + { from: "2086-01-01", to: "2086-12-31" }, + new Map([[entries[0].id, category], [entries[1].id, category]]), + ); + const amounts = entries.map(({ baseConversion }) => baseConversion.status === "converted" ? baseConversion.amountCents : 0); + expect(result.points[4].expensesCents).toBe(amounts.reduce((sum, amount) => sum + amount, 0)); + expect(result.expenseCategories).toEqual(expect.arrayContaining([ + { category, totalCents: amounts[0] + amounts[1] }, + { category: "Uncategorised", totalCents: amounts[2] }, + ])); + expect(result.expenseCategories[0].totalCents).toBeGreaterThanOrEqual(result.expenseCategories[1].totalCents); + }); + + it("rejects totals beyond the safe integer range", () => { + const entries = Array.from({ length: 2 }, () => syntheticEntry("receipt", "2092-06-09")); + entries[0].baseConversion = { status: "converted", amountCents: Number.MAX_SAFE_INTEGER }; + entries[1].baseConversion = { status: "converted", amountCents: 1 }; + expect(() => buildReportInsights(entries, { from: "2092-06-09", to: "2092-06-09" }, new Map())).toThrow(RangeError); + }); +}); diff --git a/src/features/reports/insights.ts b/src/features/reports/insights.ts new file mode 100644 index 0000000..a162db4 --- /dev/null +++ b/src/features/reports/insights.ts @@ -0,0 +1,58 @@ +import type { FinancialEntry } from "@/features/finance/ledger"; + +import { reportChartBuckets, type DateRange } from "./range"; + +export type ExpenseCategoryTotal = { category: string; totalCents: number }; + +function addAmounts(left: number, right: number): number { + const total = Number(BigInt(left) + BigInt(right)); + if (!Number.isSafeInteger(total)) { + throw new RangeError("Report total is too large"); + } + return total; +} + +export function buildReportInsights( + entries: readonly FinancialEntry[], + range: DateRange, + categoriesByExpense: ReadonlyMap, +) { + const { granularity, points } = reportChartBuckets(range); + const keyLength = granularity === "day" ? 10 : granularity === "month" ? 7 : 4; + const byPeriod = new Map(points.map((point) => [point.key, point])); + const categoryTotals = new Map(); + let invoiceCount = 0; + let receiptCount = 0; + let expenseCount = 0; + + for (const entry of entries) { + if ( + entry.date < range.from || + entry.date > range.to || + entry.baseConversion.status === "missing-rate" + ) continue; + + const point = byPeriod.get(entry.date.slice(0, keyLength)); + if (!point) continue; + const amount = entry.baseConversion.amountCents; + + if (entry.kind === "sale") { + point.salesCents = addAmounts(point.salesCents, amount); + invoiceCount += 1; + } else if (entry.kind === "receipt") { + point.receiptsCents = addAmounts(point.receiptsCents, amount); + receiptCount += 1; + } else { + point.expensesCents = addAmounts(point.expensesCents, amount); + expenseCount += 1; + const category = categoriesByExpense.get(entry.id)?.trim() || "Uncategorised"; + categoryTotals.set(category, addAmounts(categoryTotals.get(category) ?? 0, amount)); + } + } + + const expenseCategories: ExpenseCategoryTotal[] = [...categoryTotals] + .map(([category, totalCents]) => ({ category, totalCents })) + .sort((left, right) => right.totalCents - left.totalCents || left.category.localeCompare(right.category)); + + return { granularity, points, expenseCategories, invoiceCount, receiptCount, expenseCount }; +} diff --git a/src/features/reports/queries.ts b/src/features/reports/queries.ts index 2329b05..cbc73b9 100644 --- a/src/features/reports/queries.ts +++ b/src/features/reports/queries.ts @@ -5,6 +5,9 @@ import { summariseFinancialEntries, type FinancialTotals, } from "@/features/finance/ledger"; +import { db } from "@/lib/db"; + +import { buildReportInsights } from "./insights"; export { defaultReportRange, @@ -12,11 +15,38 @@ export { parseDateRange, type ChartPoint, type DateRange, + type ReportGranularity, } from "./range"; +export type { ExpenseCategoryTotal } from "./insights"; import type { DateRange } from "./range"; export type FinancialSummary = FinancialTotals; +export type ReportInsights = ReturnType & { + summary: FinancialSummary; +}; + +export async function getReportInsights( + businessId: string, + range: DateRange, + baseCurrency: string, +): Promise { + const [entries, categories] = await Promise.all([ + loadFinancialEntries({ businessId, baseCurrency, range }), + db + .selectFrom("expenses") + .select(["id", "category"]) + .where("business_id", "=", businessId) + .where("expense_date", ">=", range.from) + .where("expense_date", "<=", range.to) + .execute(), + ]); + + return { + summary: summariseFinancialEntries(entries), + ...buildReportInsights(entries, range, new Map(categories.map(({ id, category }) => [id, category]))), + }; +} export async function getFinancialSummary( businessId: string, diff --git a/src/features/reports/range.ts b/src/features/reports/range.ts index a5ff858..c89d51c 100644 --- a/src/features/reports/range.ts +++ b/src/features/reports/range.ts @@ -11,6 +11,48 @@ export interface ChartPoint { expensesCents: number; } +export type ReportGranularity = "day" | "month" | "year"; + +export function reportChartBuckets(range: DateRange): { + granularity: ReportGranularity; + points: ChartPoint[]; +} { + const start = new Date(`${range.from}T00:00:00.000Z`); + const end = new Date(`${range.to}T00:00:00.000Z`); + const days = (end.valueOf() - start.valueOf()) / 86_400_000 + 1; + const months = + (end.getUTCFullYear() - start.getUTCFullYear()) * 12 + + end.getUTCMonth() - start.getUTCMonth() + 1; + const granularity = days <= 31 ? "day" : months <= 24 ? "month" : "year"; + const cursor = new Date(start); + if (granularity === "month") cursor.setUTCDate(1); + if (granularity === "year") cursor.setUTCMonth(0, 1); + + const formatter = new Intl.DateTimeFormat("en-GB", { + ...(granularity === "day" ? { day: "numeric" } : {}), + month: "short", + timeZone: "UTC", + }); + const spansYears = start.getUTCFullYear() !== end.getUTCFullYear(); + const points: ChartPoint[] = []; + + while (cursor <= end) { + const date = cursor.toISOString().slice(0, 10); + const year = date.slice(0, 4); + const key = date.slice(0, granularity === "day" ? 10 : granularity === "month" ? 7 : 4); + const label = granularity === "year" + ? year + : `${formatter.format(cursor)}${granularity === "month" || spansYears ? ` ${year}` : ""}`; + points.push({ key, label, salesCents: 0, receiptsCents: 0, expensesCents: 0 }); + + if (granularity === "day") cursor.setUTCDate(cursor.getUTCDate() + 1); + else if (granularity === "month") cursor.setUTCMonth(cursor.getUTCMonth() + 1); + else cursor.setUTCFullYear(cursor.getUTCFullYear() + 1); + } + + return { granularity, points }; +} + export function lastSixMonths(now: Date | string = new Date()) { const points: ChartPoint[] = []; const anchor = typeof now === "string" ? new Date(`${now}T00:00:00.000Z`) : now; diff --git a/src/features/reports/report-breakdown.tsx b/src/features/reports/report-breakdown.tsx index e4bc5e3..ef4e91b 100644 --- a/src/features/reports/report-breakdown.tsx +++ b/src/features/reports/report-breakdown.tsx @@ -29,7 +29,7 @@ export function ReportBreakdown({ currency, summary }: ReportBreakdownProps) { return (
{rows.map((row) => ( -
+

{row.label}

{row.description}

@@ -39,7 +39,7 @@ export function ReportBreakdown({ currency, summary }: ReportBreakdownProps) {

))} -
+

Cash net income

Payments received less expenses

diff --git a/src/features/reports/report-highlights.tsx b/src/features/reports/report-highlights.tsx new file mode 100644 index 0000000..4c65545 --- /dev/null +++ b/src/features/reports/report-highlights.tsx @@ -0,0 +1,59 @@ +import { formatMoney } from "@/lib/format"; + +import type { ReportInsights } from "./queries"; + +export function ReportHighlights({ + currency, + insights, +}: { + currency: string; + insights: ReportInsights; +}) { + const { summary, points, granularity, invoiceCount } = insights; + const strongest = points + .filter((point) => point.receiptsCents > 0 || point.expensesCents > 0) + .reduce<(typeof points)[number] | null>((best, point) => ( + !best || point.receiptsCents - point.expensesCents > best.receiptsCents - best.expensesCents + ? point + : best + ), null); + const highlights = [ + { + label: `Best ${granularity} with activity`, + value: strongest?.label ?? "—", + detail: strongest + ? `${formatMoney(strongest.receiptsCents - strongest.expensesCents, currency)} net income` + : "No cash activity", + }, + { + label: "Expenses / receipts", + value: summary.receiptsCents > 0 + ? `${((summary.expensesCents / summary.receiptsCents) * 100).toFixed(1)}%` + : "—", + detail: summary.receiptsCents > 0 + ? "Of payments received" + : "No payments received", + }, + { + label: "Average invoice", + value: invoiceCount > 0 + ? formatMoney(Math.round(summary.salesCents / invoiceCount), currency) + : "—", + detail: invoiceCount > 0 + ? `${invoiceCount} issued ${invoiceCount === 1 ? "invoice" : "invoices"}` + : "No issued invoices", + }, + ]; + + return ( +
+ {highlights.map(({ label, value, detail }) => ( +
+
{label}
+
{value}
+
{detail}
+
+ ))} +
+ ); +} From f0a5d543940878d876af918679164277958fedec Mon Sep 17 00:00:00 2001 From: Max Moon Date: Tue, 8 Sep 2026 13:39:16 +0100 Subject: [PATCH 2/2] fix(reports): bound chart periods for wide date ranges --- src/features/reports/cash-flow-chart.tsx | 6 +++--- src/features/reports/insights.test.ts | 20 ++++++++++++++++---- src/features/reports/insights.ts | 8 ++++++-- src/features/reports/range.ts | 18 ++++++++++++------ 4 files changed, 37 insertions(+), 15 deletions(-) diff --git a/src/features/reports/cash-flow-chart.tsx b/src/features/reports/cash-flow-chart.tsx index 6852015..1df3151 100644 --- a/src/features/reports/cash-flow-chart.tsx +++ b/src/features/reports/cash-flow-chart.tsx @@ -8,7 +8,7 @@ import { TooltipProvider, TooltipTrigger, } from "@/components/ui/tooltip"; -import type { ChartPoint } from "@/features/reports/range"; +import type { ChartPoint, ReportGranularity } from "@/features/reports/range"; import { formatDate, formatMoney } from "@/lib/format"; const series = [ @@ -16,7 +16,7 @@ const series = [ { key: "expensesCents", label: "Expenses", className: "bg-warning" }, ] as const; -const granularityLabels = { day: "Daily", month: "Monthly", year: "Yearly" }; +const granularityLabels = { day: "Daily", month: "Monthly", year: "Yearly", period: "Multi-year" }; export function ReportCashFlowChart({ currency, @@ -25,7 +25,7 @@ export function ReportCashFlowChart({ }: { currency: string; points: ChartPoint[]; - granularity: "day" | "month" | "year"; + granularity: ReportGranularity; }) { const titleId = useId(); const [activeKey, setActiveKey] = useState(null); diff --git a/src/features/reports/insights.test.ts b/src/features/reports/insights.test.ts index cd83bb1..d87a807 100644 --- a/src/features/reports/insights.test.ts +++ b/src/features/reports/insights.test.ts @@ -53,14 +53,26 @@ describe("report chart buckets", () => { const early = reportChartBuckets({ from: "0099-12-31", to: "0100-01-01" }); expect(early.points.map(({ label }) => label)).toEqual(["31 Dec 0099", "1 Jan 0100"]); const widest = reportChartBuckets({ from: "0000-01-01", to: "9999-12-31" }); - expect(widest.granularity).toBe("year"); - expect(widest.points).toHaveLength(10_000); - expect(widest.points.at(0)?.label).toBe("0000"); - expect(widest.points.at(-1)?.label).toBe("9999"); + expect(widest.granularity).toBe("period"); + expect(widest.points).toHaveLength(100); + expect(widest.points.at(0)?.label).toBe("0000–0099"); + expect(widest.points.at(-1)?.label).toBe("9900–9999"); }); }); describe("report insights", () => { + it("preserves amounts and counts across grouped year boundaries", () => { + const dates = ["0007-03-01", "0106-12-31", "0107-01-01", "9999-12-31"]; + const entries = dates.map((date) => syntheticEntry("receipt", date)); + const result = buildReportInsights(entries, { from: dates[0], to: dates[3] }, new Map()); + const amounts = entries.map(({ baseConversion }) => baseConversion.status === "converted" ? baseConversion.amountCents : 0); + expect(result.points).toHaveLength(100); + expect(result.points[0]).toMatchObject({ label: "0007–0106", receiptsCents: amounts[0] + amounts[1] }); + expect(result.points[1].receiptsCents).toBe(amounts[2]); + expect(result.points.at(-1)).toMatchObject({ label: "9907–9999", receiptsCents: amounts[3] }); + expect(result.receiptCount).toBe(entries.length); + }); + it("uses converted amounts, keeps empty days and excludes missing rates from totals and counts", () => { const sale = syntheticEntry("sale", "2072-02-02"); const receipt = syntheticEntry("receipt", "2072-02-04"); diff --git a/src/features/reports/insights.ts b/src/features/reports/insights.ts index a162db4..ea84c52 100644 --- a/src/features/reports/insights.ts +++ b/src/features/reports/insights.ts @@ -17,8 +17,9 @@ export function buildReportInsights( range: DateRange, categoriesByExpense: ReadonlyMap, ) { - const { granularity, points } = reportChartBuckets(range); + const { granularity, yearsPerBucket, points } = reportChartBuckets(range); const keyLength = granularity === "day" ? 10 : granularity === "month" ? 7 : 4; + const firstYear = Number(range.from.slice(0, 4)); const byPeriod = new Map(points.map((point) => [point.key, point])); const categoryTotals = new Map(); let invoiceCount = 0; @@ -32,7 +33,10 @@ export function buildReportInsights( entry.baseConversion.status === "missing-rate" ) continue; - const point = byPeriod.get(entry.date.slice(0, keyLength)); + const key = granularity === "period" + ? String(firstYear + Math.floor((Number(entry.date.slice(0, 4)) - firstYear) / yearsPerBucket) * yearsPerBucket).padStart(4, "0") + : entry.date.slice(0, keyLength); + const point = byPeriod.get(key); if (!point) continue; const amount = entry.baseConversion.amountCents; diff --git a/src/features/reports/range.ts b/src/features/reports/range.ts index c89d51c..843cc8b 100644 --- a/src/features/reports/range.ts +++ b/src/features/reports/range.ts @@ -11,10 +11,11 @@ export interface ChartPoint { expensesCents: number; } -export type ReportGranularity = "day" | "month" | "year"; +export type ReportGranularity = "day" | "month" | "year" | "period"; export function reportChartBuckets(range: DateRange): { granularity: ReportGranularity; + yearsPerBucket: number; points: ChartPoint[]; } { const start = new Date(`${range.from}T00:00:00.000Z`); @@ -23,10 +24,12 @@ export function reportChartBuckets(range: DateRange): { const months = (end.getUTCFullYear() - start.getUTCFullYear()) * 12 + end.getUTCMonth() - start.getUTCMonth() + 1; - const granularity = days <= 31 ? "day" : months <= 24 ? "month" : "year"; + const years = end.getUTCFullYear() - start.getUTCFullYear() + 1; + const yearsPerBucket = Math.ceil(years / 100); + const granularity = days <= 31 ? "day" : months <= 24 ? "month" : years <= 100 ? "year" : "period"; const cursor = new Date(start); if (granularity === "month") cursor.setUTCDate(1); - if (granularity === "year") cursor.setUTCMonth(0, 1); + if (granularity === "year" || granularity === "period") cursor.setUTCMonth(0, 1); const formatter = new Intl.DateTimeFormat("en-GB", { ...(granularity === "day" ? { day: "numeric" } : {}), @@ -40,17 +43,20 @@ export function reportChartBuckets(range: DateRange): { const date = cursor.toISOString().slice(0, 10); const year = date.slice(0, 4); const key = date.slice(0, granularity === "day" ? 10 : granularity === "month" ? 7 : 4); - const label = granularity === "year" + const lastYear = String(Math.min(cursor.getUTCFullYear() + yearsPerBucket - 1, end.getUTCFullYear())).padStart(4, "0"); + const label = granularity === "period" + ? `${year}–${lastYear}` + : granularity === "year" ? year : `${formatter.format(cursor)}${granularity === "month" || spansYears ? ` ${year}` : ""}`; points.push({ key, label, salesCents: 0, receiptsCents: 0, expensesCents: 0 }); if (granularity === "day") cursor.setUTCDate(cursor.getUTCDate() + 1); else if (granularity === "month") cursor.setUTCMonth(cursor.getUTCMonth() + 1); - else cursor.setUTCFullYear(cursor.getUTCFullYear() + 1); + else cursor.setUTCFullYear(cursor.getUTCFullYear() + yearsPerBucket); } - return { granularity, points }; + return { granularity, yearsPerBucket, points }; } export function lastSixMonths(now: Date | string = new Date()) {