From 0bb46842a1cbb8a8c7879ccfade7de14e13e07db Mon Sep 17 00:00:00 2001 From: medic-bot Date: Sun, 8 Mar 2026 15:04:04 -0400 Subject: [PATCH] fix: order level-4 categories under parent and reduce pie chart space (#23) - Fix hierarchical sorting in budgetService so depth-2 categories stay grouped under their intermediate depth-1 parent instead of appearing at the bottom of the section. - Reduce pie chart from 1/2 to 1/3 of dashboard width to give more room to the budget vs actual table. - Collapse pie chart legend by default; expand on click. Co-Authored-By: Claude Opus 4.6 --- CHANGELOG.fr.md | 7 ++ CHANGELOG.md | 7 ++ src/components/dashboard/CategoryPieChart.tsx | 62 ++++++---- src/i18n/locales/en.json | 3 +- src/i18n/locales/fr.json | 3 +- src/pages/DashboardPage.tsx | 2 +- src/services/budgetService.ts | 116 ++++++++++++++---- 7 files changed, 146 insertions(+), 54 deletions(-) diff --git a/CHANGELOG.fr.md b/CHANGELOG.fr.md index 7066584..1b7135f 100644 --- a/CHANGELOG.fr.md +++ b/CHANGELOG.fr.md @@ -5,6 +5,13 @@ ### 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 d'en bas de section (#23) + +### Modifié +- Tableau de bord : le graphique circulaire prend moins d'espace (1/3 au lieu de 1/2) pour donner plus de place au tableau budget vs réel (#23) +- Tableau de bord : la légende du graphique circulaire est maintenant repliée par défaut et dépliable au clic (#23) + ## [0.6.3] ### Ajouté diff --git a/CHANGELOG.md b/CHANGELOG.md index edd44d6..efaf41d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,13 @@ ### 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) + +### Changed +- Dashboard: pie chart takes less space (1/3 instead of 1/2) to give more room to the budget vs actual table (#23) +- Dashboard: pie chart legend is now collapsed by default and expandable on click (#23) + ## [0.6.3] ### Added diff --git a/src/components/dashboard/CategoryPieChart.tsx b/src/components/dashboard/CategoryPieChart.tsx index 2fe8195..4f85811 100644 --- a/src/components/dashboard/CategoryPieChart.tsx +++ b/src/components/dashboard/CategoryPieChart.tsx @@ -1,7 +1,7 @@ import { useState, useRef, useCallback } from "react"; import { useTranslation } from "react-i18next"; import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip } from "recharts"; -import { Eye } from "lucide-react"; +import { Eye, ChevronDown, ChevronUp } from "lucide-react"; import type { CategoryBreakdownItem } from "../../shared/types"; import { ChartPatternDefs, getPatternFill, PatternSwatch } from "../../utils/chartPatterns"; import ChartContextMenu from "../shared/ChartContextMenu"; @@ -24,6 +24,7 @@ export default function CategoryPieChart({ const { t } = useTranslation(); const hoveredRef = useRef(null); const [contextMenu, setContextMenu] = useState<{ x: number; y: number; item: CategoryBreakdownItem } | null>(null); + const [legendExpanded, setLegendExpanded] = useState(false); const visibleData = data.filter((d) => !hiddenCategories.has(d.category_name)); const total = visibleData.reduce((sum, d) => sum + d.total, 0); @@ -67,7 +68,7 @@ export default function CategoryPieChart({ )}
- + {visibleData.map((item, index) => ( @@ -110,27 +111,38 @@ export default function CategoryPieChart({
-
- {data.map((item, index) => { - const isHidden = hiddenCategories.has(item.category_name); - return ( - - ); - })} +
+ + {legendExpanded && ( +
+ {data.map((item, index) => { + const isHidden = hiddenCategories.has(item.category_name); + return ( + + ); + })} +
+ )}
{contextMenu && ( diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index b1da85f..d40d25d 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -508,7 +508,8 @@ "showAll": "Show all", "total": "Total", "transactions": "transactions", - "clickToShow": "Click to show" + "clickToShow": "Click to show", + "legend": "Legend" }, "months": { "jan": "Jan", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 28abfd7..d248110 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -508,7 +508,8 @@ "showAll": "Tout afficher", "total": "Total", "transactions": "transactions", - "clickToShow": "Cliquer pour afficher" + "clickToShow": "Cliquer pour afficher", + "legend": "Légende" }, "months": { "jan": "Jan", diff --git a/src/pages/DashboardPage.tsx b/src/pages/DashboardPage.tsx index d484fbf..4bf8340 100644 --- a/src/pages/DashboardPage.tsx +++ b/src/pages/DashboardPage.tsx @@ -126,7 +126,7 @@ export default function DashboardPage() { ))}
-
+

{t("dashboard.expensesByCategory")}

{ - 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: "(direct)" first, then group intermediate parents with their children + // Step 1: separate depth-1 items and depth-2 items + const directRow = allChildRows.find((r) => r.category_id === cat.id && !r.is_parent); + const depth1Parents = allChildRows.filter((r) => r.is_parent && (r.depth ?? 0) === 1); + const depth1Leaves = allChildRows.filter((r) => !r.is_parent && (r.depth ?? 0) === 1 && r.category_id !== cat.id); + const depth2Items = allChildRows.filter((r) => (r.depth ?? 0) === 2); + + // Step 2: build ordered list preserving parent-child grouping + const orderedChildren: BudgetVsActualRow[] = []; + if (directRow) orderedChildren.push(directRow); + + // Collect all depth-1 items (both leaves and intermediate parents), sort alphabetically + const depth1All: BudgetVsActualRow[] = [...depth1Leaves, ...depth1Parents]; + depth1All.sort((a, b) => a.category_name.localeCompare(b.category_name)); + + for (const d1 of depth1All) { + orderedChildren.push(d1); + if (d1.is_parent) { + // Append depth-2 children of this intermediate parent, sorted alphabetically + const children2 = depth2Items + .filter((r) => r.parent_id === d1.category_id) + .sort((a, b) => { + // "(direct)" first + if (a.category_id === d1.category_id) return -1; + if (b.category_id === d1.category_id) return 1; + return a.category_name.localeCompare(b.category_name); + }); + orderedChildren.push(...children2); + } + } + + rows.push(...orderedChildren); } } // Sort by type, then within same type keep parent+children groups together + // We use a stable approach: assign an index to each row based on its group and hierarchy + 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; + } + + // Get the intermediate parent id for depth-2 items + function getIntermediateParentId(r: BudgetVsActualRow): number | null { + if ((r.depth ?? 0) !== 2) return null; + // parent_id is the intermediate parent + return r.parent_id; + } + 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) { @@ -442,12 +476,42 @@ export async function getBudgetVsActualData( 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); + + // Within same top-level group: + // depth-0 parent comes first + if ((a.depth ?? 0) === 0 && (b.depth ?? 0) !== 0) return -1; + if ((a.depth ?? 0) !== 0 && (b.depth ?? 0) === 0) return 1; + + // Both are depth 1 or 2 — group by intermediate parent + const ipA = a.is_parent && (a.depth ?? 0) === 1 ? a.category_id : getIntermediateParentId(a); + const ipB = b.is_parent && (b.depth ?? 0) === 1 ? b.category_id : getIntermediateParentId(b); + + // If both have same intermediate parent (or both null) + if (ipA === ipB) { + // parent row before children + if (a.is_parent !== b.is_parent) return a.is_parent ? -1 : 1; + // "(direct)" rows first + 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); + } + + // One is a depth-1 leaf, the other has an intermediate parent (or different parents) + // Sort by the intermediate parent's name (depth-1 leaves have null ipA, sort them first among non-parent items) + if (ipA === null && ipB !== null) { + // a is a depth-1 leaf, b belongs to an intermediate parent group + const ipBCat = catById.get(ipB); + return a.category_name.localeCompare(ipBCat?.name ?? b.category_name); + } + if (ipA !== null && ipB === null) { + const ipACat = catById.get(ipA); + return (ipACat?.name ?? a.category_name).localeCompare(b.category_name); + } + + // Both have different intermediate parents — sort by intermediate parent name + const ipACat = catById.get(ipA!); + const ipBCat = catById.get(ipB!); + return (ipACat?.name ?? "").localeCompare(ipBCat?.name ?? ""); }); return rows;