Merge PR #255: feat(reports) real-vs-real compare = income statement + previous-month default (#253)

This commit is contained in:
le king fu 2026-07-05 20:49:44 -04:00
commit 1360940336
14 changed files with 600 additions and 286 deletions

5
.gitignore vendored
View file

@ -63,8 +63,9 @@ src-tauri/icons/ios/
.claude/worktrees/ .claude/worktrees/
decisions-log.md decisions-log.md
# Autopilot scratch + daily reports # Autopilot scratch + daily reports (repo root only — must not match
reports/ # src/components/reports/, which is real tracked source)
/reports/
# Spec scratch (committed only when promoted to docs/archive/) # Spec scratch (committed only when promoted to docs/archive/)
spec-decisions-*.md spec-decisions-*.md

View file

@ -2,6 +2,11 @@
## [Non publié] ## [Non publié]
### Modifié
- Rapports → Comparaison (réel vs réel) : le rapport est désormais une **analyse de résultat**. Les catégories de revenu apparaissent aux côtés des dépenses, avec deux lignes de résultat — **résultat avant transferts** (revenus dépenses) et **résultat net** (après annulation des transferts) — pour voir d'un coup d'œil si la période dégage un surplus ou un déficit. Les catégories sont regroupées Revenus → Dépenses → Transferts, les couleurs suivent le sens (plus de revenu = vert, plus de dépense = rouge), et un montant de résultat passe au vert pour un surplus, au rouge pour un déficit (#253).
- Rapports : les rapports comparables s'ouvrent désormais par défaut sur le **mois précédent (dernier mois complet)** au lieu de décembre de l'année civile courante (#253).
## [0.11.0] - 2026-07-05 ## [0.11.0] - 2026-07-05
### Ajouté ### Ajouté

View file

@ -2,6 +2,11 @@
## [Unreleased] ## [Unreleased]
### Changed
- Reports → Compare (real vs real): the report is now an **income statement**. Revenue categories are shown alongside expenses, with two result lines — **result before transfers** (revenues expenses) and the **net result** (after netting transfers) — so you can see at a glance whether the period ran a surplus or a deficit. Categories are grouped Income → Expenses → Transfers, colours follow direction (more income is green, more spending is red), and a result amount turns green for a surplus, red for a deficit (#253).
- Reports: the comparable reports now open on the **previous (last complete) month** by default instead of the current civil year's December (#253).
## [0.11.0] - 2026-07-05 ## [0.11.0] - 2026-07-05
### Added ### Added

View file

@ -32,21 +32,14 @@ export default function ComparePeriodChart({
}: ComparePeriodChartProps) { }: ComparePeriodChartProps) {
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
if (rows.length === 0) { // The chart stays a spending view: the income-statement result (revenues +
return ( // result lines, Issue #253) lives in the table, so keep only expense leaves
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-6 text-center text-[var(--muted-foreground)] italic"> // here — mixing revenue bars into the same axis would misread. Drop subtotal
{t("reports.empty.noData")} // (is_parent) rows too so a group is not double-counted against its own
</div> // leaves. Sorted by current amount (largest spending first) for a top-down
); // read of the biggest movers.
}
// Sort by current-period amount (largest spending first) so the user's eye
// lands on the biggest categories, then reverse so the biggest appears at
// the top of the vertical bar chart. The table view groups by parent/child
// (Issue #247); the chart stays flat, so drop subtotal (is_parent) rows to
// avoid double-counting a group against its own leaves.
const chartData = rows const chartData = rows
.filter((r) => !r.is_parent) .filter((r) => !r.is_parent && (r.category_type ?? "expense") === "expense")
.sort((a, b) => b.currentAmount - a.currentAmount) .sort((a, b) => b.currentAmount - a.currentAmount)
.map((r) => ({ .map((r) => ({
name: r.categoryName, name: r.categoryName,
@ -55,6 +48,16 @@ export default function ComparePeriodChart({
color: r.categoryColor, color: r.categoryColor,
})); }));
// Empty when there is no data at all, or when the period is pure
// income/transfers (no expense bars to draw) — avoid rendering bare axes.
if (chartData.length === 0) {
return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-6 text-center text-[var(--muted-foreground)] italic">
{t("reports.empty.noData")}
</div>
);
}
const previousFill = "var(--muted-foreground)"; const previousFill = "var(--muted-foreground)";
const currentFill = "var(--primary)"; const currentFill = "var(--primary)";

View file

@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next";
import { ArrowUpDown } from "lucide-react"; import { ArrowUpDown } from "lucide-react";
import type { CategoryDelta } from "../../shared/types"; import type { CategoryDelta } from "../../shared/types";
import { reorderRows } from "../../utils/reorderRows"; import { reorderRows } from "../../utils/reorderRows";
import { type SectionType, type Totals, sumLeaves, pct, computeResults } from "./compareResults";
export interface ComparePeriodTableProps { export interface ComparePeriodTableProps {
rows: CategoryDelta[]; rows: CategoryDelta[];
@ -33,57 +34,30 @@ function formatSignedCurrency(amount: number, language: string): string {
}).format(amount); }).format(amount);
} }
function formatPct(pct: number | null, language: string): string { function formatPct(pctValue: number | null, language: string): string {
if (pct === null) return "—"; if (pctValue === null) return "—";
return new Intl.NumberFormat(language === "fr" ? "fr-CA" : "en-CA", { return new Intl.NumberFormat(language === "fr" ? "fr-CA" : "en-CA", {
style: "percent", style: "percent",
maximumFractionDigits: 1, maximumFractionDigits: 1,
signDisplay: "always", signDisplay: "always",
}).format(pct / 100); }).format(pctValue / 100);
} }
function variationColor(value: number): string { /**
// Compare report deals with expenses (abs values): spending more is negative * Delta colour, direction-aware (Issue #253). Expenses/transfers keep the
// for the user, spending less is positive. Mirror that colour convention * spending convention (increase red, decrease green); income and the
// consistently so the eye parses the delta sign at a glance. * result lines invert it (higher is better green). Also used to colour a
if (value > 0) return "var(--negative, #ef4444)"; * result *amount* by sign: a surplus is green, a deficit red.
if (value < 0) return "var(--positive, #10b981)"; */
return ""; function deltaColor(value: number, higherIsBetter: boolean): string {
if (value === 0) return "";
const good = higherIsBetter ? value > 0 : value < 0;
return good ? "var(--positive, #10b981)" : "var(--negative, #ef4444)";
} }
const STORAGE_KEY = "compare-subtotals-position"; const STORAGE_KEY = "compare-subtotals-position";
type SectionType = "expense" | "income" | "transfer"; const COL_COUNT = 9;
/** Aggregate of the 6 comparable figures across a set of leaf rows. */
interface Totals {
monthCurrent: number;
monthPrevious: number;
monthDelta: number;
ytdCurrent: number;
ytdPrevious: number;
ytdDelta: number;
}
function sumLeaves(rows: CategoryDelta[]): Totals {
return rows
.filter((r) => !r.is_parent)
.reduce<Totals>(
(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 },
);
}
function pct(delta: number, previous: number): number | null {
return previous !== 0 ? (delta / Math.abs(previous)) * 100 : null;
}
export default function ComparePeriodTable({ export default function ComparePeriodTable({
rows, rows,
@ -112,7 +86,8 @@ export default function ComparePeriodTable({
const ytdPrevLabel = cumulativePreviousLabel ?? previousLabel; const ytdPrevLabel = cumulativePreviousLabel ?? previousLabel;
const ytdCurrLabel = cumulativeCurrentLabel ?? currentLabel; const ytdCurrLabel = cumulativeCurrentLabel ?? currentLabel;
// Group rows into contiguous type sections (the service already type-sorts). // Group rows into contiguous type sections (the service already type-sorts:
// income → expense → transfer).
const sectionLabels: Record<SectionType, string> = { const sectionLabels: Record<SectionType, string> = {
expense: t("reports.compare.sections.expenses"), expense: t("reports.compare.sections.expenses"),
income: t("reports.compare.sections.income"), income: t("reports.compare.sections.income"),
@ -134,8 +109,197 @@ export default function ComparePeriodTable({
sections[sections.length - 1].rows.push(row); sections[sections.length - 1].rows.push(row);
} }
// Grand totals across every leaf. // Income-statement result lines (Issue #253): revenues expenses, then the
const totals = sumLeaves(rows); // net after transfers. Replaces the old flat grand total, which is meaningless
// once revenues and (ABS) expenses share one table.
const results = computeResults(rows);
const nonTransferSections = sections.filter((s) => s.type !== "transfer");
const transferSection = sections.find((s) => s.type === "transfer");
const renderSection = (section: { type: SectionType; rows: CategoryDelta[] }) => {
// Income section: an increase is good (green); expenses/transfers keep the
// spending convention (increase → red).
const higherIsBetter = section.type === "income";
const sectionTotals = sumLeaves(section.rows);
return (
<Fragment key={section.type}>
<tr className="bg-[var(--muted)]">
<td
colSpan={COL_COUNT}
className="px-3 py-1.5 font-semibold text-[var(--muted-foreground)] uppercase text-xs tracking-wider sticky left-0 bg-[var(--muted)]"
>
{sectionLabels[section.type]}
</td>
</tr>
{reorderRows(section.rows, subtotalsOnTop).map((row) => {
const isParent = row.is_parent ?? false;
const depth = row.depth ?? 0;
const isTopParent = isParent && depth === 0;
const isIntermediateParent = isParent && depth >= 1;
const paddingClass =
depth >= 3 ? "pl-20" : depth === 2 ? "pl-14" : depth === 1 ? "pl-8" : "px-3";
return (
<tr
key={`${row.categoryId ?? "uncat"}-${isParent}-${depth}-${row.categoryName}`}
className={`border-b border-[var(--border)]/50 ${
isTopParent
? "bg-[color-mix(in_srgb,var(--muted)_30%,var(--card))] font-semibold"
: isIntermediateParent
? "bg-[color-mix(in_srgb,var(--muted)_15%,var(--card))] font-medium"
: "hover:bg-[var(--muted)]/40"
}`}
>
<td
className={`py-1.5 sticky left-0 z-10 ${
isTopParent
? "px-3 bg-[color-mix(in_srgb,var(--muted)_30%,var(--card))]"
: isIntermediateParent
? `${paddingClass} bg-[color-mix(in_srgb,var(--muted)_15%,var(--card))]`
: `${paddingClass} bg-[var(--card)]`
}`}
>
<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, lang)}
</td>
<td className="text-right px-3 py-1.5 tabular-nums">
{formatCurrency(row.previousAmount, lang)}
</td>
<td
className="text-right px-3 py-1.5 tabular-nums font-medium"
style={{ color: deltaColor(row.deltaAbs, higherIsBetter) }}
>
{formatSignedCurrency(row.deltaAbs, lang)}
</td>
<td
className="text-right px-3 py-1.5 tabular-nums"
style={{ color: deltaColor(row.deltaAbs, higherIsBetter) }}
>
{formatPct(row.deltaPct, lang)}
</td>
{/* Cumulative YTD block */}
<td className="text-right px-3 py-1.5 border-l border-[var(--border)]/50 tabular-nums">
{formatCurrency(row.cumulativeCurrentAmount, lang)}
</td>
<td className="text-right px-3 py-1.5 tabular-nums">
{formatCurrency(row.cumulativePreviousAmount, lang)}
</td>
<td
className="text-right px-3 py-1.5 tabular-nums font-medium"
style={{ color: deltaColor(row.cumulativeDeltaAbs, higherIsBetter) }}
>
{formatSignedCurrency(row.cumulativeDeltaAbs, lang)}
</td>
<td
className="text-right px-3 py-1.5 tabular-nums"
style={{ color: deltaColor(row.cumulativeDeltaAbs, higherIsBetter) }}
>
{formatPct(row.cumulativeDeltaPct, lang)}
</td>
</tr>
);
})}
{/* Section net total */}
<tr className="border-b border-[var(--border)] bg-[color-mix(in_srgb,var(--muted)_40%,var(--card))] font-semibold text-sm">
<td className="px-3 py-2.5 sticky left-0 bg-[color-mix(in_srgb,var(--muted)_40%,var(--card))] z-10">
{t(sectionTotalKeys[section.type])}
</td>
<td className="text-right px-3 py-2.5 border-l border-[var(--border)]/50 tabular-nums">
{formatCurrency(sectionTotals.monthCurrent, lang)}
</td>
<td className="text-right px-3 py-2.5 tabular-nums">
{formatCurrency(sectionTotals.monthPrevious, lang)}
</td>
<td
className="text-right px-3 py-2.5 tabular-nums"
style={{ color: deltaColor(sectionTotals.monthDelta, higherIsBetter) }}
>
{formatSignedCurrency(sectionTotals.monthDelta, lang)}
</td>
<td
className="text-right px-3 py-2.5 tabular-nums"
style={{ color: deltaColor(sectionTotals.monthDelta, higherIsBetter) }}
>
{formatPct(pct(sectionTotals.monthDelta, sectionTotals.monthPrevious), lang)}
</td>
<td className="text-right px-3 py-2.5 border-l border-[var(--border)]/50 tabular-nums">
{formatCurrency(sectionTotals.ytdCurrent, lang)}
</td>
<td className="text-right px-3 py-2.5 tabular-nums">
{formatCurrency(sectionTotals.ytdPrevious, lang)}
</td>
<td
className="text-right px-3 py-2.5 tabular-nums"
style={{ color: deltaColor(sectionTotals.ytdDelta, higherIsBetter) }}
>
{formatSignedCurrency(sectionTotals.ytdDelta, lang)}
</td>
<td
className="text-right px-3 py-2.5 tabular-nums"
style={{ color: deltaColor(sectionTotals.ytdDelta, higherIsBetter) }}
>
{formatPct(pct(sectionTotals.ytdDelta, sectionTotals.ytdPrevious), lang)}
</td>
</tr>
</Fragment>
);
};
// A result line (before-transfers subtotal or the net total). Higher is always
// better here: amounts are coloured by sign (surplus green / deficit red) and
// deltas by improvement.
const renderResultRow = (labelKey: string, tot: Totals, strong: boolean) => {
const rowBg = strong
? "bg-[color-mix(in_srgb,var(--muted)_20%,var(--card))]"
: "bg-[color-mix(in_srgb,var(--muted)_10%,var(--card))]";
const rowClass = strong
? `border-t-2 border-[var(--border)] font-bold text-sm ${rowBg}`
: `border-b border-[var(--border)] font-semibold text-sm ${rowBg}`;
const cell = "text-right px-3 py-3 tabular-nums";
return (
<tr key={labelKey} className={rowClass}>
<td className={`px-3 py-3 sticky left-0 z-10 ${rowBg}`}>{t(labelKey)}</td>
<td
className={`${cell} border-l border-[var(--border)]/50`}
style={{ color: deltaColor(tot.monthCurrent, true) }}
>
{formatCurrency(tot.monthCurrent, lang)}
</td>
<td className={cell} style={{ color: deltaColor(tot.monthPrevious, true) }}>
{formatCurrency(tot.monthPrevious, lang)}
</td>
<td className={cell} style={{ color: deltaColor(tot.monthDelta, true) }}>
{formatSignedCurrency(tot.monthDelta, lang)}
</td>
<td className={cell} style={{ color: deltaColor(tot.monthDelta, true) }}>
{formatPct(pct(tot.monthDelta, tot.monthPrevious), lang)}
</td>
<td
className={`${cell} border-l border-[var(--border)]/50`}
style={{ color: deltaColor(tot.ytdCurrent, true) }}
>
{formatCurrency(tot.ytdCurrent, lang)}
</td>
<td className={cell} style={{ color: deltaColor(tot.ytdPrevious, true) }}>
{formatCurrency(tot.ytdPrevious, lang)}
</td>
<td className={cell} style={{ color: deltaColor(tot.ytdDelta, true) }}>
{formatSignedCurrency(tot.ytdDelta, lang)}
</td>
<td className={cell} style={{ color: deltaColor(tot.ytdDelta, true) }}>
{formatPct(pct(tot.ytdDelta, tot.ytdPrevious), lang)}
</td>
</tr>
);
};
return ( return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl overflow-hidden"> <div className="bg-[var(--card)] border border-[var(--border)] rounded-xl overflow-hidden">
@ -205,187 +369,25 @@ export default function ComparePeriodTable({
<tbody> <tbody>
{rows.length === 0 ? ( {rows.length === 0 ? (
<tr> <tr>
<td colSpan={9} className="px-3 py-4 text-center text-[var(--muted-foreground)] italic"> <td colSpan={COL_COUNT} className="px-3 py-4 text-center text-[var(--muted-foreground)] italic">
{t("reports.empty.noData")} {t("reports.empty.noData")}
</td> </td>
</tr> </tr>
) : ( ) : (
<> <>
{sections.map((section) => { {nonTransferSections.map(renderSection)}
const sectionTotals = sumLeaves(section.rows); {/* Operating result (revenues expenses), shown before the
return ( transfers section only when transfers exist otherwise it
<Fragment key={section.type}> equals the net total below and would just be noise. */}
<tr className="bg-[var(--muted)]"> {results.hasTransfers &&
<td renderResultRow(
colSpan={9} "reports.compare.resultBeforeTransfers",
className="px-3 py-1.5 font-semibold text-[var(--muted-foreground)] uppercase text-xs tracking-wider sticky left-0 bg-[var(--muted)]" results.resultBefore,
> false,
{sectionLabels[section.type]} )}
</td> {transferSection && renderSection(transferSection)}
</tr> {/* Bottom line: result after netting transfers. */}
{reorderRows(section.rows, subtotalsOnTop).map((row) => { {renderResultRow("reports.compare.resultNet", results.resultNet, true)}
const isParent = row.is_parent ?? false;
const depth = row.depth ?? 0;
const isTopParent = isParent && depth === 0;
const isIntermediateParent = isParent && depth >= 1;
const paddingClass =
depth >= 3 ? "pl-20" : depth === 2 ? "pl-14" : depth === 1 ? "pl-8" : "px-3";
return (
<tr
key={`${row.categoryId ?? "uncat"}-${isParent}-${depth}-${row.categoryName}`}
className={`border-b border-[var(--border)]/50 ${
isTopParent
? "bg-[color-mix(in_srgb,var(--muted)_30%,var(--card))] font-semibold"
: isIntermediateParent
? "bg-[color-mix(in_srgb,var(--muted)_15%,var(--card))] font-medium"
: "hover:bg-[var(--muted)]/40"
}`}
>
<td
className={`py-1.5 sticky left-0 z-10 ${
isTopParent
? "px-3 bg-[color-mix(in_srgb,var(--muted)_30%,var(--card))]"
: isIntermediateParent
? `${paddingClass} bg-[color-mix(in_srgb,var(--muted)_15%,var(--card))]`
: `${paddingClass} bg-[var(--card)]`
}`}
>
<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, lang)}
</td>
<td className="text-right px-3 py-1.5 tabular-nums">
{formatCurrency(row.previousAmount, lang)}
</td>
<td
className="text-right px-3 py-1.5 tabular-nums font-medium"
style={{ color: variationColor(row.deltaAbs) }}
>
{formatSignedCurrency(row.deltaAbs, lang)}
</td>
<td
className="text-right px-3 py-1.5 tabular-nums"
style={{ color: variationColor(row.deltaAbs) }}
>
{formatPct(row.deltaPct, lang)}
</td>
{/* Cumulative YTD block */}
<td className="text-right px-3 py-1.5 border-l border-[var(--border)]/50 tabular-nums">
{formatCurrency(row.cumulativeCurrentAmount, lang)}
</td>
<td className="text-right px-3 py-1.5 tabular-nums">
{formatCurrency(row.cumulativePreviousAmount, lang)}
</td>
<td
className="text-right px-3 py-1.5 tabular-nums font-medium"
style={{ color: variationColor(row.cumulativeDeltaAbs) }}
>
{formatSignedCurrency(row.cumulativeDeltaAbs, lang)}
</td>
<td
className="text-right px-3 py-1.5 tabular-nums"
style={{ color: variationColor(row.cumulativeDeltaAbs) }}
>
{formatPct(row.cumulativeDeltaPct, lang)}
</td>
</tr>
);
})}
{/* Section net total */}
<tr className="border-b border-[var(--border)] bg-[color-mix(in_srgb,var(--muted)_40%,var(--card))] font-semibold text-sm">
<td className="px-3 py-2.5 sticky left-0 bg-[color-mix(in_srgb,var(--muted)_40%,var(--card))] z-10">
{t(sectionTotalKeys[section.type])}
</td>
<td className="text-right px-3 py-2.5 border-l border-[var(--border)]/50 tabular-nums">
{formatCurrency(sectionTotals.monthCurrent, lang)}
</td>
<td className="text-right px-3 py-2.5 tabular-nums">
{formatCurrency(sectionTotals.monthPrevious, lang)}
</td>
<td
className="text-right px-3 py-2.5 tabular-nums"
style={{ color: variationColor(sectionTotals.monthDelta) }}
>
{formatSignedCurrency(sectionTotals.monthDelta, lang)}
</td>
<td
className="text-right px-3 py-2.5 tabular-nums"
style={{ color: variationColor(sectionTotals.monthDelta) }}
>
{formatPct(pct(sectionTotals.monthDelta, sectionTotals.monthPrevious), lang)}
</td>
<td className="text-right px-3 py-2.5 border-l border-[var(--border)]/50 tabular-nums">
{formatCurrency(sectionTotals.ytdCurrent, lang)}
</td>
<td className="text-right px-3 py-2.5 tabular-nums">
{formatCurrency(sectionTotals.ytdPrevious, lang)}
</td>
<td
className="text-right px-3 py-2.5 tabular-nums"
style={{ color: variationColor(sectionTotals.ytdDelta) }}
>
{formatSignedCurrency(sectionTotals.ytdDelta, lang)}
</td>
<td
className="text-right px-3 py-2.5 tabular-nums"
style={{ color: variationColor(sectionTotals.ytdDelta) }}
>
{formatPct(pct(sectionTotals.ytdDelta, sectionTotals.ytdPrevious), lang)}
</td>
</tr>
</Fragment>
);
})}
{/* 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, lang)}
</td>
<td className="text-right px-3 py-3 tabular-nums">
{formatCurrency(totals.monthPrevious, lang)}
</td>
<td
className="text-right px-3 py-3 tabular-nums"
style={{ color: variationColor(totals.monthDelta) }}
>
{formatSignedCurrency(totals.monthDelta, lang)}
</td>
<td
className="text-right px-3 py-3 tabular-nums"
style={{ color: variationColor(totals.monthDelta) }}
>
{formatPct(pct(totals.monthDelta, totals.monthPrevious), lang)}
</td>
<td className="text-right px-3 py-3 border-l border-[var(--border)]/50 tabular-nums">
{formatCurrency(totals.ytdCurrent, lang)}
</td>
<td className="text-right px-3 py-3 tabular-nums">
{formatCurrency(totals.ytdPrevious, lang)}
</td>
<td
className="text-right px-3 py-3 tabular-nums"
style={{ color: variationColor(totals.ytdDelta) }}
>
{formatSignedCurrency(totals.ytdDelta, lang)}
</td>
<td
className="text-right px-3 py-3 tabular-nums"
style={{ color: variationColor(totals.ytdDelta) }}
>
{formatPct(pct(totals.ytdDelta, totals.ytdPrevious), lang)}
</td>
</tr>
</> </>
)} )}
</tbody> </tbody>

View file

@ -0,0 +1,87 @@
import { describe, it, expect } from "vitest";
import type { CategoryDelta } from "../../shared/types";
import { computeResults, sumLeaves } from "./compareResults";
// Minimal leaf factory. Amounts arrive from COMPARE_DELTA_SQL already in the
// per-type sign convention (expenses = positive ABS, income/transfer = signed).
function leaf(
type: "expense" | "income" | "transfer",
mc: number,
mp: number,
cc = mc,
cp = mp,
): CategoryDelta {
return {
categoryId: 1,
categoryName: type,
categoryColor: "#000",
previousAmount: mp,
currentAmount: mc,
deltaAbs: mc - mp,
deltaPct: mp !== 0 ? ((mc - mp) / mp) * 100 : null,
cumulativePreviousAmount: cp,
cumulativeCurrentAmount: cc,
cumulativeDeltaAbs: cc - cp,
cumulativeDeltaPct: cp !== 0 ? ((cc - cp) / cp) * 100 : null,
category_type: type,
is_parent: false,
};
}
describe("computeResults — income-statement roll-up (Issue #253)", () => {
it("resultBefore = revenues expenses; resultNet adds net transfers", () => {
const r = computeResults([
leaf("income", 4350, 4500),
leaf("expense", 3900, 3700),
leaf("transfer", 0, 0),
]);
// Operating result: 4350 3900 = 450 current; 4500 3700 = 800 previous.
expect(r.resultBefore.monthCurrent).toBe(450);
expect(r.resultBefore.monthPrevious).toBe(800);
expect(r.resultBefore.monthDelta).toBe(-350);
// Balanced transfer (0) → net equals the operating result.
expect(r.resultNet.monthCurrent).toBe(450);
expect(r.hasTransfers).toBe(true);
});
it("an unbalanced (one-leg) transfer moves resultNet away from resultBefore", () => {
const r = computeResults([
leaf("income", 4000, 4000),
leaf("expense", 3000, 3000),
leaf("transfer", -200, 0), // a single categorised leg
]);
expect(r.resultBefore.monthCurrent).toBe(1000);
// Net folds the residual transfer in: 1000 + (200) = 800.
expect(r.resultNet.monthCurrent).toBe(800);
});
it("a deficit yields a negative net result and no transfer line", () => {
const r = computeResults([leaf("income", 2000, 2000), leaf("expense", 2500, 2400)]);
expect(r.resultNet.monthCurrent).toBe(-500);
expect(r.hasTransfers).toBe(false);
});
it("ignores subtotal (is_parent) rows so a group is not double-counted", () => {
const parent = { ...leaf("expense", 999, 999), is_parent: true };
const r = computeResults([parent, leaf("expense", 100, 80)]);
expect(r.expense.monthCurrent).toBe(100);
});
it("carries the YTD figures through the same arithmetic", () => {
const r = computeResults([
leaf("income", 100, 100, 1200, 1150),
leaf("expense", 60, 60, 700, 650),
]);
expect(r.resultBefore.ytdCurrent).toBe(500);
expect(r.resultBefore.ytdPrevious).toBe(500);
});
it("sumLeaves adds only leaves", () => {
const t = sumLeaves([
leaf("expense", 100, 50),
{ ...leaf("expense", 999, 999), is_parent: true },
]);
expect(t.monthCurrent).toBe(100);
expect(t.monthPrevious).toBe(50);
});
});

View file

@ -0,0 +1,90 @@
import type { CategoryDelta } from "../../shared/types";
export type SectionType = "expense" | "income" | "transfer";
/** Aggregate of the 6 comparable figures across a set of leaf rows. */
export interface Totals {
monthCurrent: number;
monthPrevious: number;
monthDelta: number;
ytdCurrent: number;
ytdPrevious: number;
ytdDelta: number;
}
const ZERO: Totals = {
monthCurrent: 0,
monthPrevious: 0,
monthDelta: 0,
ytdCurrent: 0,
ytdPrevious: 0,
ytdDelta: 0,
};
/** Sum every non-subtotal (leaf) row into a single Totals. */
export function sumLeaves(rows: CategoryDelta[]): Totals {
return rows
.filter((r) => !r.is_parent)
.reduce<Totals>(
(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,
}),
{ ...ZERO },
);
}
export function pct(delta: number, previous: number): number | null {
return previous !== 0 ? (delta / Math.abs(previous)) * 100 : null;
}
/** Component-wise a + sign*b across the 6 figures. */
function combine(a: Totals, b: Totals, sign: 1 | -1): Totals {
return {
monthCurrent: a.monthCurrent + sign * b.monthCurrent,
monthPrevious: a.monthPrevious + sign * b.monthPrevious,
monthDelta: a.monthDelta + sign * b.monthDelta,
ytdCurrent: a.ytdCurrent + sign * b.ytdCurrent,
ytdPrevious: a.ytdPrevious + sign * b.ytdPrevious,
ytdDelta: a.ytdDelta + sign * b.ytdDelta,
};
}
export interface CompareResults {
income: Totals;
expense: Totals;
transfer: Totals;
/** Revenues expenses: the operating result, before transfers. */
resultBefore: Totals;
/** resultBefore + net transfers: the bottom-line total. */
resultNet: Totals;
hasTransfers: boolean;
}
/**
* Income-statement roll-up for the real-vs-real compare (Issue #253).
*
* Sign convention flowing in from COMPARE_DELTA_SQL: expenses are positive
* magnitudes (ABS of outflows), income is a signed SUM (positive), transfers a
* signed net (~0 when balanced). So the operating result is a straight
* `income expense`, and the net adds the (usually ~0) transfer total. When a
* transfer is unbalanced (a single leg categorised) its residual shows up as
* the gap between resultBefore and resultNet.
*/
export function computeResults(rows: CategoryDelta[]): CompareResults {
const byType = (type: SectionType): Totals =>
sumLeaves(rows.filter((r) => (r.category_type ?? "expense") === type));
const income = byType("income");
const expense = byType("expense");
const transfer = byType("transfer");
const resultBefore = combine(income, expense, -1);
const resultNet = combine(resultBefore, transfer, 1);
const hasTransfers = rows.some(
(r) => (r.category_type ?? "expense") === "transfer" && !r.is_parent,
);
return { income, expense, transfer, resultBefore, resultNet, hasTransfers };
}

View file

@ -1,5 +1,10 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
import { previousMonth, defaultReferencePeriod, comparisonMeta } from "./useCompare"; import {
previousMonth,
defaultReferencePeriod,
comparisonMeta,
syncReferenceOnPeriodChange,
} from "./useCompare";
describe("useCompare helpers", () => { describe("useCompare helpers", () => {
describe("previousMonth", () => { describe("previousMonth", () => {
@ -44,4 +49,40 @@ describe("useCompare helpers", () => {
expect(comparisonMeta("yoy", 2026, 1)).toEqual({ previousYear: 2025, previousMonth: 1 }); expect(comparisonMeta("yoy", 2026, 1)).toEqual({ previousYear: 2025, previousMonth: 1 });
}); });
}); });
describe("syncReferenceOnPeriodChange", () => {
// The ref is seeded with the initial `to`, so the mount call has to===last.
it("no-ops on mount, preserving the previous-month default", () => {
const r = syncReferenceOnPeriodChange("2026-12-31", "2026-12-31", 2026, 6);
expect(r.next).toBeNull();
expect(r.lastSyncedTo).toBe("2026-12-31");
});
// StrictMode double-invokes effects in dev: calling twice with the same `to`
// must not sync (a fire-once boolean would wrongly snap to December on run 2).
it("is idempotent across a repeated `to` (StrictMode-safe)", () => {
const a = syncReferenceOnPeriodChange("2026-12-31", "2026-12-31", 2026, 6);
const b = syncReferenceOnPeriodChange(a.lastSyncedTo, "2026-12-31", 2026, 6);
expect(a.next).toBeNull();
expect(b.next).toBeNull();
});
it("syncs the reference month when `to` changes (user navigation)", () => {
const r = syncReferenceOnPeriodChange("2026-12-31", "2026-06-30", 2026, 12);
expect(r.next).toEqual({ year: 2026, month: 6 });
expect(r.lastSyncedTo).toBe("2026-06-30");
});
it("records a changed `to` but does not dispatch when it already matches state", () => {
const r = syncReferenceOnPeriodChange("2026-12-31", "2026-06-30", 2026, 6);
expect(r.next).toBeNull();
expect(r.lastSyncedTo).toBe("2026-06-30");
});
it("ignores an unparseable `to`", () => {
const r = syncReferenceOnPeriodChange("2026-12-31", "garbage", 2026, 6);
expect(r.next).toBeNull();
expect(r.lastSyncedTo).toBe("garbage");
});
});
}); });

View file

@ -60,6 +60,32 @@ export function comparisonMeta(
return { previousYear: year - 1, previousMonth: month }; return { previousYear: year - 1, previousMonth: month };
} }
/**
* Pure decision for the URL-period reference-month sync effect.
*
* Gates on a *change* of `to` (the period's upper bound) rather than a
* fire-once flag: seeded with the initial `to`, the mount is a no-op, so the
* previous-month default from initialState survives instead of snapping to the
* civil-year December that useReportsPeriod yields by default (Issue #253).
* Because it keys off a value change, it is idempotent under React StrictMode's
* dev double-invoke of effects called twice with the same `to` it dispatches
* nothing the second time (a fire-once boolean would wrongly sync on run #2).
*
* Returns the `to` to remember plus an optional reference period to dispatch.
*/
export function syncReferenceOnPeriodChange(
lastSyncedTo: string,
to: string,
currentYear: number,
currentMonth: number,
): { lastSyncedTo: string; next: { year: number; month: number } | null } {
if (to === lastSyncedTo) return { lastSyncedTo, next: null };
const [y, m] = to.split("-").map(Number);
if (!Number.isFinite(y) || !Number.isFinite(m)) return { lastSyncedTo: to, next: null };
if (y === currentYear && m === currentMonth) return { lastSyncedTo: to, next: null };
return { lastSyncedTo: to, next: { year: y, month: m } };
}
const defaultRef = defaultReferencePeriod(); const defaultRef = defaultReferencePeriod();
const initialState: State = { const initialState: State = {
mode: "actual", mode: "actual",
@ -119,15 +145,21 @@ export function useCompare() {
fetch(state.mode, state.subMode, state.year, state.month); fetch(state.mode, state.subMode, state.year, state.month);
}, [fetch, state.mode, state.subMode, state.year, state.month]); }, [fetch, state.mode, state.subMode, state.year, state.month]);
// When the URL period changes, align the reference month with `to`. // Keep the reference month in sync with the URL period when the user navigates
// The explicit dropdown remains the primary selector — this effect only // via PeriodSelector — but not on mount. The ref is seeded with the initial
// keeps the two in sync when the user navigates via PeriodSelector. // `to`, so syncReferenceOnPeriodChange only fires on an actual change of `to`,
// leaving the previous-month default from initialState intact (Issue #253) and
// staying idempotent under StrictMode's dev double-invoke.
const lastSyncedToRef = useRef(to);
useEffect(() => { useEffect(() => {
const [y, m] = to.split("-").map(Number); const { lastSyncedTo, next } = syncReferenceOnPeriodChange(
if (!Number.isFinite(y) || !Number.isFinite(m)) return; lastSyncedToRef.current,
if (y !== state.year || m !== state.month) { to,
dispatch({ type: "SET_REFERENCE_PERIOD", payload: { year: y, month: m } }); state.year,
} state.month,
);
lastSyncedToRef.current = lastSyncedTo;
if (next) dispatch({ type: "SET_REFERENCE_PERIOD", payload: next });
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [to]); }, [to]);

View file

@ -427,7 +427,6 @@
"referenceMonth": "Reference month", "referenceMonth": "Reference month",
"currentAmount": "Current", "currentAmount": "Current",
"previousAmount": "Previous", "previousAmount": "Previous",
"totalRow": "Total",
"sections": { "sections": {
"expenses": "Expenses", "expenses": "Expenses",
"income": "Income", "income": "Income",
@ -435,7 +434,9 @@
}, },
"totalExpenses": "Total Expenses", "totalExpenses": "Total Expenses",
"totalIncome": "Total Income", "totalIncome": "Total Income",
"totalTransfers": "Total Transfers" "totalTransfers": "Total Transfers",
"resultBeforeTransfers": "Result before transfers",
"resultNet": "Net result"
}, },
"cartes": { "cartes": {
"kpiSectionAria": "Key indicators for the reference month", "kpiSectionAria": "Key indicators for the reference month",

View file

@ -427,7 +427,6 @@
"referenceMonth": "Mois de référence", "referenceMonth": "Mois de référence",
"currentAmount": "Courant", "currentAmount": "Courant",
"previousAmount": "Précédent", "previousAmount": "Précédent",
"totalRow": "Total",
"sections": { "sections": {
"expenses": "Dépenses", "expenses": "Dépenses",
"income": "Revenus", "income": "Revenus",
@ -435,7 +434,9 @@
}, },
"totalExpenses": "Total des dépenses", "totalExpenses": "Total des dépenses",
"totalIncome": "Total des revenus", "totalIncome": "Total des revenus",
"totalTransfers": "Total des transferts" "totalTransfers": "Total des transferts",
"resultBeforeTransfers": "Résultat avant transferts",
"resultNet": "Résultat net"
}, },
"cartes": { "cartes": {
"kpiSectionAria": "Indicateurs clés du mois de référence", "kpiSectionAria": "Indicateurs clés du mois de référence",

View file

@ -235,6 +235,33 @@ describe("getCartesSnapshot", () => {
expect(snapshot.topMoversDown[0].categoryName).toBe("D3"); expect(snapshot.topMoversDown[0].categoryName).toBe("D3");
}); });
it("excludes income from top movers so a salary rise is not a red 'increase' (#253)", async () => {
// Post-#253 COMPARE_DELTA_SQL surfaces income as a signed positive SUM, so
// revenue rows now reach getCartesSnapshot. Top movers is a spending view
// (up = red), so income must be filtered out — otherwise a salary jump tops
// "biggest increases" in red. The category metadata query types id 99 as
// income; the two expense movers are absent from it and default to expense.
const momRows = [
{ category_id: 99, category_name: "Salaire", category_color: "#000", month_current_total: 5000, month_previous_total: 100, cumulative_current_total: 5000, cumulative_previous_total: 100 },
{ category_id: 1, category_name: "Épicerie", category_color: "#000", month_current_total: 300, month_previous_total: 100, cumulative_current_total: 300, cumulative_previous_total: 100 },
{ category_id: 2, category_name: "Resto", category_color: "#000", month_current_total: 100, month_previous_total: 400, cumulative_current_total: 100, cumulative_previous_total: 400 },
];
routeSelect([
{ match: "strftime('%Y-%m', date)", rows: [{ month: "2026-03", income: 5000, expenses: 400 }] },
{ match: "ORDER BY ABS(month_current_total - month_previous_total) DESC", rows: momRows },
{ match: "parent_id FROM categories", rows: [{ id: 99, name: "Salaire", color: null, type: "income", parent_id: null }] },
]);
const snapshot = await getCartesSnapshot(2026, 3);
const upNames = snapshot.topMoversUp.map((m) => m.categoryName);
// Income excluded despite the biggest delta (+4900).
expect(upNames).not.toContain("Salaire");
// The biggest EXPENSE increase leads instead.
expect(snapshot.topMoversUp[0].categoryName).toBe("Épicerie");
// Expense decrease still surfaces.
expect(snapshot.topMoversDown[0].categoryName).toBe("Resto");
});
it("computes YTD KPIs correctly when mode=ytd (sums Jan→refMonth of refYear)", async () => { it("computes YTD KPIs correctly when mode=ytd (sums Jan→refMonth of refYear)", async () => {
// Reference = 2026-03, YTD = Jan + Feb + Mar of 2026. // Reference = 2026-03, YTD = Jan + Feb + Mar of 2026.
routeSelect([ routeSelect([

View file

@ -487,8 +487,9 @@ describe("real-vs-real compare — transfer netting (Issue #243)", () => {
// compare. Ordinary categories keep the expense-report behavior (only // compare. Ordinary categories keep the expense-report behavior (only
// outflows, summed as positive magnitudes). The netting lives entirely in // outflows, summed as positive magnitudes). The netting lives entirely in
// the SQL (COMPARE_DELTA_SQL): a signed SUM(t.amount) for `transfer`, // the SQL (COMPARE_DELTA_SQL): a signed SUM(t.amount) for `transfer`,
// ABS(t.amount) filtered on amount < 0 for everything else, with the WHERE // ABS(t.amount) filtered on amount < 0 for plain expenses. Income, like
// broadened so transfer credits are not dropped before they can cancel. // transfers, is a signed SUM (Issue #253); the WHERE is broadened so
// income/transfer credits are not dropped before they can count or cancel.
// //
// There is no runnable SQLite in these unit tests (tauri-plugin-sql only // There is no runnable SQLite in these unit tests (tauri-plugin-sql only
// exists inside the Tauri WebView, and CI runs Node 20 → no node:sqlite), so // exists inside the Tauri WebView, and CI runs Node 20 → no node:sqlite), so
@ -499,11 +500,12 @@ describe("real-vs-real compare — transfer netting (Issue #243)", () => {
/** Per-row contribution to a bucket — the exact rule COMPARE_DELTA_SQL encodes. */ /** Per-row contribution to a bucket — the exact rule COMPARE_DELTA_SQL encodes. */
function bucketContribution(amount: number, type: string | null): number { function bucketContribution(amount: number, type: string | null): number {
return (type ?? "expense") === "transfer" const t = type ?? "expense";
? amount // signed → a debit and its matching credit cancel return t === "transfer" || t === "income"
? amount // signed → transfer debit/credit cancel; income credits kept as revenue (#253)
: amount < 0 : amount < 0
? Math.abs(amount) // expense outflow as a positive magnitude ? Math.abs(amount) // expense outflow as a positive magnitude
: 0; // non-transfer credits dropped → no revenues surfaced : 0; // other (expense) credits dropped — the report stays outflow-only for them
} }
it("nets a balanced transfer to ~0 while an expense keeps its sum of outflows", () => { it("nets a balanced transfer to ~0 while an expense keeps its sum of outflows", () => {
@ -518,6 +520,11 @@ describe("real-vs-real compare — transfer netting (Issue #243)", () => {
// Uncategorized (c.type NULL) defaults to expense behavior. // Uncategorized (c.type NULL) defaults to expense behavior.
const uncategorized = [-40, 15].reduce((s, a) => s + bucketContribution(a, null), 0); const uncategorized = [-40, 15].reduce((s, a) => s + bucketContribution(a, null), 0);
expect(uncategorized).toBe(40); expect(uncategorized).toBe(40);
// Income (paie + a small correction) is a signed SUM — credits are kept as
// revenue (Issue #253), not dropped like non-transfer expense credits.
const income = [4200, 150, -50].reduce((s, a) => s + bucketContribution(a, "income"), 0);
expect(income).toBe(4300);
}); });
it("getCompareMonthOverMonth SQL nets transfers (signed) but keeps ABS for other types", async () => { it("getCompareMonthOverMonth SQL nets transfers (signed) but keeps ABS for other types", async () => {
@ -528,11 +535,11 @@ describe("real-vs-real compare — transfer netting (Issue #243)", () => {
const sql = mockSelect.mock.calls[0][0] as string; const sql = mockSelect.mock.calls[0][0] as string;
// transfer → signed t.amount; every other type → ABS(t.amount) // transfer → signed t.amount; every other type → ABS(t.amount)
expect(sql).toContain( expect(sql).toContain(
"COALESCE(c.type, 'expense') = 'transfer' THEN t.amount ELSE ABS(t.amount)", "COALESCE(c.type, 'expense') IN ('transfer', 'income') THEN t.amount ELSE ABS(t.amount)",
); );
// WHERE broadened so transfer credits survive the expense (amount < 0) filter // WHERE broadened so transfer credits survive the expense (amount < 0) filter
expect(sql).toContain( expect(sql).toContain(
"WHERE (t.amount < 0 OR COALESCE(c.type, 'expense') = 'transfer')", "WHERE (t.amount < 0 OR COALESCE(c.type, 'expense') IN ('transfer', 'income'))",
); );
// still four date buckets // still four date buckets
expect(sql).toContain("month_current_total"); expect(sql).toContain("month_current_total");
@ -546,10 +553,10 @@ describe("real-vs-real compare — transfer netting (Issue #243)", () => {
const sql = mockSelect.mock.calls[0][0] as string; const sql = mockSelect.mock.calls[0][0] as string;
expect(sql).toContain( expect(sql).toContain(
"COALESCE(c.type, 'expense') = 'transfer' THEN t.amount ELSE ABS(t.amount)", "COALESCE(c.type, 'expense') IN ('transfer', 'income') THEN t.amount ELSE ABS(t.amount)",
); );
expect(sql).toContain( expect(sql).toContain(
"WHERE (t.amount < 0 OR COALESCE(c.type, 'expense') = 'transfer')", "WHERE (t.amount < 0 OR COALESCE(c.type, 'expense') IN ('transfer', 'income'))",
); );
}); });
@ -678,6 +685,8 @@ describe("buildCompareTree — hierarchical real-vs-real (Issue #247)", () => {
{ id: 24, name: "Restaurant", color: "#f97316", type: "expense", parent_id: 2 }, { id: 24, name: "Restaurant", color: "#f97316", type: "expense", parent_id: 2 },
{ id: 5, name: "Placements", color: null, type: "transfer", parent_id: null }, { id: 5, name: "Placements", color: null, type: "transfer", parent_id: null },
{ id: 50, name: "REER", color: null, type: "transfer", parent_id: 5 }, { id: 50, name: "REER", color: null, type: "transfer", parent_id: 5 },
{ id: 8, name: "Revenus", color: null, type: "income", parent_id: null },
{ id: 80, name: "Paie", color: null, type: "income", parent_id: 8 },
] as Parameters<typeof buildCompareTree>[1]; ] as Parameters<typeof buildCompareTree>[1];
it("nests leaves under a parent subtotal equal to the sum of its children", () => { it("nests leaves under a parent subtotal equal to the sum of its children", () => {
@ -723,14 +732,14 @@ describe("buildCompareTree — hierarchical real-vs-real (Issue #247)", () => {
expect(parent.deltaAbs).toBe(0); expect(parent.deltaAbs).toBe(0);
}); });
it("orders sections expense → income → transfer and keeps subtrees contiguous", () => { it("orders sections income → expense → transfer and keeps subtrees contiguous", () => {
const rows = buildCompareTree( const rows = buildCompareTree(
[leaf(22, "Épicerie", 500, 400), leaf(50, "REER", 0, 0)], [leaf(80, "Paie", 4200, 4000), leaf(22, "Épicerie", 500, 400), leaf(50, "REER", 0, 0)],
cats, cats,
); );
const types = rows.map((r) => r.category_type); const types = rows.map((r) => r.category_type);
// All expense rows precede all transfer rows. // Income section first, then expenses, then transfers (Issue #253).
expect(types).toEqual(["expense", "expense", "transfer", "transfer"]); expect(types).toEqual(["income", "income", "expense", "expense", "transfer", "transfer"]);
}); });
it("preserves grand-total invariance vs the flat leaves", () => { it("preserves grand-total invariance vs the flat leaves", () => {

View file

@ -447,7 +447,9 @@ interface CompareCatMeta {
const COMPARE_CATEGORIES_SQL = `SELECT id, name, color, type, parent_id FROM categories`; const COMPARE_CATEGORIES_SQL = `SELECT id, name, color, type, parent_id FROM categories`;
const COMPARE_TYPE_ORDER: Record<string, number> = { expense: 0, income: 1, transfer: 2 }; // Income statement reading order: revenues first, then expenses, then the
// (netted) transfers — the two result lines are injected around them in the UI.
const COMPARE_TYPE_ORDER: Record<string, number> = { income: 0, expense: 1, transfer: 2 };
/** Depth cap mirroring CATEGORY_TREE_CTE — guards a cyclic parent_id chain. */ /** Depth cap mirroring CATEGORY_TREE_CTE — guards a cyclic parent_id chain. */
const MAX_TREE_DEPTH = 5; const MAX_TREE_DEPTH = 5;
@ -625,7 +627,7 @@ export function buildCompareTree(
rows.push({ ...d, parent_id: null, is_parent: false, depth: 0, category_type: d.category_type ?? "expense" }); rows.push({ ...d, parent_id: null, is_parent: false, depth: 0, category_type: d.category_type ?? "expense" });
} }
// Stable sort by type so sections (expense → income → transfer) are // Stable sort by type so sections (income → expense → transfer) are
// contiguous; magnitude/tree order within a type is preserved via the index. // contiguous; magnitude/tree order within a type is preserved via the index.
const order = new Map<CategoryDelta, number>(); const order = new Map<CategoryDelta, number>();
rows.forEach((r, i) => order.set(r, i)); rows.forEach((r, i) => order.set(r, i));
@ -649,33 +651,33 @@ function previousMonth(year: number, month: number): { year: number; month: numb
* exact same query only the eight date bounds differ). Four date buckets * exact same query only the eight date bounds differ). Four date buckets
* ($1..$8): monthly current/previous and cumulative current/previous. * ($1..$8): monthly current/previous and cumulative current/previous.
* *
* Per-category sign convention (Issue #243): * This report is an INCOME STATEMENT ("analyse de résultat", Issue #253), not a
* - `transfer` categories NET via a signed SUM(t.amount), so a balanced * pure expense report: revenues are surfaced alongside expenses so the compare
* debit/credit pair (e.g. "Paiement CC": -500 out, +500 in) cancels to ~0. * can show a result (revenues expenses). Per-category sign convention:
* A transfer is a money move, not spending, and must not inflate the * - `income` and `transfer` categories use a signed SUM(t.amount). Income
* expense figures. * credits stay positive; a `transfer` debit/credit pair (e.g. "Paiement CC":
* -500 out, +500 in) nets to ~0 (Issue #243 a money move, not spending).
* - Every other type keeps the expense-report behavior: only outflows * - Every other type keeps the expense-report behavior: only outflows
* (amount < 0) count, summed as positive magnitudes via ABS. * (amount < 0) count, summed as positive magnitudes via ABS.
* *
* The WHERE is broadened with `OR COALESCE(c.type, 'expense') = 'transfer'` so * The WHERE admits outflows OR any income/transfer row, so income and transfer
* transfer *credits* survive the `amount < 0` expense filter and can actually * *credits* survive the `amount < 0` filter without it a pure-income category
* cancel their debits without it the credit leg would be dropped and the * would be dropped entirely and a transfer could never net to zero. Expense
* category could never net to zero. For non-transfer rows the WHERE still * rows still contribute only their outflows, byte-identical to before. The two
* admits only outflows, so their totals are byte-identical to the previous * result lines (before/after transfers) are computed in ComparePeriodTable from
* behavior (no revenues surfaced, no zero-rows for pure-income categories). * the per-type subtotals, not here. Uncategorized rows (c.type NULL) 'expense'.
* Uncategorized rows (LEFT JOIN c.type NULL) default to 'expense'.
*/ */
const COMPARE_DELTA_SQL = `SELECT const COMPARE_DELTA_SQL = `SELECT
t.category_id, t.category_id,
COALESCE(c.name, 'Uncategorized') AS category_name, COALESCE(c.name, 'Uncategorized') AS category_name,
COALESCE(c.color, '#9ca3af') AS category_color, COALESCE(c.color, '#9ca3af') AS category_color,
COALESCE(SUM(CASE WHEN t.date >= $1 AND t.date <= $2 THEN CASE WHEN COALESCE(c.type, 'expense') = 'transfer' THEN t.amount ELSE ABS(t.amount) END ELSE 0 END), 0) AS month_current_total, COALESCE(SUM(CASE WHEN t.date >= $1 AND t.date <= $2 THEN CASE WHEN COALESCE(c.type, 'expense') IN ('transfer', 'income') THEN t.amount ELSE ABS(t.amount) END ELSE 0 END), 0) AS month_current_total,
COALESCE(SUM(CASE WHEN t.date >= $3 AND t.date <= $4 THEN CASE WHEN COALESCE(c.type, 'expense') = 'transfer' THEN t.amount ELSE ABS(t.amount) END ELSE 0 END), 0) AS month_previous_total, COALESCE(SUM(CASE WHEN t.date >= $3 AND t.date <= $4 THEN CASE WHEN COALESCE(c.type, 'expense') IN ('transfer', 'income') THEN t.amount ELSE ABS(t.amount) END ELSE 0 END), 0) AS month_previous_total,
COALESCE(SUM(CASE WHEN t.date >= $5 AND t.date <= $6 THEN CASE WHEN COALESCE(c.type, 'expense') = 'transfer' THEN t.amount ELSE ABS(t.amount) END ELSE 0 END), 0) AS cumulative_current_total, COALESCE(SUM(CASE WHEN t.date >= $5 AND t.date <= $6 THEN CASE WHEN COALESCE(c.type, 'expense') IN ('transfer', 'income') THEN t.amount ELSE ABS(t.amount) END ELSE 0 END), 0) AS cumulative_current_total,
COALESCE(SUM(CASE WHEN t.date >= $7 AND t.date <= $8 THEN CASE WHEN COALESCE(c.type, 'expense') = 'transfer' THEN t.amount ELSE ABS(t.amount) END ELSE 0 END), 0) AS cumulative_previous_total COALESCE(SUM(CASE WHEN t.date >= $7 AND t.date <= $8 THEN CASE WHEN COALESCE(c.type, 'expense') IN ('transfer', 'income') THEN t.amount ELSE ABS(t.amount) END ELSE 0 END), 0) AS cumulative_previous_total
FROM transactions t FROM transactions t
LEFT JOIN categories c ON t.category_id = c.id LEFT JOIN categories c ON t.category_id = c.id
WHERE (t.amount < 0 OR COALESCE(c.type, 'expense') = 'transfer') WHERE (t.amount < 0 OR COALESCE(c.type, 'expense') IN ('transfer', 'income'))
AND ( AND (
(t.date >= $1 AND t.date <= $2) (t.date >= $1 AND t.date <= $2)
OR (t.date >= $3 AND t.date <= $4) OR (t.date >= $3 AND t.date <= $4)
@ -1168,12 +1170,20 @@ export async function getCartesSnapshot(
// Top movers: biggest MoM increases / decreases. `momRows` now carries the // Top movers: biggest MoM increases / decreases. `momRows` now carries the
// compare hierarchy (Issue #247) — skip the subtotal (`is_parent`) rows so a // compare hierarchy (Issue #247) — skip the subtotal (`is_parent`) rows so a
// parent group can't double-count against its own leaves. The surviving leaves // parent group can't double-count against its own leaves. Keep only EXPENSE
// are byte-identical to the previous flat output; the sort/slice below is // leaves: since Issue #253 broadened COMPARE_DELTA_SQL to surface income (and
// unchanged. `momRows` are sorted by absolute delta already; filter out // net transfers), momRows now also carries revenue rows — and this card is a
// near-zero noise and split by sign. // spending view whose colours read "up = red" (more spending is bad), so a
// salary rise must not appear under "biggest increases" in red. Mirror the
// expense-only filter ComparePeriodChart uses. The surviving expense leaves
// are byte-identical to the pre-#253 flat output. `momRows` are sorted by
// absolute delta already; filter out near-zero noise and split by sign.
const significantMovers = momRows.filter( const significantMovers = momRows.filter(
(r) => !r.is_parent && r.deltaAbs !== 0 && (r.previousAmount > 0 || r.currentAmount > 0), (r) =>
!r.is_parent &&
(r.category_type ?? "expense") === "expense" &&
r.deltaAbs !== 0 &&
(r.previousAmount > 0 || r.currentAmount > 0),
); );
// Project the richer CategoryDelta shape down to the narrower CartesTopMover // Project the richer CategoryDelta shape down to the narrower CartesTopMover
// shape so the Cartes dashboard keeps its stable contract regardless of how // shape so the Cartes dashboard keeps its stable contract regardless of how