Files
SimpleFinanceDash/.planning/research/ARCHITECTURE.md

25 KiB

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

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:

// 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:

// 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:

// 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:

// 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
DashboardContentCategorySection Props only — grouped items, budgeted/actual totals, currency, mutation handlers Mutation handlers passed down from DashboardContent which owns useBudgets() mutations
CategorySectionBudgetLineItems 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


Architecture research for: SimpleFinanceDash UI overhaul — React + Tailwind + shadcn/ui + Recharts Researched: 2026-03-16