feat(reports/compare): 8-column table with monthly + cumulative YTD blocks (#104)
All checks were successful
PR Check / rust (push) Successful in 21m21s
PR Check / frontend (push) Successful in 2m11s
PR Check / rust (pull_request) Successful in 21m30s
PR Check / frontend (pull_request) Successful in 2m8s

Mirror the BudgetVsActualTable structure in the Actual-vs-Actual compare
mode so MoM and YoY both surface a Monthly block (reference month vs
comparison month) and a Cumulative YTD block (progress through the
reference month vs progress through the previous window).

- CategoryDelta gains cumulative{Previous,Current}Amount and
  cumulativeDelta{Abs,Pct}. Legacy previousAmount / currentAmount /
  deltaAbs / deltaPct are kept as aliases of the monthly block so the
  Highlights hub, Cartes dashboard and ComparePeriodChart keep working
  unchanged.
- getCompareMonthOverMonth: cumulative-previous window ends at the end
  of the previous month within the SAME year; when the reference month
  is January, the previous window sits entirely in the prior calendar
  year (Jan → Dec).
- getCompareYearOverYear: now takes an optional reference month
  (defaults to December for backward compatibility). Monthly block
  compares the single reference month across years; cumulative block
  compares Jan → refMonth across years.
- ComparePeriodTable rebuilt with two colspan header groups, four
  sub-columns each, a totals row and month/year boundary sub-labels.
- ComparePeriodChart unchanged: still reads the monthly primary fields.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
le king fu 2026-04-18 21:17:32 -04:00
parent 8d916a1283
commit bd8a5732c6
11 changed files with 452 additions and 102 deletions

View file

@ -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 `<select>` natif (#103)
- **Rapport Comparables — Réel vs réel** (`/reports/compare`) : le tableau reprend maintenant la structure riche à 8 colonnes du tableau Réel vs budget, en scindant chaque comparaison en un bloc *Mensuel* (mois de référence vs mois de comparaison) et un bloc *Cumulatif YTD* (progression jusqu'au mois de référence vs progression jusqu'à la fenêtre précédente). En mode MoM, le cumulatif précédent couvre janvier → fin du mois précédent de la même année ; en mode YoY, il couvre janvier → même mois de l'année précédente. Le graphique reste uniquement mensuel (#104)
### Corrigé
- **Rapport Cartes** : retrait du sélecteur de période non fonctionnel — le rapport Cartes est un instantané « mois X vs X-1 vs X-12 », seul le sélecteur de mois de référence est nécessaire (#101)

View file

@ -7,6 +7,7 @@
### Changed
- **Category zoom report** (`/reports/category`): the category picker is now a typeable, searchable combobox with accent-insensitive matching, keyboard navigation (↑/↓/Enter/Esc) and hierarchy indentation, replacing the native `<select>` (#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)

View file

@ -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 (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl overflow-hidden">
<div className="overflow-x-auto">
<div className="overflow-x-auto overflow-y-auto" style={{ maxHeight: "calc(100vh - 220px)" }}>
<table className="w-full text-sm">
<thead>
<tr className="border-b border-[var(--border)]">
<th className="text-left px-3 py-2 font-medium text-[var(--muted-foreground)]">
<thead className="sticky top-0 z-20">
<tr className="border-b border-[var(--border)] bg-[var(--card)]">
<th
rowSpan={2}
className="text-left px-3 py-2 font-medium text-[var(--muted-foreground)] align-bottom sticky left-0 bg-[var(--card)] z-30 min-w-[180px]"
>
{t("reports.highlights.category")}
</th>
<th className="text-right px-3 py-2 font-medium text-[var(--muted-foreground)]">
{previousLabel}
<th
colSpan={4}
className="text-center px-3 py-1 font-medium text-[var(--muted-foreground)] border-l border-[var(--border)] bg-[var(--card)]"
>
{t("reports.bva.monthly")}
</th>
<th className="text-right px-3 py-2 font-medium text-[var(--muted-foreground)]">
{currentLabel}
<th
colSpan={4}
className="text-center px-3 py-1 font-medium text-[var(--muted-foreground)] border-l border-[var(--border)] bg-[var(--card)]"
>
{t("reports.bva.ytd")}
</th>
<th className="text-right px-3 py-2 font-medium text-[var(--muted-foreground)]">
{t("reports.highlights.variationAbs")}
</tr>
<tr className="border-b border-[var(--border)] bg-[var(--card)]">
<th className="text-right px-3 py-1 font-medium text-[var(--muted-foreground)] border-l border-[var(--border)] bg-[var(--card)]">
<div>{t("reports.compare.currentAmount")}</div>
<div className="text-[10px] font-normal opacity-70">{monthCurrLabel}</div>
</th>
<th className="text-right px-3 py-2 font-medium text-[var(--muted-foreground)]">
{t("reports.highlights.variationPct")}
<th className="text-right px-3 py-1 font-medium text-[var(--muted-foreground)] bg-[var(--card)]">
<div>{t("reports.compare.previousAmount")}</div>
<div className="text-[10px] font-normal opacity-70">{monthPrevLabel}</div>
</th>
<th className="text-right px-3 py-1 font-medium text-[var(--muted-foreground)] bg-[var(--card)]">
{t("reports.bva.dollarVar")}
</th>
<th className="text-right px-3 py-1 font-medium text-[var(--muted-foreground)] bg-[var(--card)]">
{t("reports.bva.pctVar")}
</th>
<th className="text-right px-3 py-1 font-medium text-[var(--muted-foreground)] border-l border-[var(--border)] bg-[var(--card)]">
<div>{t("reports.compare.currentAmount")}</div>
<div className="text-[10px] font-normal opacity-70">{ytdCurrLabel}</div>
</th>
<th className="text-right px-3 py-1 font-medium text-[var(--muted-foreground)] bg-[var(--card)]">
<div>{t("reports.compare.previousAmount")}</div>
<div className="text-[10px] font-normal opacity-70">{ytdPrevLabel}</div>
</th>
<th className="text-right px-3 py-1 font-medium text-[var(--muted-foreground)] bg-[var(--card)]">
{t("reports.bva.dollarVar")}
</th>
<th className="text-right px-3 py-1 font-medium text-[var(--muted-foreground)] bg-[var(--card)]">
{t("reports.bva.pctVar")}
</th>
</tr>
</thead>
<tbody>
{rows.length === 0 ? (
<tr>
<td colSpan={5} className="px-3 py-4 text-center text-[var(--muted-foreground)] italic">
<td
colSpan={9}
className="px-3 py-4 text-center text-[var(--muted-foreground)] italic"
>
{t("reports.empty.noData")}
</td>
</tr>
) : (
rows.map((row) => (
<tr
key={`${row.categoryId ?? "uncat"}-${row.categoryName}`}
className="border-b border-[var(--border)] last:border-0 hover:bg-[var(--muted)]/40"
>
<td className="px-3 py-2">
<span className="flex items-center gap-2">
<span
className="w-2 h-2 rounded-full flex-shrink-0"
style={{ backgroundColor: row.categoryColor }}
/>
{row.categoryName}
</span>
<>
{rows.map((row) => (
<tr
key={`${row.categoryId ?? "uncat"}-${row.categoryName}`}
className="border-b border-[var(--border)]/50 hover:bg-[var(--muted)]/40"
>
<td className="px-3 py-1.5 sticky left-0 bg-[var(--card)] z-10">
<span className="flex items-center gap-2">
<span
className="w-2.5 h-2.5 rounded-full shrink-0"
style={{ backgroundColor: row.categoryColor }}
/>
{row.categoryName}
</span>
</td>
{/* Monthly block */}
<td className="text-right px-3 py-1.5 border-l border-[var(--border)]/50 tabular-nums">
{formatCurrency(row.currentAmount, i18n.language)}
</td>
<td className="text-right px-3 py-1.5 tabular-nums">
{formatCurrency(row.previousAmount, i18n.language)}
</td>
<td
className="text-right px-3 py-1.5 tabular-nums font-medium"
style={{ color: variationColor(row.deltaAbs) }}
>
{formatSignedCurrency(row.deltaAbs, i18n.language)}
</td>
<td
className="text-right px-3 py-1.5 tabular-nums"
style={{ color: variationColor(row.deltaAbs) }}
>
{formatPct(row.deltaPct, i18n.language)}
</td>
{/* Cumulative YTD block */}
<td className="text-right px-3 py-1.5 border-l border-[var(--border)]/50 tabular-nums">
{formatCurrency(row.cumulativeCurrentAmount, i18n.language)}
</td>
<td className="text-right px-3 py-1.5 tabular-nums">
{formatCurrency(row.cumulativePreviousAmount, i18n.language)}
</td>
<td
className="text-right px-3 py-1.5 tabular-nums font-medium"
style={{ color: variationColor(row.cumulativeDeltaAbs) }}
>
{formatSignedCurrency(row.cumulativeDeltaAbs, i18n.language)}
</td>
<td
className="text-right px-3 py-1.5 tabular-nums"
style={{ color: variationColor(row.cumulativeDeltaAbs) }}
>
{formatPct(row.cumulativeDeltaPct, i18n.language)}
</td>
</tr>
))}
{/* Grand totals row */}
<tr className="border-t-2 border-[var(--border)] font-bold text-sm bg-[color-mix(in_srgb,var(--muted)_20%,var(--card))]">
<td className="px-3 py-3 sticky left-0 bg-[color-mix(in_srgb,var(--muted)_20%,var(--card))] z-10">
{t("reports.compare.totalRow")}
</td>
<td className="px-3 py-2 text-right tabular-nums">
{formatCurrency(row.previousAmount, i18n.language)}
<td className="text-right px-3 py-3 border-l border-[var(--border)]/50 tabular-nums">
{formatCurrency(totals.monthCurrent, i18n.language)}
</td>
<td className="px-3 py-2 text-right tabular-nums">
{formatCurrency(row.currentAmount, i18n.language)}
<td className="text-right px-3 py-3 tabular-nums">
{formatCurrency(totals.monthPrevious, i18n.language)}
</td>
<td
className="px-3 py-2 text-right tabular-nums font-medium"
style={{
color:
row.deltaAbs >= 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)}
</td>
<td
className="px-3 py-2 text-right tabular-nums"
style={{
color:
row.deltaAbs >= 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)}
</td>
<td className="text-right px-3 py-3 border-l border-[var(--border)]/50 tabular-nums">
{formatCurrency(totals.ytdCurrent, i18n.language)}
</td>
<td className="text-right px-3 py-3 tabular-nums">
{formatCurrency(totals.ytdPrevious, i18n.language)}
</td>
<td
className="text-right px-3 py-3 tabular-nums"
style={{ color: variationColor(totals.ytdDelta) }}
>
{formatSignedCurrency(totals.ytdDelta, i18n.language)}
</td>
<td
className="text-right px-3 py-3 tabular-nums"
style={{ color: variationColor(totals.ytdDelta) }}
>
{formatPct(totalYtdPct, i18n.language)}
</td>
</tr>
))
</>
)}
</tbody>
</table>

View file

@ -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) {

View file

@ -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",

View file

@ -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",

View file

@ -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}
/>
) : (
<ComparePeriodTable rows={rows} previousLabel={previousLabel} currentLabel={currentLabel} />
<ComparePeriodTable
rows={rows}
previousLabel={previousLabel}
currentLabel={currentLabel}
cumulativePreviousLabel={cumulativePreviousLabel}
cumulativeCurrentLabel={cumulativeCurrentLabel}
/>
)}
</div>
);

View file

@ -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,
},
]);

View file

@ -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);
});
});

View file

@ -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
* (JanrefMonth of refYear vs JanprevMonth 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<RawDeltaRow[]>(
`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 (JanrefMonth of refYear vs JanrefMonth 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<CategoryDelta[]> {
export async function getCompareYearOverYear(
year: number,
month: number = 12,
): Promise<CategoryDelta[]> {
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<RawDeltaRow[]>(
`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

View file

@ -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.