Generalize the report category collapse from level-1-only to every hierarchy level, on the three hierarchical report tables (real-vs-real Compare, real-vs-budget Compare, Trends by category). Visibility is now decided by an ancestor walk, not by row adjacency, so it is independent of row order (the level-ordered budget grid emits a non-DFS order). - collapsibleRows: rewrite visibleRows as an ancestor walk (a row is hidden iff any ancestor is collapsed); add parentKeyOf + injective `p:` keys; collapsibleKeys returns all parents (any depth); extract the pure, tested isCollapsedFor polarity helper; MAX_TREE_DEPTH cycle guard. - useCollapsibleGroups: persist in user_preferences (per-profile, destroyed with the profile) instead of localStorage; storageKey nullable (no persistence); options.defaultExpanded; async hydration (no flash); collapseAll(rows). - 3 tables: fix BOTH gates (collapsed flag + button) isTopParent -> isParent, add parentKeyOf accessors, aria-level on parent rows. - Delete dead CategoryTable.tsx (0 imports). - Tests: rewrite collapsibleRows.test.ts (BFS==DFS masking, cycle guard, cross-section ancestor, "(direct)" leaf, polarity); extend overTimeTableModel fixture to 3 levels with cascade assertions. Collapse stays purely visual: subtotals and result lines are computed from raw rows, never from visible rows. Resolves #288 Generated autonomously by /autopilot run of 2026-07-15 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
254 lines
11 KiB
TypeScript
254 lines
11 KiB
TypeScript
import { Fragment } from "react";
|
||
import { useTranslation } from "react-i18next";
|
||
import { ChevronDown, ChevronRight, ChevronsDownUp, ChevronsUpDown } from "lucide-react";
|
||
import type { CategoryOverTimeData, OverTimeRow } from "../../shared/types";
|
||
import { useCollapsibleGroups } from "../../hooks/useCollapsibleGroups";
|
||
import { computeOverTimeResults, type OverTimeSeries, type OverTimeType } from "./overTimeResults";
|
||
import {
|
||
OVERTIME_COLLAPSE_ACCESSORS,
|
||
OVERTIME_EXPANDED_STORAGE_KEY,
|
||
groupOverTimeSections,
|
||
type OverTimeRenderSection,
|
||
} from "./overTimeTableModel";
|
||
|
||
const cadFormatter = (value: number) =>
|
||
new Intl.NumberFormat("en-CA", { style: "currency", currency: "CAD", maximumFractionDigits: 0 }).format(value);
|
||
|
||
function formatMonth(month: string): string {
|
||
const [year, m] = month.split("-");
|
||
const date = new Date(Number(year), Number(m) - 1);
|
||
return date.toLocaleDateString("default", { month: "short", year: "2-digit" });
|
||
}
|
||
|
||
/** Colours a result figure by sign: surplus green, deficit red, zero neutral. */
|
||
function resultColor(value: number): string {
|
||
if (value > 0) return "var(--positive, #10b981)";
|
||
if (value < 0) return "var(--negative, #ef4444)";
|
||
return "";
|
||
}
|
||
|
||
const SECTION_LABEL_KEY: Record<OverTimeType, string> = {
|
||
income: "reports.compare.sections.income",
|
||
expense: "reports.compare.sections.expenses",
|
||
transfer: "reports.compare.sections.transfers",
|
||
};
|
||
|
||
const SECTION_TOTAL_KEY: Record<OverTimeType, string> = {
|
||
income: "reports.compare.totalIncome",
|
||
expense: "reports.compare.totalExpenses",
|
||
transfer: "reports.compare.totalTransfers",
|
||
};
|
||
|
||
interface CategoryOverTimeTableProps {
|
||
data: CategoryOverTimeData;
|
||
}
|
||
|
||
export default function CategoryOverTimeTable({ data }: CategoryOverTimeTableProps) {
|
||
const { t } = useTranslation();
|
||
|
||
// Collapse/expand of sub-category groups — collapsed by default (issue #265),
|
||
// persisted under a key distinct from the comparable tables. Called before the
|
||
// early return so the hook order stays stable.
|
||
const groups = useCollapsibleGroups<OverTimeRow>(
|
||
OVERTIME_EXPANDED_STORAGE_KEY,
|
||
OVERTIME_COLLAPSE_ACCESSORS,
|
||
);
|
||
|
||
if (data.data.length === 0) {
|
||
return (
|
||
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-8 text-center text-[var(--muted-foreground)]">
|
||
{t("dashboard.noData")}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// Consumes the id-keyed tree (every category, no top-N/"Other"): subtotals and
|
||
// the result lines are exact, and homonym categories never collide.
|
||
const analysis = computeOverTimeResults(data);
|
||
const { months, hasTransfers, net, beforeTransfers } = analysis;
|
||
const { nonTransferSections, transferSection } = groupOverTimeSections(analysis, data.tree);
|
||
const colSpan = months.length + 2;
|
||
|
||
const hasGroups = groups.groupCount(data.tree) > 0;
|
||
const allExpanded = groups.allExpanded(data.tree);
|
||
|
||
// A section = its header, its (visibly) indented hierarchy rows, and a
|
||
// leaf-summed subtotal row. Only top-level parents collapse (parity with the
|
||
// comparable tables); the subtotal is always the reducer's leaf sum, so it
|
||
// never changes when a group is folded away.
|
||
const renderSection = (section: OverTimeRenderSection) => (
|
||
<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 leaf/parent magnitudes (not deltas) */}
|
||
{groups.visible(section.rows).map((row) => {
|
||
const isParent = row.is_parent;
|
||
const depth = row.depth;
|
||
const isTopParent = isParent && depth === 0;
|
||
const isIntermediateParent = isParent && depth >= 1;
|
||
const collapsed = isParent && groups.isCollapsed(row);
|
||
const paddingClass =
|
||
depth >= 3 ? "pl-20" : depth === 2 ? "pl-14" : depth === 1 ? "pl-8" : "px-3";
|
||
return (
|
||
<tr
|
||
key={`${row.categoryId ?? "uncat"}-${isParent}-${depth}-${row.categoryName}`}
|
||
aria-level={isParent ? depth + 1 : undefined}
|
||
className={`border-b border-[var(--border)]/50 ${
|
||
isTopParent
|
||
? "bg-[color-mix(in_srgb,var(--muted)_30%,var(--card))] font-semibold"
|
||
: isIntermediateParent
|
||
? "bg-[color-mix(in_srgb,var(--muted)_15%,var(--card))] font-medium"
|
||
: "hover:bg-[var(--muted)]/40"
|
||
}`}
|
||
>
|
||
<td
|
||
className={`py-1.5 sticky left-0 z-10 ${
|
||
isTopParent
|
||
? "px-3 bg-[color-mix(in_srgb,var(--muted)_30%,var(--card))]"
|
||
: isIntermediateParent
|
||
? `${paddingClass} bg-[color-mix(in_srgb,var(--muted)_15%,var(--card))]`
|
||
: `${paddingClass} bg-[var(--card)]`
|
||
}`}
|
||
>
|
||
{isParent ? (
|
||
<button
|
||
type="button"
|
||
onClick={() => groups.toggle(row)}
|
||
aria-expanded={!collapsed}
|
||
className="flex items-center gap-2 w-full text-left hover:opacity-80 transition-opacity"
|
||
>
|
||
{collapsed ? (
|
||
<ChevronRight size={14} className="shrink-0 text-[var(--muted-foreground)]" />
|
||
) : (
|
||
<ChevronDown size={14} className="shrink-0 text-[var(--muted-foreground)]" />
|
||
)}
|
||
<span
|
||
className="w-2.5 h-2.5 rounded-full shrink-0"
|
||
style={{ backgroundColor: row.categoryColor }}
|
||
/>
|
||
{row.categoryName}
|
||
</button>
|
||
) : (
|
||
<span className="flex items-center gap-2">
|
||
<span
|
||
className="w-2.5 h-2.5 rounded-full shrink-0"
|
||
style={{ backgroundColor: row.categoryColor }}
|
||
/>
|
||
{row.categoryName}
|
||
</span>
|
||
)}
|
||
</td>
|
||
{months.map((month, monthIdx) => {
|
||
const value = row.monthly[monthIdx] ?? 0;
|
||
return (
|
||
<td key={month} className="text-right px-3 py-1.5 tabular-nums">
|
||
{value ? cadFormatter(value) : "—"}
|
||
</td>
|
||
);
|
||
})}
|
||
<td className="text-right px-3 py-1.5 font-semibold border-l border-[var(--border)]/50 tabular-nums">
|
||
{cadFormatter(row.total)}
|
||
</td>
|
||
</tr>
|
||
);
|
||
})}
|
||
{/* Section subtotal */}
|
||
<tr className="border-b border-[var(--border)] bg-[color-mix(in_srgb,var(--muted)_40%,var(--card))] font-semibold">
|
||
<td className="px-3 py-2.5 sticky left-0 bg-[color-mix(in_srgb,var(--muted)_40%,var(--card))] z-10">
|
||
{t(SECTION_TOTAL_KEY[section.type])}
|
||
</td>
|
||
{months.map((month) => (
|
||
<td key={month} className="text-right px-3 py-2.5 tabular-nums">
|
||
{cadFormatter(section.monthly[month])}
|
||
</td>
|
||
))}
|
||
<td className="text-right px-3 py-2.5 border-l border-[var(--border)]/50 tabular-nums">
|
||
{cadFormatter(section.total)}
|
||
</td>
|
||
</tr>
|
||
</Fragment>
|
||
);
|
||
|
||
// A result line (before-transfers subtotal or the net bottom line). Amounts are
|
||
// coloured by sign (surplus green / deficit red). Computed from the raw tree,
|
||
// so folding groups never moves these figures.
|
||
const renderResultRow = (labelKey: string, series: OverTimeSeries, strong: boolean) => (
|
||
<tr
|
||
className={`font-bold bg-[color-mix(in_srgb,var(--muted)_20%,var(--card))] ${
|
||
strong ? "border-t-2 border-[var(--border)]" : "border-b 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(labelKey)}
|
||
</td>
|
||
{months.map((month) => (
|
||
<td
|
||
key={month}
|
||
className="text-right px-3 py-3 tabular-nums"
|
||
style={{ color: resultColor(series.monthly[month]) }}
|
||
>
|
||
{cadFormatter(series.monthly[month])}
|
||
</td>
|
||
))}
|
||
<td
|
||
className="text-right px-3 py-3 border-l border-[var(--border)]/50 tabular-nums"
|
||
style={{ color: resultColor(series.total) }}
|
||
>
|
||
{cadFormatter(series.total)}
|
||
</td>
|
||
</tr>
|
||
);
|
||
|
||
return (
|
||
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl overflow-hidden">
|
||
{hasGroups && (
|
||
<div className="flex justify-end items-center gap-1 px-3 py-2 border-b border-[var(--border)]">
|
||
<button
|
||
type="button"
|
||
onClick={() => (allExpanded ? groups.collapseAll(data.tree) : groups.expandAll(data.tree))}
|
||
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-medium text-[var(--muted-foreground)] hover:bg-[var(--muted)] transition-colors"
|
||
>
|
||
{allExpanded ? <ChevronsDownUp size={13} /> : <ChevronsUpDown size={13} />}
|
||
{allExpanded ? t("reports.collapse.collapseAll") : t("reports.collapse.expandAll")}
|
||
</button>
|
||
</div>
|
||
)}
|
||
<div className="overflow-x-auto overflow-y-auto" style={{ maxHeight: "calc(100vh - 220px)" }}>
|
||
<table className="w-full text-sm whitespace-nowrap">
|
||
<thead className="sticky top-0 z-20">
|
||
<tr className="border-b border-[var(--border)] bg-[var(--card)]">
|
||
<th className="text-left px-3 py-2 font-medium text-[var(--muted-foreground)] bg-[var(--card)] sticky left-0 z-30 min-w-[140px]">
|
||
{t("budget.category")}
|
||
</th>
|
||
{months.map((month) => (
|
||
<th key={month} className="text-right px-3 py-2 font-medium text-[var(--muted-foreground)] bg-[var(--card)] min-w-[90px]">
|
||
{formatMonth(month)}
|
||
</th>
|
||
))}
|
||
<th className="text-right px-3 py-2 font-medium text-[var(--muted-foreground)] bg-[var(--card)] border-l border-[var(--border)] min-w-[90px]">
|
||
{t("common.total")}
|
||
</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{nonTransferSections.map(renderSection)}
|
||
{/* Operating result (revenues − expenses), interleaved before the
|
||
transfers section only when transfers exist — otherwise it equals
|
||
the net total below and would just be noise. */}
|
||
{hasTransfers && renderResultRow("reports.compare.resultBeforeTransfers", beforeTransfers, false)}
|
||
{transferSection && renderSection(transferSection)}
|
||
{/* Bottom line: result after netting transfers. */}
|
||
{renderResultRow("reports.compare.resultNet", net, true)}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|