Files
SimpleFinanceDash/.planning/research/ARCHITECTURE.md

453 lines
25 KiB
Markdown

# Architecture Research
**Domain:** Personal finance dashboard UI — React SPA overhaul
**Researched:** 2026-03-16
**Confidence:** HIGH (existing codebase is fully inspected; patterns are grounded in Radix/shadcn/Recharts official docs)
---
## Standard Architecture
### System Overview
The existing three-tier architecture (Pages → Hooks → Supabase) is sound and must be preserved. The UI overhaul introduces a new layer of **dashboard-specific view components** that sit between pages and the primitive shadcn/ui atoms. Nothing touches hooks or the library layer.
```
┌───────────────────────────────────────────────────────────────┐
│ Pages Layer │
│ DashboardPage CategoriesPage BudgetDetailPage ... │
│ (routing, data loading, layout composition) │
├───────────────────────────────────────────────────────────────┤
│ View Components Layer [NEW] │
│ ┌─────────────────┐ ┌──────────────┐ ┌─────────────────┐ │
│ │ DashboardContent│ │CategorySection│ │ ChartPanel │ │
│ │ (hybrid layout) │ │(collapsible) │ │ (chart wrappers)│ │
│ └─────────────────┘ └──────────────┘ └─────────────────┘ │
│ ┌─────────────────┐ ┌──────────────┐ ┌─────────────────┐ │
│ │ SummaryStrip │ │ BudgetTable │ │ PageShell │ │
│ │ (KPI cards row) │ │ (line items) │ │ (consistent │ │
│ └─────────────────┘ └──────────────┘ │ header+CTA) │ │
│ └─────────────────┘ │
├───────────────────────────────────────────────────────────────┤
│ Primitive UI Layer (shadcn/ui) │
│ Card Button Table Dialog Select Collapsible Badge ... │
├───────────────────────────────────────────────────────────────┤
│ Hooks Layer [UNCHANGED] │
│ useBudgets useBudgetDetail useCategories useAuth ... │
├───────────────────────────────────────────────────────────────┤
│ Library Layer [UNCHANGED] │
│ supabase.ts types.ts format.ts palette.ts utils.ts │
│ index.css (@theme tokens — EXTEND for new color tokens) │
└───────────────────────────────────────────────────────────────┘
```
**The constraint is strict:** hooks and library are read-only during this milestone. All UI overhaul changes land in `src/pages/`, `src/components/`, and `src/index.css` only.
---
### Component Responsibilities
| Component | Responsibility | Typical Implementation |
|-----------|----------------|------------------------|
| `DashboardPage` | Find current month budget, render shell | Unchanged outer page; delegates to `DashboardContent` |
| `DashboardContent` | Hybrid layout orchestration | Calls `useBudgetDetail`; computes derived data with `useMemo`; renders SummaryStrip + charts + CategorySections |
| `SummaryStrip` | Three KPI cards (income, expenses, balance) | Grid of `StatCard` components; color-coded balance |
| `StatCard` | Single KPI display unit | shadcn Card with title, large number, optional trend indicator |
| `ChartPanel` | Houses all charts in responsive grid | Two-column grid on desktop: income bar chart left, expense donut right, spend horizontal bar full-width below |
| `IncomeBarChart` | Budgeted vs actual income vertical bar | Recharts `BarChart` wrapped in `ChartContainer` with `ChartConfig` |
| `ExpenseDonutChart` | Expense category breakdown donut | Recharts `PieChart` with `innerRadius`/`outerRadius` + custom legend |
| `SpendBarChart` | Horizontal budget vs actual by category type | Recharts `BarChart layout="vertical"` |
| `CategorySection` | Collapsible group for one category type | Radix `Collapsible.Root` wrapping a header row + `BudgetLineItems` |
| `CategorySectionHeader` | Always-visible row: type label, color dot, group totals, chevron | Trigger for the collapsible; shows budgeted/actual/diff inline |
| `BudgetLineItems` | Table of individual line items inside a section | shadcn `Table`; thin wrapper around existing `InlineEditCell` / `DifferenceCell` atoms |
| `PageShell` | Consistent page header with title + primary CTA | Reusable wrapper used by every page; enforces padding, heading size, CTA slot |
| `AppLayout` | Sidebar navigation shell | Minor visual refresh only; structure unchanged |
---
## Recommended Project Structure
The existing structure is well-organized. The overhaul adds a `dashboard/` subfolder and a `shared/` subfolder under components — no reorganization of hooks or lib.
```
src/
├── components/
│ ├── ui/ # shadcn primitives (do not modify)
│ │ └── collapsible.tsx # ADD — Radix Collapsible primitive
│ ├── dashboard/ # ADD — dashboard-specific view components
│ │ ├── DashboardContent.tsx # hybrid layout orchestrator
│ │ ├── SummaryStrip.tsx # KPI cards row
│ │ ├── StatCard.tsx # single KPI card
│ │ ├── ChartPanel.tsx # chart grid container
│ │ ├── IncomeBarChart.tsx # budgeted vs actual income bar
│ │ ├── ExpenseDonutChart.tsx # donut + legend
│ │ ├── SpendBarChart.tsx # horizontal budget vs actual
│ │ ├── CategorySection.tsx # collapsible category group
│ │ └── BudgetLineItems.tsx # line-item table inside section
│ ├── shared/ # ADD — cross-page reusable components
│ │ └── PageShell.tsx # consistent page header + CTA slot
│ ├── AppLayout.tsx # MODIFY — visual refresh only
│ └── QuickAddPicker.tsx # unchanged
├── pages/ # MODIFY — swap DashboardContent import; apply PageShell
│ ├── DashboardPage.tsx
│ ├── BudgetDetailPage.tsx
│ ├── BudgetListPage.tsx
│ ├── CategoriesPage.tsx
│ ├── TemplatePage.tsx
│ ├── QuickAddPage.tsx
│ ├── SettingsPage.tsx
│ ├── LoginPage.tsx
│ └── RegisterPage.tsx
├── hooks/ # UNCHANGED
├── lib/
│ ├── palette.ts # UNCHANGED — CSS vars already defined
│ └── ... # everything else unchanged
├── i18n/
│ ├── en.json # ADD new translation keys
│ └── de.json # ADD new translation keys
└── index.css # ADD semantic color tokens if needed
```
### Structure Rationale
- **`components/dashboard/`:** All dashboard-specific view components are co-located. They have no meaning outside the dashboard, so they do not belong in `shared/`. Avoids polluting the top-level components directory.
- **`components/shared/`:** `PageShell` is the one genuinely cross-page component introduced by this milestone. Keeping it separate signals that it is intentionally reusable, not page-specific.
- **`components/ui/collapsible.tsx`:** The Radix Collapsible primitive is not yet in the project (inspected file list confirms absence). It must be added via `npx shadcn@latest add collapsible` before building `CategorySection`.
---
## Architectural Patterns
### Pattern 1: Derived Data via `useMemo` in DashboardContent
**What:** All computed values — category group totals, chart data arrays, KPI numbers — are derived in one place (`DashboardContent`) using `useMemo`, then passed as plain props to presentational child components. Child components never call hooks or perform calculations themselves.
**When to use:** Any time a value depends on `items` array from `useBudgetDetail`. Centralizing derivation means one cache invalidation (after a budget item update) triggers one recalculation, and all children rerender from the same consistent snapshot.
**Trade-offs:** Slightly more props-passing verbosity. Benefit: children are trivially testable pure components.
**Example:**
```typescript
// DashboardContent.tsx
const { budget, items } = useBudgetDetail(budgetId)
const totals = useMemo(() => {
const income = items
.filter(i => i.category?.type === "income")
.reduce((sum, i) => sum + i.actual_amount, 0)
const expenses = items
.filter(i => i.category?.type !== "income")
.reduce((sum, i) => sum + i.actual_amount, 0)
return { income, expenses, balance: income - expenses + (budget?.carryover_amount ?? 0) }
}, [items, budget?.carryover_amount])
const groupedItems = useMemo(() =>
CATEGORY_TYPES.map(type => ({
type,
items: items.filter(i => i.category?.type === type),
budgeted: items.filter(i => i.category?.type === type).reduce((s, i) => s + i.budgeted_amount, 0),
actual: items.filter(i => i.category?.type === type).reduce((s, i) => s + i.actual_amount, 0),
})).filter(g => g.items.length > 0),
[items])
// Pass totals and groupedItems as props; child components are pure
```
### Pattern 2: Collapsible Category Sections via Radix Collapsible
**What:** Each category group (income, bills, variable expenses, debt, savings, investment) is wrapped in a `Collapsible.Root`. The always-visible trigger row shows the category label, color dot, and group-level budget/actual/difference totals. The collapsible content reveals the individual line-item table.
**When to use:** The dashboard hybrid view — users need the summary at a glance without scrolling through every line item. Opening a section is an explicit drill-down action.
**Trade-offs:** Adds `open` state per section. Use `useState` per section (not global state — there are at most 6 sections). Do not persist open state in localStorage for v1; the sections should open fresh on each visit so the summary view is the default.
**Example:**
```typescript
// CategorySection.tsx
import { Collapsible, CollapsibleContent, CollapsibleTrigger }
from "@/components/ui/collapsible"
import { ChevronDown } from "lucide-react"
import { useState } from "react"
interface CategorySectionProps {
type: CategoryType
budgeted: number
actual: number
items: BudgetItem[]
currency: string
}
export function CategorySection({ type, budgeted, actual, items, currency }: CategorySectionProps) {
const [open, setOpen] = useState(false)
const { t } = useTranslation()
return (
<Collapsible open={open} onOpenChange={setOpen}>
<CollapsibleTrigger asChild>
<button className="flex w-full items-center gap-3 rounded-lg border px-4 py-3 hover:bg-muted/40">
<span className="size-3 shrink-0 rounded-full"
style={{ backgroundColor: categoryColors[type] }} />
<span className="font-medium">{t(`categories.types.${type}`)}</span>
<span className="ml-auto tabular-nums text-sm text-muted-foreground">
{formatCurrency(actual, currency)} / {formatCurrency(budgeted, currency)}
</span>
<ChevronDown className={`size-4 transition-transform ${open ? "rotate-180" : ""}`} />
</button>
</CollapsibleTrigger>
<CollapsibleContent>
<BudgetLineItems items={items} currency={currency} type={type} />
</CollapsibleContent>
</Collapsible>
)
}
```
### Pattern 3: shadcn ChartContainer + ChartConfig for All Charts
**What:** Wrap every Recharts chart in shadcn's `ChartContainer` component. Define colors and labels in a `ChartConfig` object that references existing CSS variable tokens from `index.css` (`var(--color-income)`, `var(--color-bill)`, etc.). Do not hardcode hex values inside chart components.
**When to use:** All three chart types (bar, horizontal bar, donut). This ensures charts automatically theme with the design system and dark mode works at zero extra cost.
**Trade-offs:** Requires adding the shadcn `chart` component (`npx shadcn@latest add chart`). Minor wrapper overhead, but the CSS variable binding and tooltip consistency is worth it.
**Example:**
```typescript
// IncomeBarChart.tsx
import { ChartContainer, ChartTooltip, ChartTooltipContent }
from "@/components/ui/chart"
import { BarChart, Bar, XAxis, YAxis, CartesianGrid } from "recharts"
const chartConfig = {
budgeted: { label: "Budgeted", color: "var(--color-income)" },
actual: { label: "Actual", color: "var(--color-income)" },
} satisfies ChartConfig
// data: [{ month: "March", budgeted: 3000, actual: 2850 }]
export function IncomeBarChart({ data, currency }: Props) {
return (
<ChartContainer config={chartConfig} className="min-h-[200px] w-full">
<BarChart data={data}>
<CartesianGrid vertical={false} />
<XAxis dataKey="month" />
<YAxis tickFormatter={v => formatCurrency(v, currency)} />
<ChartTooltip content={<ChartTooltipContent />} />
<Bar dataKey="budgeted" fill="var(--color-income)" radius={4} />
<Bar dataKey="actual" fill="var(--color-income)" fillOpacity={0.6} radius={4} />
</BarChart>
</ChartContainer>
)
}
```
### Pattern 4: PageShell for Consistent Page Headers
**What:** A `PageShell` component accepts `title`, `description` (optional), and `action` (optional ReactNode slot for a primary CTA button). Every page wraps its top section in `PageShell`. This enforces a consistent heading size, spacing, and CTA placement across the entire app refresh.
**When to use:** All 9 pages in the overhaul. Any new page added in future milestones should also use it.
**Trade-offs:** Adds one wrapper per page. The benefit is that a single visual change to the page header propagates everywhere without hunting through 9 files.
**Example:**
```typescript
// shared/PageShell.tsx
interface PageShellProps {
title: string
description?: string
action?: React.ReactNode
children: React.ReactNode
}
export function PageShell({ title, description, action, children }: PageShellProps) {
return (
<div className="flex flex-col gap-6">
<div className="flex items-start justify-between gap-4">
<div>
<h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
{description && (
<p className="text-sm text-muted-foreground mt-1">{description}</p>
)}
</div>
{action && <div className="shrink-0">{action}</div>}
</div>
{children}
</div>
)
}
```
---
## Data Flow
### Dashboard Read Flow
```
DashboardPage renders
useBudgets() → finds current month budget → passes budgetId prop
DashboardContent mounts
useBudgetDetail(budgetId) → TanStack Query cache or Supabase fetch
↓ data arrives
useMemo recalculates: totals, groupedItems, chartData
Props flow DOWN to pure presentational children:
SummaryStrip(totals)
ChartPanel(chartData, currency)
├── IncomeBarChart(barData)
├── ExpenseDonutChart(pieData)
└── SpendBarChart(horizontalData)
CategorySection[] (groupedItems, per-type items)
└── BudgetLineItems(items, currency)
```
### Budget Item Edit Flow (unchanged, flows back up)
```
InlineEditCell: user types new actual_amount
onCommit → updateItem.mutateAsync({ id, budgetId, actual_amount })
Supabase updates budget_items row
onSuccess: queryClient.invalidateQueries(["budgets", budgetId, "items"])
useBudgetDetail re-fetches items
DashboardContent useMemo recalculates all derived values
ALL children rerender with consistent new data
```
### State Management (what lives where)
| State | Location | Why |
|-------|----------|-----|
| Budget and items data | TanStack Query cache | Server state, must survive component unmounts |
| Collapsible open/closed | `useState` in each `CategorySection` | Purely local UI state; 6 booleans maximum |
| Chart tooltip hover | Recharts internal | Library-managed interaction state |
| Dialog open/closed | `useState` in page components | Unchanged from current pattern |
| Currency, locale | `Profile` via Supabase → hooks | Read from `budget.currency`; no separate state |
---
## Scaling Considerations
This is a personal finance app for a single authenticated user at a time. Scale is not a concern for rendering. The relevant concern is **perceived performance** on the dashboard when `items` is large (100+ line items).
| Scale | Architecture Adjustment |
|-------|--------------------------|
| <50 items (normal) | No optimization needed — current useMemo pattern is fast |
| 50-200 items | useMemo already handles this — O(n) passes are negligible |
| 200+ items | Consider React.memo on CategorySection to skip unchanged sections; still no virtualization needed |
| Dashboard load time | TanStack Query 5-min staleTime means instant rerender on navigate-back; no change required |
---
## Anti-Patterns
### Anti-Pattern 1: Deriving Chart Data Inside Chart Components
**What people do:** Put `items.filter(...).reduce(...)` directly inside `IncomeBarChart` or `ExpenseDonutChart`, passing the raw `items` array from `useBudgetDetail` as a prop.
**Why it's wrong:** Each chart component recalculates from scratch. If `items` reference changes (after a mutation), all three charts recalculate independently. Chart components become impure, harder to test, and cannot be reused with different data shapes without changing their internals.
**Do this instead:** Derive all chart data in `DashboardContent` with `useMemo`. Pass prepared `barData`, `pieData`, `horizontalData` arrays to each chart. Charts receive typed data arrays and render only.
---
### Anti-Pattern 2: Hardcoding Colors Inside Chart Components
**What people do:** Paste hex values like `fill="#4ade80"` into `<Bar>` and `<Cell>` components to match the design.
**Why it's wrong:** The existing `index.css` already defines category colors as OKLCH CSS variables (`--color-income`, `--color-bill`, etc.) and `palette.ts` maps them to `var(--color-income)` etc. Hardcoding breaks dark mode adaptation, creates a second source of truth, and means a palette change requires editing multiple files.
**Do this instead:** Always reference `categoryColors[type]` from `palette.ts` (which returns the CSS variable string) for both chart fills and UI color dots. For shadcn `ChartContainer`, pass `color: categoryColors[type]` in the `ChartConfig` object.
---
### Anti-Pattern 3: One Monolithic DashboardContent Component
**What people do:** Add all new dashboard sections — summary cards, three charts, six collapsible sections, QuickAdd button — directly into one large `DashboardContent.tsx` that becomes 400+ lines.
**Why it's wrong:** The existing `DashboardContent` is already 200 lines with just two charts and progress bars. A full hybrid dashboard with three chart types and six collapsible sections will exceed 600 lines inline, making it impossible to review, test, or modify individual sections without reading the whole file.
**Do this instead:** Extract into the component tree defined above. `DashboardContent` orchestrates layout and owns derived data. Each distinct visual section (`SummaryStrip`, `ChartPanel`, `CategorySection`) is its own file. The rule: if a block of JSX has a distinct visual purpose, it gets its own component.
---
### Anti-Pattern 4: Using shadcn Accordion Instead of Collapsible for Category Sections
**What people do:** Reach for the `Accordion` component (which is already in some shadcn setups) to build collapsible category sections because it looks similar.
**Why it's wrong:** Accordion by default enforces "only one open at a time" (type="single") or requires explicit `type="multiple"` with `collapsible` prop to allow free open/close. For budget sections, users may want to compare two categories side-by-side with both open simultaneously. Using individual `Collapsible` per section gives full independent control without fighting Accordion's root-state coordination.
**Do this instead:** Use Radix `Collapsible` (shadcn wrapper) on each `CategorySection` with independent `useState`. Six independent booleans are trivially managed.
---
### Anti-Pattern 5: Modifying Hooks or Supabase Queries
**What people do:** Add derived fields or aggregations to the `useBudgetDetail` return value, or add new Supabase query fields, because it seems convenient during the UI overhaul.
**Why it's wrong:** Explicitly out of scope (PROJECT.md: "No Supabase schema changes — UI-only modifications"). Any hook changes risk breaking `BudgetDetailPage` and other consumers. The data model is sufficient — all needed aggregations can be computed with `useMemo` from the existing `items` array.
**Do this instead:** Keep hooks read-only. All computation lives in the presentation layer via `useMemo`.
---
## Integration Points
### External Services
| Service | Integration | Notes |
|---------|-------------|-------|
| Supabase | Unchanged — hooks layer handles all DB calls | No new RPC calls, no schema changes |
| Recharts | Chart primitives — wrap with `ChartContainer` | Requires adding shadcn `chart` component |
| Radix UI | `Collapsible` primitive — add via shadcn CLI | Not yet in project; must be added before CategorySection work |
| i18next | All new UI text needs keys in `en.json` and `de.json` | Add keys before rendering any new text; no runtime fallback acceptable |
### Internal Boundaries
| Boundary | Communication | Notes |
|----------|---------------|-------|
| `DashboardContent` ↔ chart components | Props only — typed data arrays + currency string | Charts are pure; they do not call hooks |
| `DashboardContent``CategorySection` | Props only — grouped items, budgeted/actual totals, currency, mutation handlers | Mutation handlers passed down from `DashboardContent` which owns `useBudgets()` mutations |
| `CategorySection``BudgetLineItems` | Props only — items array, currency, type | `BudgetLineItems` is a thin table wrapper; all mutations are callbacks from parent |
| `PageShell` ↔ all pages | Props (title, action slot, children) | No state shared; purely compositional |
| `index.css` @theme tokens ↔ components | CSS variables via `var(--color-X)` and `palette.ts` | Single source of truth for all color; never duplicate in component style props |
---
## Build Order Implications
Components have dependencies that dictate implementation order:
1. **`components/ui/collapsible.tsx`** — Add via `npx shadcn@latest add collapsible`. Required by `CategorySection`. Do first.
2. **`components/ui/chart.tsx`** — Add via `npx shadcn@latest add chart`. Required by all chart components. Do first.
3. **`shared/PageShell.tsx`** — No dependencies. Build early; apply to all pages as each is refreshed.
4. **`StatCard` + `SummaryStrip`** — Only depends on `formatCurrency` and Tailwind. Build second after primitives.
5. **Chart components** (`IncomeBarChart`, `ExpenseDonutChart`, `SpendBarChart`) — Depend on `ChartContainer`. Build after step 2.
6. **`ChartPanel`** — Composes the three chart components. Build after step 5.
7. **`BudgetLineItems`** — Refactored from existing `BudgetDetailPage` table code; existing `InlineEditCell` and `DifferenceCell` are reusable as-is.
8. **`CategorySection`** — Depends on `Collapsible` primitive and `BudgetLineItems`. Build after steps 1 and 7.
9. **`DashboardContent`** — Orchestrates everything. Build last, wiring together all children. Replace the existing `DashboardContent` function in `DashboardPage.tsx`.
10. **Non-dashboard page refreshes** — Apply `PageShell` + visual refresh to remaining 7 pages. Independent of dashboard work; can be done in any order.
---
## Sources
- Radix UI Collapsible: https://www.radix-ui.com/primitives/docs/components/collapsible (HIGH confidence — official docs)
- shadcn/ui Chart component: https://ui.shadcn.com/docs/components/radix/chart (HIGH confidence — official docs)
- shadcn/ui Accordion: https://ui.shadcn.com/docs/components/radix/accordion (HIGH confidence — official docs)
- Tailwind CSS v4 @theme tokens: https://tailwindcss.com/docs/theme (HIGH confidence — official docs)
- React useMemo: https://react.dev/reference/react/useMemo (HIGH confidence — official docs)
- Existing codebase: inspected `src/` fully — `DashboardPage.tsx`, `BudgetDetailPage.tsx`, `CategoriesPage.tsx`, `AppLayout.tsx`, `palette.ts`, `types.ts`, `index.css`, `useBudgets.ts` (HIGH confidence — primary source)
---
*Architecture research for: SimpleFinanceDash UI overhaul — React + Tailwind + shadcn/ui + Recharts*
*Researched: 2026-03-16*