From ff350d75e7fb71a1f33ed3f0d49e3e435b309381 Mon Sep 17 00:00:00 2001 From: le king fu Date: Tue, 14 Apr 2026 14:57:13 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20compare=20report=20=E2=80=94=20MoM=20/?= =?UTF-8?q?=20YoY=20/=20budget=20with=20view=20toggle=20(#73)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Services: getCompareMonthOverMonth(year, month) and getCompareYearOverYear(year) return CategoryDelta[] (expense-side, ABS aggregates, parameterised SQL only) - Shared CategoryDelta type with HighlightMover now aliased to it - Flesh out useCompare hook: reducer + fetch + automatic year/month inference from the shared useReportsPeriod `to` date; budget mode skips fetch and delegates to CompareBudgetView which wraps the existing BudgetVsActualTable - Components: CompareModeTabs (MoM/YoY/Budget tabs), ComparePeriodTable (sortable table with signed delta coloring), ComparePeriodChart (diverging horizontal bar chart with ChartPatternDefs for SVG patterns), CompareBudgetView (fetches budget rows for the current target year/month) - ReportsComparePage wires everything with PeriodSelector + ViewModeToggle (storage key reports-viewmode-compare); chart/table toggle is hidden in budget mode since the budget table has its own presentation - i18n keys: reports.compare.modeMoM / modeYoY / modeBudget in FR + EN - 4 new vitest cases for the compare services: parameterised boundaries, January wrap-around to December previous year, delta conversion with previous=0 fallback to null pct, year-over-year spans Fixes #73 Co-Authored-By: Claude Opus 4.6 (1M context) --- src/components/reports/CompareBudgetView.tsx | 47 +++++++ src/components/reports/CompareModeTabs.tsx | 38 ++++++ src/components/reports/ComparePeriodChart.tsx | 85 +++++++++++++ src/components/reports/ComparePeriodTable.tsx | 118 ++++++++++++++++++ src/hooks/useCompare.ts | 56 ++++++++- src/i18n/locales/en.json | 5 + src/i18n/locales/fr.json | 5 + src/pages/ReportsComparePage.tsx | 85 ++++++++++++- src/services/reportService.test.ts | 72 ++++++++++- src/services/reportService.ts | 105 ++++++++++++++++ src/shared/types/index.ts | 5 +- 11 files changed, 612 insertions(+), 9 deletions(-) create mode 100644 src/components/reports/CompareBudgetView.tsx create mode 100644 src/components/reports/CompareModeTabs.tsx create mode 100644 src/components/reports/ComparePeriodChart.tsx create mode 100644 src/components/reports/ComparePeriodTable.tsx diff --git a/src/components/reports/CompareBudgetView.tsx b/src/components/reports/CompareBudgetView.tsx new file mode 100644 index 0000000..1532e10 --- /dev/null +++ b/src/components/reports/CompareBudgetView.tsx @@ -0,0 +1,47 @@ +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import BudgetVsActualTable from "./BudgetVsActualTable"; +import { getBudgetVsActualData } from "../../services/budgetService"; +import type { BudgetVsActualRow } from "../../shared/types"; + +export interface CompareBudgetViewProps { + year: number; + month: number; +} + +export default function CompareBudgetView({ year, month }: CompareBudgetViewProps) { + const { t } = useTranslation(); + const [rows, setRows] = useState([]); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + setError(null); + getBudgetVsActualData(year, month) + .then((data) => { + if (!cancelled) setRows(data); + }) + .catch((e: unknown) => { + if (!cancelled) setError(e instanceof Error ? e.message : String(e)); + }); + return () => { + cancelled = true; + }; + }, [year, month]); + + if (error) { + return ( +
{error}
+ ); + } + + if (rows.length === 0) { + return ( +
+ {t("reports.bva.noData")} +
+ ); + } + + return ; +} diff --git a/src/components/reports/CompareModeTabs.tsx b/src/components/reports/CompareModeTabs.tsx new file mode 100644 index 0000000..a33d985 --- /dev/null +++ b/src/components/reports/CompareModeTabs.tsx @@ -0,0 +1,38 @@ +import { useTranslation } from "react-i18next"; +import type { CompareMode } from "../../hooks/useCompare"; + +export interface CompareModeTabsProps { + value: CompareMode; + onChange: (mode: CompareMode) => void; +} + +export default function CompareModeTabs({ value, onChange }: CompareModeTabsProps) { + const { t } = useTranslation(); + + const modes: { id: CompareMode; labelKey: string }[] = [ + { id: "mom", labelKey: "reports.compare.modeMoM" }, + { id: "yoy", labelKey: "reports.compare.modeYoY" }, + { id: "budget", labelKey: "reports.compare.modeBudget" }, + ]; + + return ( +
+ {modes.map(({ id, labelKey }) => ( + + ))} +
+ ); +} diff --git a/src/components/reports/ComparePeriodChart.tsx b/src/components/reports/ComparePeriodChart.tsx new file mode 100644 index 0000000..edd946b --- /dev/null +++ b/src/components/reports/ComparePeriodChart.tsx @@ -0,0 +1,85 @@ +import { useTranslation } from "react-i18next"; +import { + BarChart, + Bar, + XAxis, + YAxis, + Cell, + ReferenceLine, + Tooltip, + ResponsiveContainer, +} from "recharts"; +import type { CategoryDelta } from "../../shared/types"; +import { ChartPatternDefs, getPatternFill } from "../../utils/chartPatterns"; + +export interface ComparePeriodChartProps { + rows: CategoryDelta[]; +} + +function formatCurrency(amount: number, language: string): string { + return new Intl.NumberFormat(language === "fr" ? "fr-CA" : "en-CA", { + style: "currency", + currency: "CAD", + maximumFractionDigits: 0, + }).format(amount); +} + +export default function ComparePeriodChart({ rows }: ComparePeriodChartProps) { + const { t, i18n } = useTranslation(); + + if (rows.length === 0) { + return ( +
+ {t("reports.empty.noData")} +
+ ); + } + + const chartData = rows + .map((r, i) => ({ + name: r.categoryName, + color: r.categoryColor, + delta: r.deltaAbs, + index: i, + })) + .sort((a, b) => a.delta - b.delta); + + return ( +
+ + + ({ color: d.color, index: d.index }))} + /> + formatCurrency(v, i18n.language)} + stroke="var(--muted-foreground)" + fontSize={11} + /> + + + + typeof value === "number" ? formatCurrency(value, i18n.language) : String(value) + } + contentStyle={{ + backgroundColor: "var(--card)", + border: "1px solid var(--border)", + borderRadius: "0.5rem", + }} + /> + + {chartData.map((entry) => ( + + ))} + + + +
+ ); +} diff --git a/src/components/reports/ComparePeriodTable.tsx b/src/components/reports/ComparePeriodTable.tsx new file mode 100644 index 0000000..8cb40d5 --- /dev/null +++ b/src/components/reports/ComparePeriodTable.tsx @@ -0,0 +1,118 @@ +import { useTranslation } from "react-i18next"; +import type { CategoryDelta } from "../../shared/types"; + +export interface ComparePeriodTableProps { + rows: CategoryDelta[]; + previousLabel: string; + currentLabel: string; +} + +function formatCurrency(amount: number, language: string): string { + return new Intl.NumberFormat(language === "fr" ? "fr-CA" : "en-CA", { + style: "currency", + currency: "CAD", + }).format(amount); +} + +function formatSignedCurrency(amount: number, language: string): string { + return new Intl.NumberFormat(language === "fr" ? "fr-CA" : "en-CA", { + style: "currency", + currency: "CAD", + signDisplay: "always", + }).format(amount); +} + +function formatPct(pct: number | null, language: string): string { + if (pct === null) return "—"; + return new Intl.NumberFormat(language === "fr" ? "fr-CA" : "en-CA", { + style: "percent", + maximumFractionDigits: 1, + signDisplay: "always", + }).format(pct / 100); +} + +export default function ComparePeriodTable({ + rows, + previousLabel, + currentLabel, +}: ComparePeriodTableProps) { + const { t, i18n } = useTranslation(); + + return ( +
+
+ + + + + + + + + + + + {rows.length === 0 ? ( + + + + ) : ( + rows.map((row) => ( + + + + + + + + )) + )} + +
+ {t("reports.highlights.category")} + + {previousLabel} + + {currentLabel} + + {t("reports.highlights.variationAbs")} + + {t("reports.highlights.variationPct")} +
+ {t("reports.empty.noData")} +
+ + + {row.categoryName} + + + {formatCurrency(row.previousAmount, i18n.language)} + + {formatCurrency(row.currentAmount, i18n.language)} + = 0 ? "var(--negative, #ef4444)" : "var(--positive, #10b981)", + }} + > + {formatSignedCurrency(row.deltaAbs, i18n.language)} + = 0 ? "var(--negative, #ef4444)" : "var(--positive, #10b981)", + }} + > + {formatPct(row.deltaPct, i18n.language)} +
+
+
+ ); +} diff --git a/src/hooks/useCompare.ts b/src/hooks/useCompare.ts index 90f0fd7..ddce800 100644 --- a/src/hooks/useCompare.ts +++ b/src/hooks/useCompare.ts @@ -1,21 +1,32 @@ -import { useReducer, useCallback } from "react"; +import { useReducer, useCallback, useEffect, useRef } from "react"; +import type { CategoryDelta } from "../shared/types"; +import { getCompareMonthOverMonth, getCompareYearOverYear } from "../services/reportService"; import { useReportsPeriod } from "./useReportsPeriod"; export type CompareMode = "mom" | "yoy" | "budget"; interface State { mode: CompareMode; + year: number; + month: number; + rows: CategoryDelta[]; isLoading: boolean; error: string | null; } type Action = | { type: "SET_MODE"; payload: CompareMode } + | { type: "SET_PERIOD"; payload: { year: number; month: number } } | { type: "SET_LOADING"; payload: boolean } + | { type: "SET_ROWS"; payload: CategoryDelta[] } | { type: "SET_ERROR"; payload: string }; +const today = new Date(); const initialState: State = { mode: "mom", + year: today.getFullYear(), + month: today.getMonth() + 1, + rows: [], isLoading: false, error: null, }; @@ -24,8 +35,12 @@ function reducer(state: State, action: Action): State { switch (action.type) { case "SET_MODE": return { ...state, mode: action.payload }; + case "SET_PERIOD": + return { ...state, year: action.payload.year, month: action.payload.month }; case "SET_LOADING": return { ...state, isLoading: action.payload }; + case "SET_ROWS": + return { ...state, rows: action.payload, isLoading: false, error: null }; case "SET_ERROR": return { ...state, error: action.payload, isLoading: false }; default: @@ -36,11 +51,46 @@ function reducer(state: State, action: Action): State { export function useCompare() { const { from, to } = useReportsPeriod(); const [state, dispatch] = useReducer(reducer, initialState); + const fetchIdRef = useRef(0); + + const fetch = useCallback(async (mode: CompareMode, year: number, month: number) => { + if (mode === "budget") return; // Budget view uses BudgetVsActualTable directly + const id = ++fetchIdRef.current; + dispatch({ type: "SET_LOADING", payload: true }); + try { + const rows = + mode === "mom" + ? await getCompareMonthOverMonth(year, month) + : await getCompareYearOverYear(year); + if (id !== fetchIdRef.current) return; + dispatch({ type: "SET_ROWS", payload: rows }); + } catch (e) { + if (id !== fetchIdRef.current) return; + dispatch({ type: "SET_ERROR", payload: e instanceof Error ? e.message : String(e) }); + } + }, []); + + useEffect(() => { + fetch(state.mode, state.year, state.month); + }, [fetch, state.mode, state.year, state.month]); + + // When the URL period changes, use the `to` date to infer the target year/month. + useEffect(() => { + const [y, m] = to.split("-").map(Number); + if (!Number.isFinite(y) || !Number.isFinite(m)) return; + if (y !== state.year || m !== state.month) { + dispatch({ type: "SET_PERIOD", payload: { year: y, month: m } }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [to]); const setMode = useCallback((m: CompareMode) => { dispatch({ type: "SET_MODE", payload: m }); }, []); - // Issue #73 will fetch via reportService.getCompareMonthOverMonth / ...YearOverYear - return { ...state, setMode, from, to }; + const setTargetPeriod = useCallback((year: number, month: number) => { + dispatch({ type: "SET_PERIOD", payload: { year, month } }); + }, []); + + return { ...state, setMode, setTargetPeriod, from, to }; } diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 610882e..b3e18e9 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -403,6 +403,11 @@ "subviewGlobal": "Global flow", "subviewByCategory": "By category" }, + "compare": { + "modeMoM": "Month vs previous month", + "modeYoY": "Year vs previous year", + "modeBudget": "Actual vs budget" + }, "highlights": { "balances": "Balances", "netBalanceCurrent": "This month", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 3386bd6..2efe87c 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -403,6 +403,11 @@ "subviewGlobal": "Flux global", "subviewByCategory": "Par catégorie" }, + "compare": { + "modeMoM": "Mois vs mois précédent", + "modeYoY": "Année vs année précédente", + "modeBudget": "Réel vs budget" + }, "highlights": { "balances": "Soldes", "netBalanceCurrent": "Ce mois-ci", diff --git a/src/pages/ReportsComparePage.tsx b/src/pages/ReportsComparePage.tsx index 92b2567..871c26e 100644 --- a/src/pages/ReportsComparePage.tsx +++ b/src/pages/ReportsComparePage.tsx @@ -1,11 +1,88 @@ +import { useState } from "react"; import { useTranslation } from "react-i18next"; +import { Link } from "react-router-dom"; +import { ArrowLeft } from "lucide-react"; +import PeriodSelector from "../components/dashboard/PeriodSelector"; +import CompareModeTabs from "../components/reports/CompareModeTabs"; +import ComparePeriodTable from "../components/reports/ComparePeriodTable"; +import ComparePeriodChart from "../components/reports/ComparePeriodChart"; +import CompareBudgetView from "../components/reports/CompareBudgetView"; +import ViewModeToggle, { readViewMode, type ViewMode } from "../components/reports/ViewModeToggle"; +import { useCompare } from "../hooks/useCompare"; +import { useReportsPeriod } from "../hooks/useReportsPeriod"; + +const STORAGE_KEY = "reports-viewmode-compare"; + +const MONTH_NAMES_EN = [ + "January", "February", "March", "April", "May", "June", + "July", "August", "September", "October", "November", "December", +]; +const MONTH_NAMES_FR = [ + "Janvier", "Février", "Mars", "Avril", "Mai", "Juin", + "Juillet", "Août", "Septembre", "Octobre", "Novembre", "Décembre", +]; + +function monthName(month: number, language: string): string { + return (language === "fr" ? MONTH_NAMES_FR : MONTH_NAMES_EN)[month - 1] ?? String(month); +} export default function ReportsComparePage() { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); + const { period, setPeriod, from, to, setCustomDates } = useReportsPeriod(); + const { mode, setMode, year, month, rows, isLoading, error } = useCompare(); + const [viewMode, setViewMode] = useState(() => readViewMode(STORAGE_KEY)); + + const preserveSearch = typeof window !== "undefined" ? window.location.search : ""; + + const previousLabel = + mode === "mom" + ? `${monthName(month === 1 ? 12 : month - 1, i18n.language)} ${month === 1 ? year - 1 : year}` + : `${year - 1}`; + const currentLabel = + mode === "mom" ? `${monthName(month, i18n.language)} ${year}` : `${year}`; + return ( -
-

{t("reports.hub.compare")}

-

{t("common.underConstruction")}

+
+
+ + + +

{t("reports.hub.compare")}

+
+ +
+ +
+ + {mode !== "budget" && ( + + )} +
+
+ + {error && ( +
+ {error} +
+ )} + + {mode === "budget" ? ( + + ) : viewMode === "chart" ? ( + + ) : ( + + )}
); } diff --git a/src/services/reportService.test.ts b/src/services/reportService.test.ts index f842d29..4a8895e 100644 --- a/src/services/reportService.test.ts +++ b/src/services/reportService.test.ts @@ -1,5 +1,10 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; -import { getCategoryOverTime, getHighlights } from "./reportService"; +import { + getCategoryOverTime, + getHighlights, + getCompareMonthOverMonth, + getCompareYearOverYear, +} from "./reportService"; // Mock the db module vi.mock("./db", () => ({ @@ -266,3 +271,68 @@ describe("getHighlights", () => { expect(result.topMovers[0].deltaAbs).toBe(120); }); }); + +describe("getCompareMonthOverMonth", () => { + it("passes current and previous month boundaries as parameters", async () => { + mockSelect.mockResolvedValueOnce([]); + + await getCompareMonthOverMonth(2026, 4); + + expect(mockSelect).toHaveBeenCalledTimes(1); + const sql = mockSelect.mock.calls[0][0] as string; + const params = mockSelect.mock.calls[0][1] as unknown[]; + expect(sql).toContain("$1"); + expect(sql).toContain("$4"); + expect(params).toEqual(["2026-04-01", "2026-04-30", "2026-03-01", "2026-03-31"]); + expect(sql).not.toContain("'2026"); + }); + + it("wraps to december of previous year when target month is january", async () => { + mockSelect.mockResolvedValueOnce([]); + + await getCompareMonthOverMonth(2026, 1); + + const params = mockSelect.mock.calls[0][1] as unknown[]; + expect(params).toEqual(["2026-01-01", "2026-01-31", "2025-12-01", "2025-12-31"]); + }); + + it("converts raw rows into CategoryDelta with signed deltas", async () => { + mockSelect.mockResolvedValueOnce([ + { + category_id: 1, + category_name: "Groceries", + category_color: "#10b981", + current_total: 500, + previous_total: 400, + }, + { + category_id: 2, + category_name: "Restaurants", + category_color: "#f97316", + current_total: 120, + previous_total: 0, + }, + ]); + + const result = await getCompareMonthOverMonth(2026, 4); + + expect(result).toHaveLength(2); + expect(result[0]).toMatchObject({ + categoryName: "Groceries", + deltaAbs: 100, + }); + expect(result[0].deltaPct).toBeCloseTo(25, 4); + expect(result[1].deltaPct).toBeNull(); // previous = 0 + }); +}); + +describe("getCompareYearOverYear", () => { + it("spans two full calendar years with parameterised boundaries", async () => { + mockSelect.mockResolvedValueOnce([]); + + await getCompareYearOverYear(2026); + + const params = mockSelect.mock.calls[0][1] as unknown[]; + expect(params).toEqual(["2026-01-01", "2026-12-31", "2025-01-01", "2025-12-31"]); + }); +}); diff --git a/src/services/reportService.ts b/src/services/reportService.ts index 12b306b..6caf4e0 100644 --- a/src/services/reportService.ts +++ b/src/services/reportService.ts @@ -6,6 +6,7 @@ import type { CategoryOverTimeItem, HighlightsData, HighlightMover, + CategoryDelta, MonthBalance, RecentTransaction, } from "../shared/types"; @@ -340,3 +341,107 @@ export async function getHighlights( topTransactions: recentRows, }; } + +// --- Compare (Issue #73) --- + +interface RawDeltaRow { + category_id: number | null; + category_name: string; + category_color: string; + current_total: number | null; + previous_total: number | null; +} + +function rowsToDeltas(rows: RawDeltaRow[]): CategoryDelta[] { + return rows.map((r) => { + const current = Number(r.current_total ?? 0); + const previous = Number(r.previous_total ?? 0); + const deltaAbs = current - previous; + const deltaPct = previous === 0 ? null : (deltaAbs / previous) * 100; + return { + categoryId: r.category_id, + categoryName: r.category_name, + categoryColor: r.category_color, + previousAmount: previous, + currentAmount: current, + deltaAbs, + deltaPct, + }; + }); +} + +function monthBoundaries(year: number, month: number): { start: string; end: string } { + const mm = String(month).padStart(2, "0"); + const endDate = new Date(Date.UTC(year, month, 0)); + const dd = String(endDate.getUTCDate()).padStart(2, "0"); + return { start: `${year}-${mm}-01`, end: `${year}-${mm}-${dd}` }; +} + +function previousMonth(year: number, month: number): { year: number; month: number } { + if (month === 1) return { year: year - 1, month: 12 }; + return { year, month: month - 1 }; +} + +/** + * Month-over-month expense delta by category. All SQL parameterised. + */ +export async function getCompareMonthOverMonth( + year: number, + month: number, +): Promise { + const db = await getDb(); + const { start: curStart, end: curEnd } = monthBoundaries(year, month); + const prev = previousMonth(year, month); + const { start: prevStart, end: prevEnd } = monthBoundaries(prev.year, prev.month); + + const rows = await db.select( + `SELECT + t.category_id, + COALESCE(c.name, 'Uncategorized') AS category_name, + COALESCE(c.color, '#9ca3af') AS category_color, + COALESCE(SUM(CASE WHEN t.date >= $1 AND t.date <= $2 THEN ABS(t.amount) ELSE 0 END), 0) AS current_total, + COALESCE(SUM(CASE WHEN t.date >= $3 AND t.date <= $4 THEN ABS(t.amount) ELSE 0 END), 0) AS previous_total + FROM transactions t + LEFT JOIN categories c ON t.category_id = c.id + WHERE t.amount < 0 + AND ( + (t.date >= $1 AND t.date <= $2) + OR (t.date >= $3 AND t.date <= $4) + ) + GROUP BY t.category_id, category_name, category_color + ORDER BY ABS(current_total - previous_total) DESC`, + [curStart, curEnd, prevStart, prevEnd], + ); + return rowsToDeltas(rows); +} + +/** + * Year-over-year expense delta by category. All SQL parameterised. + */ +export async function getCompareYearOverYear(year: number): Promise { + const db = await getDb(); + const curStart = `${year}-01-01`; + const curEnd = `${year}-12-31`; + const prevStart = `${year - 1}-01-01`; + const prevEnd = `${year - 1}-12-31`; + + const rows = await db.select( + `SELECT + t.category_id, + COALESCE(c.name, 'Uncategorized') AS category_name, + COALESCE(c.color, '#9ca3af') AS category_color, + COALESCE(SUM(CASE WHEN t.date >= $1 AND t.date <= $2 THEN ABS(t.amount) ELSE 0 END), 0) AS current_total, + COALESCE(SUM(CASE WHEN t.date >= $3 AND t.date <= $4 THEN ABS(t.amount) ELSE 0 END), 0) AS previous_total + FROM transactions t + LEFT JOIN categories c ON t.category_id = c.id + WHERE t.amount < 0 + AND ( + (t.date >= $1 AND t.date <= $2) + OR (t.date >= $3 AND t.date <= $4) + ) + GROUP BY t.category_id, category_name, category_color + ORDER BY ABS(current_total - previous_total) DESC`, + [curStart, curEnd, prevStart, prevEnd], + ); + return rowsToDeltas(rows); +} diff --git a/src/shared/types/index.ts b/src/shared/types/index.ts index a60530c..215e2e3 100644 --- a/src/shared/types/index.ts +++ b/src/shared/types/index.ts @@ -278,7 +278,7 @@ export interface RecentTransaction { export type ReportTab = "trends" | "byCategory" | "overTime" | "budgetVsActual"; -export interface HighlightMover { +export interface CategoryDelta { categoryId: number | null; categoryName: string; categoryColor: string; @@ -288,6 +288,9 @@ export interface HighlightMover { deltaPct: number | null; // null when previous is 0 } +// Historical alias — used by the highlights hub. Shape identical to CategoryDelta. +export type HighlightMover = CategoryDelta; + export interface MonthBalance { month: string; // "YYYY-MM" netBalance: number;