diff --git a/.planning/phases/03-collapsible-dashboard-sections/03-RESEARCH.md b/.planning/phases/03-collapsible-dashboard-sections/03-RESEARCH.md
new file mode 100644
index 0000000..4790a85
--- /dev/null
+++ b/.planning/phases/03-collapsible-dashboard-sections/03-RESEARCH.md
@@ -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 (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.
+
+
+---
+
+
+## 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 |
+
+
+---
+
+## 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 ``/`` | 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
+
+ {/* table content */}
+
+```
+
+**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>(() =>
+ 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
+}
+
+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 (
+
+
+
+
+
+ {/* Table with items + footer */}
+
+
+ )
+}
+```
+
+**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 `.
+- **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 */}
+
+ ...charts...
+
+```
+
+**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
+
+
+
+```
+
+### 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"
+
+
+
+
+
+
+ {/* table */}
+
+
+```
+
+### 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 && (
+