From dbe249783e076d69ce217614e9f143b717e36340 Mon Sep 17 00:00:00 2001 From: medic-bot Date: Mon, 9 Mar 2026 01:10:46 -0400 Subject: [PATCH 1/4] fix: display level 4+ categories under their parent in dashboard budget table (#23) - Replace flat alphabetical sort with tree-order traversal so child categories appear directly under their parent subtotal row - Make category hierarchy recursive (supports arbitrary depth) - Reduce pie chart width from 1/2 to 1/3 of the dashboard - Show pie chart labels only on hover via tooltip Co-Authored-By: Claude Opus 4.6 --- CHANGELOG.fr.md | 8 ++ CHANGELOG.md | 8 ++ src/components/budget/BudgetTable.tsx | 2 +- src/components/dashboard/CategoryPieChart.tsx | 39 ++---- .../reports/BudgetVsActualTable.tsx | 4 +- src/pages/DashboardPage.tsx | 6 +- src/services/budgetService.ts | 123 +++++++----------- src/shared/types/index.ts | 4 +- 8 files changed, 77 insertions(+), 117 deletions(-) diff --git a/CHANGELOG.fr.md b/CHANGELOG.fr.md index 7066584..ba58876 100644 --- a/CHANGELOG.fr.md +++ b/CHANGELOG.fr.md @@ -5,6 +5,14 @@ ### Ajouté - Tableau de budget : colonne du total de l'année précédente affichée comme première colonne de données pour servir de référence (#16) +### Corrigé +- Tableau de bord : les catégories de niveau 4+ apparaissent maintenant sous leur parent au lieu du bas de la section (#23) +- Tableau de bord : la hiérarchie de catégories supporte maintenant une profondeur de niveaux arbitraire (#23) + +### Modifié +- Tableau de bord : le graphique circulaire prend 1/3 de la largeur au lieu de 1/2, donnant plus d'espace au tableau budget (#23) +- Tableau de bord : les étiquettes du graphique circulaire s'affichent uniquement au survol via le tooltip (#23) + ## [0.6.3] ### Ajouté diff --git a/CHANGELOG.md b/CHANGELOG.md index edd44d6..dd0c18a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,14 @@ ### Added - Budget table: previous year total column displayed as first data column for baseline reference (#16) +### Fixed +- Dashboard: level 4+ categories now appear under their parent instead of at the bottom of the section (#23) +- Dashboard: category hierarchy now supports arbitrary nesting depth (#23) + +### Changed +- Dashboard: pie chart takes 1/3 width instead of 1/2, giving more space to the budget table (#23) +- Dashboard: pie chart labels now shown only on hover via tooltip instead of permanent legend (#23) + ## [0.6.3] ### Added diff --git a/src/components/budget/BudgetTable.tsx b/src/components/budget/BudgetTable.tsx index c7666c3..6ed81b7 100644 --- a/src/components/budget/BudgetTable.tsx +++ b/src/components/budget/BudgetTable.tsx @@ -18,7 +18,7 @@ const MONTH_KEYS = [ const STORAGE_KEY = "subtotals-position"; -function reorderRows( +function reorderRows( rows: T[], subtotalsOnTop: boolean, ): T[] { diff --git a/src/components/dashboard/CategoryPieChart.tsx b/src/components/dashboard/CategoryPieChart.tsx index 2fe8195..328f78f 100644 --- a/src/components/dashboard/CategoryPieChart.tsx +++ b/src/components/dashboard/CategoryPieChart.tsx @@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next"; import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip } from "recharts"; import { Eye } from "lucide-react"; import type { CategoryBreakdownItem } from "../../shared/types"; -import { ChartPatternDefs, getPatternFill, PatternSwatch } from "../../utils/chartPatterns"; +import { ChartPatternDefs, getPatternFill } from "../../utils/chartPatterns"; import ChartContextMenu from "../shared/ChartContextMenu"; interface CategoryPieChartProps { @@ -67,7 +67,7 @@ export default function CategoryPieChart({ )}
- + {visibleData.map((item, index) => ( @@ -94,9 +94,11 @@ export default function CategoryPieChart({ ))} - new Intl.NumberFormat("en-CA", { style: "currency", currency: "CAD" }).format(Number(value)) - } + formatter={(value) => { + const formatted = new Intl.NumberFormat("en-CA", { style: "currency", currency: "CAD" }).format(Number(value)); + const pct = total > 0 ? ` (${Math.round((Number(value) / total) * 100)}%)` : ""; + return `${formatted}${pct}`; + }} contentStyle={{ backgroundColor: "var(--card)", border: "1px solid var(--border)", @@ -110,29 +112,6 @@ export default function CategoryPieChart({
-
- {data.map((item, index) => { - const isHidden = hiddenCategories.has(item.category_name); - return ( - - ); - })} -
- {contextMenu && ( ( +function reorderRows( rows: T[], subtotalsOnTop: boolean, ): T[] { @@ -215,7 +215,7 @@ export default function BudgetVsActualTable({ data }: BudgetVsActualTableProps) const isParent = row.is_parent; const depth = row.depth ?? (row.parent_id !== null && !row.is_parent ? 1 : 0); const isIntermediateParent = isParent && depth === 1; - const paddingClass = depth === 2 ? "pl-14" : depth === 1 ? "pl-8" : "px-3"; + const paddingClass = depth >= 3 ? "pl-20" : depth === 2 ? "pl-14" : depth === 1 ? "pl-8" : "px-3"; return ( -
-
+
+

{t("dashboard.expensesByCategory")}

-
+

{t("dashboard.budgetVsActual")}

diff --git a/src/services/budgetService.ts b/src/services/budgetService.ts index 8a59581..0bfcd30 100644 --- a/src/services/budgetService.ts +++ b/src/services/budgetService.ts @@ -231,7 +231,6 @@ export async function getBudgetVsActualData( } // Index categories - const catById = new Map(allCategories.map((c) => [c.id, c])); const childrenByParent = new Map(); for (const cat of allCategories) { if (cat.parent_id) { @@ -244,7 +243,7 @@ export async function getBudgetVsActualData( const signFor = (type: string) => (type === "expense" ? -1 : 1); // Compute leaf row values - function buildLeaf(cat: Category, parentId: number | null, depth: 0 | 1 | 2): BudgetVsActualRow { + function buildLeaf(cat: Category, parentId: number | null, depth: number): BudgetVsActualRow { const sign = signFor(cat.type); const monthMap = entryMap.get(cat.id); const rawMonthBudget = monthMap?.get(month) ?? 0; @@ -281,7 +280,7 @@ export async function getBudgetVsActualData( }; } - function buildSubtotal(cat: Category, childRows: BudgetVsActualRow[], parentId: number | null, depth: 0 | 1 | 2): BudgetVsActualRow { + function buildSubtotal(cat: Category, childRows: BudgetVsActualRow[], parentId: number | null, depth: number): BudgetVsActualRow { const row: BudgetVsActualRow = { category_id: cat.id, category_name: cat.name, @@ -323,35 +322,40 @@ export async function getBudgetVsActualData( ); } - // Build rows for a level-2 parent (intermediate parent with grandchildren) - function buildLevel2Group(cat: Category, grandparentId: number): BudgetVsActualRow[] { - const grandchildren = (childrenByParent.get(cat.id) || []).filter((c) => c.is_inputable); - if (grandchildren.length === 0 && cat.is_inputable) { - // Leaf at level 2 - const leaf = buildLeaf(cat, grandparentId, 2); + // Build rows for a sub-group (recursive, supports arbitrary depth) + function buildSubGroup(cat: Category, groupParentId: number, depth: number): BudgetVsActualRow[] { + const subChildren = childrenByParent.get(cat.id) || []; + const hasSubChildren = subChildren.some( + (c) => c.is_inputable || (childrenByParent.get(c.id) || []).length > 0 + ); + + if (!hasSubChildren && cat.is_inputable) { + const leaf = buildLeaf(cat, groupParentId, depth); return isRowAllZero(leaf) ? [] : [leaf]; } - if (grandchildren.length === 0) return []; + if (!hasSubChildren) return []; - const gcRows: BudgetVsActualRow[] = []; + const childRows: BudgetVsActualRow[] = []; if (cat.is_inputable) { - const direct = buildLeaf(cat, cat.id, 2); + const direct = buildLeaf(cat, cat.id, depth + 1); direct.category_name = `${cat.name} (direct)`; - if (!isRowAllZero(direct)) gcRows.push(direct); + if (!isRowAllZero(direct)) childRows.push(direct); } - for (const gc of grandchildren) { - const leaf = buildLeaf(gc, cat.id, 2); - if (!isRowAllZero(leaf)) gcRows.push(leaf); + for (const child of subChildren) { + const grandchildren = childrenByParent.get(child.id) || []; + if (grandchildren.length > 0) { + const subRows = buildSubGroup(child, cat.id, depth + 1); + childRows.push(...subRows); + } else if (child.is_inputable) { + const leaf = buildLeaf(child, cat.id, depth + 1); + if (!isRowAllZero(leaf)) childRows.push(leaf); + } } - if (gcRows.length === 0) return []; + if (childRows.length === 0) return []; - const subtotal = buildSubtotal(cat, gcRows, grandparentId, 1); - gcRows.sort((a, b) => { - if (a.category_id === cat.id) return -1; - if (b.category_id === cat.id) return 1; - return a.category_name.localeCompare(b.category_name); - }); - return [subtotal, ...gcRows]; + const leafRows = childRows.filter((r) => !r.is_parent); + const subtotal = buildSubtotal(cat, leafRows, groupParentId, depth); + return [subtotal, ...childRows]; } const rows: BudgetVsActualRow[] = []; @@ -359,15 +363,15 @@ export async function getBudgetVsActualData( for (const cat of topLevel) { const children = childrenByParent.get(cat.id) || []; - const inputableChildren = children.filter((c) => c.is_inputable); - // Also check for non-inputable intermediate parents that have their own children - const intermediateParents = children.filter((c) => !c.is_inputable && (childrenByParent.get(c.id) || []).length > 0); + const hasChildren = children.some( + (c) => c.is_inputable || (childrenByParent.get(c.id) || []).length > 0 + ); - if (inputableChildren.length === 0 && intermediateParents.length === 0 && cat.is_inputable) { + if (!hasChildren && cat.is_inputable) { // Standalone leaf at level 0 const leaf = buildLeaf(cat, null, 0); if (!isRowAllZero(leaf)) rows.push(leaf); - } else if (inputableChildren.length > 0 || intermediateParents.length > 0) { + } else if (hasChildren) { const allChildRows: BudgetVsActualRow[] = []; // Direct transactions on the parent itself @@ -377,25 +381,18 @@ export async function getBudgetVsActualData( if (!isRowAllZero(direct)) allChildRows.push(direct); } - // Level-2 leaves (direct children that are inputable and have no children) - for (const child of inputableChildren) { + // Process all children in sort order (preserves tree structure) + for (const child of children) { const grandchildren = childrenByParent.get(child.id) || []; - if (grandchildren.length === 0) { + if (grandchildren.length > 0) { + const subRows = buildSubGroup(child, cat.id, 1); + allChildRows.push(...subRows); + } else if (child.is_inputable) { const leaf = buildLeaf(child, cat.id, 1); if (!isRowAllZero(leaf)) allChildRows.push(leaf); - } else { - // This child has its own children — it's an intermediate parent at level 1 - const subRows = buildLevel2Group(child, cat.id); - allChildRows.push(...subRows); } } - // Non-inputable intermediate parents at level 1 - for (const ip of intermediateParents) { - const subRows = buildLevel2Group(ip, cat.id); - allChildRows.push(...subRows); - } - if (allChildRows.length === 0) continue; // Collect only leaf rows for parent subtotal (avoid double-counting) @@ -403,51 +400,19 @@ export async function getBudgetVsActualData( const parent = buildSubtotal(cat, leafRows, null, 0); rows.push(parent); - - // Sort: "(direct)" first, then subtotals with their children, then alphabetical leaves - allChildRows.sort((a, b) => { - if (a.category_id === cat.id && !a.is_parent) return -1; - if (b.category_id === cat.id && !b.is_parent) return 1; - return a.category_name.localeCompare(b.category_name); - }); rows.push(...allChildRows); } } - // Sort by type, then within same type keep parent+children groups together + // Sort by type only, preserving tree order within groups (already built correctly) + const rowOrder = new Map(); + rows.forEach((r, i) => rowOrder.set(r, i)); + rows.sort((a, b) => { const typeA = TYPE_ORDER[a.category_type] ?? 9; const typeB = TYPE_ORDER[b.category_type] ?? 9; if (typeA !== typeB) return typeA - typeB; - // Find the top-level group id - function getGroupId(r: BudgetVsActualRow): number { - if (r.depth === 0) return r.category_id; - if (r.is_parent && r.parent_id === null) return r.category_id; - // Walk up to find the root - let pid = r.parent_id; - while (pid !== null) { - const pCat = catById.get(pid); - if (!pCat || !pCat.parent_id) return pid; - pid = pCat.parent_id; - } - return r.category_id; - } - const groupA = getGroupId(a); - const groupB = getGroupId(b); - if (groupA !== groupB) { - const catA = catById.get(groupA); - const catB = catById.get(groupB); - const orderA = catA?.sort_order ?? 999; - const orderB = catB?.sort_order ?? 999; - if (orderA !== orderB) return orderA - orderB; - return (catA?.name ?? "").localeCompare(catB?.name ?? ""); - } - // Within same group: sort by depth, then parent before children - if (a.is_parent !== b.is_parent && (a.depth ?? 0) === (b.depth ?? 0)) return a.is_parent ? -1 : 1; - if ((a.depth ?? 0) !== (b.depth ?? 0)) return (a.depth ?? 0) - (b.depth ?? 0); - if (a.parent_id && a.category_id === a.parent_id) return -1; - if (b.parent_id && b.category_id === b.parent_id) return 1; - return a.category_name.localeCompare(b.category_name); + return rowOrder.get(a)! - rowOrder.get(b)!; }); return rows; diff --git a/src/shared/types/index.ts b/src/shared/types/index.ts index f32bef5..3c96ae7 100644 --- a/src/shared/types/index.ts +++ b/src/shared/types/index.ts @@ -139,7 +139,7 @@ export interface BudgetYearRow { category_type: "expense" | "income" | "transfer"; parent_id: number | null; is_parent: boolean; - depth?: 0 | 1 | 2; + depth?: number; months: number[]; // index 0-11 = Jan-Dec planned amounts annual: number; // computed sum previousYearTotal: number; // total budget from the previous year @@ -332,7 +332,7 @@ export interface BudgetVsActualRow { category_type: "expense" | "income" | "transfer"; parent_id: number | null; is_parent: boolean; - depth?: 0 | 1 | 2; + depth?: number; monthActual: number; monthBudget: number; monthVariation: number; -- 2.45.2 From 4a5b5fb5fe3e5ca4d03ae934226b1e31c64a75b6 Mon Sep 17 00:00:00 2001 From: medic-bot Date: Mon, 9 Mar 2026 20:46:00 -0400 Subject: [PATCH 2/4] fix: address reviewer feedback (#23) - Make reorderRows() recursive to support subtotals toggle at all depth levels (not just depth 0-1) - Restore pie chart legend (show on hover only to save space) - Give more horizontal space to budget table (3/4 grid vs 2/3) --- src/components/budget/BudgetTable.tsx | 64 +++++++------------ src/components/dashboard/CategoryPieChart.tsx | 34 +++++++++- .../reports/BudgetVsActualTable.tsx | 61 +++++++----------- src/pages/DashboardPage.tsx | 4 +- 4 files changed, 82 insertions(+), 81 deletions(-) diff --git a/src/components/budget/BudgetTable.tsx b/src/components/budget/BudgetTable.tsx index 6ed81b7..60c72d1 100644 --- a/src/components/budget/BudgetTable.tsx +++ b/src/components/budget/BudgetTable.tsx @@ -23,51 +23,35 @@ function reorderRows { - if (!parent) return children; - // Also move intermediate subtotals (depth-1 parents) to bottom of their sub-groups - const reorderedChildren: T[] = []; - let subParent: T | null = null; - const subChildren: T[] = []; - for (const child of children) { - if (child.is_parent && (child.depth ?? 0) === 1) { - // Flush previous sub-group - if (subParent) { - reorderedChildren.push(...subChildren, subParent); - subChildren.length = 0; + + // Recursively move subtotal (parent) rows below their children at every depth level + function reorderGroup(groupRows: T[], parentDepth: number): T[] { + const result: T[] = []; + let currentParent: T | null = null; + let currentChildren: T[] = []; + + for (const row of groupRows) { + if (row.is_parent && (row.depth ?? 0) === parentDepth) { + // Flush previous group + if (currentParent) { + result.push(...reorderGroup(currentChildren, parentDepth + 1), currentParent); + currentChildren = []; } - subParent = child; - } else if (subParent && child.parent_id === subParent.category_id) { - subChildren.push(child); + currentParent = row; + } else if (currentParent) { + currentChildren.push(row); } else { - if (subParent) { - reorderedChildren.push(...subChildren, subParent); - subParent = null; - subChildren.length = 0; - } - reorderedChildren.push(child); + result.push(row); } } - if (subParent) { - reorderedChildren.push(...subChildren, subParent); + // Flush last group + if (currentParent) { + result.push(...reorderGroup(currentChildren, parentDepth + 1), currentParent); } - return [...reorderedChildren, parent]; - }); + return result; + } + + return reorderGroup(rows, 0); } interface BudgetTableProps { diff --git a/src/components/dashboard/CategoryPieChart.tsx b/src/components/dashboard/CategoryPieChart.tsx index 328f78f..44e7fbd 100644 --- a/src/components/dashboard/CategoryPieChart.tsx +++ b/src/components/dashboard/CategoryPieChart.tsx @@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next"; import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip } from "recharts"; import { Eye } from "lucide-react"; import type { CategoryBreakdownItem } from "../../shared/types"; -import { ChartPatternDefs, getPatternFill } from "../../utils/chartPatterns"; +import { ChartPatternDefs, getPatternFill, PatternSwatch } from "../../utils/chartPatterns"; import ChartContextMenu from "../shared/ChartContextMenu"; interface CategoryPieChartProps { @@ -23,6 +23,7 @@ export default function CategoryPieChart({ }: CategoryPieChartProps) { const { t } = useTranslation(); const hoveredRef = useRef(null); + const [isChartHovered, setIsChartHovered] = useState(false); const [contextMenu, setContextMenu] = useState<{ x: number; y: number; item: CategoryBreakdownItem } | null>(null); const visibleData = data.filter((d) => !hiddenCategories.has(d.category_name)); @@ -66,7 +67,11 @@ export default function CategoryPieChart({
)} -
+
setIsChartHovered(true)} + onMouseLeave={() => setIsChartHovered(false)} + >
+
+ {data.map((item, index) => { + const isHidden = hiddenCategories.has(item.category_name); + return ( + + ); + })} +
+ {contextMenu && ( { - if (!parent) return children; - const reorderedChildren: T[] = []; - let subParent: T | null = null; - const subChildren: T[] = []; - for (const child of children) { - if (child.is_parent && (child.depth ?? 0) === 1) { - if (subParent) { - reorderedChildren.push(...subChildren, subParent); - subChildren.length = 0; + + // Recursively move subtotal (parent) rows below their children at every depth level + function reorderGroup(groupRows: T[], parentDepth: number): T[] { + const result: T[] = []; + let currentParent: T | null = null; + let currentChildren: T[] = []; + + for (const row of groupRows) { + if (row.is_parent && (row.depth ?? 0) === parentDepth) { + // Flush previous group + if (currentParent) { + result.push(...reorderGroup(currentChildren, parentDepth + 1), currentParent); + currentChildren = []; } - subParent = child; - } else if (subParent && child.parent_id === subParent.category_id) { - subChildren.push(child); + currentParent = row; + } else if (currentParent) { + currentChildren.push(row); } else { - if (subParent) { - reorderedChildren.push(...subChildren, subParent); - subParent = null; - subChildren.length = 0; - } - reorderedChildren.push(child); + result.push(row); } } - if (subParent) { - reorderedChildren.push(...subChildren, subParent); + // Flush last group + if (currentParent) { + result.push(...reorderGroup(currentChildren, parentDepth + 1), currentParent); } - return [...reorderedChildren, parent]; - }); + return result; + } + + return reorderGroup(rows, 0); } export default function BudgetVsActualTable({ data }: BudgetVsActualTableProps) { diff --git a/src/pages/DashboardPage.tsx b/src/pages/DashboardPage.tsx index 89e00e9..d3b1fd0 100644 --- a/src/pages/DashboardPage.tsx +++ b/src/pages/DashboardPage.tsx @@ -126,7 +126,7 @@ export default function DashboardPage() { ))}
-
+

{t("dashboard.expensesByCategory")}

-
+

{t("dashboard.budgetVsActual")}

-- 2.45.2 From 66d0cd85ffa92127cde2dbfdba6b28c1ae9bba3f Mon Sep 17 00:00:00 2001 From: medic-bot Date: Mon, 9 Mar 2026 21:02:24 -0400 Subject: [PATCH 3/4] fix: address reviewer feedback (#23) - Extract reorderRows into shared utility (src/utils/reorderRows.ts) to deduplicate identical function in BudgetTable and BudgetVsActualTable - Restore alphabetical sorting of children in budgetService.ts - Fix styling for intermediate parent rows at depth 2+ (was only handling depth 0-1) - Reduce pie chart size (height 220->180, radii reduced) and padding to give more space to the table Co-Authored-By: Claude Opus 4.6 --- src/components/budget/BudgetTable.tsx | 47 +++---------------- src/components/dashboard/CategoryPieChart.tsx | 12 ++--- .../reports/BudgetVsActualTable.tsx | 44 ++--------------- src/services/budgetService.ts | 8 ++-- src/utils/reorderRows.ts | 38 +++++++++++++++ 5 files changed, 61 insertions(+), 88 deletions(-) create mode 100644 src/utils/reorderRows.ts diff --git a/src/components/budget/BudgetTable.tsx b/src/components/budget/BudgetTable.tsx index 60c72d1..aa92e4f 100644 --- a/src/components/budget/BudgetTable.tsx +++ b/src/components/budget/BudgetTable.tsx @@ -2,6 +2,7 @@ import { useState, useRef, useEffect, Fragment } from "react"; import { useTranslation } from "react-i18next"; import { AlertTriangle, ArrowUpDown } from "lucide-react"; import type { BudgetYearRow } from "../../shared/types"; +import { reorderRows } from "../../utils/reorderRows"; const fmt = new Intl.NumberFormat("en-CA", { style: "currency", @@ -18,42 +19,6 @@ const MONTH_KEYS = [ const STORAGE_KEY = "subtotals-position"; -function reorderRows( - rows: T[], - subtotalsOnTop: boolean, -): T[] { - if (subtotalsOnTop) return rows; - - // Recursively move subtotal (parent) rows below their children at every depth level - function reorderGroup(groupRows: T[], parentDepth: number): T[] { - const result: T[] = []; - let currentParent: T | null = null; - let currentChildren: T[] = []; - - for (const row of groupRows) { - if (row.is_parent && (row.depth ?? 0) === parentDepth) { - // Flush previous group - if (currentParent) { - result.push(...reorderGroup(currentChildren, parentDepth + 1), currentParent); - currentChildren = []; - } - currentParent = row; - } else if (currentParent) { - currentChildren.push(row); - } else { - result.push(row); - } - } - // Flush last group - if (currentParent) { - result.push(...reorderGroup(currentChildren, parentDepth + 1), currentParent); - } - return result; - } - - return reorderGroup(rows, 0); -} - interface BudgetTableProps { rows: BudgetYearRow[]; onUpdatePlanned: (categoryId: number, month: number, amount: number) => void; @@ -214,13 +179,15 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu if (row.is_parent) { // Parent subtotal row: read-only, bold, distinct background const parentDepth = row.depth ?? 0; - const isIntermediateParent = parentDepth === 1; + const isTopParent = parentDepth === 0; + const isIntermediateParent = parentDepth >= 1; + const parentPaddingClass = parentDepth >= 3 ? "pl-20 pr-3" : parentDepth === 2 ? "pl-14 pr-3" : parentDepth === 1 ? "pl-8 pr-3" : "px-3"; return ( - +
{/* Category name - sticky */} - + = 3 ? "pl-20 pr-3" : depth === 2 ? "pl-14 pr-3" : depth === 1 ? "pl-8 pr-3" : "px-3"}`}>
-

{t("dashboard.noData")}

+
+

{t("dashboard.noData")}

); } return ( -
+
{hiddenCategories.size > 0 && (
{t("charts.hiddenCategories")}: @@ -72,7 +72,7 @@ export default function CategoryPieChart({ onMouseEnter={() => setIsChartHovered(true)} onMouseLeave={() => setIsChartHovered(false)} > - + {visibleData.map((item, index) => ( diff --git a/src/components/reports/BudgetVsActualTable.tsx b/src/components/reports/BudgetVsActualTable.tsx index 2162f05..a7ef69f 100644 --- a/src/components/reports/BudgetVsActualTable.tsx +++ b/src/components/reports/BudgetVsActualTable.tsx @@ -2,6 +2,7 @@ import { Fragment, useState } from "react"; import { useTranslation } from "react-i18next"; import { ArrowUpDown } from "lucide-react"; import type { BudgetVsActualRow } from "../../shared/types"; +import { reorderRows } from "../../utils/reorderRows"; const cadFormatter = (value: number) => new Intl.NumberFormat("en-CA", { @@ -25,42 +26,6 @@ interface BudgetVsActualTableProps { const STORAGE_KEY = "subtotals-position"; -function reorderRows( - rows: T[], - subtotalsOnTop: boolean, -): T[] { - if (subtotalsOnTop) return rows; - - // Recursively move subtotal (parent) rows below their children at every depth level - function reorderGroup(groupRows: T[], parentDepth: number): T[] { - const result: T[] = []; - let currentParent: T | null = null; - let currentChildren: T[] = []; - - for (const row of groupRows) { - if (row.is_parent && (row.depth ?? 0) === parentDepth) { - // Flush previous group - if (currentParent) { - result.push(...reorderGroup(currentChildren, parentDepth + 1), currentParent); - currentChildren = []; - } - currentParent = row; - } else if (currentParent) { - currentChildren.push(row); - } else { - result.push(row); - } - } - // Flush last group - if (currentParent) { - result.push(...reorderGroup(currentChildren, parentDepth + 1), currentParent); - } - return result; - } - - return reorderGroup(rows, 0); -} - export default function BudgetVsActualTable({ data }: BudgetVsActualTableProps) { const { t } = useTranslation(); const [subtotalsOnTop, setSubtotalsOnTop] = useState(() => { @@ -201,17 +166,18 @@ export default function BudgetVsActualTable({ data }: BudgetVsActualTableProps) {reorderRows(section.rows, subtotalsOnTop).map((row) => { const isParent = row.is_parent; const depth = row.depth ?? (row.parent_id !== null && !row.is_parent ? 1 : 0); - const isIntermediateParent = isParent && depth === 1; + 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 ( - + a.name.localeCompare(b.name)); + for (const child of sortedSubChildren) { const grandchildren = childrenByParent.get(child.id) || []; if (grandchildren.length > 0) { const subRows = buildSubGroup(child, cat.id, depth + 1); @@ -381,8 +382,9 @@ export async function getBudgetVsActualData( if (!isRowAllZero(direct)) allChildRows.push(direct); } - // Process all children in sort order (preserves tree structure) - for (const child of children) { + // Process children in alphabetical order + const sortedChildren = [...children].sort((a, b) => a.name.localeCompare(b.name)); + for (const child of sortedChildren) { const grandchildren = childrenByParent.get(child.id) || []; if (grandchildren.length > 0) { const subRows = buildSubGroup(child, cat.id, 1); diff --git a/src/utils/reorderRows.ts b/src/utils/reorderRows.ts new file mode 100644 index 0000000..4ac0181 --- /dev/null +++ b/src/utils/reorderRows.ts @@ -0,0 +1,38 @@ +/** + * Shared utility for reordering budget table rows. + * Recursively moves subtotal (parent) rows below their children + * at every depth level when "subtotals on bottom" is enabled. + */ +export function reorderRows< + T extends { is_parent: boolean; parent_id: number | null; category_id: number; depth?: number }, +>(rows: T[], subtotalsOnTop: boolean): T[] { + if (subtotalsOnTop) return rows; + + function reorderGroup(groupRows: T[], parentDepth: number): T[] { + const result: T[] = []; + let currentParent: T | null = null; + let currentChildren: T[] = []; + + for (const row of groupRows) { + if (row.is_parent && (row.depth ?? 0) === parentDepth) { + // Flush previous group + if (currentParent) { + result.push(...reorderGroup(currentChildren, parentDepth + 1), currentParent); + currentChildren = []; + } + currentParent = row; + } else if (currentParent) { + currentChildren.push(row); + } else { + result.push(row); + } + } + // Flush last group + if (currentParent) { + result.push(...reorderGroup(currentChildren, parentDepth + 1), currentParent); + } + return result; + } + + return reorderGroup(rows, 0); +} -- 2.45.2 From 18c7ef3ee96db6453fb0109410cca82d32ee01e2 Mon Sep 17 00:00:00 2001 From: medic-bot Date: Mon, 9 Mar 2026 21:17:59 -0400 Subject: [PATCH 4/4] fix: make pie chart legend always visible with hover percentages (#23) Show category names permanently in compact form (text-xs) so they are always discoverable. Percentages appear only on chart hover to save space, matching the original request while keeping the legend accessible without interaction. Co-Authored-By: Claude Opus 4.6 --- src/components/dashboard/CategoryPieChart.tsx | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/components/dashboard/CategoryPieChart.tsx b/src/components/dashboard/CategoryPieChart.tsx index 1d82cf4..773ccd5 100644 --- a/src/components/dashboard/CategoryPieChart.tsx +++ b/src/components/dashboard/CategoryPieChart.tsx @@ -117,15 +117,14 @@ export default function CategoryPieChart({
-
+
{data.map((item, index) => { const isHidden = hiddenCategories.has(item.category_name); + const pct = total > 0 && !isHidden ? Math.round((item.total / total) * 100) : null; return ( ); -- 2.45.2