diff --git a/CHANGELOG.fr.md b/CHANGELOG.fr.md index 44af3fc..a07c8e4 100644 --- a/CHANGELOG.fr.md +++ b/CHANGELOG.fr.md @@ -7,6 +7,7 @@ ### Modifié - **Rapport Zoom catégorie** (`/reports/category`) : le sélecteur de catégorie est désormais un combobox saisissable et filtrable avec recherche insensible aux accents, navigation clavier (↑/↓/Entrée/Échap) et indentation hiérarchique, en remplacement du `` (#103) +- **Compare report — Actual vs. actual** (`/reports/compare`): the table now mirrors the rich 8-column structure of the Actual vs. budget table, splitting each comparison into a *Monthly* block (reference month vs. comparison month) and a *Cumulative YTD* block (progress through the reference month vs. progress through the previous window). MoM cumulative-previous uses Jan → end-of-previous-month of the same year; YoY cumulative-previous uses Jan → same-month of the previous year. The chart remains a monthly-only view (#104) ### Fixed - **Cartes report**: removed the non-functional period selector — the Cartes report is a "month X vs X-1 vs X-12" snapshot, so only the reference-month picker is needed (#101) diff --git a/src/components/reports/ComparePeriodTable.tsx b/src/components/reports/ComparePeriodTable.tsx index 8cb40d5..aff094d 100644 --- a/src/components/reports/ComparePeriodTable.tsx +++ b/src/components/reports/ComparePeriodTable.tsx @@ -3,14 +3,21 @@ import type { CategoryDelta } from "../../shared/types"; export interface ComparePeriodTableProps { rows: CategoryDelta[]; + /** Label for the "previous" monthly column (e.g. "March 2026" or "2025"). */ previousLabel: string; + /** Label for the "current" monthly column (e.g. "April 2026" or "2026"). */ currentLabel: string; + /** Optional label for the previous cumulative window (YTD). Falls back to previousLabel. */ + cumulativePreviousLabel?: string; + /** Optional label for the current cumulative window (YTD). Falls back to currentLabel. */ + cumulativeCurrentLabel?: string; } 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); } @@ -18,6 +25,7 @@ function formatSignedCurrency(amount: number, language: string): string { return new Intl.NumberFormat(language === "fr" ? "fr-CA" : "en-CA", { style: "currency", currency: "CAD", + maximumFractionDigits: 0, signDisplay: "always", }).format(amount); } @@ -31,84 +39,211 @@ function formatPct(pct: number | null, language: string): string { }).format(pct / 100); } +function variationColor(value: number): string { + // Compare report deals with expenses (abs values): spending more is negative + // for the user, spending less is positive. Mirror that colour convention + // consistently so the eye parses the delta sign at a glance. + if (value > 0) return "var(--negative, #ef4444)"; + if (value < 0) return "var(--positive, #10b981)"; + return ""; +} + export default function ComparePeriodTable({ rows, previousLabel, currentLabel, + cumulativePreviousLabel, + cumulativeCurrentLabel, }: ComparePeriodTableProps) { const { t, i18n } = useTranslation(); + const monthPrevLabel = previousLabel; + const monthCurrLabel = currentLabel; + const ytdPrevLabel = cumulativePreviousLabel ?? previousLabel; + const ytdCurrLabel = cumulativeCurrentLabel ?? currentLabel; + + // Totals across all rows (there is no parent/child structure in compare mode). + const totals = rows.reduce( + (acc, r) => ({ + monthCurrent: acc.monthCurrent + r.currentAmount, + monthPrevious: acc.monthPrevious + r.previousAmount, + monthDelta: acc.monthDelta + r.deltaAbs, + ytdCurrent: acc.ytdCurrent + r.cumulativeCurrentAmount, + ytdPrevious: acc.ytdPrevious + r.cumulativePreviousAmount, + ytdDelta: acc.ytdDelta + r.cumulativeDeltaAbs, + }), + { monthCurrent: 0, monthPrevious: 0, monthDelta: 0, ytdCurrent: 0, ytdPrevious: 0, ytdDelta: 0 }, + ); + const totalMonthPct = + totals.monthPrevious !== 0 ? (totals.monthDelta / Math.abs(totals.monthPrevious)) * 100 : null; + const totalYtdPct = + totals.ytdPrevious !== 0 ? (totals.ytdDelta / Math.abs(totals.ytdPrevious)) * 100 : null; + return (
-
+
- - - + + - - - + + - + + + + + + {rows.length === 0 ? ( - ) : ( - rows.map((row) => ( - - + + {/* Monthly block */} + + + + + {/* Cumulative YTD block */} + + + + + + ))} + {/* Grand totals row */} + + - - + + + + - )) + )}
+
{t("reports.highlights.category")} - {previousLabel} + + {t("reports.bva.monthly")} - {currentLabel} + + {t("reports.bva.ytd")} - {t("reports.highlights.variationAbs")} +
+
{t("reports.compare.currentAmount")}
+
{monthCurrLabel}
- {t("reports.highlights.variationPct")} + +
{t("reports.compare.previousAmount")}
+
{monthPrevLabel}
+
+ {t("reports.bva.dollarVar")} + + {t("reports.bva.pctVar")} + +
{t("reports.compare.currentAmount")}
+
{ytdCurrLabel}
+
+
{t("reports.compare.previousAmount")}
+
{ytdPrevLabel}
+
+ {t("reports.bva.dollarVar")} + + {t("reports.bva.pctVar")}
+ {t("reports.empty.noData")}
- - - {row.categoryName} - + <> + {rows.map((row) => ( +
+ + + {row.categoryName} + + + {formatCurrency(row.currentAmount, i18n.language)} + + {formatCurrency(row.previousAmount, i18n.language)} + + {formatSignedCurrency(row.deltaAbs, i18n.language)} + + {formatPct(row.deltaPct, i18n.language)} + + {formatCurrency(row.cumulativeCurrentAmount, i18n.language)} + + {formatCurrency(row.cumulativePreviousAmount, i18n.language)} + + {formatSignedCurrency(row.cumulativeDeltaAbs, i18n.language)} + + {formatPct(row.cumulativeDeltaPct, i18n.language)} +
+ {t("reports.compare.totalRow")} - {formatCurrency(row.previousAmount, i18n.language)} + + {formatCurrency(totals.monthCurrent, i18n.language)} - {formatCurrency(row.currentAmount, i18n.language)} + + {formatCurrency(totals.monthPrevious, i18n.language)} = 0 ? "var(--negative, #ef4444)" : "var(--positive, #10b981)", - }} + className="text-right px-3 py-3 tabular-nums" + style={{ color: variationColor(totals.monthDelta) }} > - {formatSignedCurrency(row.deltaAbs, i18n.language)} + {formatSignedCurrency(totals.monthDelta, i18n.language)} = 0 ? "var(--negative, #ef4444)" : "var(--positive, #10b981)", - }} + className="text-right px-3 py-3 tabular-nums" + style={{ color: variationColor(totals.monthDelta) }} > - {formatPct(row.deltaPct, i18n.language)} + {formatPct(totalMonthPct, i18n.language)} + + {formatCurrency(totals.ytdCurrent, i18n.language)} + + {formatCurrency(totals.ytdPrevious, i18n.language)} + + {formatSignedCurrency(totals.ytdDelta, i18n.language)} + + {formatPct(totalYtdPct, i18n.language)}
diff --git a/src/hooks/useCompare.ts b/src/hooks/useCompare.ts index 846f914..8cc6a2c 100644 --- a/src/hooks/useCompare.ts +++ b/src/hooks/useCompare.ts @@ -102,7 +102,7 @@ export function useCompare() { const rows = subMode === "mom" ? await getCompareMonthOverMonth(year, month) - : await getCompareYearOverYear(year); + : await getCompareYearOverYear(year, month); if (id !== fetchIdRef.current) return; dispatch({ type: "SET_ROWS", payload: rows }); } catch (e) { diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 02101eb..b14fe59 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -410,7 +410,10 @@ "subModeMoM": "Previous month", "subModeYoY": "Previous year", "subModeAria": "Comparison period", - "referenceMonth": "Reference month" + "referenceMonth": "Reference month", + "currentAmount": "Current", + "previousAmount": "Previous", + "totalRow": "Total" }, "cartes": { "kpiSectionAria": "Key indicators for the reference month", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 5f3805f..122aeff 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -410,7 +410,10 @@ "subModeMoM": "Mois précédent", "subModeYoY": "Année précédente", "subModeAria": "Période de comparaison", - "referenceMonth": "Mois de référence" + "referenceMonth": "Mois de référence", + "currentAmount": "Courant", + "previousAmount": "Précédent", + "totalRow": "Total" }, "cartes": { "kpiSectionAria": "Indicateurs clés du mois de référence", diff --git a/src/pages/ReportsComparePage.tsx b/src/pages/ReportsComparePage.tsx index 25463af..fbba1ad 100644 --- a/src/pages/ReportsComparePage.tsx +++ b/src/pages/ReportsComparePage.tsx @@ -43,12 +43,24 @@ export default function ReportsComparePage() { const preserveSearch = typeof window !== "undefined" ? window.location.search : ""; const { previousYear, previousMonth: prevMonth } = comparisonMeta(subMode, year, month); - const currentLabel = - subMode === "mom" ? formatMonthLabel(year, month, i18n.language) : String(year); - const previousLabel = + // Monthly block labels: a specific month on both sides. For MoM the + // previous = previous month of same year; for YoY the previous = same + // month one year earlier (comparisonMeta already resolves both). + const currentLabel = formatMonthLabel(year, month, i18n.language); + const previousLabel = formatMonthLabel(previousYear, prevMonth, i18n.language); + // Cumulative YTD block labels. For MoM both sides live in the same year + // but the previous side only covers up to the end of the previous month, + // so we surface the end-of-window month label on each side. For YoY we + // compare Jan→refMonth across two different years; the year + month label + // makes the window boundary unambiguous. + const cumulativeCurrentLabel = subMode === "mom" - ? formatMonthLabel(previousYear, prevMonth, i18n.language) - : String(previousYear); + ? `→ ${formatMonthLabel(year, month, i18n.language)}` + : `${String(year)} → ${formatMonthLabel(year, month, i18n.language)}`; + const cumulativePreviousLabel = + subMode === "mom" + ? `→ ${formatMonthLabel(previousYear, prevMonth, i18n.language)}` + : `${String(previousYear)} → ${formatMonthLabel(previousYear, prevMonth, i18n.language)}`; const showActualControls = mode === "actual"; @@ -109,7 +121,13 @@ export default function ReportsComparePage() { currentLabel={currentLabel} /> ) : ( - + )}
); diff --git a/src/services/reportService.cartes.test.ts b/src/services/reportService.cartes.test.ts index 3d31032..a488e28 100644 --- a/src/services/reportService.cartes.test.ts +++ b/src/services/reportService.cartes.test.ts @@ -195,17 +195,20 @@ describe("getCartesSnapshot", () => { it("splits top movers by sign and caps each list at 5", async () => { // Seven up-movers, three down-movers — verify we get 5 up and 3 down. + // Cumulative totals mirror the monthly ones in this fixture — the Cartes + // service only reads the monthly block for top movers, so cumulative + // values are inert here. const momRows = [ - { category_id: 1, category_name: "C1", category_color: "#000", current_total: 200, previous_total: 100 }, - { category_id: 2, category_name: "C2", category_color: "#000", current_total: 400, previous_total: 100 }, - { category_id: 3, category_name: "C3", category_color: "#000", current_total: 500, previous_total: 100 }, - { category_id: 4, category_name: "C4", category_color: "#000", current_total: 700, previous_total: 100 }, - { category_id: 5, category_name: "C5", category_color: "#000", current_total: 900, previous_total: 100 }, - { category_id: 6, category_name: "C6", category_color: "#000", current_total: 1100, previous_total: 100 }, - { category_id: 7, category_name: "C7", category_color: "#000", current_total: 1300, previous_total: 100 }, - { category_id: 8, category_name: "D1", category_color: "#000", current_total: 100, previous_total: 500 }, - { category_id: 9, category_name: "D2", category_color: "#000", current_total: 100, previous_total: 700 }, - { category_id: 10, category_name: "D3", category_color: "#000", current_total: 100, previous_total: 900 }, + { category_id: 1, category_name: "C1", category_color: "#000", month_current_total: 200, month_previous_total: 100, cumulative_current_total: 200, cumulative_previous_total: 100 }, + { category_id: 2, category_name: "C2", category_color: "#000", month_current_total: 400, month_previous_total: 100, cumulative_current_total: 400, cumulative_previous_total: 100 }, + { category_id: 3, category_name: "C3", category_color: "#000", month_current_total: 500, month_previous_total: 100, cumulative_current_total: 500, cumulative_previous_total: 100 }, + { category_id: 4, category_name: "C4", category_color: "#000", month_current_total: 700, month_previous_total: 100, cumulative_current_total: 700, cumulative_previous_total: 100 }, + { category_id: 5, category_name: "C5", category_color: "#000", month_current_total: 900, month_previous_total: 100, cumulative_current_total: 900, cumulative_previous_total: 100 }, + { category_id: 6, category_name: "C6", category_color: "#000", month_current_total: 1100, month_previous_total: 100, cumulative_current_total: 1100, cumulative_previous_total: 100 }, + { category_id: 7, category_name: "C7", category_color: "#000", month_current_total: 1300, month_previous_total: 100, cumulative_current_total: 1300, cumulative_previous_total: 100 }, + { category_id: 8, category_name: "D1", category_color: "#000", month_current_total: 100, month_previous_total: 500, cumulative_current_total: 100, cumulative_previous_total: 500 }, + { category_id: 9, category_name: "D2", category_color: "#000", month_current_total: 100, month_previous_total: 700, cumulative_current_total: 100, cumulative_previous_total: 700 }, + { category_id: 10, category_name: "D3", category_color: "#000", month_current_total: 100, month_previous_total: 900, cumulative_current_total: 100, cumulative_previous_total: 900 }, ]; routeSelect([ { @@ -214,7 +217,7 @@ describe("getCartesSnapshot", () => { }, { // Matches the getCompareMonthOverMonth SQL pattern. - match: "ORDER BY ABS(current_total - previous_total) DESC", + match: "ORDER BY ABS(month_current_total - month_previous_total) DESC", rows: momRows, }, ]); diff --git a/src/services/reportService.test.ts b/src/services/reportService.test.ts index 4bc31bc..8f24367 100644 --- a/src/services/reportService.test.ts +++ b/src/services/reportService.test.ts @@ -274,7 +274,7 @@ describe("getHighlights", () => { }); describe("getCompareMonthOverMonth", () => { - it("passes current and previous month boundaries as parameters", async () => { + it("passes monthly and cumulative boundaries as parameters", async () => { mockSelect.mockResolvedValueOnce([]); await getCompareMonthOverMonth(2026, 4); @@ -283,8 +283,15 @@ describe("getCompareMonthOverMonth", () => { 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).toContain("$8"); + // Monthly current ($1, $2), monthly previous ($3, $4), + // cumulative current Jan→ref ($5, $6), cumulative previous Jan→prev ($7, $8). + expect(params).toEqual([ + "2026-04-01", "2026-04-30", + "2026-03-01", "2026-03-31", + "2026-01-01", "2026-04-30", + "2026-01-01", "2026-03-31", + ]); expect(sql).not.toContain("'2026"); }); @@ -294,24 +301,35 @@ describe("getCompareMonthOverMonth", () => { 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"]); + // Cumulative-previous window lives entirely in 2025 (Jan → Dec), since the + // previous month (Dec 2025) belongs to the prior calendar year. + expect(params).toEqual([ + "2026-01-01", "2026-01-31", + "2025-12-01", "2025-12-31", + "2026-01-01", "2026-01-31", + "2025-01-01", "2025-12-31", + ]); }); - it("converts raw rows into CategoryDelta with signed deltas", async () => { + it("converts raw rows into CategoryDelta with monthly and cumulative deltas", async () => { mockSelect.mockResolvedValueOnce([ { category_id: 1, category_name: "Groceries", category_color: "#10b981", - current_total: 500, - previous_total: 400, + month_current_total: 500, + month_previous_total: 400, + cumulative_current_total: 2000, + cumulative_previous_total: 1500, }, { category_id: 2, category_name: "Restaurants", category_color: "#f97316", - current_total: 120, - previous_total: 0, + month_current_total: 120, + month_previous_total: 0, + cumulative_current_total: 300, + cumulative_previous_total: 180, }, ]); @@ -320,21 +338,95 @@ describe("getCompareMonthOverMonth", () => { expect(result).toHaveLength(2); expect(result[0]).toMatchObject({ categoryName: "Groceries", + currentAmount: 500, + previousAmount: 400, deltaAbs: 100, + cumulativeCurrentAmount: 2000, + cumulativePreviousAmount: 1500, + cumulativeDeltaAbs: 500, }); expect(result[0].deltaPct).toBeCloseTo(25, 4); - expect(result[1].deltaPct).toBeNull(); // previous = 0 + expect(result[0].cumulativeDeltaPct).toBeCloseTo((500 / 1500) * 100, 4); + expect(result[1].deltaPct).toBeNull(); // monthly previous = 0 + expect(result[1].cumulativeDeltaPct).toBeCloseTo(((300 - 180) / 180) * 100, 4); + }); + + it("yields null deltaPct for both blocks when previous is zero", async () => { + mockSelect.mockResolvedValueOnce([ + { + category_id: 3, + category_name: "New", + category_color: "#3b82f6", + month_current_total: 50, + month_previous_total: 0, + cumulative_current_total: 80, + cumulative_previous_total: 0, + }, + ]); + + const result = await getCompareMonthOverMonth(2026, 4); + + expect(result[0].deltaPct).toBeNull(); + expect(result[0].cumulativeDeltaPct).toBeNull(); + expect(result[0].deltaAbs).toBe(50); + expect(result[0].cumulativeDeltaAbs).toBe(80); }); }); describe("getCompareYearOverYear", () => { - it("spans two full calendar years with parameterised boundaries", async () => { + it("compares a single month YoY and the YTD window through that month", async () => { + mockSelect.mockResolvedValueOnce([]); + + await getCompareYearOverYear(2026, 4); + + const params = mockSelect.mock.calls[0][1] as unknown[]; + expect(params).toEqual([ + "2026-04-01", "2026-04-30", + "2025-04-01", "2025-04-30", + "2026-01-01", "2026-04-30", + "2025-01-01", "2025-04-30", + ]); + }); + + it("defaults to december when no reference month is supplied", 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"]); + expect(params).toEqual([ + "2026-12-01", "2026-12-31", + "2025-12-01", "2025-12-31", + "2026-01-01", "2026-12-31", + "2025-01-01", "2025-12-31", + ]); + }); + + it("populates both blocks for a YoY row", async () => { + mockSelect.mockResolvedValueOnce([ + { + category_id: 1, + category_name: "Groceries", + category_color: "#10b981", + month_current_total: 600, + month_previous_total: 400, + cumulative_current_total: 2500, + cumulative_previous_total: 1600, + }, + ]); + + const result = await getCompareYearOverYear(2026, 4); + + expect(result[0]).toMatchObject({ + currentAmount: 600, + previousAmount: 400, + deltaAbs: 200, + cumulativeCurrentAmount: 2500, + cumulativePreviousAmount: 1600, + cumulativeDeltaAbs: 900, + }); + expect(result[0].deltaPct).toBeCloseTo(50, 4); + expect(result[0].cumulativeDeltaPct).toBeCloseTo((900 / 1600) * 100, 4); }); }); diff --git a/src/services/reportService.ts b/src/services/reportService.ts index 5ca02a7..a678a37 100644 --- a/src/services/reportService.ts +++ b/src/services/reportService.ts @@ -317,6 +317,9 @@ export async function getHighlights( const previous = Number(r.previous_total ?? 0); const deltaAbs = current - previous; const deltaPct = previous === 0 ? null : (deltaAbs / previous) * 100; + // Highlights only exposes a monthly view, so the cumulative block mirrors + // the monthly values — keeps the CategoryDelta shape uniform for + // downstream consumers that never read the cumulative block. return { categoryId: r.category_id, categoryName: r.category_name, @@ -325,6 +328,10 @@ export async function getHighlights( currentAmount: current, deltaAbs, deltaPct, + cumulativePreviousAmount: previous, + cumulativeCurrentAmount: current, + cumulativeDeltaAbs: deltaAbs, + cumulativeDeltaPct: deltaPct, }; }); @@ -361,24 +368,38 @@ interface RawDeltaRow { category_id: number | null; category_name: string; category_color: string; - current_total: number | null; - previous_total: number | null; + month_current_total: number | null; + month_previous_total: number | null; + cumulative_current_total: number | null; + cumulative_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; + const monthCurrent = Number(r.month_current_total ?? 0); + const monthPrevious = Number(r.month_previous_total ?? 0); + const monthDeltaAbs = monthCurrent - monthPrevious; + const monthDeltaPct = monthPrevious === 0 ? null : (monthDeltaAbs / monthPrevious) * 100; + + const cumCurrent = Number(r.cumulative_current_total ?? 0); + const cumPrevious = Number(r.cumulative_previous_total ?? 0); + const cumDeltaAbs = cumCurrent - cumPrevious; + const cumDeltaPct = cumPrevious === 0 ? null : (cumDeltaAbs / cumPrevious) * 100; + return { categoryId: r.category_id, categoryName: r.category_name, categoryColor: r.category_color, - previousAmount: previous, - currentAmount: current, - deltaAbs, - deltaPct, + // Monthly block (primary — also the legacy field set). + previousAmount: monthPrevious, + currentAmount: monthCurrent, + deltaAbs: monthDeltaAbs, + deltaPct: monthDeltaPct, + // Cumulative YTD block. + cumulativePreviousAmount: cumPrevious, + cumulativeCurrentAmount: cumCurrent, + cumulativeDeltaAbs: cumDeltaAbs, + cumulativeDeltaPct: cumDeltaPct, }; }); } @@ -396,7 +417,11 @@ function previousMonth(year: number, month: number): { year: number; month: numb } /** - * Month-over-month expense delta by category. All SQL parameterised. + * Month-over-month expense delta by category. Returns both a monthly view + * (reference month vs immediately-previous month) and a cumulative YTD view + * (Jan→refMonth of refYear vs Jan→prevMonth of refYear — i.e. "cumulative + * progress through end of last month" vs "cumulative progress through end of + * this month"). All SQL parameterised. */ export async function getCompareMonthOverMonth( year: number, @@ -407,54 +432,94 @@ export async function getCompareMonthOverMonth( const prev = previousMonth(year, month); const { start: prevStart, end: prevEnd } = monthBoundaries(prev.year, prev.month); + // Cumulative window: from Jan 1st of the reference year up to the end of + // the month we care about. For MoM, the "previous" cumulative window ends + // at the end of the previous month within the SAME year — so Jan only has + // no cumulative-previous (empty window). We still bound the previous-year + // handling: when month === 1 the previous month is in year - 1, so the + // cumulative-previous window sits entirely in year - 1 (Jan → Dec). + const cumCurrentStart = `${year}-01-01`; + const cumCurrentEnd = curEnd; + const cumPreviousStart = `${prev.year}-01-01`; + const cumPreviousEnd = prevEnd; + 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 + COALESCE(SUM(CASE WHEN t.date >= $1 AND t.date <= $2 THEN ABS(t.amount) ELSE 0 END), 0) AS month_current_total, + COALESCE(SUM(CASE WHEN t.date >= $3 AND t.date <= $4 THEN ABS(t.amount) ELSE 0 END), 0) AS month_previous_total, + COALESCE(SUM(CASE WHEN t.date >= $5 AND t.date <= $6 THEN ABS(t.amount) ELSE 0 END), 0) AS cumulative_current_total, + COALESCE(SUM(CASE WHEN t.date >= $7 AND t.date <= $8 THEN ABS(t.amount) ELSE 0 END), 0) AS cumulative_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) + OR (t.date >= $5 AND t.date <= $6) + OR (t.date >= $7 AND t.date <= $8) ) GROUP BY t.category_id, category_name, category_color - ORDER BY ABS(current_total - previous_total) DESC`, - [curStart, curEnd, prevStart, prevEnd], + ORDER BY ABS(month_current_total - month_previous_total) DESC`, + [ + curStart, curEnd, + prevStart, prevEnd, + cumCurrentStart, cumCurrentEnd, + cumPreviousStart, cumPreviousEnd, + ], ); return rowsToDeltas(rows); } /** - * Year-over-year expense delta by category. All SQL parameterised. + * Year-over-year expense delta by category. Returns both a monthly view + * (reference month vs same month of the previous year) and a cumulative YTD + * view (Jan→refMonth of refYear vs Jan→refMonth of refYear - 1). Uses the + * reference year's December as the "current month" when no explicit + * reference month is provided; callers typically pass the user's chosen + * reference month. All SQL parameterised. */ -export async function getCompareYearOverYear(year: number): Promise { +export async function getCompareYearOverYear( + year: number, + month: number = 12, +): 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 { start: curMonthStart, end: curMonthEnd } = monthBoundaries(year, month); + const { start: prevMonthStart, end: prevMonthEnd } = monthBoundaries(year - 1, month); + + const cumCurrentStart = `${year}-01-01`; + const cumCurrentEnd = curMonthEnd; + const cumPreviousStart = `${year - 1}-01-01`; + const cumPreviousEnd = prevMonthEnd; 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 + COALESCE(SUM(CASE WHEN t.date >= $1 AND t.date <= $2 THEN ABS(t.amount) ELSE 0 END), 0) AS month_current_total, + COALESCE(SUM(CASE WHEN t.date >= $3 AND t.date <= $4 THEN ABS(t.amount) ELSE 0 END), 0) AS month_previous_total, + COALESCE(SUM(CASE WHEN t.date >= $5 AND t.date <= $6 THEN ABS(t.amount) ELSE 0 END), 0) AS cumulative_current_total, + COALESCE(SUM(CASE WHEN t.date >= $7 AND t.date <= $8 THEN ABS(t.amount) ELSE 0 END), 0) AS cumulative_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) + OR (t.date >= $5 AND t.date <= $6) + OR (t.date >= $7 AND t.date <= $8) ) GROUP BY t.category_id, category_name, category_color - ORDER BY ABS(current_total - previous_total) DESC`, - [curStart, curEnd, prevStart, prevEnd], + ORDER BY ABS(month_current_total - month_previous_total) DESC`, + [ + curMonthStart, curMonthEnd, + prevMonthStart, prevMonthEnd, + cumCurrentStart, cumCurrentEnd, + cumPreviousStart, cumPreviousEnd, + ], ); return rowsToDeltas(rows); } @@ -801,14 +866,28 @@ export async function getCartesSnapshot( const significantMovers = momRows.filter( (r) => r.deltaAbs !== 0 && (r.previousAmount > 0 || r.currentAmount > 0), ); + // Project the richer CategoryDelta shape down to the narrower CartesTopMover + // shape so the Cartes dashboard keeps its stable contract regardless of how + // many views (monthly / cumulative) the Compare service exposes. + const toCartesMover = (r: CategoryDelta): CartesTopMover => ({ + categoryId: r.categoryId, + categoryName: r.categoryName, + categoryColor: r.categoryColor, + previousAmount: r.previousAmount, + currentAmount: r.currentAmount, + deltaAbs: r.deltaAbs, + deltaPct: r.deltaPct, + }); const topMoversUp: CartesTopMover[] = significantMovers .filter((r) => r.deltaAbs > 0) .sort((a, b) => b.deltaAbs - a.deltaAbs) - .slice(0, 5); + .slice(0, 5) + .map(toCartesMover); const topMoversDown: CartesTopMover[] = significantMovers .filter((r) => r.deltaAbs < 0) .sort((a, b) => a.deltaAbs - b.deltaAbs) - .slice(0, 5); + .slice(0, 5) + .map(toCartesMover); // Budget adherence — only expense categories with a non-zero budget count. // monthActual is signed from transactions; expense categories have diff --git a/src/shared/types/index.ts b/src/shared/types/index.ts index 4602133..97a7a2d 100644 --- a/src/shared/types/index.ts +++ b/src/shared/types/index.ts @@ -282,10 +282,25 @@ export interface CategoryDelta { categoryId: number | null; categoryName: string; categoryColor: string; + // Monthly block — single reference month vs its comparison month. + // Old field names (previousAmount / currentAmount / deltaAbs / deltaPct) are + // kept as aliases of the monthly values for backward compatibility with the + // Highlights hub, Cartes top movers and the Compare chart, all of which show + // monthly deltas only. previousAmount: number; currentAmount: number; deltaAbs: number; deltaPct: number | null; // null when previous is 0 + // Cumulative block — YTD window. For MoM, current = Jan→refMonth of refYear, + // previous = Jan→(refMonth-1) of refYear (progress through last month). + // For YoY, current = Jan→refMonth of refYear, previous = Jan→refMonth of + // refYear-1. Values are populated only by the Compare service; other + // consumers (Highlights, Cartes) leave them mirrored on the monthly block + // since they do not expose a cumulative view. + cumulativePreviousAmount: number; + cumulativeCurrentAmount: number; + cumulativeDeltaAbs: number; + cumulativeDeltaPct: number | null; } // Historical alias — used by the highlights hub. Shape identical to CategoryDelta.