docs(03): research collapsible dashboard sections phase
This commit is contained in:
@@ -0,0 +1,656 @@
|
||||
# Phase 3: Collapsible Dashboard Sections - Research
|
||||
|
||||
**Researched:** 2026-03-17
|
||||
**Domain:** React collapsible UI, Radix UI Collapsible, Tailwind CSS animation, ResizeObserver, dashboard data grouping
|
||||
**Confidence:** HIGH
|
||||
|
||||
---
|
||||
|
||||
<user_constraints>
|
||||
## User Constraints (from CONTEXT.md)
|
||||
|
||||
### Locked Decisions
|
||||
|
||||
**Section header design**
|
||||
- Badge-style chips for totals: two small colored badges showing `[Budget $X]` and `[Actual $X]` right-aligned in the header row
|
||||
- Left border accent using the category's CSS variable color (thick colored left border on the header row)
|
||||
- Difference shown in header with color coding: green (`--color-on-budget`) when under/on budget, red (`--color-over-budget`) when over budget
|
||||
- Chevron-right icon that rotates to chevron-down when expanded (standard Radix Collapsible pattern)
|
||||
- Group label (from `categoryLabels` in palette.ts) on the left, badges and difference on the right
|
||||
|
||||
**Line-item table columns**
|
||||
- Four columns: Item Name, Budgeted, Actual, Difference
|
||||
- No tier badge — keep it clean for the dashboard summary view
|
||||
- No notes column — full detail lives on BudgetDetailPage
|
||||
- Difference column uses red text when over budget (`--color-over-budget`), no full-row tint
|
||||
- Footer row with bold group totals summing Budget, Actual, and Diff columns
|
||||
- Read-only — no clickable rows, no navigation links to BudgetDetailPage
|
||||
|
||||
**Default expand/collapse behavior**
|
||||
- Smart default: over-budget sections auto-expand on load, on/under-budget sections start collapsed
|
||||
- Over-budget logic is direction-aware:
|
||||
- Spending categories (bill, variable_expense, debt): actual > budget = over budget (expand)
|
||||
- Income category: actual < budget = under-earned (expand)
|
||||
- Savings/investment categories: actual < budget = under-saved (expand)
|
||||
- Empty category groups (no items of that type) are hidden entirely — only show sections with at least one budget item
|
||||
- Expand/collapse state resets on month navigation — smart defaults recalculate per month
|
||||
|
||||
**Carryover display**
|
||||
- Subtitle line below balance amount on the balance StatCard: "Includes $X carryover" when non-zero
|
||||
- Carryover is included in the balance calculation: Balance = Income Actual - Expenses Actual + Carryover
|
||||
- When carryover is zero, the subtitle line is hidden entirely (clean card for the common case)
|
||||
- Negative carryover is supported: shown with red styling (e.g., "Includes -$150 carryover"), deducts from balance
|
||||
|
||||
### Claude's Discretion
|
||||
- Smooth collapse/expand CSS animation details (timing, easing)
|
||||
- Preventing ResizeObserver loop errors when toggling rapidly (success criteria #3)
|
||||
- Preventing chart resize jank when sections toggle (success criteria #3)
|
||||
- Exact spacing between section headers and between sections and the chart grid above
|
||||
- Table cell alignment and typography within line items
|
||||
- DashboardSkeleton updates for the collapsible sections area
|
||||
- How to derive and memoize per-group data from budget items
|
||||
|
||||
### Deferred Ideas (OUT OF SCOPE)
|
||||
None — discussion stayed within phase scope.
|
||||
</user_constraints>
|
||||
|
||||
---
|
||||
|
||||
<phase_requirements>
|
||||
## Phase Requirements
|
||||
|
||||
| ID | Description | Research Support |
|
||||
|----|-------------|-----------------|
|
||||
| UI-DASH-01 | Redesign dashboard with hybrid layout — summary cards, charts, and collapsible category sections (income, bills, expenses, debt, savings) with budget/actual columns | Collapsible sections inserted between chart grid and QuickAdd in DashboardContent; all grouping/totals derived from existing `items` array via useMemo |
|
||||
| UI-COLLAPSE-01 | Add collapsible inline sections on dashboard for each category group showing individual line items | Radix Collapsible v1.1.12 already installed and wrapped in collapsible.tsx; exposes `--radix-collapsible-content-height` CSS variable for height animation |
|
||||
</phase_requirements>
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
Phase 3 adds collapsible per-category sections to the dashboard between the chart grid and the QuickAdd button. The codebase is well-prepared: `collapsible.tsx` wraps Radix Collapsible v1.1.12, the `Badge`, `Table`, and `StatCard` primitives are ready, `categoryColors`/`categoryLabels` from `palette.ts` map cleanly to section styling, and `CATEGORY_TYPES` + `EXPENSE_TYPES` constants already define the display order. `BudgetDetailPage.tsx` already implements identical grouping logic (group items by `category.type`, derive per-group totals, render a `Table` with a `TableFooter` row) — the dashboard sections are a read-only, collapsible variant of that pattern.
|
||||
|
||||
The primary technical considerations are: (1) animating the Radix `CollapsibleContent` height smoothly using the `--radix-collapsible-content-height` CSS variable it exposes, (2) preventing `ResizeObserver loop` errors that Recharts can trigger when layout shifts affect chart container dimensions, and (3) threading `budget.carryover_amount` through `SummaryStrip` → `StatCard` to display the carryover subtitle on the balance card.
|
||||
|
||||
**Primary recommendation:** Build a `CategorySection` component that wraps `Collapsible`/`CollapsibleTrigger`/`CollapsibleContent` with inline `border-l-4` styling, derive all section data in a single `useMemo` in `DashboardContent`, and isolate Recharts charts in a stable wrapper div to prevent ResizeObserver jank.
|
||||
|
||||
---
|
||||
|
||||
## Standard Stack
|
||||
|
||||
### Core
|
||||
| Library | Version | Purpose | Why Standard |
|
||||
|---------|---------|---------|--------------|
|
||||
| `@radix-ui/react-collapsible` (via `radix-ui`) | 1.1.12 | Accessible expand/collapse primitive | Already installed; exposes `data-state`, `aria-expanded`, `--radix-collapsible-content-height` |
|
||||
| `tailwindcss` | 4.2.x | Utility classes for animation, border accent, spacing | Already in use; v4 `@theme inline` CSS variable pattern used throughout |
|
||||
| `lucide-react` | 0.577.x | ChevronRight / ChevronDown icons | Already in use |
|
||||
|
||||
### Supporting
|
||||
| Library | Version | Purpose | When to Use |
|
||||
|---------|---------|---------|-------------|
|
||||
| `Badge` (ui/badge.tsx) | — | Budget/Actual chip badges in section header | Used for the two right-aligned total chips |
|
||||
| `Table` / `TableBody` / `TableCell` / `TableFooter` (ui/table.tsx) | — | Line-item rows and group total footer | Used for the expanded content table |
|
||||
| `StatCard` (dashboard/StatCard.tsx) | — | Balance card needing carryover subtitle | Needs a new optional `subtitle` prop |
|
||||
|
||||
### Alternatives Considered
|
||||
| Instead of | Could Use | Tradeoff |
|
||||
|------------|-----------|----------|
|
||||
| Radix Collapsible | HTML `<details>`/`<summary>` | No animation support; no `data-state` for CSS targeting; not Radix-integrated |
|
||||
| CSS height animation via `--radix-collapsible-content-height` | framer-motion `AnimatePresence` | framer-motion not in the stack; adding it would violate the "no new major dependencies" constraint |
|
||||
|
||||
**Installation:** No new packages needed. All primitives are already installed.
|
||||
|
||||
---
|
||||
|
||||
## Architecture Patterns
|
||||
|
||||
### Recommended Project Structure
|
||||
|
||||
New files for this phase:
|
||||
|
||||
```
|
||||
src/components/dashboard/
|
||||
├── CategorySection.tsx # Collapsible section: header + table
|
||||
├── CollapsibleSections.tsx # Renders ordered list of CategorySection
|
||||
```
|
||||
|
||||
Modified files:
|
||||
```
|
||||
src/components/dashboard/StatCard.tsx # Add optional subtitle prop
|
||||
src/components/dashboard/SummaryStrip.tsx # Thread carryover subtitle to balance StatCard
|
||||
src/pages/DashboardPage.tsx # DashboardContent: add grouped sections data, pass carryover to SummaryStrip
|
||||
src/components/dashboard/DashboardSkeleton.tsx # Add skeleton rows for sections area
|
||||
src/i18n/en.json # New keys: dashboard.sections.*, dashboard.carryoverIncludes
|
||||
src/i18n/de.json # German equivalents
|
||||
```
|
||||
|
||||
### Pattern 1: Radix Collapsible with CSS Height Animation
|
||||
|
||||
**What:** `CollapsibleContent` exposes `--radix-collapsible-content-height` as an inline CSS variable on the content div. A Tailwind keyframe animation reads this variable to animate `max-height` from `0` to the measured natural height.
|
||||
|
||||
**When to use:** Any time the Radix Collapsible content needs a smooth open/close height transition without a JS animation library.
|
||||
|
||||
**How Radix sets the variable (from source):**
|
||||
|
||||
```typescript
|
||||
// @radix-ui/react-collapsible source (CollapsibleContentImpl)
|
||||
style: {
|
||||
[`--radix-collapsible-content-height`]: height ? `${height}px` : undefined,
|
||||
[`--radix-collapsible-content-width`]: width ? `${width}px` : undefined,
|
||||
...props.style
|
||||
}
|
||||
```
|
||||
|
||||
The `height` value is measured from `getBoundingClientRect()` in a `useLayoutEffect` — it is the element's natural height when fully open. The variable is set on the content div itself.
|
||||
|
||||
**Tailwind v4 animation pattern (index.css addition):**
|
||||
|
||||
```css
|
||||
@theme inline {
|
||||
/* ... existing tokens ... */
|
||||
--animate-collapsible-open: collapsible-open 200ms ease-out;
|
||||
--animate-collapsible-close: collapsible-close 200ms ease-out;
|
||||
}
|
||||
|
||||
@keyframes collapsible-open {
|
||||
from { height: 0; overflow: hidden; }
|
||||
to { height: var(--radix-collapsible-content-height); overflow: hidden; }
|
||||
}
|
||||
|
||||
@keyframes collapsible-close {
|
||||
from { height: var(--radix-collapsible-content-height); overflow: hidden; }
|
||||
to { height: 0; overflow: hidden; }
|
||||
}
|
||||
```
|
||||
|
||||
**CollapsibleContent usage:**
|
||||
|
||||
```tsx
|
||||
// Source: Radix Collapsible docs pattern + project CSS variable system
|
||||
<CollapsibleContent
|
||||
className="data-[state=open]:animate-collapsible-open data-[state=closed]:animate-collapsible-close overflow-hidden"
|
||||
>
|
||||
{/* table content */}
|
||||
</CollapsibleContent>
|
||||
```
|
||||
|
||||
**Key detail:** `data-[state=open]` and `data-[state=closed]` are set by Radix on the `CollapsibleContent` div (from `getState(context.open)` — returns `"open"` or `"closed"`). Tailwind v4's arbitrary variant syntax `data-[state=open]:` works directly against these attributes.
|
||||
|
||||
### Pattern 2: Controlled Collapsible with Smart Defaults
|
||||
|
||||
**What:** Drive open/close state with local `useState` in `DashboardContent` (not inside `CategorySection`). Compute initial state from budget data, reset when `budgetId` changes (i.e., on month navigation).
|
||||
|
||||
**When to use:** When expand state needs to be computed from data (smart defaults) and reset on navigation.
|
||||
|
||||
**Example:**
|
||||
|
||||
```typescript
|
||||
// In DashboardContent — after all item-derived useMemos
|
||||
const CATEGORY_TYPES_ALL: CategoryType[] = [
|
||||
"income", "bill", "variable_expense", "debt", "saving", "investment"
|
||||
]
|
||||
|
||||
const groupedSections = useMemo(() =>
|
||||
CATEGORY_TYPES_ALL
|
||||
.map((type) => {
|
||||
const groupItems = items.filter((i) => i.category?.type === type)
|
||||
if (groupItems.length === 0) return null
|
||||
const budgeted = groupItems.reduce((s, i) => s + i.budgeted_amount, 0)
|
||||
const actual = groupItems.reduce((s, i) => s + i.actual_amount, 0)
|
||||
// Direction-aware over-budget check
|
||||
const isOverBudget =
|
||||
type === "income" || type === "saving" || type === "investment"
|
||||
? actual < budgeted // under-earned / under-saved
|
||||
: actual > budgeted // overspent
|
||||
return { type, items: groupItems, budgeted, actual, isOverBudget }
|
||||
})
|
||||
.filter(Boolean),
|
||||
[items]
|
||||
)
|
||||
|
||||
// Initial expand state: over-budget sections open, others closed
|
||||
// Key on budgetId so state resets when month changes
|
||||
const [openSections, setOpenSections] = useState<Record<string, boolean>>(() =>
|
||||
Object.fromEntries(
|
||||
(groupedSections ?? []).map((g) => [g!.type, g!.isOverBudget])
|
||||
)
|
||||
)
|
||||
|
||||
// Reset when budgetId (month) changes
|
||||
useEffect(() => {
|
||||
setOpenSections(
|
||||
Object.fromEntries(
|
||||
(groupedSections ?? []).map((g) => [g!.type, g!.isOverBudget])
|
||||
)
|
||||
)
|
||||
}, [budgetId]) // budgetId is the stable dependency; groupedSections flows from it
|
||||
```
|
||||
|
||||
**Note on Rules of Hooks:** `useState` initializer runs once. The `useEffect` driven by `budgetId` handles the reset-on-navigation requirement without violating hooks rules. All `useMemo` hooks for `groupedSections` must be declared before any early returns (established pattern from Phase 2).
|
||||
|
||||
### Pattern 3: CategorySection Component
|
||||
|
||||
**What:** A pure presentational component — accepts pre-computed group data and delegates all state management to the parent via `open` / `onOpenChange` props (controlled pattern).
|
||||
|
||||
**Example:**
|
||||
|
||||
```tsx
|
||||
// Source: project conventions + Radix Collapsible controlled pattern
|
||||
interface CategorySectionProps {
|
||||
type: CategoryType
|
||||
label: string
|
||||
items: BudgetItem[]
|
||||
budgeted: number
|
||||
actual: number
|
||||
currency: string
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
t: (key: string, opts?: Record<string, unknown>) => string
|
||||
}
|
||||
|
||||
export function CategorySection({
|
||||
type, label, items, budgeted, actual, currency, open, onOpenChange, t
|
||||
}: CategorySectionProps) {
|
||||
const diff = /* direction-aware difference */
|
||||
const isOver = /* direction-aware over-budget flag */
|
||||
const accentColor = categoryColors[type] // "var(--color-income)" etc.
|
||||
|
||||
return (
|
||||
<Collapsible open={open} onOpenChange={onOpenChange}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<button
|
||||
className="flex w-full items-center gap-3 rounded-md border-l-4 bg-card px-4 py-3 hover:bg-muted/40"
|
||||
style={{ borderLeftColor: accentColor }}
|
||||
>
|
||||
<ChevronRight
|
||||
className="size-4 shrink-0 transition-transform duration-200 [[data-state=open]_&]:rotate-90"
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="font-medium">{label}</span>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<Badge variant="outline" className="tabular-nums">
|
||||
{t("budgets.budgeted")} {formatCurrency(budgeted, currency)}
|
||||
</Badge>
|
||||
<Badge variant="secondary" className="tabular-nums">
|
||||
{t("budgets.actual")} {formatCurrency(actual, currency)}
|
||||
</Badge>
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm font-medium tabular-nums",
|
||||
isOver ? "text-over-budget" : "text-on-budget"
|
||||
)}
|
||||
>
|
||||
{formatCurrency(Math.abs(diff), currency)}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="data-[state=open]:animate-collapsible-open data-[state=closed]:animate-collapsible-close overflow-hidden">
|
||||
{/* Table with items + footer */}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**Chevron rotation note:** The `[[data-state=open]_&]:rotate-90` class uses Tailwind v4 ancestor-state targeting. An ancestor element with `data-state="open"` (the `CollapsibleTrigger` button itself has `data-state` set by Radix) rotates the icon. Alternative: target the trigger's own `data-state` with `group-data-[state=open]:rotate-90` if a `group` class is applied to the trigger.
|
||||
|
||||
### Pattern 4: StatCard Carryover Subtitle
|
||||
|
||||
**What:** Add an optional `subtitle` string prop to `StatCard`. When provided, renders below the value with small muted text. The balance card passes "Includes $X carryover" when `budget.carryover_amount !== 0`.
|
||||
|
||||
**Modified StatCard interface:**
|
||||
|
||||
```typescript
|
||||
interface StatCardProps {
|
||||
title: string
|
||||
value: string
|
||||
valueClassName?: string
|
||||
subtitle?: string // NEW — optional small text below value
|
||||
subtitleClassName?: string // NEW — optional class override (for negative carryover red)
|
||||
variance?: { ... }
|
||||
}
|
||||
```
|
||||
|
||||
**SummaryStrip carryover prop threading:**
|
||||
|
||||
```typescript
|
||||
interface SummaryStripProps {
|
||||
income: { value: string; budgeted: string }
|
||||
expenses: { value: string; budgeted: string }
|
||||
balance: { value: string; isPositive: boolean; carryoverSubtitle?: string; carryoverIsNegative?: boolean }
|
||||
t: (key: string) => string
|
||||
}
|
||||
```
|
||||
|
||||
### Anti-Patterns to Avoid
|
||||
|
||||
- **Storing expand state inside `CategorySection`:** Breaks the reset-on-navigation requirement. All expand state must live in `DashboardContent` keyed to `budgetId`.
|
||||
- **Computing grouped data inside `CategorySection`:** Items should be pre-grouped in `DashboardContent` via `useMemo`. `CategorySection` is purely presentational.
|
||||
- **Using `overflow: hidden` on the outer `Collapsible` root:** Only apply `overflow: hidden` to `CollapsibleContent` (animated element), not the outer root, to avoid clipping box-shadows on the header.
|
||||
- **Declaring `useState`/`useMemo` after early returns:** Violates React hooks rules. All hooks must be declared before `if (loading) return <DashboardSkeleton />`.
|
||||
- **Animating with `max-height: 9999px`:** Produces visible animation lag. Use `--radix-collapsible-content-height` (exact measured height) with `height` animation instead.
|
||||
|
||||
---
|
||||
|
||||
## Don't Hand-Roll
|
||||
|
||||
| Problem | Don't Build | Use Instead | Why |
|
||||
|---------|-------------|-------------|-----|
|
||||
| Accessible expand/collapse | Custom `aria-expanded` + DOM toggle | `Collapsible` / `CollapsibleTrigger` / `CollapsibleContent` from `ui/collapsible.tsx` | Radix handles `aria-expanded`, `aria-controls`, `id` linkage, keyboard (Enter/Space), and disabled state |
|
||||
| Height measurement for animation | `ResizeObserver` + state | `--radix-collapsible-content-height` CSS variable from Radix | Radix measures height in `useLayoutEffect` and sets the variable; no custom measurement needed |
|
||||
| Category color accent border | Tailwind color class | Inline `style={{ borderLeftColor: categoryColors[type] }}` | `categoryColors` maps to CSS custom property strings (`"var(--color-income)"`); Tailwind can't generate arbitrary CSS variable values without JIT config |
|
||||
|
||||
**Key insight:** The Radix `CollapsibleContent` implementation already handles the tricky edge cases: mount-animation prevention on initial render (the `isMountAnimationPreventedRef.current` flag), measurement timing (uses `useLayoutEffect` to measure before paint), and `hidden` attribute management (element is `hidden` when closed, preventing tab focus and screen reader access to invisible content).
|
||||
|
||||
---
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
### Pitfall 1: ResizeObserver Loop Errors from Recharts
|
||||
**What goes wrong:** When collapsible sections open/close, the document layout shifts. Recharts' `ResponsiveContainer` uses a `ResizeObserver` internally. If the observer callback fires mid-layout and triggers a chart re-render that itself changes layout, the browser fires `ResizeObserver loop limit exceeded` console errors.
|
||||
|
||||
**Why it happens:** Recharts charts are already rendered above the sections. The layout shift from section expand/collapse propagates upward through the document flow if the chart grid is not height-stable.
|
||||
|
||||
**How to avoid:**
|
||||
1. Give the chart grid a stable height by ensuring the three chart cards have `min-h-[xxx]` or fixed `h-[xxx]` Tailwind classes. The existing card + `ChartContainer` with `min-h-[250px]` (from Phase 2) already creates a floor.
|
||||
2. Wrap the chart grid in a div with `overflow: hidden` or `contain: layout` to prevent section expand/collapse from reflowing chart dimensions.
|
||||
3. Use CSS `contain: layout style` on the chart grid container:
|
||||
|
||||
```tsx
|
||||
{/* 3-column chart grid — isolated from section-toggle reflow */}
|
||||
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3 [contain:layout_style]">
|
||||
...charts...
|
||||
</div>
|
||||
```
|
||||
|
||||
**Warning signs:** `ResizeObserver loop limit exceeded` or `ResizeObserver loop completed with undelivered notifications` in the browser console after toggling sections.
|
||||
|
||||
**Confidence:** MEDIUM — the `min-h-[250px]` on ChartContainer from Phase 2 may already be sufficient. Add `contain` only if errors appear in testing.
|
||||
|
||||
### Pitfall 2: Mount-Time Animation Flicker
|
||||
**What goes wrong:** Sections that start expanded (over-budget auto-expand) animate open on first render even though they should appear pre-opened.
|
||||
|
||||
**Why it happens:** The Radix `CollapsibleContent` animation keyframe fires on mount if `defaultOpen={true}`.
|
||||
|
||||
**How to avoid:** Radix handles this internally: `isMountAnimationPreventedRef.current` is initialized to `isOpen` (true if open on mount), and the `animationName` is set to `"none"` during the initial layout effect, then restored after a `requestAnimationFrame`. This means the animation is suppressed on mount automatically. No additional handling needed.
|
||||
|
||||
### Pitfall 3: Chevron Rotation Targeting
|
||||
**What goes wrong:** The chevron icon doesn't rotate because the Tailwind ancestor-state class references the wrong ancestor's `data-state`.
|
||||
|
||||
**Why it happens:** In Radix Collapsible, the `data-state` attribute is set on both the `CollapsibleTrigger` button element AND the root `Collapsible` div. The icon is a child of the trigger button.
|
||||
|
||||
**How to avoid:** Use the trigger's own `data-state` as the ancestor for rotation. The simplest approach — add `group` class to the `CollapsibleTrigger` (or its `asChild` element) and use `group-data-[state=open]:rotate-90` on the icon:
|
||||
|
||||
```tsx
|
||||
<CollapsibleTrigger asChild>
|
||||
<button className="group flex w-full items-center ...">
|
||||
<ChevronRight className="size-4 transition-transform duration-200 group-data-[state=open]:rotate-90" />
|
||||
...
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
```
|
||||
|
||||
### Pitfall 4: `useEffect` Reset Dependency Array
|
||||
**What goes wrong:** Expand state doesn't reset on month navigation, or resets on every render.
|
||||
|
||||
**Why it happens:** Wrong dependency in the `useEffect` that resets `openSections`.
|
||||
|
||||
**How to avoid:** Depend on `budgetId` (the string prop from `DashboardContent`), not on `groupedSections` (which changes reference on every render due to `useMemo`). `budgetId` changes exactly when the user navigates months.
|
||||
|
||||
### Pitfall 5: i18n Key Interpolation for Carryover Subtitle
|
||||
**What goes wrong:** Carryover subtitle shows `"Includes {{amount}} carryover"` as literal text.
|
||||
|
||||
**Why it happens:** i18next interpolation requires `t("key", { amount: "..." })` and the JSON to use `{{amount}}` syntax.
|
||||
|
||||
**How to avoid:**
|
||||
```json
|
||||
// en.json
|
||||
"dashboard": {
|
||||
"carryoverIncludes": "Includes {{amount}} carryover"
|
||||
}
|
||||
```
|
||||
```typescript
|
||||
t("dashboard.carryoverIncludes", { amount: formatCurrency(carryover, currency) })
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Code Examples
|
||||
|
||||
Verified patterns from source inspection and project conventions:
|
||||
|
||||
### Collapsible Controlled Pattern
|
||||
```tsx
|
||||
// Source: @radix-ui/react-collapsible v1.1.12 type definitions + project conventions
|
||||
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from "@/components/ui/collapsible"
|
||||
|
||||
<Collapsible open={open} onOpenChange={onOpenChange}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<button className="group flex w-full items-center gap-3 rounded-md border-l-4 px-4 py-3"
|
||||
style={{ borderLeftColor: "var(--color-income)" }}>
|
||||
<ChevronRight className="size-4 shrink-0 transition-transform duration-200 group-data-[state=open]:rotate-90" />
|
||||
<span className="font-medium">Income</span>
|
||||
{/* badges and diff */}
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="overflow-hidden data-[state=open]:animate-collapsible-open data-[state=closed]:animate-collapsible-close">
|
||||
{/* table */}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
```
|
||||
|
||||
### CSS Keyframe Animation (index.css addition)
|
||||
```css
|
||||
/* Source: Radix docs pattern + project @theme inline convention */
|
||||
@theme inline {
|
||||
--animate-collapsible-open: collapsible-open 200ms ease-out;
|
||||
--animate-collapsible-close: collapsible-close 200ms ease-out;
|
||||
}
|
||||
|
||||
@keyframes collapsible-open {
|
||||
from { height: 0; overflow: hidden; }
|
||||
to { height: var(--radix-collapsible-content-height); overflow: hidden; }
|
||||
}
|
||||
|
||||
@keyframes collapsible-close {
|
||||
from { height: var(--radix-collapsible-content-height); overflow: hidden; }
|
||||
to { height: 0; overflow: hidden; }
|
||||
}
|
||||
```
|
||||
|
||||
### Direction-Aware Over-Budget Logic
|
||||
```typescript
|
||||
// Source: CONTEXT.md locked decisions
|
||||
function isOverBudget(type: CategoryType, budgeted: number, actual: number): boolean {
|
||||
if (type === "income" || type === "saving" || type === "investment") {
|
||||
return actual < budgeted // under-earned / under-saved = problem
|
||||
}
|
||||
return actual > budgeted // overspent = problem
|
||||
}
|
||||
```
|
||||
|
||||
### Carryover Subtitle in StatCard
|
||||
```typescript
|
||||
// Modified StatCard — add optional subtitle prop
|
||||
interface StatCardProps {
|
||||
title: string
|
||||
value: string
|
||||
valueClassName?: string
|
||||
subtitle?: string // e.g. "Includes €150.00 carryover"
|
||||
subtitleClassName?: string // e.g. "text-over-budget" for negative carryover
|
||||
variance?: { amount: string; direction: "up" | "down" | "neutral"; label: string }
|
||||
}
|
||||
|
||||
// In render:
|
||||
{subtitle && (
|
||||
<p className={cn("mt-0.5 text-xs text-muted-foreground", subtitleClassName)}>
|
||||
{subtitle}
|
||||
</p>
|
||||
)}
|
||||
```
|
||||
|
||||
### Read-Only Line-Item Table Pattern (Dashboard variant)
|
||||
```tsx
|
||||
// Source: BudgetDetailPage.tsx pattern, adapted for read-only dashboard use
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("categories.name")}</TableHead>
|
||||
<TableHead className="text-right">{t("budgets.budgeted")}</TableHead>
|
||||
<TableHead className="text-right">{t("budgets.actual")}</TableHead>
|
||||
<TableHead className="text-right">{t("budgets.difference")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.map((item) => (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell className="font-medium">{item.category?.name ?? item.category_id}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{formatCurrency(item.budgeted_amount, currency)}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{formatCurrency(item.actual_amount, currency)}</TableCell>
|
||||
<TableCell className={cn("text-right tabular-nums", isOverItem ? "text-over-budget" : "text-muted-foreground")}>
|
||||
{formatCurrency(Math.abs(diff), currency)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
<TableFooter>
|
||||
<TableRow>
|
||||
<TableCell className="font-medium">{t(`categories.types.${type}`)} Total</TableCell>
|
||||
<TableCell className="text-right tabular-nums font-medium">{formatCurrency(budgeted, currency)}</TableCell>
|
||||
<TableCell className="text-right tabular-nums font-medium">{formatCurrency(actual, currency)}</TableCell>
|
||||
<TableCell className={cn("text-right tabular-nums font-medium", isOver ? "text-over-budget" : "text-on-budget")}>
|
||||
{formatCurrency(Math.abs(diff), currency)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableFooter>
|
||||
</Table>
|
||||
```
|
||||
|
||||
### New i18n Keys Required
|
||||
```json
|
||||
// en.json additions under "dashboard":
|
||||
{
|
||||
"dashboard": {
|
||||
"sections": {
|
||||
"itemName": "Item",
|
||||
"groupTotal": "{{label}} Total"
|
||||
},
|
||||
"carryoverIncludes": "Includes {{amount}} carryover"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
// de.json additions:
|
||||
{
|
||||
"dashboard": {
|
||||
"sections": {
|
||||
"itemName": "Posten",
|
||||
"groupTotal": "{{label}} Gesamt"
|
||||
},
|
||||
"carryoverIncludes": "Inkl. {{amount}} Übertrag"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## State of the Art
|
||||
|
||||
| Old Approach | Current Approach | When Changed | Impact |
|
||||
|--------------|------------------|--------------|--------|
|
||||
| `max-height: 9999px` CSS hack | `height` animation with `--radix-collapsible-content-height` | Radix ~v1.0 | Smooth animation with no lag |
|
||||
| Custom `aria-expanded` management | Radix `CollapsibleTrigger` manages `aria-expanded` automatically | Radix v1.x | Correct accessibility with zero effort |
|
||||
| Separate `@radix-ui/react-collapsible` install | Included in `radix-ui` v1.4.3 umbrella package | radix-ui monorepo consolidation | Already present in project — no install needed |
|
||||
|
||||
**Deprecated/outdated:**
|
||||
- `defaultOpen` on `Collapsible` root: Still valid, but we use controlled `open` + `onOpenChange` for the reset-on-navigation requirement
|
||||
- `hidden` prop removed from `CollapsibleContent` in newer Radix: Radix manages `hidden` attribute internally; never pass it manually
|
||||
|
||||
---
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. **CSS `contain` on chart grid to prevent ResizeObserver errors**
|
||||
- What we know: Recharts uses ResizeObserver; layout shifts from sections opening can trigger loop errors
|
||||
- What's unclear: Whether Phase 2's `min-h-[250px]` on charts is already sufficient to prevent jank
|
||||
- Recommendation: Implement without `contain` first; add `[contain:layout_style]` to chart grid div only if ResizeObserver errors appear in manual testing
|
||||
|
||||
2. **Tailwind v4 `data-[]` variant syntax for `group-data-[state=open]`**
|
||||
- What we know: Tailwind v4 supports arbitrary group variants; the project uses `group` pattern already (sidebar.tsx)
|
||||
- What's unclear: Whether Tailwind v4's JIT generates `group-data-[state=open]:` without explicit config
|
||||
- Recommendation: Use it — Tailwind v4 generates arbitrary variants JIT; if it doesn't compile, fall back to the `[[data-state=open]_&]:rotate-90` CSS selector approach
|
||||
|
||||
---
|
||||
|
||||
## Validation Architecture
|
||||
|
||||
### Test Framework
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| Framework | None installed — no test infrastructure in project |
|
||||
| Config file | None — Wave 0 would need `vitest.config.ts` |
|
||||
| Quick run command | N/A |
|
||||
| Full suite command | `bun run lint` (only automated check available) |
|
||||
|
||||
### Phase Requirements → Test Map
|
||||
| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|
||||
|--------|----------|-----------|-------------------|-------------|
|
||||
| UI-DASH-01 | Collapsible sections render between charts and QuickAdd | manual-only | — | N/A |
|
||||
| UI-DASH-01 | Over-budget sections auto-expand on load | manual-only | — | N/A |
|
||||
| UI-DASH-01 | Carryover subtitle appears on balance card when non-zero | manual-only | — | N/A |
|
||||
| UI-COLLAPSE-01 | Section expands/collapses with smooth animation | manual-only | — | N/A |
|
||||
| UI-COLLAPSE-01 | No ResizeObserver loop errors on rapid toggle | manual-only | browser console check | N/A |
|
||||
| UI-COLLAPSE-01 | Empty category groups are hidden | manual-only | — | N/A |
|
||||
| UI-COLLAPSE-01 | State resets on month navigation | manual-only | — | N/A |
|
||||
|
||||
**Justification for manual-only:** No test framework is installed. The project has no `vitest`, `jest`, `@testing-library/react`, or similar. Installing a test framework is out of scope for Phase 3 (the project's TESTING.md notes this explicitly). All validation will be manual browser testing.
|
||||
|
||||
The primary automated check available is `bun run lint` (ESLint), which catches hooks rules violations, unused variables, and TypeScript errors.
|
||||
|
||||
### Sampling Rate
|
||||
- **Per task commit:** `bun run lint` (catches TypeScript errors and hooks violations)
|
||||
- **Per wave merge:** `bun run build` (full TypeScript compile + Vite bundle)
|
||||
- **Phase gate:** Manual browser testing of all 7 behaviors above before `/gsd:verify-work`
|
||||
|
||||
### Wave 0 Gaps
|
||||
None — no test infrastructure to create. Lint and build are the only automated gates.
|
||||
|
||||
---
|
||||
|
||||
## Sources
|
||||
|
||||
### Primary (HIGH confidence)
|
||||
- `node_modules/@radix-ui/react-collapsible/dist/index.mjs` — Full source inspection of `CollapsibleContentImpl`; confirmed `--radix-collapsible-content-height` CSS variable, `data-state` values `"open"`/`"closed"`, `hidden` attribute management, mount-animation prevention via `isMountAnimationPreventedRef`
|
||||
- `node_modules/@radix-ui/react-collapsible/dist/index.d.ts` — Confirmed type signatures: `CollapsibleProps.open`, `CollapsibleProps.onOpenChange`, `CollapsibleProps.defaultOpen`
|
||||
- `src/components/ui/collapsible.tsx` — Confirmed wrapper already in project, exports `Collapsible`, `CollapsibleTrigger`, `CollapsibleContent`
|
||||
- `src/pages/DashboardPage.tsx` — Confirmed existing `DashboardContent` structure, `useMemo` placement before early returns, `budget.carryover_amount` already in scope
|
||||
- `src/pages/BudgetDetailPage.tsx` — Confirmed grouping pattern (`CATEGORY_TYPES.map`, `items.filter`, per-group totals), `Table`/`TableFooter` pattern, `DifferenceCell` logic
|
||||
- `src/lib/palette.ts` — Confirmed `categoryColors` returns `"var(--color-income)"` etc.; `categoryLabels` for EN/DE display strings
|
||||
- `src/lib/types.ts` — Confirmed `Budget.carryover_amount: number`, `BudgetItem.category?.type`
|
||||
- `src/index.css` — Confirmed `--color-over-budget`, `--color-on-budget` semantic tokens; `@theme inline` pattern for CSS custom properties
|
||||
- `src/i18n/en.json` + `de.json` — Confirmed existing keys; identified gaps for new keys
|
||||
- `src/components/dashboard/StatCard.tsx` — Confirmed current interface (no subtitle prop); variance prop pattern
|
||||
|
||||
### Secondary (MEDIUM confidence)
|
||||
- `.planning/codebase/CONVENTIONS.md` — Component structure, hooks-before-returns rule, import ordering, TypeScript strict mode
|
||||
- `.planning/codebase/TESTING.md` — Confirmed no test framework installed; lint/build are only automated checks
|
||||
- `.planning/STATE.md` — Confirmed pre-existing lint errors in unrelated files; Phase 2 patterns (useMemo before early returns, QuickAdd position)
|
||||
|
||||
### Tertiary (LOW confidence)
|
||||
- None — all findings verified against installed source code
|
||||
|
||||
---
|
||||
|
||||
## Metadata
|
||||
|
||||
**Confidence breakdown:**
|
||||
- Standard stack: HIGH — all libraries inspected from installed node_modules source
|
||||
- Architecture: HIGH — patterns derived directly from existing codebase files
|
||||
- Pitfalls: MEDIUM — ResizeObserver issue is known Recharts behavior; specific CSS `contain` fix is speculative pending testing
|
||||
- Animation pattern: HIGH — `--radix-collapsible-content-height` confirmed from source, Tailwind v4 `data-[]` variants confirmed from existing sidebar.tsx usage
|
||||
|
||||
**Research date:** 2026-03-17
|
||||
**Valid until:** 2026-04-17 (stable libraries; CSS variables are fixed in installed source)
|
||||
Reference in New Issue
Block a user