diff --git a/CHANGELOG.fr.md b/CHANGELOG.fr.md index 719c4c6..ed8bc9a 100644 --- a/CHANGELOG.fr.md +++ b/CHANGELOG.fr.md @@ -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 : 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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 7eeedea..ec7b121 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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: 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 diff --git a/src/components/reports/CategoryOverTimeTable.tsx b/src/components/reports/CategoryOverTimeTable.tsx index 03e04b7..985226a 100644 --- a/src/components/reports/CategoryOverTimeTable.tsx +++ b/src/components/reports/CategoryOverTimeTable.tsx @@ -1,5 +1,7 @@ +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); @@ -10,6 +12,25 @@ 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 = { + income: "reports.compare.sections.income", + expense: "reports.compare.sections.expenses", + transfer: "reports.compare.sections.transfers", +}; + +const SECTION_TOTAL_KEY: Record = { + income: "reports.compare.totalIncome", + expense: "reports.compare.totalExpenses", + transfer: "reports.compare.totalTransfers", +}; + interface CategoryOverTimeTableProps { data: CategoryOverTimeData; hiddenCategories?: Set; @@ -30,7 +51,8 @@ export default function CategoryOverTimeTable({ data, hiddenCategories }: Catego ? data.categories.filter((name) => !hiddenCategories.has(name)) : 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 (
@@ -52,55 +74,112 @@ export default function CategoryOverTimeTable({ data, hiddenCategories }: Catego - {visibleCategories.map((category) => { - const rowTotal = data.data.reduce((sum, d) => sum + ((d as Record)[category] as number || 0), 0); - return ( - - - - - {category} - - - {months.map((month) => { - const monthData = data.data.find((d) => d.month === month); - const value = (monthData as Record)?.[category] as number || 0; - return ( - - {value ? cadFormatter(value) : "—"} - - ); - })} - - {cadFormatter(rowTotal)} + {sections.map((section) => ( + + {/* Section header */} + + + {t(SECTION_LABEL_KEY[section.type])} - ); - })} - - {t("common.total")} - {months.map((month) => { - const monthData = data.data.find((d) => d.month === month); - const monthTotal = visibleCategories.reduce( - (sum, cat) => sum + ((monthData as Record)?.[cat] as number || 0), - 0, - ); - return ( - - {cadFormatter(monthTotal)} - - ); - })} - - {cadFormatter( - visibleCategories.reduce( - (sum, cat) => sum + data.data.reduce((s, d) => s + ((d as Record)[cat] as number || 0), 0), + {/* Category rows — neutral levels, not deltas */} + {section.categories.map((category) => { + const rowTotal = data.data.reduce( + (sum, d) => sum + ((d as Record)[category] as number || 0), 0, - ), - )} + ); + return ( + + + + + {category} + + + {months.map((month) => { + const monthData = data.data.find((d) => d.month === month); + const value = (monthData as Record)?.[category] as number || 0; + return ( + + {value ? cadFormatter(value) : "—"} + + ); + })} + + {cadFormatter(rowTotal)} + + + ); + })} + {/* Section subtotal */} + + + {t(SECTION_TOTAL_KEY[section.type])} + + {months.map((month) => ( + + {cadFormatter(section.monthly[month])} + + ))} + + {cadFormatter(section.total)} + + + + ))} + + {/* Result before transfers — shown only when transfers exist */} + {hasTransfers && ( + + + {t("reports.compare.resultBeforeTransfers")} + + {months.map((month) => ( + + {cadFormatter(beforeTransfers.monthly[month])} + + ))} + + {cadFormatter(beforeTransfers.total)} + + + )} + {/* Net result — bottom line (income − expenses + transfers) */} + + + {t("reports.compare.resultNet")} + + {months.map((month) => ( + + {cadFormatter(net.monthly[month])} + + ))} + + {cadFormatter(net.total)} diff --git a/src/components/reports/overTimeResults.test.ts b/src/components/reports/overTimeResults.test.ts new file mode 100644 index 0000000..07622ba --- /dev/null +++ b/src/components/reports/overTimeResults.test.ts @@ -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, +): 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); + }); +}); diff --git a/src/components/reports/overTimeResults.ts b/src/components/reports/overTimeResults.ts new file mode 100644 index 0000000..d94bb52 --- /dev/null +++ b/src/components/reports/overTimeResults.ts @@ -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 = { + 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; + /** 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 | 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(); + 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 = { 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 = {}; + 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 }; +} diff --git a/src/hooks/useDashboard.ts b/src/hooks/useDashboard.ts index 1611d98..afbdbf8 100644 --- a/src/hooks/useDashboard.ts +++ b/src/hooks/useDashboard.ts @@ -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: {} }, + categoryOverTime: { categories: [], data: [], colors: {}, categoryIds: {}, types: {} }, budgetVsActual: [], period: "year", budgetYear: now.getMonth() === 0 ? now.getFullYear() - 1 : now.getFullYear(), diff --git a/src/hooks/useTrends.ts b/src/hooks/useTrends.ts index 8edecb8..bf1ba2e 100644 --- a/src/hooks/useTrends.ts +++ b/src/hooks/useTrends.ts @@ -23,7 +23,7 @@ type Action = const initialState: State = { subView: "global", monthlyTrends: [], - categoryOverTime: { categories: [], data: [], colors: {}, categoryIds: {} }, + categoryOverTime: { categories: [], data: [], colors: {}, categoryIds: {}, types: {} }, isLoading: false, error: null, }; diff --git a/src/services/reportService.test.ts b/src/services/reportService.test.ts index a4373bf..df2bea9 100644 --- a/src/services/reportService.test.ts +++ b/src/services/reportService.test.ts @@ -115,33 +115,58 @@ describe("getCategoryOverTime", () => { 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 .mockResolvedValueOnce([ - { category_id: 1, category_name: "Food", category_color: "#ff0000", total: 500 }, - { category_id: 2, category_name: "Transport", category_color: "#00ff00", total: 200 }, + { 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 }, ]) .mockResolvedValueOnce([ { 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: 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"); - expect(result.categories).toEqual(["Food", "Transport"]); - expect(result.colors).toEqual({ Food: "#ff0000", Transport: "#00ff00" }); - expect(result.categoryIds).toEqual({ Food: 1, Transport: 2 }); + 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.data).toHaveLength(2); - expect(result.data[0]).toEqual({ month: "2025-01", Food: 300, Transport: 100 }); - expect(result.data[1]).toEqual({ month: "2025-02", Food: 200, Transport: 100 }); + expect(result.data[0]).toEqual({ month: "2025-01", Food: 300, Salary: 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 .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([ { 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.colors["Other"]).toBe("#9ca3af"); + expect(result.types).toEqual({ Food: "expense" }); expect(result.data[0]).toEqual({ month: "2025-01", Food: 300, Other: 150 }); }); }); diff --git a/src/services/reportService.ts b/src/services/reportService.ts index 49b2e07..7e1af0a 100644 --- a/src/services/reportService.ts +++ b/src/services/reportService.ts @@ -106,11 +106,14 @@ 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( + const topCategories = await db.select< + (CategoryBreakdownItem & { category_type: "expense" | "income" | "transfer" })[] + >( `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 @@ -124,9 +127,11 @@ export async function getCategoryOverTime( const topCategoryIds = new Set(topCategories.map((c) => c.category_id)); const colors: Record = {}; const categoryIds: Record = {}; + const types: Record = {}; 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 @@ -135,6 +140,7 @@ export async function getCategoryOverTime( month: string; category_id: number | null; category_name: string; + category_type: "expense" | "income" | "transfer"; total: number; }> >( @@ -142,6 +148,7 @@ export async function getCategoryOverTime( strftime('%Y-%m', t.date) AS month, t.category_id, COALESCE(c.name, 'Uncategorized') AS category_name, + 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 @@ -183,6 +190,7 @@ export async function getCategoryOverTime( data: Array.from(monthMap.values()), colors, categoryIds, + types, }; } diff --git a/src/shared/types/index.ts b/src/shared/types/index.ts index 444d6f5..947c8dd 100644 --- a/src/shared/types/index.ts +++ b/src/shared/types/index.ts @@ -372,6 +372,8 @@ export interface CategoryOverTimeData { data: CategoryOverTimeItem[]; colors: Record; categoryIds: Record; + /** Category name -> its type. Built from the top-N rows (mirrors `colors`). */ + types: Record; } export interface BudgetVsActualRow {