feat(reports/compare): 8-column table with monthly + cumulative YTD blocks (#104)
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:
parent
8d916a1283
commit
bd8a5732c6
11 changed files with 452 additions and 102 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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) => (
|
||||
<>
|
||||
{rows.map((row) => (
|
||||
<tr
|
||||
key={`${row.categoryId ?? "uncat"}-${row.categoryName}`}
|
||||
className="border-b border-[var(--border)] last:border-0 hover:bg-[var(--muted)]/40"
|
||||
className="border-b border-[var(--border)]/50 hover:bg-[var(--muted)]/40"
|
||||
>
|
||||
<td className="px-3 py-2">
|
||||
<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 h-2 rounded-full flex-shrink-0"
|
||||
className="w-2.5 h-2.5 rounded-full shrink-0"
|
||||
style={{ backgroundColor: row.categoryColor }}
|
||||
/>
|
||||
{row.categoryName}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums">
|
||||
{formatCurrency(row.previousAmount, i18n.language)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums">
|
||||
{/* 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="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-1.5 tabular-nums font-medium"
|
||||
style={{ color: variationColor(row.deltaAbs) }}
|
||||
>
|
||||
{formatSignedCurrency(row.deltaAbs, 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-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="text-right px-3 py-3 border-l border-[var(--border)]/50 tabular-nums">
|
||||
{formatCurrency(totals.monthCurrent, i18n.language)}
|
||||
</td>
|
||||
<td className="text-right px-3 py-3 tabular-nums">
|
||||
{formatCurrency(totals.monthPrevious, i18n.language)}
|
||||
</td>
|
||||
<td
|
||||
className="text-right px-3 py-3 tabular-nums"
|
||||
style={{ color: variationColor(totals.monthDelta) }}
|
||||
>
|
||||
{formatSignedCurrency(totals.monthDelta, i18n.language)}
|
||||
</td>
|
||||
<td
|
||||
className="text-right px-3 py-3 tabular-nums"
|
||||
style={{ color: variationColor(totals.monthDelta) }}
|
||||
>
|
||||
{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>
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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<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 (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<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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in a new issue