Compare commits
No commits in common. "450229522b7708d3d9b5477841cfed6a6267c838" and "51531ec0ce49feb5f9dd12002f239cfadabde384" have entirely different histories.
450229522b
...
51531ec0ce
20 changed files with 348 additions and 1029 deletions
5
.gitignore
vendored
5
.gitignore
vendored
|
|
@ -63,9 +63,8 @@ src-tauri/icons/ios/
|
|||
.claude/worktrees/
|
||||
decisions-log.md
|
||||
|
||||
# Autopilot scratch + daily reports (repo root only — must not match
|
||||
# src/components/reports/, which is real tracked source)
|
||||
/reports/
|
||||
# Autopilot scratch + daily reports
|
||||
reports/
|
||||
|
||||
# Spec scratch (committed only when promoted to docs/archive/)
|
||||
spec-decisions-*.md
|
||||
|
|
|
|||
|
|
@ -2,12 +2,6 @@
|
|||
|
||||
## [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).
|
||||
- Rapports → Tendances → Par catégorie (vue tableau) : le tableau des catégories dans le temps se lit désormais comme une analyse de résultat, à l'image du rapport de comparaison. Les catégories sont regroupées en sections **Revenus → Dépenses → Transferts** avec des sous-totaux par mois et un total, et l'ancienne ligne « Total » mélangée — qui additionnait revenus, dépenses et transferts en valeurs absolues pour un chiffre sans signification — est remplacée par une ligne **Résultat net** par mois (revenus − dépenses + transferts), colorée en vert pour un surplus et en rouge pour un déficit, plus une ligne **Résultat avant transferts** affichée lorsque des transferts existent. Les cellules de catégorie restent des niveaux neutres ; seules les lignes de résultat portent une couleur de signe (#256).
|
||||
|
||||
## [0.11.0] - 2026-07-05
|
||||
|
||||
### Ajouté
|
||||
|
|
|
|||
|
|
@ -2,12 +2,6 @@
|
|||
|
||||
## [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).
|
||||
- Reports → Trends → By category (table view): the category-over-time table now reads as a result analysis, mirroring the compare report. Categories are grouped into **Income → Expenses → Transfers** sections with per-month and total subtotals, and the old mixed "Total" row — which summed income, expenses, and transfers as absolute magnitudes into a meaningless figure — is replaced by a **Net result** row per month (income − expenses + transfers), coloured green for a surplus and red for a deficit, plus a **Result before transfers** row shown when transfers exist. Category cells stay neutral levels; only the result rows carry a sign colour (#256).
|
||||
|
||||
## [0.11.0] - 2026-07-05
|
||||
|
||||
### Added
|
||||
|
|
|
|||
|
|
@ -1,7 +1,5 @@
|
|||
import { Fragment } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { CategoryOverTimeData } from "../../shared/types";
|
||||
import { computeOverTimeResults, type OverTimeType } from "./overTimeResults";
|
||||
|
||||
const cadFormatter = (value: number) =>
|
||||
new Intl.NumberFormat("en-CA", { style: "currency", currency: "CAD", maximumFractionDigits: 0 }).format(value);
|
||||
|
|
@ -12,25 +10,6 @@ function formatMonth(month: string): string {
|
|||
return date.toLocaleDateString("default", { month: "short", year: "2-digit" });
|
||||
}
|
||||
|
||||
/** Colours a result figure by sign: surplus green, deficit red, zero neutral. */
|
||||
function resultColor(value: number): string {
|
||||
if (value > 0) return "var(--positive, #10b981)";
|
||||
if (value < 0) return "var(--negative, #ef4444)";
|
||||
return "";
|
||||
}
|
||||
|
||||
const SECTION_LABEL_KEY: Record<OverTimeType, string> = {
|
||||
income: "reports.compare.sections.income",
|
||||
expense: "reports.compare.sections.expenses",
|
||||
transfer: "reports.compare.sections.transfers",
|
||||
};
|
||||
|
||||
const SECTION_TOTAL_KEY: Record<OverTimeType, string> = {
|
||||
income: "reports.compare.totalIncome",
|
||||
expense: "reports.compare.totalExpenses",
|
||||
transfer: "reports.compare.totalTransfers",
|
||||
};
|
||||
|
||||
interface CategoryOverTimeTableProps {
|
||||
data: CategoryOverTimeData;
|
||||
hiddenCategories?: Set<string>;
|
||||
|
|
@ -51,8 +30,7 @@ export default function CategoryOverTimeTable({ data, hiddenCategories }: Catego
|
|||
? data.categories.filter((name) => !hiddenCategories.has(name))
|
||||
: data.categories;
|
||||
|
||||
const { months, sections, hasTransfers, net, beforeTransfers } = computeOverTimeResults(data, visibleCategories);
|
||||
const colSpan = months.length + 2;
|
||||
const months = data.data.map((d) => d.month);
|
||||
|
||||
return (
|
||||
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl overflow-hidden">
|
||||
|
|
@ -74,112 +52,55 @@ export default function CategoryOverTimeTable({ data, hiddenCategories }: Catego
|
|||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sections.map((section) => (
|
||||
<Fragment key={section.type}>
|
||||
{/* Section header */}
|
||||
<tr className="bg-[var(--muted)]">
|
||||
<td
|
||||
colSpan={colSpan}
|
||||
className="px-3 py-1.5 font-semibold text-[var(--muted-foreground)] uppercase text-xs tracking-wider sticky left-0 bg-[var(--muted)]"
|
||||
>
|
||||
{t(SECTION_LABEL_KEY[section.type])}
|
||||
{visibleCategories.map((category) => {
|
||||
const rowTotal = data.data.reduce((sum, d) => sum + ((d as Record<string, unknown>)[category] as number || 0), 0);
|
||||
return (
|
||||
<tr key={category} className="border-b border-[var(--border)]/50">
|
||||
<td className="px-3 py-1.5 sticky left-0 bg-[var(--card)] z-10">
|
||||
<span className="flex items-center gap-2">
|
||||
<span
|
||||
className="w-2.5 h-2.5 rounded-full shrink-0"
|
||||
style={{ backgroundColor: data.colors[category] }}
|
||||
/>
|
||||
{category}
|
||||
</span>
|
||||
</td>
|
||||
{months.map((month) => {
|
||||
const monthData = data.data.find((d) => d.month === month);
|
||||
const value = (monthData as Record<string, unknown>)?.[category] as number || 0;
|
||||
return (
|
||||
<td key={month} className="text-right px-3 py-1.5">
|
||||
{value ? cadFormatter(value) : "—"}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
<td className="text-right px-3 py-1.5 font-semibold border-l border-[var(--border)]/50">
|
||||
{cadFormatter(rowTotal)}
|
||||
</td>
|
||||
</tr>
|
||||
{/* Category rows — neutral levels, not deltas */}
|
||||
{section.categories.map((category) => {
|
||||
const rowTotal = data.data.reduce(
|
||||
(sum, d) => sum + ((d as Record<string, unknown>)[category] as number || 0),
|
||||
);
|
||||
})}
|
||||
<tr className="border-t-2 border-[var(--border)] font-bold text-sm bg-[var(--muted)]/20">
|
||||
<td className="px-3 py-3 sticky left-0 bg-[var(--muted)]/20 z-10">{t("common.total")}</td>
|
||||
{months.map((month) => {
|
||||
const monthData = data.data.find((d) => d.month === month);
|
||||
const monthTotal = visibleCategories.reduce(
|
||||
(sum, cat) => sum + ((monthData as Record<string, unknown>)?.[cat] as number || 0),
|
||||
0,
|
||||
);
|
||||
return (
|
||||
<td key={month} className="text-right px-3 py-3">
|
||||
{cadFormatter(monthTotal)}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
<td className="text-right px-3 py-3 border-l border-[var(--border)]/50">
|
||||
{cadFormatter(
|
||||
visibleCategories.reduce(
|
||||
(sum, cat) => sum + data.data.reduce((s, d) => s + ((d as Record<string, unknown>)[cat] as number || 0), 0),
|
||||
0,
|
||||
);
|
||||
return (
|
||||
<tr key={category} className="border-b border-[var(--border)]/50 hover:bg-[var(--muted)]/40">
|
||||
<td className="px-3 py-1.5 sticky left-0 bg-[var(--card)] z-10">
|
||||
<span className="flex items-center gap-2">
|
||||
<span
|
||||
className="w-2.5 h-2.5 rounded-full shrink-0"
|
||||
style={{ backgroundColor: data.colors[category] }}
|
||||
/>
|
||||
{category}
|
||||
</span>
|
||||
</td>
|
||||
{months.map((month) => {
|
||||
const monthData = data.data.find((d) => d.month === month);
|
||||
const value = (monthData as Record<string, unknown>)?.[category] as number || 0;
|
||||
return (
|
||||
<td key={month} className="text-right px-3 py-1.5 tabular-nums">
|
||||
{value ? cadFormatter(value) : "—"}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
<td className="text-right px-3 py-1.5 font-semibold border-l border-[var(--border)]/50 tabular-nums">
|
||||
{cadFormatter(rowTotal)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
{/* Section subtotal */}
|
||||
<tr className="border-b border-[var(--border)] bg-[color-mix(in_srgb,var(--muted)_40%,var(--card))] font-semibold">
|
||||
<td className="px-3 py-2.5 sticky left-0 bg-[color-mix(in_srgb,var(--muted)_40%,var(--card))] z-10">
|
||||
{t(SECTION_TOTAL_KEY[section.type])}
|
||||
</td>
|
||||
{months.map((month) => (
|
||||
<td key={month} className="text-right px-3 py-2.5 tabular-nums">
|
||||
{cadFormatter(section.monthly[month])}
|
||||
</td>
|
||||
))}
|
||||
<td className="text-right px-3 py-2.5 border-l border-[var(--border)]/50 tabular-nums">
|
||||
{cadFormatter(section.total)}
|
||||
</td>
|
||||
</tr>
|
||||
</Fragment>
|
||||
))}
|
||||
|
||||
{/* Result before transfers — shown only when transfers exist */}
|
||||
{hasTransfers && (
|
||||
<tr className="border-t-2 border-[var(--border)] font-bold 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.resultBeforeTransfers")}
|
||||
</td>
|
||||
{months.map((month) => (
|
||||
<td
|
||||
key={month}
|
||||
className="text-right px-3 py-3 tabular-nums"
|
||||
style={{ color: resultColor(beforeTransfers.monthly[month]) }}
|
||||
>
|
||||
{cadFormatter(beforeTransfers.monthly[month])}
|
||||
</td>
|
||||
))}
|
||||
<td
|
||||
className="text-right px-3 py-3 border-l border-[var(--border)]/50 tabular-nums"
|
||||
style={{ color: resultColor(beforeTransfers.total) }}
|
||||
>
|
||||
{cadFormatter(beforeTransfers.total)}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{/* Net result — bottom line (income − expenses + transfers) */}
|
||||
<tr
|
||||
className={`font-bold bg-[color-mix(in_srgb,var(--muted)_20%,var(--card))] ${
|
||||
hasTransfers ? "border-b border-[var(--border)]" : "border-t-2 border-[var(--border)]"
|
||||
}`}
|
||||
>
|
||||
<td className="px-3 py-3 sticky left-0 bg-[color-mix(in_srgb,var(--muted)_20%,var(--card))] z-10">
|
||||
{t("reports.compare.resultNet")}
|
||||
</td>
|
||||
{months.map((month) => (
|
||||
<td
|
||||
key={month}
|
||||
className="text-right px-3 py-3 tabular-nums"
|
||||
style={{ color: resultColor(net.monthly[month]) }}
|
||||
>
|
||||
{cadFormatter(net.monthly[month])}
|
||||
</td>
|
||||
))}
|
||||
<td
|
||||
className="text-right px-3 py-3 border-l border-[var(--border)]/50 tabular-nums"
|
||||
style={{ color: resultColor(net.total) }}
|
||||
>
|
||||
{cadFormatter(net.total)}
|
||||
),
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
|
|
|
|||
|
|
@ -32,14 +32,21 @@ export default function ComparePeriodChart({
|
|||
}: ComparePeriodChartProps) {
|
||||
const { t, i18n } = useTranslation();
|
||||
|
||||
// The chart stays a spending view: the income-statement result (revenues +
|
||||
// result lines, Issue #253) lives in the table, so keep only expense leaves
|
||||
// here — mixing revenue bars into the same axis would misread. Drop subtotal
|
||||
// (is_parent) rows too so a group is not double-counted against its own
|
||||
// leaves. Sorted by current amount (largest spending first) for a top-down
|
||||
// read of the biggest movers.
|
||||
if (rows.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>
|
||||
);
|
||||
}
|
||||
|
||||
// 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
|
||||
.filter((r) => !r.is_parent && (r.category_type ?? "expense") === "expense")
|
||||
.filter((r) => !r.is_parent)
|
||||
.sort((a, b) => b.currentAmount - a.currentAmount)
|
||||
.map((r) => ({
|
||||
name: r.categoryName,
|
||||
|
|
@ -48,16 +55,6 @@ export default function ComparePeriodChart({
|
|||
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 currentFill = "var(--primary)";
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ import { useTranslation } from "react-i18next";
|
|||
import { ArrowUpDown } from "lucide-react";
|
||||
import type { CategoryDelta } from "../../shared/types";
|
||||
import { reorderRows } from "../../utils/reorderRows";
|
||||
import { type SectionType, type Totals, sumLeaves, pct, computeResults } from "./compareResults";
|
||||
|
||||
export interface ComparePeriodTableProps {
|
||||
rows: CategoryDelta[];
|
||||
|
|
@ -34,30 +33,57 @@ function formatSignedCurrency(amount: number, language: string): string {
|
|||
}).format(amount);
|
||||
}
|
||||
|
||||
function formatPct(pctValue: number | null, language: string): string {
|
||||
if (pctValue === null) return "—";
|
||||
function formatPct(pct: number | null, language: string): string {
|
||||
if (pct === null) return "—";
|
||||
return new Intl.NumberFormat(language === "fr" ? "fr-CA" : "en-CA", {
|
||||
style: "percent",
|
||||
maximumFractionDigits: 1,
|
||||
signDisplay: "always",
|
||||
}).format(pctValue / 100);
|
||||
}).format(pct / 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delta colour, direction-aware (Issue #253). Expenses/transfers keep the
|
||||
* spending convention (increase → red, decrease → green); income and the
|
||||
* result lines invert it (higher is better → green). Also used to colour a
|
||||
* result *amount* by sign: a surplus is green, a deficit red.
|
||||
*/
|
||||
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)";
|
||||
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 "";
|
||||
}
|
||||
|
||||
const STORAGE_KEY = "compare-subtotals-position";
|
||||
|
||||
const COL_COUNT = 9;
|
||||
type SectionType = "expense" | "income" | "transfer";
|
||||
|
||||
/** 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({
|
||||
rows,
|
||||
|
|
@ -86,8 +112,7 @@ export default function ComparePeriodTable({
|
|||
const ytdPrevLabel = cumulativePreviousLabel ?? previousLabel;
|
||||
const ytdCurrLabel = cumulativeCurrentLabel ?? currentLabel;
|
||||
|
||||
// Group rows into contiguous type sections (the service already type-sorts:
|
||||
// income → expense → transfer).
|
||||
// Group rows into contiguous type sections (the service already type-sorts).
|
||||
const sectionLabels: Record<SectionType, string> = {
|
||||
expense: t("reports.compare.sections.expenses"),
|
||||
income: t("reports.compare.sections.income"),
|
||||
|
|
@ -109,197 +134,8 @@ export default function ComparePeriodTable({
|
|||
sections[sections.length - 1].rows.push(row);
|
||||
}
|
||||
|
||||
// Income-statement result lines (Issue #253): revenues − expenses, then the
|
||||
// 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>
|
||||
);
|
||||
};
|
||||
// Grand totals across every leaf.
|
||||
const totals = sumLeaves(rows);
|
||||
|
||||
return (
|
||||
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl overflow-hidden">
|
||||
|
|
@ -369,25 +205,187 @@ export default function ComparePeriodTable({
|
|||
<tbody>
|
||||
{rows.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={COL_COUNT} 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>
|
||||
) : (
|
||||
<>
|
||||
{nonTransferSections.map(renderSection)}
|
||||
{/* Operating result (revenues − expenses), shown before the
|
||||
transfers section only when transfers exist — otherwise it
|
||||
equals the net total below and would just be noise. */}
|
||||
{results.hasTransfers &&
|
||||
renderResultRow(
|
||||
"reports.compare.resultBeforeTransfers",
|
||||
results.resultBefore,
|
||||
false,
|
||||
)}
|
||||
{transferSection && renderSection(transferSection)}
|
||||
{/* Bottom line: result after netting transfers. */}
|
||||
{renderResultRow("reports.compare.resultNet", results.resultNet, true)}
|
||||
{sections.map((section) => {
|
||||
const sectionTotals = sumLeaves(section.rows);
|
||||
return (
|
||||
<Fragment key={section.type}>
|
||||
<tr className="bg-[var(--muted)]">
|
||||
<td
|
||||
colSpan={9}
|
||||
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: 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>
|
||||
|
|
|
|||
|
|
@ -1,87 +0,0 @@
|
|||
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);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,90 +0,0 @@
|
|||
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 };
|
||||
}
|
||||
|
|
@ -1,137 +0,0 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { computeOverTimeResults, OVER_TIME_TYPE_ORDER } from "./overTimeResults";
|
||||
import type { CategoryOverTimeData } from "../../shared/types";
|
||||
|
||||
function makeData(
|
||||
data: CategoryOverTimeData["data"],
|
||||
types: Record<string, "income" | "expense" | "transfer">,
|
||||
): CategoryOverTimeData {
|
||||
return { categories: Object.keys(types), data, colors: {}, categoryIds: {}, types };
|
||||
}
|
||||
|
||||
describe("OVER_TIME_TYPE_ORDER", () => {
|
||||
it("is income-first (income → expense → transfer)", () => {
|
||||
expect(OVER_TIME_TYPE_ORDER).toEqual({ income: 0, expense: 1, transfer: 2 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeOverTimeResults", () => {
|
||||
it("groups categories into ordered sections with per-month + total subtotals", () => {
|
||||
const data = makeData(
|
||||
[
|
||||
{ month: "2025-01", Salary: 3000, Rent: 1000, Groceries: 500, Savings: 200 },
|
||||
{ month: "2025-02", Salary: 3000, Rent: 1000, Groceries: 700, Savings: 200 },
|
||||
],
|
||||
{ Salary: "income", Rent: "expense", Groceries: "expense", Savings: "transfer" },
|
||||
);
|
||||
|
||||
const r = computeOverTimeResults(data, ["Salary", "Rent", "Groceries", "Savings"]);
|
||||
|
||||
expect(r.months).toEqual(["2025-01", "2025-02"]);
|
||||
// Income-first ordering, regardless of the input category order.
|
||||
expect(r.sections.map((s) => s.type)).toEqual(["income", "expense", "transfer"]);
|
||||
|
||||
const income = r.sections.find((s) => s.type === "income")!;
|
||||
const expense = r.sections.find((s) => s.type === "expense")!;
|
||||
const transfer = r.sections.find((s) => s.type === "transfer")!;
|
||||
|
||||
expect(income.monthly).toEqual({ "2025-01": 3000, "2025-02": 3000 });
|
||||
expect(income.total).toBe(6000);
|
||||
expect(expense.categories).toEqual(["Rent", "Groceries"]);
|
||||
expect(expense.monthly).toEqual({ "2025-01": 1500, "2025-02": 1700 });
|
||||
expect(expense.total).toBe(3200);
|
||||
expect(transfer.monthly).toEqual({ "2025-01": 200, "2025-02": 200 });
|
||||
expect(transfer.total).toBe(400);
|
||||
});
|
||||
|
||||
it("computes net (income − expense + transfer) and before-transfers per month", () => {
|
||||
const data = makeData(
|
||||
[
|
||||
{ month: "2025-01", Salary: 3000, Rent: 1000, Groceries: 500, Savings: 200 },
|
||||
{ month: "2025-02", Salary: 3000, Rent: 1000, Groceries: 700, Savings: 200 },
|
||||
],
|
||||
{ Salary: "income", Rent: "expense", Groceries: "expense", Savings: "transfer" },
|
||||
);
|
||||
|
||||
const r = computeOverTimeResults(data, ["Salary", "Rent", "Groceries", "Savings"]);
|
||||
|
||||
expect(r.hasTransfers).toBe(true);
|
||||
// before = income − expense
|
||||
expect(r.beforeTransfers.monthly).toEqual({ "2025-01": 1500, "2025-02": 1300 });
|
||||
expect(r.beforeTransfers.total).toBe(2800);
|
||||
// net = before + transfer
|
||||
expect(r.net.monthly).toEqual({ "2025-01": 1700, "2025-02": 1500 });
|
||||
expect(r.net.total).toBe(3200);
|
||||
});
|
||||
|
||||
it("collapses net to before-transfers when there are no transfers", () => {
|
||||
const data = makeData(
|
||||
[{ month: "2025-01", Salary: 2000, Rent: 2500 }],
|
||||
{ Salary: "income", Rent: "expense" },
|
||||
);
|
||||
|
||||
const r = computeOverTimeResults(data, ["Salary", "Rent"]);
|
||||
|
||||
expect(r.hasTransfers).toBe(false);
|
||||
expect(r.sections.map((s) => s.type)).toEqual(["income", "expense"]);
|
||||
// Deficit: 2000 − 2500 = −500, identical for net and before-transfers.
|
||||
expect(r.beforeTransfers.monthly["2025-01"]).toBe(-500);
|
||||
expect(r.net.monthly["2025-01"]).toBe(-500);
|
||||
expect(r.net.total).toBe(-500);
|
||||
});
|
||||
|
||||
it("defaults an untyped category (e.g. Other) to expense", () => {
|
||||
const data = makeData(
|
||||
[{ month: "2025-01", Salary: 1000, Other: 300 }],
|
||||
{ Salary: "income" }, // Other has no type entry
|
||||
);
|
||||
|
||||
const r = computeOverTimeResults(data, ["Salary", "Other"]);
|
||||
|
||||
const expense = r.sections.find((s) => s.type === "expense")!;
|
||||
expect(expense.categories).toEqual(["Other"]);
|
||||
expect(expense.total).toBe(300);
|
||||
expect(r.net.monthly["2025-01"]).toBe(700); // 1000 − 300
|
||||
});
|
||||
|
||||
it("drops empty sections and still nets a lone transfer section", () => {
|
||||
const data = makeData([{ month: "2025-01", Move: 100 }], { Move: "transfer" });
|
||||
|
||||
const r = computeOverTimeResults(data, ["Move"]);
|
||||
|
||||
expect(r.sections.map((s) => s.type)).toEqual(["transfer"]);
|
||||
expect(r.hasTransfers).toBe(true);
|
||||
expect(r.beforeTransfers.monthly["2025-01"]).toBe(0);
|
||||
expect(r.net.monthly["2025-01"]).toBe(100);
|
||||
});
|
||||
|
||||
it("only counts the categories passed as visible (hidden ones excluded)", () => {
|
||||
const data = makeData(
|
||||
[{ month: "2025-01", Salary: 2000, Rent: 500, Hidden: 999 }],
|
||||
{ Salary: "income", Rent: "expense", Hidden: "expense" },
|
||||
);
|
||||
|
||||
const r = computeOverTimeResults(data, ["Salary", "Rent"]); // Hidden filtered out upstream
|
||||
|
||||
const expense = r.sections.find((s) => s.type === "expense")!;
|
||||
expect(expense.total).toBe(500);
|
||||
expect(r.net.monthly["2025-01"]).toBe(1500); // 2000 − 500
|
||||
});
|
||||
|
||||
it("treats a category absent from a month as zero", () => {
|
||||
const data = makeData(
|
||||
[
|
||||
{ month: "2025-01", Salary: 1000 },
|
||||
{ month: "2025-02", Salary: 1000, Bonus: 500 },
|
||||
],
|
||||
{ Salary: "income", Bonus: "income" },
|
||||
);
|
||||
|
||||
const r = computeOverTimeResults(data, ["Salary", "Bonus"]);
|
||||
|
||||
const income = r.sections.find((s) => s.type === "income")!;
|
||||
expect(income.monthly).toEqual({ "2025-01": 1000, "2025-02": 1500 });
|
||||
expect(r.net.monthly).toEqual({ "2025-01": 1000, "2025-02": 1500 });
|
||||
expect(r.net.total).toBe(2500);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,115 +0,0 @@
|
|||
import type { CategoryOverTimeData, CategoryOverTimeItem } from "../../shared/types";
|
||||
|
||||
export type OverTimeType = "income" | "expense" | "transfer";
|
||||
|
||||
/**
|
||||
* Income-statement reading order: revenue first, then expenses, then transfers,
|
||||
* so the "net result" row reads naturally at the bottom (Income − Expenses +
|
||||
* Transfers). Note this is income-first, unlike the compare report's
|
||||
* `COMPARE_TYPE_ORDER` (expense-first) — see issue #256.
|
||||
*/
|
||||
export const OVER_TIME_TYPE_ORDER: Record<OverTimeType, number> = {
|
||||
income: 0,
|
||||
expense: 1,
|
||||
transfer: 2,
|
||||
};
|
||||
|
||||
/** A month -> value map plus the sum across all its months. */
|
||||
export interface OverTimeSeries {
|
||||
/** month (YYYY-MM) -> value */
|
||||
monthly: Record<string, number>;
|
||||
/** sum across every month */
|
||||
total: number;
|
||||
}
|
||||
|
||||
/** One rendered section: a type, its member categories, and per-month + total subtotals. */
|
||||
export interface OverTimeSection extends OverTimeSeries {
|
||||
type: OverTimeType;
|
||||
categories: string[];
|
||||
}
|
||||
|
||||
export interface OverTimeAnalysis {
|
||||
/** Ordered list of months (mirrors `data.data` order). */
|
||||
months: string[];
|
||||
/** Sections ordered income → expense → transfer; empty types are dropped. */
|
||||
sections: OverTimeSection[];
|
||||
/** True when at least one visible transfer-type category is present. */
|
||||
hasTransfers: boolean;
|
||||
/** Income − Expenses + Transfers, per month (+ grand total). */
|
||||
net: OverTimeSeries;
|
||||
/** Income − Expenses, per month (+ grand total). Only shown when `hasTransfers`. */
|
||||
beforeTransfers: OverTimeSeries;
|
||||
}
|
||||
|
||||
/** Reads a numeric category cell off a pivot row, treating anything non-numeric as 0. */
|
||||
function cellValue(item: CategoryOverTimeItem | undefined, category: string): number {
|
||||
const v = (item as Record<string, unknown> | undefined)?.[category];
|
||||
return typeof v === "number" ? v : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure reducer that turns the category-over-time pivot into an "income statement"
|
||||
* shape: per-type sections with per-month subtotals, plus the net-result rows.
|
||||
*
|
||||
* Values coming from `getCategoryOverTime` are positive magnitudes (`ABS(SUM())`
|
||||
* in SQL), so income/expense/transfer subtotals are all ≥ 0, and the net is
|
||||
* `income − expense + transfer`. A category whose type is unknown (e.g. the
|
||||
* "Other" bucket that aggregates non-top-N categories) defaults to `expense`,
|
||||
* mirroring the `COALESCE(c.type, 'expense')` used by the service.
|
||||
*
|
||||
* Extracted from `CategoryOverTimeTable` and unit-tested independently — the
|
||||
* project has no React render harness, so the arithmetic lives in a pure module.
|
||||
*/
|
||||
export function computeOverTimeResults(
|
||||
data: CategoryOverTimeData,
|
||||
visibleCategories: string[],
|
||||
): OverTimeAnalysis {
|
||||
const months = data.data.map((d) => d.month);
|
||||
const itemByMonth = new Map<string, CategoryOverTimeItem>();
|
||||
for (const item of data.data) itemByMonth.set(item.month, item);
|
||||
|
||||
const typeOf = (cat: string): OverTimeType => {
|
||||
const t = data.types[cat];
|
||||
return t === "income" || t === "transfer" ? t : "expense";
|
||||
};
|
||||
|
||||
// Bucket visible categories by type, preserving their incoming (magnitude) order.
|
||||
const byType: Record<OverTimeType, string[]> = { income: [], expense: [], transfer: [] };
|
||||
for (const cat of visibleCategories) byType[typeOf(cat)].push(cat);
|
||||
|
||||
const buildSection = (type: OverTimeType): OverTimeSection => {
|
||||
const categories = byType[type];
|
||||
const monthly: Record<string, number> = {};
|
||||
let total = 0;
|
||||
for (const month of months) {
|
||||
const item = itemByMonth.get(month);
|
||||
const sum = categories.reduce((s, cat) => s + cellValue(item, cat), 0);
|
||||
monthly[month] = sum;
|
||||
total += sum;
|
||||
}
|
||||
return { type, categories, monthly, total };
|
||||
};
|
||||
|
||||
const income = buildSection("income");
|
||||
const expense = buildSection("expense");
|
||||
const transfer = buildSection("transfer");
|
||||
|
||||
const hasTransfers = transfer.categories.length > 0;
|
||||
|
||||
const net: OverTimeSeries = { monthly: {}, total: 0 };
|
||||
const beforeTransfers: OverTimeSeries = { monthly: {}, total: 0 };
|
||||
for (const month of months) {
|
||||
const before = income.monthly[month] - expense.monthly[month];
|
||||
const netMonth = before + transfer.monthly[month];
|
||||
beforeTransfers.monthly[month] = before;
|
||||
beforeTransfers.total += before;
|
||||
net.monthly[month] = netMonth;
|
||||
net.total += netMonth;
|
||||
}
|
||||
|
||||
const sections = [income, expense, transfer]
|
||||
.filter((s) => s.categories.length > 0)
|
||||
.sort((a, b) => OVER_TIME_TYPE_ORDER[a.type] - OVER_TIME_TYPE_ORDER[b.type]);
|
||||
|
||||
return { months, sections, hasTransfers, net, beforeTransfers };
|
||||
}
|
||||
|
|
@ -1,10 +1,5 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
previousMonth,
|
||||
defaultReferencePeriod,
|
||||
comparisonMeta,
|
||||
syncReferenceOnPeriodChange,
|
||||
} from "./useCompare";
|
||||
import { previousMonth, defaultReferencePeriod, comparisonMeta } from "./useCompare";
|
||||
|
||||
describe("useCompare helpers", () => {
|
||||
describe("previousMonth", () => {
|
||||
|
|
@ -49,40 +44,4 @@ describe("useCompare helpers", () => {
|
|||
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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -60,32 +60,6 @@ export function comparisonMeta(
|
|||
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 initialState: State = {
|
||||
mode: "actual",
|
||||
|
|
@ -145,21 +119,15 @@ export function useCompare() {
|
|||
fetch(state.mode, state.subMode, state.year, state.month);
|
||||
}, [fetch, state.mode, state.subMode, state.year, state.month]);
|
||||
|
||||
// Keep the reference month in sync with the URL period when the user navigates
|
||||
// via PeriodSelector — but not on mount. The ref is seeded with the initial
|
||||
// `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);
|
||||
// When the URL period changes, align the reference month with `to`.
|
||||
// The explicit dropdown remains the primary selector — this effect only
|
||||
// keeps the two in sync when the user navigates via PeriodSelector.
|
||||
useEffect(() => {
|
||||
const { lastSyncedTo, next } = syncReferenceOnPeriodChange(
|
||||
lastSyncedToRef.current,
|
||||
to,
|
||||
state.year,
|
||||
state.month,
|
||||
);
|
||||
lastSyncedToRef.current = lastSyncedTo;
|
||||
if (next) dispatch({ type: "SET_REFERENCE_PERIOD", payload: next });
|
||||
const [y, m] = to.split("-").map(Number);
|
||||
if (!Number.isFinite(y) || !Number.isFinite(m)) return;
|
||||
if (y !== state.year || m !== state.month) {
|
||||
dispatch({ type: "SET_REFERENCE_PERIOD", payload: { year: y, month: m } });
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [to]);
|
||||
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@ const yearStartStr = `${now.getFullYear()}-01-01`;
|
|||
const initialState: DashboardState = {
|
||||
summary: { totalCount: 0, totalAmount: 0, incomeTotal: 0, expenseTotal: 0 },
|
||||
categoryBreakdown: [],
|
||||
categoryOverTime: { categories: [], data: [], colors: {}, categoryIds: {}, types: {} },
|
||||
categoryOverTime: { categories: [], data: [], colors: {}, categoryIds: {} },
|
||||
budgetVsActual: [],
|
||||
period: "year",
|
||||
budgetYear: now.getMonth() === 0 ? now.getFullYear() - 1 : now.getFullYear(),
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ type Action =
|
|||
const initialState: State = {
|
||||
subView: "global",
|
||||
monthlyTrends: [],
|
||||
categoryOverTime: { categories: [], data: [], colors: {}, categoryIds: {}, types: {} },
|
||||
categoryOverTime: { categories: [], data: [], colors: {}, categoryIds: {} },
|
||||
isLoading: false,
|
||||
error: null,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -427,6 +427,7 @@
|
|||
"referenceMonth": "Reference month",
|
||||
"currentAmount": "Current",
|
||||
"previousAmount": "Previous",
|
||||
"totalRow": "Total",
|
||||
"sections": {
|
||||
"expenses": "Expenses",
|
||||
"income": "Income",
|
||||
|
|
@ -434,9 +435,7 @@
|
|||
},
|
||||
"totalExpenses": "Total Expenses",
|
||||
"totalIncome": "Total Income",
|
||||
"totalTransfers": "Total Transfers",
|
||||
"resultBeforeTransfers": "Result before transfers",
|
||||
"resultNet": "Net result"
|
||||
"totalTransfers": "Total Transfers"
|
||||
},
|
||||
"cartes": {
|
||||
"kpiSectionAria": "Key indicators for the reference month",
|
||||
|
|
|
|||
|
|
@ -427,6 +427,7 @@
|
|||
"referenceMonth": "Mois de référence",
|
||||
"currentAmount": "Courant",
|
||||
"previousAmount": "Précédent",
|
||||
"totalRow": "Total",
|
||||
"sections": {
|
||||
"expenses": "Dépenses",
|
||||
"income": "Revenus",
|
||||
|
|
@ -434,9 +435,7 @@
|
|||
},
|
||||
"totalExpenses": "Total des dépenses",
|
||||
"totalIncome": "Total des revenus",
|
||||
"totalTransfers": "Total des transferts",
|
||||
"resultBeforeTransfers": "Résultat avant transferts",
|
||||
"resultNet": "Résultat net"
|
||||
"totalTransfers": "Total des transferts"
|
||||
},
|
||||
"cartes": {
|
||||
"kpiSectionAria": "Indicateurs clés du mois de référence",
|
||||
|
|
|
|||
|
|
@ -235,33 +235,6 @@ describe("getCartesSnapshot", () => {
|
|||
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 () => {
|
||||
// Reference = 2026-03, YTD = Jan + Feb + Mar of 2026.
|
||||
routeSelect([
|
||||
|
|
|
|||
|
|
@ -115,58 +115,33 @@ describe("getCategoryOverTime", () => {
|
|||
expect(topCatParams).toEqual(["expense", "2025-01-01", "2025-06-30", 2, 10]);
|
||||
});
|
||||
|
||||
it("projects category type into the top-N SELECT", async () => {
|
||||
mockSelect
|
||||
.mockResolvedValueOnce([]) // topCategories
|
||||
.mockResolvedValueOnce([]); // monthlyRows
|
||||
|
||||
await getCategoryOverTime();
|
||||
|
||||
const topCatSQL = mockSelect.mock.calls[0][0] as string;
|
||||
expect(topCatSQL).toContain("COALESCE(c.type, 'expense') AS category_type");
|
||||
});
|
||||
|
||||
it("returns correct structure with categories, data, colors, categoryIds, and types", async () => {
|
||||
it("returns correct structure with categories, data, colors, and categoryIds", async () => {
|
||||
mockSelect
|
||||
.mockResolvedValueOnce([
|
||||
{ category_id: 1, category_name: "Food", category_color: "#ff0000", category_type: "expense", total: 500 },
|
||||
{ category_id: 2, category_name: "Salary", category_color: "#00ff00", category_type: "income", total: 200 },
|
||||
{ category_id: 1, category_name: "Food", category_color: "#ff0000", total: 500 },
|
||||
{ category_id: 2, category_name: "Transport", category_color: "#00ff00", total: 200 },
|
||||
])
|
||||
.mockResolvedValueOnce([
|
||||
{ month: "2025-01", category_id: 1, category_name: "Food", total: 300 },
|
||||
{ month: "2025-01", category_id: 2, category_name: "Salary", total: 100 },
|
||||
{ month: "2025-01", category_id: 2, category_name: "Transport", total: 100 },
|
||||
{ month: "2025-02", category_id: 1, category_name: "Food", total: 200 },
|
||||
{ month: "2025-02", category_id: 2, category_name: "Salary", total: 100 },
|
||||
{ month: "2025-02", category_id: 2, category_name: "Transport", total: 100 },
|
||||
]);
|
||||
|
||||
const result = await getCategoryOverTime("2025-01-01", "2025-02-28", 50, undefined, "expense");
|
||||
|
||||
expect(result.categories).toEqual(["Food", "Salary"]);
|
||||
expect(result.colors).toEqual({ Food: "#ff0000", Salary: "#00ff00" });
|
||||
expect(result.categoryIds).toEqual({ Food: 1, Salary: 2 });
|
||||
expect(result.types).toEqual({ Food: "expense", Salary: "income" });
|
||||
expect(result.categories).toEqual(["Food", "Transport"]);
|
||||
expect(result.colors).toEqual({ Food: "#ff0000", Transport: "#00ff00" });
|
||||
expect(result.categoryIds).toEqual({ Food: 1, Transport: 2 });
|
||||
expect(result.data).toHaveLength(2);
|
||||
expect(result.data[0]).toEqual({ month: "2025-01", Food: 300, Salary: 100 });
|
||||
expect(result.data[1]).toEqual({ month: "2025-02", Food: 200, Salary: 100 });
|
||||
expect(result.data[0]).toEqual({ month: "2025-01", Food: 300, Transport: 100 });
|
||||
expect(result.data[1]).toEqual({ month: "2025-02", Food: 200, Transport: 100 });
|
||||
});
|
||||
|
||||
it("defaults a null category type to expense in the types map", async () => {
|
||||
it("groups non-top-N categories into Other", async () => {
|
||||
mockSelect
|
||||
.mockResolvedValueOnce([
|
||||
// COALESCE(c.type, 'expense') yields 'expense'; a null slipping through defaults too.
|
||||
{ category_id: 1, category_name: "Food", category_color: "#ff0000", category_type: null, total: 500 },
|
||||
])
|
||||
.mockResolvedValueOnce([{ month: "2025-01", category_id: 1, category_name: "Food", total: 300 }]);
|
||||
|
||||
const result = await getCategoryOverTime();
|
||||
|
||||
expect(result.types).toEqual({ Food: "expense" });
|
||||
});
|
||||
|
||||
it("groups non-top-N categories into Other (Other left out of the types map)", async () => {
|
||||
mockSelect
|
||||
.mockResolvedValueOnce([
|
||||
{ category_id: 1, category_name: "Food", category_color: "#ff0000", category_type: "expense", total: 500 },
|
||||
{ category_id: 1, category_name: "Food", category_color: "#ff0000", total: 500 },
|
||||
])
|
||||
.mockResolvedValueOnce([
|
||||
{ month: "2025-01", category_id: 1, category_name: "Food", total: 300 },
|
||||
|
|
@ -178,7 +153,6 @@ describe("getCategoryOverTime", () => {
|
|||
|
||||
expect(result.categories).toEqual(["Food", "Other"]);
|
||||
expect(result.colors["Other"]).toBe("#9ca3af");
|
||||
expect(result.types).toEqual({ Food: "expense" });
|
||||
expect(result.data[0]).toEqual({ month: "2025-01", Food: 300, Other: 150 });
|
||||
});
|
||||
});
|
||||
|
|
@ -513,9 +487,8 @@ describe("real-vs-real compare — transfer netting (Issue #243)", () => {
|
|||
// compare. Ordinary categories keep the expense-report behavior (only
|
||||
// outflows, summed as positive magnitudes). The netting lives entirely in
|
||||
// the SQL (COMPARE_DELTA_SQL): a signed SUM(t.amount) for `transfer`,
|
||||
// ABS(t.amount) filtered on amount < 0 for plain expenses. Income, like
|
||||
// transfers, is a signed SUM (Issue #253); the WHERE is broadened so
|
||||
// income/transfer credits are not dropped before they can count or cancel.
|
||||
// ABS(t.amount) filtered on amount < 0 for everything else, with the WHERE
|
||||
// broadened so transfer credits are not dropped before they can cancel.
|
||||
//
|
||||
// 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
|
||||
|
|
@ -526,12 +499,11 @@ describe("real-vs-real compare — transfer netting (Issue #243)", () => {
|
|||
|
||||
/** Per-row contribution to a bucket — the exact rule COMPARE_DELTA_SQL encodes. */
|
||||
function bucketContribution(amount: number, type: string | null): number {
|
||||
const t = type ?? "expense";
|
||||
return t === "transfer" || t === "income"
|
||||
? amount // signed → transfer debit/credit cancel; income credits kept as revenue (#253)
|
||||
return (type ?? "expense") === "transfer"
|
||||
? amount // signed → a debit and its matching credit cancel
|
||||
: amount < 0
|
||||
? Math.abs(amount) // expense outflow as a positive magnitude
|
||||
: 0; // other (expense) credits dropped — the report stays outflow-only for them
|
||||
: 0; // non-transfer credits dropped → no revenues surfaced
|
||||
}
|
||||
|
||||
it("nets a balanced transfer to ~0 while an expense keeps its sum of outflows", () => {
|
||||
|
|
@ -546,11 +518,6 @@ describe("real-vs-real compare — transfer netting (Issue #243)", () => {
|
|||
// Uncategorized (c.type NULL) defaults to expense behavior.
|
||||
const uncategorized = [-40, 15].reduce((s, a) => s + bucketContribution(a, null), 0);
|
||||
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 () => {
|
||||
|
|
@ -561,11 +528,11 @@ describe("real-vs-real compare — transfer netting (Issue #243)", () => {
|
|||
const sql = mockSelect.mock.calls[0][0] as string;
|
||||
// transfer → signed t.amount; every other type → ABS(t.amount)
|
||||
expect(sql).toContain(
|
||||
"COALESCE(c.type, 'expense') IN ('transfer', 'income') THEN t.amount ELSE ABS(t.amount)",
|
||||
"COALESCE(c.type, 'expense') = 'transfer' THEN t.amount ELSE ABS(t.amount)",
|
||||
);
|
||||
// WHERE broadened so transfer credits survive the expense (amount < 0) filter
|
||||
expect(sql).toContain(
|
||||
"WHERE (t.amount < 0 OR COALESCE(c.type, 'expense') IN ('transfer', 'income'))",
|
||||
"WHERE (t.amount < 0 OR COALESCE(c.type, 'expense') = 'transfer')",
|
||||
);
|
||||
// still four date buckets
|
||||
expect(sql).toContain("month_current_total");
|
||||
|
|
@ -579,10 +546,10 @@ describe("real-vs-real compare — transfer netting (Issue #243)", () => {
|
|||
|
||||
const sql = mockSelect.mock.calls[0][0] as string;
|
||||
expect(sql).toContain(
|
||||
"COALESCE(c.type, 'expense') IN ('transfer', 'income') THEN t.amount ELSE ABS(t.amount)",
|
||||
"COALESCE(c.type, 'expense') = 'transfer' THEN t.amount ELSE ABS(t.amount)",
|
||||
);
|
||||
expect(sql).toContain(
|
||||
"WHERE (t.amount < 0 OR COALESCE(c.type, 'expense') IN ('transfer', 'income'))",
|
||||
"WHERE (t.amount < 0 OR COALESCE(c.type, 'expense') = 'transfer')",
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -711,8 +678,6 @@ describe("buildCompareTree — hierarchical real-vs-real (Issue #247)", () => {
|
|||
{ id: 24, name: "Restaurant", color: "#f97316", type: "expense", parent_id: 2 },
|
||||
{ id: 5, name: "Placements", color: null, type: "transfer", parent_id: null },
|
||||
{ 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];
|
||||
|
||||
it("nests leaves under a parent subtotal equal to the sum of its children", () => {
|
||||
|
|
@ -758,14 +723,14 @@ describe("buildCompareTree — hierarchical real-vs-real (Issue #247)", () => {
|
|||
expect(parent.deltaAbs).toBe(0);
|
||||
});
|
||||
|
||||
it("orders sections income → expense → transfer and keeps subtrees contiguous", () => {
|
||||
it("orders sections expense → income → transfer and keeps subtrees contiguous", () => {
|
||||
const rows = buildCompareTree(
|
||||
[leaf(80, "Paie", 4200, 4000), leaf(22, "Épicerie", 500, 400), leaf(50, "REER", 0, 0)],
|
||||
[leaf(22, "Épicerie", 500, 400), leaf(50, "REER", 0, 0)],
|
||||
cats,
|
||||
);
|
||||
const types = rows.map((r) => r.category_type);
|
||||
// Income section first, then expenses, then transfers (Issue #253).
|
||||
expect(types).toEqual(["income", "income", "expense", "expense", "transfer", "transfer"]);
|
||||
// All expense rows precede all transfer rows.
|
||||
expect(types).toEqual(["expense", "expense", "transfer", "transfer"]);
|
||||
});
|
||||
|
||||
it("preserves grand-total invariance vs the flat leaves", () => {
|
||||
|
|
|
|||
|
|
@ -106,14 +106,11 @@ export async function getCategoryOverTime(
|
|||
const whereSQL = whereClauses.length > 0 ? `WHERE ${whereClauses.join(" AND ")}` : "";
|
||||
|
||||
// Get top N categories by total spend
|
||||
const topCategories = await db.select<
|
||||
(CategoryBreakdownItem & { category_type: "expense" | "income" | "transfer" })[]
|
||||
>(
|
||||
const topCategories = await db.select<CategoryBreakdownItem[]>(
|
||||
`SELECT
|
||||
t.category_id,
|
||||
COALESCE(c.name, 'Uncategorized') AS category_name,
|
||||
COALESCE(c.color, '#9ca3af') AS category_color,
|
||||
COALESCE(c.type, 'expense') AS category_type,
|
||||
ABS(SUM(t.amount)) AS total
|
||||
FROM transactions t
|
||||
LEFT JOIN categories c ON t.category_id = c.id
|
||||
|
|
@ -127,11 +124,9 @@ export async function getCategoryOverTime(
|
|||
const topCategoryIds = new Set(topCategories.map((c) => c.category_id));
|
||||
const colors: Record<string, string> = {};
|
||||
const categoryIds: Record<string, number | null> = {};
|
||||
const types: Record<string, "expense" | "income" | "transfer"> = {};
|
||||
for (const cat of topCategories) {
|
||||
colors[cat.category_name] = cat.category_color;
|
||||
categoryIds[cat.category_name] = cat.category_id;
|
||||
types[cat.category_name] = cat.category_type ?? "expense";
|
||||
}
|
||||
|
||||
// Get monthly breakdown for all categories
|
||||
|
|
@ -188,7 +183,6 @@ export async function getCategoryOverTime(
|
|||
data: Array.from(monthMap.values()),
|
||||
colors,
|
||||
categoryIds,
|
||||
types,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -453,9 +447,7 @@ interface CompareCatMeta {
|
|||
|
||||
const COMPARE_CATEGORIES_SQL = `SELECT id, name, color, type, parent_id FROM categories`;
|
||||
|
||||
// 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 };
|
||||
const COMPARE_TYPE_ORDER: Record<string, number> = { expense: 0, income: 1, transfer: 2 };
|
||||
|
||||
/** Depth cap mirroring CATEGORY_TREE_CTE — guards a cyclic parent_id chain. */
|
||||
const MAX_TREE_DEPTH = 5;
|
||||
|
|
@ -633,7 +625,7 @@ export function buildCompareTree(
|
|||
rows.push({ ...d, parent_id: null, is_parent: false, depth: 0, category_type: d.category_type ?? "expense" });
|
||||
}
|
||||
|
||||
// Stable sort by type so sections (income → expense → transfer) are
|
||||
// Stable sort by type so sections (expense → income → transfer) are
|
||||
// contiguous; magnitude/tree order within a type is preserved via the index.
|
||||
const order = new Map<CategoryDelta, number>();
|
||||
rows.forEach((r, i) => order.set(r, i));
|
||||
|
|
@ -657,33 +649,33 @@ function previousMonth(year: number, month: number): { year: number; month: numb
|
|||
* exact same query — only the eight date bounds differ). Four date buckets
|
||||
* ($1..$8): monthly current/previous and cumulative current/previous.
|
||||
*
|
||||
* This report is an INCOME STATEMENT ("analyse de résultat", Issue #253), not a
|
||||
* pure expense report: revenues are surfaced alongside expenses so the compare
|
||||
* can show a result (revenues − expenses). Per-category sign convention:
|
||||
* - `income` and `transfer` categories use a signed SUM(t.amount). Income
|
||||
* 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).
|
||||
* Per-category sign convention (Issue #243):
|
||||
* - `transfer` categories NET via a signed SUM(t.amount), so a balanced
|
||||
* debit/credit pair (e.g. "Paiement CC": -500 out, +500 in) cancels to ~0.
|
||||
* A transfer is a money move, not spending, and must not inflate the
|
||||
* expense figures.
|
||||
* - Every other type keeps the expense-report behavior: only outflows
|
||||
* (amount < 0) count, summed as positive magnitudes via ABS.
|
||||
*
|
||||
* The WHERE admits outflows OR any income/transfer row, so income and transfer
|
||||
* *credits* survive the `amount < 0` filter — without it a pure-income category
|
||||
* would be dropped entirely and a transfer could never net to zero. Expense
|
||||
* rows still contribute only their outflows, byte-identical to before. The two
|
||||
* result lines (before/after transfers) are computed in ComparePeriodTable from
|
||||
* the per-type subtotals, not here. Uncategorized rows (c.type NULL) → 'expense'.
|
||||
* The WHERE is broadened with `OR COALESCE(c.type, 'expense') = 'transfer'` so
|
||||
* transfer *credits* survive the `amount < 0` expense filter and can actually
|
||||
* cancel their debits — without it the credit leg would be dropped and the
|
||||
* category could never net to zero. For non-transfer rows the WHERE still
|
||||
* admits only outflows, so their totals are byte-identical to the previous
|
||||
* behavior (no revenues surfaced, no zero-rows for pure-income categories).
|
||||
* Uncategorized rows (LEFT JOIN → c.type NULL) default to 'expense'.
|
||||
*/
|
||||
const COMPARE_DELTA_SQL = `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 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') 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') 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') IN ('transfer', 'income') THEN t.amount ELSE ABS(t.amount) END ELSE 0 END), 0) AS cumulative_previous_total
|
||||
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 >= $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 >= $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 >= $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
|
||||
FROM transactions t
|
||||
LEFT JOIN categories c ON t.category_id = c.id
|
||||
WHERE (t.amount < 0 OR COALESCE(c.type, 'expense') IN ('transfer', 'income'))
|
||||
WHERE (t.amount < 0 OR COALESCE(c.type, 'expense') = 'transfer')
|
||||
AND (
|
||||
(t.date >= $1 AND t.date <= $2)
|
||||
OR (t.date >= $3 AND t.date <= $4)
|
||||
|
|
@ -1176,20 +1168,12 @@ export async function getCartesSnapshot(
|
|||
|
||||
// Top movers: biggest MoM increases / decreases. `momRows` now carries the
|
||||
// compare hierarchy (Issue #247) — skip the subtotal (`is_parent`) rows so a
|
||||
// parent group can't double-count against its own leaves. Keep only EXPENSE
|
||||
// leaves: since Issue #253 broadened COMPARE_DELTA_SQL to surface income (and
|
||||
// net transfers), momRows now also carries revenue rows — and this card is a
|
||||
// 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.
|
||||
// parent group can't double-count against its own leaves. The surviving leaves
|
||||
// are byte-identical to the previous flat output; the sort/slice below is
|
||||
// unchanged. `momRows` are sorted by absolute delta already; filter out
|
||||
// near-zero noise and split by sign.
|
||||
const significantMovers = momRows.filter(
|
||||
(r) =>
|
||||
!r.is_parent &&
|
||||
(r.category_type ?? "expense") === "expense" &&
|
||||
r.deltaAbs !== 0 &&
|
||||
(r.previousAmount > 0 || r.currentAmount > 0),
|
||||
(r) => !r.is_parent && 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
|
||||
|
|
|
|||
|
|
@ -372,8 +372,6 @@ export interface CategoryOverTimeData {
|
|||
data: CategoryOverTimeItem[];
|
||||
colors: Record<string, string>;
|
||||
categoryIds: Record<string, number | null>;
|
||||
/** Category name -> its type. Built from the top-N rows (mirrors `colors`). */
|
||||
types: Record<string, "expense" | "income" | "transfer">;
|
||||
}
|
||||
|
||||
export interface BudgetVsActualRow {
|
||||
|
|
|
|||
Loading…
Reference in a new issue