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 |
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 inshared/. Avoids polluting the top-level components directory.components/shared/:PageShellis 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 vianpx shadcn@latest add collapsiblebefore buildingCategorySection.
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 |
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:
components/ui/collapsible.tsx— Add vianpx shadcn@latest add collapsible. Required byCategorySection. Do first.components/ui/chart.tsx— Add vianpx shadcn@latest add chart. Required by all chart components. Do first.shared/PageShell.tsx— No dependencies. Build early; apply to all pages as each is refreshed.StatCard+SummaryStrip— Only depends onformatCurrencyand Tailwind. Build second after primitives.- Chart components (
IncomeBarChart,ExpenseDonutChart,SpendBarChart) — Depend onChartContainer. Build after step 2. ChartPanel— Composes the three chart components. Build after step 5.BudgetLineItems— Refactored from existingBudgetDetailPagetable code; existingInlineEditCellandDifferenceCellare reusable as-is.CategorySection— Depends onCollapsibleprimitive andBudgetLineItems. Build after steps 1 and 7.DashboardContent— Orchestrates everything. Build last, wiring together all children. Replace the existingDashboardContentfunction inDashboardPage.tsx.- 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