# Conflicts: # .gitignore # CHANGELOG.fr.md # CHANGELOG.md
This commit is contained in:
commit
44976767d1
10 changed files with 431 additions and 62 deletions
|
|
@ -6,6 +6,7 @@
|
||||||
|
|
||||||
- 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 → 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 : 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
|
## [0.11.0] - 2026-07-05
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@
|
||||||
|
|
||||||
- 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 → 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: 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
|
## [0.11.0] - 2026-07-05
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
|
import { Fragment } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import type { CategoryOverTimeData } from "../../shared/types";
|
import type { CategoryOverTimeData } from "../../shared/types";
|
||||||
|
import { computeOverTimeResults, type OverTimeType } from "./overTimeResults";
|
||||||
|
|
||||||
const cadFormatter = (value: number) =>
|
const cadFormatter = (value: number) =>
|
||||||
new Intl.NumberFormat("en-CA", { style: "currency", currency: "CAD", maximumFractionDigits: 0 }).format(value);
|
new Intl.NumberFormat("en-CA", { style: "currency", currency: "CAD", maximumFractionDigits: 0 }).format(value);
|
||||||
|
|
@ -10,6 +12,25 @@ function formatMonth(month: string): string {
|
||||||
return date.toLocaleDateString("default", { month: "short", year: "2-digit" });
|
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 {
|
interface CategoryOverTimeTableProps {
|
||||||
data: CategoryOverTimeData;
|
data: CategoryOverTimeData;
|
||||||
hiddenCategories?: Set<string>;
|
hiddenCategories?: Set<string>;
|
||||||
|
|
@ -30,7 +51,8 @@ export default function CategoryOverTimeTable({ data, hiddenCategories }: Catego
|
||||||
? data.categories.filter((name) => !hiddenCategories.has(name))
|
? data.categories.filter((name) => !hiddenCategories.has(name))
|
||||||
: data.categories;
|
: data.categories;
|
||||||
|
|
||||||
const months = data.data.map((d) => d.month);
|
const { months, sections, hasTransfers, net, beforeTransfers } = computeOverTimeResults(data, visibleCategories);
|
||||||
|
const colSpan = months.length + 2;
|
||||||
|
|
||||||
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">
|
||||||
|
|
@ -52,10 +74,25 @@ export default function CategoryOverTimeTable({ data, hiddenCategories }: Catego
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{visibleCategories.map((category) => {
|
{sections.map((section) => (
|
||||||
const rowTotal = data.data.reduce((sum, d) => sum + ((d as Record<string, unknown>)[category] as number || 0), 0);
|
<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])}
|
||||||
|
</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),
|
||||||
|
0,
|
||||||
|
);
|
||||||
return (
|
return (
|
||||||
<tr key={category} className="border-b border-[var(--border)]/50">
|
<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">
|
<td className="px-3 py-1.5 sticky left-0 bg-[var(--card)] z-10">
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
<span
|
<span
|
||||||
|
|
@ -69,38 +106,80 @@ export default function CategoryOverTimeTable({ data, hiddenCategories }: Catego
|
||||||
const monthData = data.data.find((d) => d.month === month);
|
const monthData = data.data.find((d) => d.month === month);
|
||||||
const value = (monthData as Record<string, unknown>)?.[category] as number || 0;
|
const value = (monthData as Record<string, unknown>)?.[category] as number || 0;
|
||||||
return (
|
return (
|
||||||
<td key={month} className="text-right px-3 py-1.5">
|
<td key={month} className="text-right px-3 py-1.5 tabular-nums">
|
||||||
{value ? cadFormatter(value) : "—"}
|
{value ? cadFormatter(value) : "—"}
|
||||||
</td>
|
</td>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
<td className="text-right px-3 py-1.5 font-semibold border-l border-[var(--border)]/50">
|
<td className="text-right px-3 py-1.5 font-semibold border-l border-[var(--border)]/50 tabular-nums">
|
||||||
{cadFormatter(rowTotal)}
|
{cadFormatter(rowTotal)}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
<tr className="border-t-2 border-[var(--border)] font-bold text-sm bg-[var(--muted)]/20">
|
{/* Section subtotal */}
|
||||||
<td className="px-3 py-3 sticky left-0 bg-[var(--muted)]/20 z-10">{t("common.total")}</td>
|
<tr className="border-b border-[var(--border)] bg-[color-mix(in_srgb,var(--muted)_40%,var(--card))] font-semibold">
|
||||||
{months.map((month) => {
|
<td className="px-3 py-2.5 sticky left-0 bg-[color-mix(in_srgb,var(--muted)_40%,var(--card))] z-10">
|
||||||
const monthData = data.data.find((d) => d.month === month);
|
{t(SECTION_TOTAL_KEY[section.type])}
|
||||||
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>
|
||||||
);
|
{months.map((month) => (
|
||||||
})}
|
<td key={month} className="text-right px-3 py-2.5 tabular-nums">
|
||||||
<td className="text-right px-3 py-3 border-l border-[var(--border)]/50">
|
{cadFormatter(section.monthly[month])}
|
||||||
{cadFormatter(
|
</td>
|
||||||
visibleCategories.reduce(
|
))}
|
||||||
(sum, cat) => sum + data.data.reduce((s, d) => s + ((d as Record<string, unknown>)[cat] as number || 0), 0),
|
<td className="text-right px-3 py-2.5 border-l border-[var(--border)]/50 tabular-nums">
|
||||||
0,
|
{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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|
|
||||||
137
src/components/reports/overTimeResults.test.ts
Normal file
137
src/components/reports/overTimeResults.test.ts
Normal file
|
|
@ -0,0 +1,137 @@
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
115
src/components/reports/overTimeResults.ts
Normal file
115
src/components/reports/overTimeResults.ts
Normal file
|
|
@ -0,0 +1,115 @@
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
|
@ -51,7 +51,7 @@ const yearStartStr = `${now.getFullYear()}-01-01`;
|
||||||
const initialState: DashboardState = {
|
const initialState: DashboardState = {
|
||||||
summary: { totalCount: 0, totalAmount: 0, incomeTotal: 0, expenseTotal: 0 },
|
summary: { totalCount: 0, totalAmount: 0, incomeTotal: 0, expenseTotal: 0 },
|
||||||
categoryBreakdown: [],
|
categoryBreakdown: [],
|
||||||
categoryOverTime: { categories: [], data: [], colors: {}, categoryIds: {} },
|
categoryOverTime: { categories: [], data: [], colors: {}, categoryIds: {}, types: {} },
|
||||||
budgetVsActual: [],
|
budgetVsActual: [],
|
||||||
period: "year",
|
period: "year",
|
||||||
budgetYear: now.getMonth() === 0 ? now.getFullYear() - 1 : now.getFullYear(),
|
budgetYear: now.getMonth() === 0 ? now.getFullYear() - 1 : now.getFullYear(),
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ type Action =
|
||||||
const initialState: State = {
|
const initialState: State = {
|
||||||
subView: "global",
|
subView: "global",
|
||||||
monthlyTrends: [],
|
monthlyTrends: [],
|
||||||
categoryOverTime: { categories: [], data: [], colors: {}, categoryIds: {} },
|
categoryOverTime: { categories: [], data: [], colors: {}, categoryIds: {}, types: {} },
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
error: null,
|
error: null,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -115,33 +115,58 @@ describe("getCategoryOverTime", () => {
|
||||||
expect(topCatParams).toEqual(["expense", "2025-01-01", "2025-06-30", 2, 10]);
|
expect(topCatParams).toEqual(["expense", "2025-01-01", "2025-06-30", 2, 10]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns correct structure with categories, data, colors, and categoryIds", async () => {
|
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 () => {
|
||||||
mockSelect
|
mockSelect
|
||||||
.mockResolvedValueOnce([
|
.mockResolvedValueOnce([
|
||||||
{ category_id: 1, category_name: "Food", category_color: "#ff0000", total: 500 },
|
{ category_id: 1, category_name: "Food", category_color: "#ff0000", category_type: "expense", total: 500 },
|
||||||
{ category_id: 2, category_name: "Transport", category_color: "#00ff00", total: 200 },
|
{ category_id: 2, category_name: "Salary", category_color: "#00ff00", category_type: "income", total: 200 },
|
||||||
])
|
])
|
||||||
.mockResolvedValueOnce([
|
.mockResolvedValueOnce([
|
||||||
{ month: "2025-01", category_id: 1, category_name: "Food", total: 300 },
|
{ month: "2025-01", category_id: 1, category_name: "Food", total: 300 },
|
||||||
{ month: "2025-01", category_id: 2, category_name: "Transport", total: 100 },
|
{ month: "2025-01", category_id: 2, category_name: "Salary", total: 100 },
|
||||||
{ month: "2025-02", category_id: 1, category_name: "Food", total: 200 },
|
{ month: "2025-02", category_id: 1, category_name: "Food", total: 200 },
|
||||||
{ month: "2025-02", category_id: 2, category_name: "Transport", total: 100 },
|
{ month: "2025-02", category_id: 2, category_name: "Salary", total: 100 },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const result = await getCategoryOverTime("2025-01-01", "2025-02-28", 50, undefined, "expense");
|
const result = await getCategoryOverTime("2025-01-01", "2025-02-28", 50, undefined, "expense");
|
||||||
|
|
||||||
expect(result.categories).toEqual(["Food", "Transport"]);
|
expect(result.categories).toEqual(["Food", "Salary"]);
|
||||||
expect(result.colors).toEqual({ Food: "#ff0000", Transport: "#00ff00" });
|
expect(result.colors).toEqual({ Food: "#ff0000", Salary: "#00ff00" });
|
||||||
expect(result.categoryIds).toEqual({ Food: 1, Transport: 2 });
|
expect(result.categoryIds).toEqual({ Food: 1, Salary: 2 });
|
||||||
|
expect(result.types).toEqual({ Food: "expense", Salary: "income" });
|
||||||
expect(result.data).toHaveLength(2);
|
expect(result.data).toHaveLength(2);
|
||||||
expect(result.data[0]).toEqual({ month: "2025-01", Food: 300, Transport: 100 });
|
expect(result.data[0]).toEqual({ month: "2025-01", Food: 300, Salary: 100 });
|
||||||
expect(result.data[1]).toEqual({ month: "2025-02", Food: 200, Transport: 100 });
|
expect(result.data[1]).toEqual({ month: "2025-02", Food: 200, Salary: 100 });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("groups non-top-N categories into Other", async () => {
|
it("defaults a null category type to expense in the types map", async () => {
|
||||||
mockSelect
|
mockSelect
|
||||||
.mockResolvedValueOnce([
|
.mockResolvedValueOnce([
|
||||||
{ category_id: 1, category_name: "Food", category_color: "#ff0000", total: 500 },
|
// 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 },
|
||||||
])
|
])
|
||||||
.mockResolvedValueOnce([
|
.mockResolvedValueOnce([
|
||||||
{ month: "2025-01", category_id: 1, category_name: "Food", total: 300 },
|
{ month: "2025-01", category_id: 1, category_name: "Food", total: 300 },
|
||||||
|
|
@ -153,6 +178,7 @@ describe("getCategoryOverTime", () => {
|
||||||
|
|
||||||
expect(result.categories).toEqual(["Food", "Other"]);
|
expect(result.categories).toEqual(["Food", "Other"]);
|
||||||
expect(result.colors["Other"]).toBe("#9ca3af");
|
expect(result.colors["Other"]).toBe("#9ca3af");
|
||||||
|
expect(result.types).toEqual({ Food: "expense" });
|
||||||
expect(result.data[0]).toEqual({ month: "2025-01", Food: 300, Other: 150 });
|
expect(result.data[0]).toEqual({ month: "2025-01", Food: 300, Other: 150 });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -106,11 +106,14 @@ export async function getCategoryOverTime(
|
||||||
const whereSQL = whereClauses.length > 0 ? `WHERE ${whereClauses.join(" AND ")}` : "";
|
const whereSQL = whereClauses.length > 0 ? `WHERE ${whereClauses.join(" AND ")}` : "";
|
||||||
|
|
||||||
// Get top N categories by total spend
|
// Get top N categories by total spend
|
||||||
const topCategories = await db.select<CategoryBreakdownItem[]>(
|
const topCategories = await db.select<
|
||||||
|
(CategoryBreakdownItem & { category_type: "expense" | "income" | "transfer" })[]
|
||||||
|
>(
|
||||||
`SELECT
|
`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(c.type, 'expense') AS category_type,
|
||||||
ABS(SUM(t.amount)) AS total
|
ABS(SUM(t.amount)) AS 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
|
||||||
|
|
@ -124,9 +127,11 @@ export async function getCategoryOverTime(
|
||||||
const topCategoryIds = new Set(topCategories.map((c) => c.category_id));
|
const topCategoryIds = new Set(topCategories.map((c) => c.category_id));
|
||||||
const colors: Record<string, string> = {};
|
const colors: Record<string, string> = {};
|
||||||
const categoryIds: Record<string, number | null> = {};
|
const categoryIds: Record<string, number | null> = {};
|
||||||
|
const types: Record<string, "expense" | "income" | "transfer"> = {};
|
||||||
for (const cat of topCategories) {
|
for (const cat of topCategories) {
|
||||||
colors[cat.category_name] = cat.category_color;
|
colors[cat.category_name] = cat.category_color;
|
||||||
categoryIds[cat.category_name] = cat.category_id;
|
categoryIds[cat.category_name] = cat.category_id;
|
||||||
|
types[cat.category_name] = cat.category_type ?? "expense";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get monthly breakdown for all categories
|
// Get monthly breakdown for all categories
|
||||||
|
|
@ -135,6 +140,7 @@ export async function getCategoryOverTime(
|
||||||
month: string;
|
month: string;
|
||||||
category_id: number | null;
|
category_id: number | null;
|
||||||
category_name: string;
|
category_name: string;
|
||||||
|
category_type: "expense" | "income" | "transfer";
|
||||||
total: number;
|
total: number;
|
||||||
}>
|
}>
|
||||||
>(
|
>(
|
||||||
|
|
@ -142,6 +148,7 @@ export async function getCategoryOverTime(
|
||||||
strftime('%Y-%m', t.date) AS month,
|
strftime('%Y-%m', t.date) AS month,
|
||||||
t.category_id,
|
t.category_id,
|
||||||
COALESCE(c.name, 'Uncategorized') AS category_name,
|
COALESCE(c.name, 'Uncategorized') AS category_name,
|
||||||
|
COALESCE(c.type, 'expense') AS category_type,
|
||||||
ABS(SUM(t.amount)) AS total
|
ABS(SUM(t.amount)) AS 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
|
||||||
|
|
@ -183,6 +190,7 @@ export async function getCategoryOverTime(
|
||||||
data: Array.from(monthMap.values()),
|
data: Array.from(monthMap.values()),
|
||||||
colors,
|
colors,
|
||||||
categoryIds,
|
categoryIds,
|
||||||
|
types,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -372,6 +372,8 @@ export interface CategoryOverTimeData {
|
||||||
data: CategoryOverTimeItem[];
|
data: CategoryOverTimeItem[];
|
||||||
colors: Record<string, string>;
|
colors: Record<string, string>;
|
||||||
categoryIds: Record<string, number | null>;
|
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 {
|
export interface BudgetVsActualRow {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue