BillShield UK — Technical Documentation (A to Z)
Version: 0.1.0 Status: Living document Last measured: 2026-07-14

Full-stack technical reference for the BillShield UK household support application. Covers the React/TypeScript frontend in depth, the FastAPI backend for end-to-end traceability, infrastructure, performance metrics, and operational concerns.

1. Executive Summary #

BillShield UK is a household support web application that helps UK residents under cost-of-living pressure understand their bills, identify realistic savings, find local support, and create a practical 30-day survival plan.

The Problem

UK households face rising energy costs, confusing tariff structures, and scattered support options. People under financial pressure need clear, ranked, actionable guidance — not 50 generic tips or shame-based advice.

The Solution: 4-Step Journey

StepPageWhat the user doesTimeConcrete benefit
1OnboardingEnter postcode, household type, income band, monthly costs3 minPersonalised calculations tuned to your household profile
2Bill UploadUpload an energy bill PDF (or skip to demo)1 clickInstant extraction of tariff rates, DD amount, annual usage
3DashboardView monthly pressure summary, forecast chart, bill breakdownInstantTop-3 ranked actions with exact savings estimates
4ActionSimulate scenarios, find local support, generate a 30-day plan5 minCall scripts, step-by-step instructions, Google Maps directions

Design Principles

2. System Architecture #

Context Diagram

flowchart LR
    User(["UK Resident\nunder cost-of-living\npressure"])

    subgraph BillShield["BillShield UK"]
        FE["Frontend SPA\nReact 19 + TypeScript\nVite build to static files"]
        BE["Backend API\nFastAPI + Python 3.12\n12 recommendation engines"]
        DB[("Database\nSQLite WAL / PostgreSQL")]
    end

    ExtOCR["OCR Provider\n(mocked to Textract)"]
    ExtTariff["Tariff Benchmark\n(mocked to Ofgem API)"]
    ExtCouncil["Council Lookup\n(mocked to GOV.UK)"]
    ExtMaps["Google Maps\n(directions links)"]

    User -->|uses| FE
    FE -->|fetch /api/v1/*| BE
    BE -->|SQLAlchemy 2.x| DB
    BE -.->|mocked MVP| ExtOCR
    BE -.->|mocked MVP| ExtTariff
    BE -.->|mocked MVP| ExtCouncil
    FE -->|directions URL| ExtMaps

    style FE fill:#3b82f6,color:#fff
    style BE fill:#10b981,color:#fff
    style DB fill:#8b5cf6,color:#fff
        

Deployment Topology

+-----------------------------------------------------------+
|                     User's Browser                         |
|  React 19.2 + TypeScript 5.9 + Tailwind CSS 4.2           |
|  Vite 7.3 build -> static SPA                              |
+----------------------------+------------------------------+
                             | fetch()
                             | /api/v1/*
                             v
+----------------------------------------------------------+
|          AWS EC2 (t2.micro, 2 vCPUs, 908 MB RAM)          |
|                                                          |
|  +----------------------------------------------------+  |
|  |  nginx (reverse proxy + static serving)            |  |
|  |  - gzip compression                                |  |
|  |  - SPA fallback: try_files -> /index.html          |  |
|  |  - /api/ -> proxy_pass to backend                  |  |
|  |  - /assets/* -> immutable cache (1 year)           |  |
|  +-------------------+-------------------------------+  |
|                      |                                   |
|  +-------------------v-------------------------------+  |
|  |  Docker: FastAPI container                         |  |
|  |  Uvicorn (2 workers) - Port 8000                   |  |
|  |  SQLite (WAL mode) + 2 GB swap                     |  |
|  +----------------------------------------------------+  |
+----------------------------------------------------------+
      

Full-Stack Component Map

flowchart TD
    subgraph Frontend["Frontend (React SPA)"]
        Router["BrowserRouter\n9 routes"]
        Pages["9 Page Components"]
        Components["Custom Components\n+ 58 shadcn/ui primitives"]
        APIClient["API Client\nfetch wrapper + mock fallback"]
        Theme["ThemeProvider\ndark/light/system"]
    end

    subgraph Backend["Backend (FastAPI)"]
        Routers["Router Layer (9)"]
        Services["Service Layer (7)"]
        Engines["Engine Layer (12)"]
        Providers["Provider Layer (6 interfaces)"]
        Repos["Repository Layer (5)"]
        Models["Data Models (5)"]
    end

    subgraph Data["Data Layer"]
        SQLite[("SQLite / PostgreSQL")]
        Seed["Mock Seed Data"]
    end

    Router --> Pages
    Pages --> Components
    Pages --> APIClient
    APIClient -->|HTTP /api/v1/*| Routers
    Routers --> Services
    Services --> Engines
    Services --> Providers
    Services --> Repos
    Repos --> Models
    Models --> SQLite
    Providers --> Seed

    style Frontend fill:#1e3a5f,color:#fff
    style Backend fill:#1e5f3a,color:#fff
    style Data fill:#5f1e3a,color:#fff
        

3. Technology Stack #

Complete Stack Matrix

LayerFrontendBackend
FrameworkReact 19.2FastAPI 0.115
LanguageTypeScript 5.9 (strict)Python 3.12
Build toolVite 7.3setuptools
RoutingReact Router 6.30FastAPI routers
StylingTailwind CSS 4.2 + shadcn/ui (new-york)
ChartsRecharts 3.8
IconsLucide React 1.6
ToastsSonner 2.0
FormsReact Hook Form 7.72 + Zod 4.3Pydantic v2
ORMSQLAlchemy 2.x
MigrationsAlembic 1.14
DatabaseSQLite (dev) / PostgreSQL (prod)
Servernginx (static + proxy)Uvicorn (2 workers)
ValidationZod schemasPydantic v2
TestingVitest 3.1 + RTL 16Pytest 8.3 + HTTPX
Lintingtsc (strict, no unused locals)Ruff
Loggingstructlog (JSON)
Rate limitingslowapi
ContainerDocker (node:22-alpine)Docker (python:3.12-slim)
CORSWildcard * (configurable)

Key Frontend Dependencies

PackageVersionPurpose
react / react-dom^19.2UI framework (concurrent features)
react-router-dom^6.30Client-side routing
radix-ui^1.4.3Accessible primitives (shadcn/ui base)
tailwindcss^4.2Utility-first CSS (Vite plugin)
recharts^3.8ComposedChart, BarChart for data viz
react-hook-form^7.72Form state management
@hookform/resolvers^5.2Zod resolver for RHF
zod^4.3Schema validation
sonner^2.0Toast notifications
lucide-react^1.6Icon set
class-variance-authority^0.7Component variant typing
clsx + tailwind-merge^2.1 / ^3.5cn() class merging utility
date-fns^4.1Date formatting
next-themes^0.4(installed, but custom ThemeProvider used)

4. Frontend Project Structure #

Annotated Source Tree

frontend/
|-- src/
|   |-- main.tsx                      # Entry point: createRoot + ThemeProvider + Toaster
|   |-- App.tsx                       # ErrorBoundary -> BrowserRouter -> 9 routes
|   |-- index.css                     # Tailwind v4 + shadcn theme tokens (OKLCH)
|   |
|   |-- api/                          # API layer (all backend communication)
|   |   |-- client.ts                 # fetch wrapper + mock-data fallback (131 lines)
|   |   |-- endpoints.ts              # 15 typed endpoint functions (125 lines)
|   |   |-- types.ts                  # 30+ TypeScript interfaces (315 lines)
|   |   `-- mock-data.ts              # In-browser mock implementations (390 lines)
|   |
|   |-- pages/                        # 9 route-level page components
|   |   |-- LandingPage.tsx           # Hero + demo button (439 lines)
|   |   |-- OnboardingPage.tsx        # 3-step wizard, 20 form fields
|   |   |-- BillUploadPage.tsx        # Drag-drop + skip-to-demo
|   |   |-- BillReviewPage.tsx        # Editable extraction + confidence badges
|   |   |-- DashboardPage.tsx         # Summary cards + chart + top-3 actions
|   |   |-- ScenarioSimulatorPage.tsx # Sliders + toggles + bar chart
|   |   |-- SupportMapPage.tsx        # Postcode search + 7 filters + Maps links
|   |   |-- ThirtyDayPlanPage.tsx     # Week-by-week checklist
|   |   `-- SettingsPage.tsx          # Profile edit + data deletion
|   |
|   |-- components/
|   |   |-- layout/
|   |   |   `-- AppLayout.tsx         # Desktop sidebar + mobile drawer (133 lines)
|   |   |-- dashboard/
|   |   |   |-- SummaryCard.tsx       # Stat card with variant colors (46 lines)
|   |   |   |-- PressureChart.tsx     # Recharts ComposedChart (128 lines)
|   |   |   `-- BillBreakdownCard.tsx # Cost line items + tariff badge (57 lines)
|   |   |-- recommendations/
|   |   |   `-- RecommendationCard.tsx # Expandable action card (181 lines)
|   |   |-- ui/                       # 58 shadcn/ui primitives
|   |   |-- ErrorBoundary.tsx         # Class component error catcher (61 lines)
|   |   |-- theme-provider.tsx        # Custom ThemeProvider (230 lines)
|   |   `-- mode-toggle.tsx           # Light/Dark/System dropdown (37 lines)
|   |
|   |-- hooks/
|   |   `-- use-mobile.ts             # useIsMobile() breakpoint hook (19 lines)
|   |
|   |-- lib/
|   |   `-- utils.ts                  # cn() - clsx + tailwind-merge (6 lines)
|   |
|   `-- utils/
|       |-- formatters.ts             # formatCurrency, formatPence, formatKwh (36 lines)
|       |-- badgeStyles.ts            # Badge color/label maps for 6 enum types (93 lines)
|       `-- storage.ts                # localStorage helpers for householdId/billId (27 lines)
|
|-- tests/                            # Vitest test suite (~181 test cases)
|-- vite.config.ts                    # Vite config: React + Tailwind + Vitest
|-- tsconfig.json                     # Root TS config (project references)
|-- tsconfig.app.json                 # App TS config (strict mode)
|-- package.json                      # Dependencies + scripts
|-- components.json                   # shadcn/ui config (new-york style)
|-- index.html                        # HTML shell (469 bytes)
`-- Dockerfile                        # node:22-alpine dev container
      

Path Aliases

AliasResolves toConfigured in
@/*./src/*tsconfig.json:9-11, vite.config.ts:10-12

Both TypeScript and Vite resolve @/ identically, ensuring compile-time and build-time path resolution match.

5. Build & Tooling #

Vite Configuration

vite.config.ts:7-19 — minimal, three concerns:

export default defineConfig({
  plugins: [react(), tailwindcss()],         // React Fast Refresh + Tailwind v4 JIT
  resolve: {
    alias: { "@": path.resolve(__dirname, "./src") },
  },
  test: {
    globals: true,                           // describe/it/expect available globally
    environment: "jsdom",                    // DOM simulation for component tests
    setupFiles: ["./tests/setup.ts"],        // jest-dom matchers
    css: false,                              // CSS not processed in tests
  },
});

TypeScript Strict Configuration

tsconfig.app.json — maximum strictness:

SettingValueEffect
stricttrueAll strict checks (null, no-implicit-any, etc.)
noUnusedLocalstrueError on unused local variables
noUnusedParameterstrueError on unused function parameters
noFallthroughCasesInSwitchtrueError on switch fallthrough
verbatimModuleSyntaxtrueEnforces import type for type-only imports
moduleDetectionforceAll files treated as modules
targetES2022Modern JS output
moduleESNextNative ESM
moduleResolutionbundlerVite-style resolution
jsxreact-jsxAutomatic runtime (no React import needed)
erasableSyntaxOnlytrueOnly TS syntax that erases cleanly

NPM Scripts

ScriptCommandPurpose
devviteDev server at http://localhost:5173
buildtsc -b && vite buildType-check + production build to dist/
typechecktsc --noEmitType validation only (no output)
previewvite previewServe built dist/ locally
testvitest runRun test suite once
test:watchvitestRun tests in watch mode

Environment Variables

FileVITE_API_BASE_URLUsed for
.envhttp://localhost:8000/api/v1Local development (separate backend)
.env.production/api/v1Same-origin production (nginx proxy)

Mock mode: If VITE_API_BASE_URL is unset/empty, USE_MOCK = !BASE evaluates to true (api/client.ts:20), and the frontend uses in-browser mock data from api/mock-data.ts — no backend required. This enables standalone development and preview deployments without a running backend.

Dockerfile

Dockerfile:1-12 — development container:

FROM node:22-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci                              # Deterministic install from lockfile
COPY . .
EXPOSE 5173
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]

Note: This Dockerfile runs the dev server. Production serving is handled by nginx pointing at the pre-built dist/ directory (see Section 19).

6. Application Bootstrap #

Bootstrap Sequence

flowchart TD
    HTML["index.html\n(469 B shell)"] -->|loads| Main["main.tsx"]
    Main -->|createRoot| Root["document.getElementById(root)"]
    Root --> StrictMode["StrictMode"]
    StrictMode --> Theme["ThemeProvider\ndefaultTheme=light\nstorageKey=billshield-theme"]
    Theme --> App["App"]
    Theme --> Toaster["Toaster\nposition=top-right\nrichColors + closeButton"]
    App --> EB["ErrorBoundary"]
    EB --> BR["BrowserRouter"]
    BR --> Routes["Routes"]
    Routes --> Layout["AppLayout (wrapper)"]
    Layout --> Pages["9 page components"]

    style Main fill:#3b82f6,color:#fff
    style Theme fill:#8b5cf6,color:#fff
    style EB fill:#ef4444,color:#fff
        

Entry Point (main.tsx:9-16)

createRoot(document.getElementById("root")!).render(
  <StrictMode>
    <ThemeProvider defaultTheme="light" storageKey="billshield-theme">
      <App />
      <Toaster position="top-right" richColors closeButton />
    </ThemeProvider>
  </StrictMode>
);

Key decisions:

App Component (App.tsx:14-33)

flowchart LR
    subgraph App["App()"]
        EB["ErrorBoundary"] --> BR["BrowserRouter"]
        BR --> R["Routes"]
        R --> AL["AppLayout\n(layout route)"]
        AL --> R1["/ -> LandingPage"]
        AL --> R2["/onboarding -> OnboardingPage"]
        AL --> R3["/upload -> BillUploadPage"]
        AL --> R4["/review/:billId -> BillReviewPage"]
        AL --> R5["/dashboard -> DashboardPage"]
        AL --> R6["/scenarios -> ScenarioSimulatorPage"]
        AL --> R7["/support -> SupportMapPage"]
        AL --> R8["/plan -> ThirtyDayPlanPage"]
        AL --> R9["/settings -> SettingsPage"]
    end
        

All 9 routes are children of a single <Route element={<AppLayout />}> wrapper route. AppLayout uses <Outlet /> to render the matched child page, providing the sidebar and header chrome uniformly (with a special bypass for the landing page — see Section 8).

7. Routing #

Route Table

PathComponentKey API CallPurpose
/LandingPagePOST /dev/seed (demo button only)Marketing hero + demo entry
/onboardingOnboardingPagePOST /households3-step wizard, 20 fields
/uploadBillUploadPagePOST /bills/upload (multipart)Drag-drop bill PDF upload
/review/:billIdBillReviewPageGET /bills/{id} then PATCH /bills/{id}/confirmEditable OCR extraction review
/dashboardDashboardPageGET /dashboard/{householdId}Summary + chart + top-3 actions
/scenariosScenarioSimulatorPagePOST /scenarios/simulateSavings simulator (sliders + toggles)
/supportSupportMapPageGET /support-servicesLocal support service finder
/planThirtyDayPlanPagePOST /plans/30-dayWeek-by-week action plan
/settingsSettingsPageGET/PATCH /households/{id} + DELETE /bills/{id}/dataProfile edit + data deletion

Route Tree

flowchart TD
    Root["App\nErrorBoundary -> BrowserRouter"]
    Root --> Layout["AppLayout\n(layout route, renders Outlet)"]
    Layout --> R1[" / \nLandingPage"]
    Layout --> R2[" /onboarding \nOnboardingPage"]
    Layout --> R3[" /upload \nBillUploadPage"]
    Layout --> R4[" /review/:billId \nBillReviewPage"]
    Layout --> R5[" /dashboard \nDashboardPage"]
    Layout --> R6[" /scenarios \nScenarioSimulatorPage"]
    Layout --> R7[" /support \nSupportMapPage"]
    Layout --> R8[" /plan \nThirtyDayPlanPage"]
    Layout --> R9[" /settings \nSettingsPage"]

    style Layout fill:#3b82f6,color:#fff
        

Navigation Flow

flowchart LR
    Landing -->|Start check| Onboarding
    Landing -->|View demo| Dashboard
    Onboarding -->|submit| Upload
    Upload -->|upload| Review
    Upload -->|skip to demo| Dashboard
    Review -->|confirm| Dashboard
    Dashboard -->|sidebar| Scenarios
    Dashboard -->|sidebar| Support
    Dashboard -->|sidebar| Plan
    Dashboard -->|sidebar| Settings
    Scenarios -->|sidebar| Dashboard
    Support -->|sidebar| Plan
        

8. Layout & Navigation #

The layout component provides the application chrome (sidebar, header, theme toggle) and uses React Router's <Outlet /> to render the active page.

Navigation Items (AppLayout.tsx:18-25)

const navItems = [
  { to: "/dashboard", label: "Dashboard", icon: LayoutDashboard },
  { to: "/upload", label: "Upload Bill", icon: Upload },
  { to: "/scenarios", label: "Scenarios", icon: SlidersHorizontal },
  { to: "/support", label: "Support Map", icon: MapPin },
  { to: "/plan", label: "30-Day Plan", icon: CalendarCheck },
  { to: "/settings", label: "Settings", icon: Settings },
];

Landing Page Bypass (AppLayout.tsx:32-36)

const isLanding = location.pathname === "/";
if (isLanding) {
  return <Outlet />;  // Landing page renders fullscreen, no sidebar/header
}

The landing page is a marketing surface that doesn't need app chrome. When the user navigates to any other route, the full layout (sidebar + header) appears.

Responsive Layout State Machine

stateDiagram-v2
    [*] --> Desktop : viewport >= 1024px (lg)
    [*] --> Mobile : viewport < 1024px

    Desktop --> DesktopSidebar : fixed left sidebar (w-64)
    Desktop --> MainContent : lg:ml-64

    Mobile --> MobileHeader : fixed top bar
    MobileHeader --> DrawerClosed : hamburger
    DrawerClosed --> DrawerOpen : tap hamburger
    DrawerOpen --> DrawerClosed : tap backdrop
    DrawerOpen --> DrawerClosed : tap nav link
    DrawerOpen --> MainContent : route change

    MainContent --> [*] : renders Outlet
        

Layout Component Hierarchy

AppLayout
+-- DesktopSidebar (hidden below lg:)
|   +-- Logo button (-> /)
|   +-- 6 NavLinks (NavLink with active styling)
|   `-- ModeToggle + privacy note
|
+-- MobileHeader (lg: hidden)
|   +-- Logo button (-> /)
|   +-- ModeToggle
|   `-- HamburgerMenu (toggles mobileOpen)
|
+-- MobileMenu (conditional: mobileOpen)
|   +-- Backdrop (click-to-close, backdrop-blur)
|   `-- Slide-out drawer (w-64, pt-16)
|       `-- 6 NavLinks (click -> setMobileOpen(false))
|
`-- Main (flex-1, lg:ml-64, pt-14 on mobile)
    `-- max-w-6xl container
        `-- <Outlet /> (active page)
      

Active NavLink Styling (AppLayout.tsx:54-61)

className={({ isActive }) =>
  cn(
    "flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors",
    isActive
      ? "bg-primary text-primary-foreground"
      : "text-muted-foreground hover:bg-accent hover:text-accent-foreground"
  )
}

9. Theme System #

A custom theme provider — no runtime dependency on next-themes. Supports three modes: light, dark, system.

Architecture

flowchart TD
    Init["useState: read localStorage\nor fallback to defaultTheme"]
    Init --> Apply["applyTheme(theme)\nroot.classList.remove(light,dark)\nroot.classList.add(resolved)"]
    Apply --> Effect["useEffect: theme change"]

    Effect --> SysCheck{theme === system?}
    SysCheck -->|yes| MQ["matchMedia\n(prefers-color-scheme: dark)\naddEventListener(change)"]
    SysCheck -->|no| Skip["no listener"]

    Effect --> Key["useEffect: keydown\nif key === d (not in input)\ntoggle dark/light"]
    Effect --> Storage["useEffect: storage event\nsync across tabs"]

    MQ --> Apply
    Key --> SetState["setThemeState + localStorage"]
    Storage --> SetState

    style Apply fill:#8b5cf6,color:#fff
    style Key fill:#3b82f6,color:#fff
        

Key Behaviors

FeatureImplementationLocation
Theme modeslight / dark / systemtheme-provider.tsx:4
System detectionwindow.matchMedia('(prefers-color-scheme: dark)')theme-provider.tsx:34-40
PersistencelocalStorage.setItem(storageKey, theme)theme-provider.tsx:98
Keyboard shortcutD key toggles dark/light (ignores modifiers + editable fields)theme-provider.tsx:142-180
Cross-tab syncwindow.addEventListener('storage', ...) listenertheme-provider.tsx:182-205
Transition suppressionInjects *{transition:none!important} during switch, removed after 2 RAFstheme-provider.tsx:42-59
Editable field guardisEditableTarget() checks input/textarea/select/contenteditabletheme-provider.tsx:61-78

Keyboard Shortcut Logic (theme-provider.tsx:160-172)

setThemeState((currentTheme) => {
  const nextTheme =
    currentTheme === "dark" ? "light"
    : currentTheme === "light" ? "dark"
    : getSystemTheme() === "dark" ? "light" : "dark"
  localStorage.setItem(storageKey, nextTheme)
  return nextTheme
})

The shortcut intelligently handles all three modes: if currently system, it resolves to the opposite of the system theme.

CSS Theme Tokens (index.css:74-143)

The theme uses OKLCH color space (uniform perceptual lightness) with CSS custom properties. Tailwind v4's @theme inline maps these to utility classes.

THEME CONFIGURATION
=============================================================
Theme: neutral (default)          Base color: neutral
Mode:  light (:root) / dark (.dark)
Prefix: (none)                    CSS Variables: enabled

Light (:root)                      Dark (.dark)
---------------------------        ---------------------------
--background: oklch(1 0 0)         --background: oklch(0.145 0 0)
--foreground: oklch(0.145 0 0)     --foreground: oklch(0.985 0 0)
--card: oklch(1 0 0)               --card: oklch(0.205 0 0)
--primary: oklch(0.52 0.2 250)     --primary: oklch(0.65 0.18 250)
--muted: oklch(0.97 0 0)           --muted: oklch(0.269 0 0)
--destructive: oklch(0.577...)     --destructive: oklch(0.704...)
--border: oklch(0.922 0 0)         --border: oklch(1 0 0 / 10%)
--chart-1: oklch(0.646 0.222 41)   --chart-1: oklch(0.488 0.243 264)
  ...12 more tokens                  ...12 more tokens

Dark mode variant: @custom-variant dark (&:is(.dark *))
Radius: 0.625rem (10px) base
      

ModeToggle (components/mode-toggle.tsx:12-37)

A dropdown menu with three items (Light / Dark / System). The trigger button shows a Sun icon in light mode and Moon icon in dark mode, with a CSS-only cross-fade animation (scale-100 rotate-0 to scale-0 rotate-90).

10. Component Library #

The project uses shadcn/ui with the new-york style preset (components.json:3). Unlike a traditional npm dependency, shadcn/ui components are copied into the codebase (src/components/ui/) and fully owned — they can be customized freely.

shadcn/ui Primitives (58 components)

CategoryComponents
Form inputsbutton, input, textarea, select, native-select, checkbox, radio-group, switch, slider, toggle, toggle-group, input-otp, input-group, field, label
Layoutcard, separator, aspect-ratio, resizable, scroll-area, sidebar, tabs, collapsible
Overlaysdialog, alert-dialog, sheet, drawer, popover, tooltip, hover-card, dropdown-menu, context-menu, menubar, navigation-menu, command
Feedbackalert, badge, progress, skeleton, spinner, sonner (toaster), loading-skeleton, empty-state, empty, error-banner
Data displaytable, avatar, breadcrumb, pagination, chart, calendar
Utilitykbd, direction, item, carousel, button-group, accordion

Accuracy note: The existing ARCHITECTURE.md and README.md reference "52 primitives." The actual count as of this writing is 58. This discrepancy is documented here for accuracy; the older docs should be updated.

cn() Utility (lib/utils.ts:4-6)

export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs))
}

Combines clsx (conditional class joining) with tailwind-merge (deduplicates conflicting Tailwind classes). Used by every component for variant-based styling.

Custom Components

ErrorBoundary (components/ErrorBoundary.tsx:14-60)

A React class component that catches unhandled rendering errors:

flowchart TD
    Render["render() children"]
    Render --> Error{getDerivedStateFromError?}
    Error -->|no| Normal["Normal rendering"]
    Error -->|yes| Fallback["Fallback UI"]
    Fallback --> Icon["AlertTriangle icon"]
    Fallback --> Title["'Something went wrong'"]
    Fallback --> Msg["'Your data is safe'"]
    Fallback --> Retry["Try again button\n-> setState(hasError: false)"]
    Fallback --> Details["<details> collapsible\nerror.message (technical)"]
        

Key design: the retry button resets hasError to false, which re-renders children. The collapsible <details> element shows technical error messages for debugging without overwhelming end users.

SummaryCard (components/dashboard/SummaryCard.tsx:13-46)

A stat display card with four visual variants:

VariantBorder colorUse case
defaultborder-borderNeutral stats
successemerald borderPositive metrics (savings)
warningamber borderCaution metrics
dangerorange borderRisk metrics (high energy bill risk)

PressureChart (components/dashboard/PressureChart.tsx:40-127)

A Recharts ComposedChart combining stacked bars (7 cost categories) with a dashed total line:

ElementRecharts componentConfiguration
7 stacked bars<Bar stackId="a">Energy, Rent, Food, Transport, Council Tax, Water, Broadband
Total line<Line type="monotone">strokeDasharray="6 3", themed foreground color
Top labels<LabelList position="top">Total value per month, GBP{value} format
X axis<XAxis dataKey="month">Month labels
Y axis<YAxis tickFormatter>GBP{value} format, 50px width
Tooltip<Tooltip>Themed card background, GBP formatting
Grid<CartesianGrid strokeDasharray="3 3">Subtle dashed grid

The chart data is memoized with useMemo (PressureChart.tsx:41-46), adding a total field computed by summing all 7 category values per month.

BillBreakdownCard (components/dashboard/BillBreakdownCard.tsx:9-57)

Renders a vertical list of cost line items (electricity, gas, standing charges, DD, annual cost, avoidable cost, unavoidable standing charge) with a tariff status badge and an insight callout box.

RecommendationCard (components/recommendations/RecommendationCard.tsx:25-180)

The most complex custom component — an expandable action card with:

RecommendationCard
+-- Header: rank badge + title + description + monthly saving (GBP/month)
+-- Badges row: savingLabel, effort, confidence, urgency, safetyRisk
+-- Detection context: "What we detected" + "Why it matters"
+-- Caveats: eligibility caveat (purple) + safety caveat (amber)
+-- Next step callout
+-- Expandable steps: ChevronDown button -> numbered <ol>
+-- Call script: blockquote + Copy button (clipboard API + 2s "Copied" feedback)
      

The copy-to-clipboard feature (RecommendationCard.tsx:29-34) uses navigator.clipboard.writeText() with a 2-second "Copied" confirmation state.

useIsMobile Hook (hooks/use-mobile.ts:5-19)

const MOBILE_BREAKPOINT = 768

export function useIsMobile() {
  const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
  React.useEffect(() => {
    const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
    const onChange = () => { setIsMobile(window.innerWidth < MOBILE_BREAKPOINT) }
    mql.addEventListener("change", onChange)
    setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
    return () => mql.removeEventListener("change", onChange)
  }, [])
  return !!isMobile
}

Returns true when viewport < 768px. Uses matchMedia for efficient change detection. Initial state is undefined (renders as false via !!) to avoid hydration mismatch, then corrects after mount.

11. API Client Architecture #

The API client is the single point of communication with the backend. It has a built-in mock-data fallback that activates when VITE_API_BASE_URL is not set.

Request Flow

sequenceDiagram
    participant Page as Page Component
    participant EP as endpoints.ts
    participant Client as client.ts
    participant Mock as mock-data.ts
    participant API as Backend API

    Page->>EP: call endpoint function
    EP->>Client: request<T>(path, options)

    alt USE_MOCK (no BASE URL)
        Client->>Client: delay(400ms)
        Client->>Mock: resolveMock(path, options)
        Mock-->>Client: mock data
    else Real API
        Client->>API: fetch(BASE + path, options)
        alt response.ok
            API-->>Client: JSON response
            Client-->>EP: parsed JSON
        else response not ok
            API-->>Client: error JSON
            Client->>Client: throw Error(err.error.message)
        end
    end

    EP-->>Page: typed response or Error
    Page->>Page: setError + toast.error() OR setData
        

JSON Request Function (client.ts:26-43)

export async function request<T>(path: string, options?: RequestInit): Promise<T> {
  if (USE_MOCK) {
    await delay();
    return resolveMock<T>(path, options);
  }

  const res = await fetch(`${BASE}${path}`, {
    headers: { "Content-Type": "application/json", ...options?.headers },
    ...options,
  });

  if (!res.ok) {
    const err = await res.json().catch(() => ({}));
    throw new Error(err.error?.message || `API error ${res.status}`);
  }

  return res.json();
}

Multipart Upload Function (client.ts:45-63)

export async function uploadRequest<T>(path: string, body: FormData): Promise<T> {
  if (USE_MOCK) {
    await delay(800);
    const householdId = body.get("householdId") as string;
    return mockUploadBill(householdId) as unknown as T;
  }

  const res = await fetch(`${BASE}${path}`, { method: "POST", body });

  if (!res.ok) {
    const err = await res.json().catch(() => ({}));
    throw new Error(err.error?.message || `API error ${res.status}`);
  }

  return res.json();
}

Note: multipart uploads do not set Content-Type — the browser automatically sets the multipart/form-data boundary. Setting it manually would break the request.

Mock Resolution (client.ts:65-130)

The resolveMock function pattern-matches against the path and method to return appropriate mock data. It handles all 15 endpoints using regex matching:

Mock Route Resolution
=====================
GET  /health                       -> mockHealthCheck()
POST /dev/seed                     -> mockSeedDemoData()
POST /dev/reset                    -> { message: "Reset complete" }
POST /households                   -> mockCreateHousehold(payload)
GET  /households/{id}              -> mockGetHousehold(id)
PATCH /households/{id}             -> mockGetHousehold(id)
POST /bills/upload                 -> mockUploadBill(householdId)
GET  /bills/{id}                   -> mockGetBillExtraction(id)
PATCH /bills/{id}/confirm          -> mockConfirmBillFields(id, payload)
DELETE /bills/{id}/data            -> { billId, status: "deleted" }
GET  /dashboard/{id}               -> mockGetDashboard(id)
GET  /recommendations/{id}         -> { recommendations: [...] }
POST /scenarios/simulate           -> mockSimulateScenario(payload)
GET  /support-services?...         -> mockGetSupportServices(postcode, filters)
POST /plans/30-day                 -> mockGenerateThirtyDayPlan(householdId)
      

Endpoint Functions (api/endpoints.ts:17-125)

15 typed endpoint functions, each wrapping request<T>() or uploadRequest<T>():

FunctionMethodPathReturns
healthCheck()GET/healthHealthResponse
seedDemoData()POST/dev/seedSeedResponse
resetDemoData()POST/dev/reset{ message }
createHousehold(payload)POST/householdsHousehold
getHousehold(id)GET/households/{id}Household
updateHousehold(id, payload)PATCH/households/{id}Household
uploadBill(...)POST/bills/uploadBillUploadResponse
getBillExtraction(billId)GET/bills/{id}BillExtraction
confirmBillFields(...)PATCH/bills/{id}/confirmBillConfirmResponse
getDashboard(householdId)GET/dashboard/{id}DashboardData
getRecommendations(householdId)GET/recommendations/{id}{ recommendations }
simulateScenario(payload)POST/scenarios/simulateScenarioResult
getSupportServices(...)GET/support-services?...SupportServicesResponse
generateThirtyDayPlan(payload)POST/plans/30-dayThirtyDayPlan
deleteBillData(billId)DELETE/bills/{id}/data{ billId, status, message }

Error Handling Pattern

flowchart TD
    API["fetch() returns"] --> OK{res.ok?}
    OK -->|yes| Parse["res.json() -> typed response"]
    OK -->|no| ErrJSON["res.json().catch(() => ({}))"]
    ErrJSON --> Throw["throw new Error(err.error?.message || 'API error {status}')"]
    Throw --> Page["Page try/catch"]
    Page --> SetErr["setError(err.message)"]
    Page --> Toast["toast.error(err.message)"]
    SetErr --> Banner["<ErrorBanner message onRetry />"]

    style Throw fill:#ef4444,color:#fff
        

Storage Helpers (utils/storage.ts:1-27)

localStorage Keys
=================
billshield_household_id  -> set after onboarding, read by every page
billshield_bill_id       -> set after upload, read by dashboard/review/settings

Functions:
  getHouseholdId()       -> string | null
  setHouseholdId(id)     -> void
  getBillId()            -> string | null
  setBillId(id)          -> void
  removeBillId()         -> void  (called on data deletion)
  clearAll()             -> void  (removes both keys)
      

Utility Formatters (utils/formatters.ts:1-36)

FunctionInputOutputExample
formatCurrency(n)numberGBP{n} (en-GB locale)formatCurrency(1234)GBP1,234
formatPence(n)number{n}p (2 decimals)formatPence(27.5)27.50p
formatKwh(n)number{n} kWh (en-GB locale)formatKwh(3100)3,100 kWh
capitalize(str)stringFirst letter uppercapitalize("low")Low
formatEnumLabel(v)snake_case stringTitle CaseformatEnumLabel("low_effort")Low Effort
formatRiskLabel(risk)risk enumDisplay labelformatRiskLabel("medium_high")Medium-High

Note: GBP in this document represents the pound sterling symbol used in the code.

12. Type System & Domain Model #

The type system is the contract between frontend and backend. All types are defined in a single file and imported throughout the codebase.

API Types (api/types.ts — 315 lines, 30+ interfaces)

Union Types (Enums)

TypeValues
HouseholdTypesingle_adult, couple, family_with_children, pensioner_household, shared_household
IncomeBandunder_15k, 15k_25k, 25k_40k, 40k_60k, 60k_plus
PaymentMethoddirect_debit, prepayment_meter, standard_credit, unknown
Confidencehigh, medium, low, needs_review, only_if_eligible, only_if_tariff_eligible
Effortlow, medium, high
Urgencylow, medium, high
SafetyRisknone, low, medium, high
SavingLabelestimated_saving, potential_saving, cashflow_improvement, support_value, green_only, no_direct_saving, billing_accuracy, risk_reduction, support_access, planning
EnergyBillRisklow, medium, medium_high, high
SupportServiceTypewarm_space, food_bank, citizens_advice, library, council_emergency_support, debt_advice, energy_help
OpeningStatusopen_now, closed_now, opens_today, appointment_only

Core Interfaces

classDiagram
    class HouseholdPayload {
        +string postcode
        +HouseholdType householdType
        +IncomeBand incomeBand
        +string energyProvider
        +PaymentMethod paymentMethod
        +number monthlyRentOrMortgage
        +number monthlyFoodCost
        +number monthlyTransportCost
        +number monthlyCouncilTax
        +number monthlyBroadbandMobileCost
        +number monthlyWaterCost
        +boolean receivesQualifyingBenefits
        +boolean hasChildren
        +boolean hasPensioner
        +boolean hasHealthCondition
        +boolean hasDisability
        +number bedrooms
        +number occupants
        +boolean waterMetered
    }

    class Household {
        +string id
        +string normalizedPostcode
        +string createdAt
    }

    class BillExtraction {
        +string billId
        +string householdId
        +string status
        +string billType
        +Extraction extraction
        +string reviewWarning
    }

    class DashboardData {
        +string householdId
        +Summary summary
        +MonthlyPressureForecast[] monthlyPressureForecast
        +BillBreakdown billBreakdown
        +RecommendedAction[] topRecommendedActions
        +RecommendedAction[] otherRecommendedActions
        +DashboardInsight[] insights
    }

    class RecommendedAction {
        +string id
        +number rank
        +string engineType
        +string title
        +number monthlySavingPounds
        +SavingLabel savingLabel
        +Effort effort
        +Confidence confidence
        +string[] steps
        +string callScript
    }

    class ThirtyDayPlan {
        +string planId
        +PlanSection thisWeek
        +PlanSection nextTwoWeeks
        +PlanSection byDayThirty
    }

    Household --|> HouseholdPayload
    DashboardData o-- RecommendedAction
    DashboardData o-- MonthlyPressureForecast
    ThirtyDayPlan o-- PlanSection
        

Badge Style System (utils/badgeStyles.ts:1-93)

Each enum value maps to a badge with a display label and Tailwind CSS classes:

Badge Color Matrix
==================
                    Light mode                     Dark mode
                    ---------------------------    ---------------------------
Green/Emerald       bg-emerald-100                 bg-emerald-900/30
                    text-emerald-800               text-emerald-400

Amber               bg-amber-100                   bg-amber-900/30
                    text-amber-800                 text-amber-400

Orange              bg-orange-100                  bg-orange-900/30
                    text-orange-800                text-orange-400

Red                 bg-red-100                     bg-red-900/30
                    text-red-800                   text-red-400

Blue                bg-blue-100                    bg-blue-900/30
                    text-blue-800                  text-blue-400

Purple              bg-purple-100                  bg-purple-900/30
                    text-purple-800                text-purple-400

Slate               bg-slate-100                   bg-slate-800
                    text-slate-700                 text-slate-300

Muted               bg-muted                       bg-muted
                    text-muted-foreground          text-muted-foreground
      

13. State Management Strategy #

BillShield deliberately avoids Redux, Zustand, or any global state library. The state strategy is minimal and per-page, leveraging React's built-in primitives.

State Layers

flowchart TD
    subgraph Persistent["Persistent Layer"]
        LS["localStorage\nhouseholdId + billId\n+ theme"]
    end

    subgraph PerPage["Per-Page State (useState)"]
        P1["LandingPage\nloading"]
        P2["OnboardingPage\nstep, form, errors"]
        P3["BillUploadPage\nfile, dragOver, loading"]
        P4["BillReviewPage\nextraction, editedFields"]
        P5["DashboardPage\ndata, loading, error"]
        P6["ScenarioSimulator\nsliders, toggles, result"]
        P7["SupportMapPage\npostcode, filters, data"]
        P8["ThirtyDayPlanPage\nplan, completedItems"]
        P9["SettingsPage\nhousehold, form, delete"]
    end

    subgraph Derived["Derived State (useMemo)"]
        D1["PressureChart\nchartData + total"]
        D2["ScenarioSimulator\ndisplayResult\n(merges appliance + backend)"]
    end

    subgraph Shared["App-Level State"]
        Theme["ThemeProvider context\ntheme + setTheme"]
    end

    LS --> P5 & P6 & P7 & P8 & P9
    Theme --> All["All pages + AppLayout"]
        

State Management Decisions

DecisionRationale
No Redux/Zustand9 pages, each self-contained. useState + localStorage is simpler than a global store
localStorage for IDshouseholdId + billId persisted across sessions. Read by every page. No auth tokens needed
No API cacheFresh fetch on every page visit (API is <300ms, no cache invalidation complexity)
Single form stateOnboardingPage has 20 fields in one useState<HouseholdPayload>, updated via a single update() helper
Debounce for slidersScenarioSimulator debounces API calls by 500ms. Appliance slider bypasses API entirely
useMemo for computeAppliance saving computed via useMemo — 0ms API latency
ErrorBoundaryClass component catches unhandled React errors, shows fallback UI

Per-Page State Summary

LandingPage
  `-- loading: boolean              Demo button spinner

OnboardingPage
  |-- step: 1 | 2 | 3              Wizard step
  |-- form: HouseholdPayload       All 20 onboarding fields
  |-- stepError: string | null     Inline validation
  `-- loading/error                Submit state

BillUploadPage
  |-- file: File | null            Selected file
  |-- dragOver: boolean            Drop zone highlight
  |-- loading: boolean             Upload in progress
  |-- skipping: boolean            Skip-to-demo in progress
  `-- error: string | null         Validation/API errors

BillReviewPage
  |-- extraction: BillExtraction   Fetched from API
  |-- edited fields (local)        User corrections before confirm
  `-- confidence badges            high/medium/needs_review per field

DashboardPage
  |-- data: DashboardData | null   Full API response
  |-- loading: boolean             Initial fetch
  `-- error: string | null         Retry-able error banner

ScenarioSimulatorPage
  |-- heating: 0-2                 Slider (frontend-only render)
  |-- appliance: 0-30%             Slider (frontend compute)
  |-- 6 toggles (backend API)      offPeak, ddReview, councilTax, broadband, water, paymentDate
  |-- result: ScenarioResult       Backend API response
  |-- displayResult (useMemo)      Merges appliance + backend result
  `-- loading: boolean             500ms debounce

SupportMapPage
  |-- postcode: string             From household / manual input
  |-- filters: string[]            7 checkbox toggles
  `-- data: SupportServicesResponse  25-33 services

ThirtyDayPlanPage
  |-- plan: ThirtyDayPlan          API response
  |-- completedItems: Set          Local checkbox state (not persisted)
  `-- download/copy actions

SettingsPage
  |-- household: Household         Fetched on mount
  |-- form: edit fields            energyProvider + 6 costs
  `-- delete: billId               Soft-delete + localStorage clear

AppLayout
  |-- mobileOpen: boolean          Slide-out drawer
  `-- location: useLocation        Hides sidebar on landing page (/)
      

14. Page-by-Page Deep Dive #

LandingPage (pages/LandingPage.tsx:23-439)

The marketing entry point. Fullscreen hero with no app chrome (bypasses AppLayout).

Sections: Sticky header, Hero, Problem cards, How it works, Features, Trust, Final CTA, Footer.

Demo flow: Clicking "View demo" calls seedDemoData(), persists householdId + billId to localStorage, then navigates to /dashboard.

OnboardingPage

A 3-step wizard collecting 20 household fields. API: POST /households on final step submit. On success, stores householdId and navigates to /upload.

BillUploadPage

Drag-and-drop file upload with a "Skip to demo" button. API: POST /bills/upload (multipart FormData). Skip-to-demo flow: Creates a mock PDF blob in memory, uploads it, auto-confirms extracted fields, then navigates to /dashboard.

BillReviewPage

Review and correct OCR-extracted bill fields before savings are calculated. API: GET /bills/{billId} on mount, PATCH /bills/{billId}/confirm on submit. Confidence badges highlight low-confidence fields for user review.

DashboardPage

The primary value-delivery page — shows everything the backend computed. API: GET /dashboard/{householdId}. Renders 4 SummaryCards, PressureChart, BillBreakdownCard, Top-3 RecommendationCards, and 2 Insights.

ScenarioSimulatorPage

Interactive savings simulator with sliders and toggle switches. Two compute paths: Appliance slider (frontend-only via useMemo, 0ms latency) and toggle switches (backend API with 500ms debounce, ~680ms total).

SupportMapPage

Local support service finder with postcode search and 7 filter checkboxes. API: GET /support-services?postcode=...&filters=...&radiusMiles=5. Each service card links to Google Maps directions.

ThirtyDayPlanPage

Week-by-week action checklist generated from recommendations. API: POST /plans/30-day. Three sections: thisWeek, nextTwoWeeks, byDayThirty — each with checklist items.

SettingsPage

Profile editing and data deletion. API: GET /households/{id} on mount, PATCH /households/{id} on save, DELETE /bills/{id}/data on delete. Delete is a soft-delete with localStorage cleanup.

15. Data Flow Traces #

Trace 1: Skip-to-Demo (End-to-End)

sequenceDiagram
    participant U as User
    participant LP as LandingPage
    participant API as Backend API
    participant LS as localStorage
    participant DP as DashboardPage

    U->>LP: Click "View demo"
    LP->>LP: setLoading(true)
    LP->>API: POST /dev/seed
    API-->>LP: { householdId, billId, message }
    LP->>LS: setHouseholdId(result.householdId)
    LP->>LS: setBillId(result.billId)
    LP->>LP: navigate("/dashboard")
    LP->>DP: Route change

    DP->>DP: useEffect on mount
    DP->>LS: getHouseholdId()
    LS-->>DP: householdId
    DP->>API: GET /dashboard/{householdId}
    Note over API: Runs 12 engines
Ranks top-3 actions
Calculates summary + forecast + breakdown API-->>DP: DashboardData DP->>DP: setData(response), setLoading(false) DP-->>U: Renders 4 SummaryCards + chart + breakdown + 3 RecommendationCards

Total latency: ~480ms (170ms upload + 100ms confirm + 210ms dashboard)

Trace 2: Appliance Slider (Frontend-Only Compute)

sequenceDiagram
    participant U as User
    participant SS as ScenarioSimulator
    participant Memo as useMemo

    U->>SS: Drag "Reduce appliance use" slider to 15%
    SS->>SS: setAppliance(15) - instant React state update
    SS->>Memo: useMemo recomputes (deps: result, appliance)
    Note over Memo: applianceSaving = round(15 * 0.10) = 2
estimatedMonthlySaving = 57 + 2 = 59
breakdown: [...backend, { category: "Appliance", monthlySaving: 2 }] Memo-->>SS: displayResult (merged) SS-->>U: UI updates instantly: GBP59/mo

Total latency: 0ms (no API call, no debounce, synchronous useMemo)

Trace 3: Toggle Switch (Backend API with Debounce)

sequenceDiagram
    participant U as User
    participant SS as ScenarioSimulator
    participant Timer as setTimeout
    participant API as Backend API

    U->>SS: Toggle "Request Direct Debit review" ON
    SS->>SS: setDdReview(true) - instant state update
    SS->>Timer: useEffect -> setTimeout(simulate, 500)
    Note over Timer: 500ms debounce window
If another toggle changes within 500ms,
previous timer is cancelled Timer->>API: POST /scenarios/simulate (after 500ms) Note over API: Checks household vulnerability
Returns breakdown array API-->>SS: ScenarioResult { estimatedMonthlySaving: 57, breakdown: [...] } SS-->>U: UI updates

Total latency: ~680ms (500ms debounce + 180ms API)

Trace 4: Error Handling Flow

flowchart TD
    Fail["API call fails\n(network / 404 / 500)"] --> NotOK["fetch() returns !res.ok"]
    NotOK --> ErrJson["res.json().catch(() => ({}))"]
    ErrJson --> Throw["throw new Error(err.error?.message || 'API error {status}')"]
    Throw --> Catch["Page catches in try/catch"]
    Catch --> SetErr["setError(err.message)"]
    Catch --> Toast["toast.error(err.message)"]
    SetErr --> Banner["Render <ErrorBanner message onRetry />"]
    Banner --> Retry["User clicks retry -> refetch()"]

    Crash["Uncaught React error\n(rendering crash)"] --> EB["<ErrorBoundary>"]
    EB --> Fallback["Friendly fallback UI"]
    Fallback --> Details["Collapsible <details>\nerror.message"]
    Fallback --> RetryBtn["Try again button\n-> setState(hasError: false)"]

    style Throw fill:#ef4444,color:#fff
    style EB fill:#f59e0b,color:#fff
        

16. Backend Architecture #

This section provides full-stack context for end-to-end traceability. The backend is a FastAPI application deployed on AWS EC2.

Layer Architecture

flowchart TD
    subgraph Routers["Router Layer (9 routers, /api/v1/*)"]
        R1[health] & R2[households] & R3[bills] & R4[dashboard] & R5[scenarios]
        R6[support-services] & R7[plans] & R8[recommendations] & R9[dev]
    end

    subgraph Services["Service Layer (7 services)"]
        S1[household] & S2[bill] & S3[dashboard] & S4[scenario]
        S5[support] & S6[plan] & S7[recommendation]
    end

    subgraph Engines["Engine Layer (12 engines)"]
        E1[ranking] & E2[tariff_checker] & E3[direct_debit_health]
        E4[meter_reading] & E5[heating_optimisation] & E6[appliance_shifting]
        E7[standing_charge] & E8[eligibility_scanner] & E9[council_tax_checker]
        E10[broadband_tariff] & E11[water_optimiser] & E12[debt_arrears_triage]
    end

    subgraph Providers["Provider Layer (6 interfaces, all mocked)"]
        P1[OcrProvider] & P2[TariffBenchmark] & P3[CouncilLookup]
        P4[SupportLocator] & P5[PlanGenerator] & P6[CarbonWindow]
    end

    subgraph Repos["Repository Layer (5 repos)"]
        RP1[household] & RP2[bill] & RP3[recommendation] & RP4[support_service] & RP5[plan]
    end

    subgraph Models["Data Models (5)"]
        M1[Household] & M2[Bill] & M3[Recommendation] & M4[ThirtyDayPlan] & M5[SupportService]
    end

    Routers --> Services
    Services --> Engines
    Services --> Providers
    Services --> Repos
    Repos --> Models

    style Routers fill:#1e3a5f,color:#fff
    style Engines fill:#1e5f3a,color:#fff
    style Providers fill:#5f3a1e,color:#fff
        

12 Recommendation Engines

#EngineWhat it checksHow it calculates
1RankingAll generated candidatesComposite score (see formula below)
2Tariff CheckerUser rates vs Ofgem benchmark% above benchmark x estimated annual saving
3DD HealthMonthly DD vs forecast usageannualDD = monthlyDD x 12; forecast = usage x unit rates + standing charges
4Meter ReadingDays from price-cap change (1 Jan/Apr/Jul/Oct)if today within 7 days of cap date: remind
5HeatingVulnerability flags (children, pensioner, health, disability)if vulnerable: non-temperature actions only
6ApplianceTariff type (flat vs Economy 7)if flat: green_only; if Economy 7: potential GBP7/mo
7Standing ChargeFixed daily costs(elecSC + gasSC) x 365 / 12 / 100
8EligibilityBenefits, health, disability, pensionermatch flags to: Warm Home Discount, PSR, hardship grants
9Council TaxIncome band + council tax ratioif income < GBP25k and councilTax > 8% of income: recommend CTR
10BroadbandQualifying benefits + cost > GBP25/moif receives benefits and cost > GBP25: recommend social tariff
11WaterMetered status + occupants vs bedroomsif unmetered and occupants < bedrooms: suggest water meter check
12Debt TriageDisposable buffer (income - pressure)if buffer < 0: prioritise rent, council tax, energy as highest-consequence

Ranking Algorithm

RANKING SCORING FORMULA
=============================================================

score = savingScore + urgencyScore + confidenceScore
        - effortPenalty - safetyRiskPenalty + vulnerabilityBoost

COMPONENT VALUES:
  savingScore         = min(monthlySavingPounds, 100)     [max +100]
  urgencyScore        = { high: +30, medium: +15, low: +5 }
  confidenceScore     = { high: +20, medium: +10, low: +3, needs_review: 0 }
  effortPenalty       = { low: 0, medium: -8, high: -18 }
  safetyRiskPenalty   = { none: 0, low: -5, medium: -25, high: 999 }
  vulnerabilityBoost  = +2 per flag

RULES:
  1. High safety risk (penalty 999) -> excluded entirely
  2. Green-only actions demoted below financial/support actions
  3. Candidates sorted by composite score, ranks assigned 1 -> N
  4. Top 3 displayed as "Top actions this month"
      

Provider Abstraction

ProviderInterface methodMock implementationFuture real
OCRextract_energy_bill(file_path)Pre-configured BrightSpark Energy dataAWS Textract / Google Vision
Tariffget_benchmark(postcode, payment_method)Q3-2026 benchmark valuesOfgem price-cap API
Councilget_council_for_postcode(postcode)"{Area} City Council"GOV.UK local authority lookup
Supportfind_services(postcode, filters, radius)25 wildcard + 22 area-specificMapbox Places API
Plangenerate_plan(household, recommendations)Template-based deterministic planOpenAI GPT-4 / Claude
Carbonget_low_carbon_windows(postcode)Tonight 22:00-06:00UK Carbon Intensity API

Data Model (ER Diagram)

erDiagram
    Household ||--o{ Bill : has
    Household ||--o{ Recommendation : generates
    Household ||--o{ ThirtyDayPlan : creates
    Recommendation }o--|| Bill : references
    SupportService {
        string id PK
        string name
        string type
        string postcode_area
        float distance_miles
        string opening_status
        string address_line1
        string town
        string phone
        string website
    }

    Household {
        string id PK
        string postcode
        string household_type
        string income_band
        string energy_provider
        string payment_method
        number monthly_rent_or_mortgage
        number monthly_food_cost
        number monthly_transport_cost
        number monthly_council_tax
        number monthly_broadband_mobile_cost
        number monthly_water_cost
        boolean receives_qualifying_benefits
        boolean has_children
        boolean has_pensioner
        boolean has_health_condition
        boolean has_disability
        number bedrooms
        number occupants
        boolean water_metered
        datetime created_at
    }

    Bill {
        string id PK
        string household_id FK
        string status
        string bill_type
        json extraction_json
        json confirmed_fields_json
        string storage_path
        datetime deleted_at
    }

    Recommendation {
        string id PK
        string household_id FK
        string bill_id FK
        string engine_type
        int rank
        string title
        number monthly_saving
        string saving_label
        string effort
        string confidence
        string urgency
        string safety_risk
        json steps_json
        string call_script
        string status
    }

    ThirtyDayPlan {
        string id PK
        string household_id FK
        json this_week_json
        json next_2_weeks_json
        json by_day_30_json
        string tone
    }
        

17. API Contract Reference #

Complete Endpoint Reference

MethodPathRequest BodyResponse
GET/api/v1/healthHealthResponse
POST/api/v1/householdsHouseholdPayloadHousehold
GET/api/v1/households/{id}Household
PATCH/api/v1/households/{id}Partial<HouseholdPayload>Household
POST/api/v1/bills/uploadFormData (multipart)BillUploadResponse
GET/api/v1/bills/{id}BillExtraction
PATCH/api/v1/bills/{id}/confirmRecord<string, unknown>BillConfirmResponse
DELETE/api/v1/bills/{id}/data{ billId, status, message }
GET/api/v1/dashboard/{householdId}DashboardData
GET/api/v1/recommendations/{householdId}{ recommendations }
POST/api/v1/scenarios/simulateScenarioPayloadScenarioResult
GET/api/v1/support-servicesQuery paramsSupportServicesResponse
POST/api/v1/plans/30-day{ householdId, includeCompletedActions?, tone? }ThirtyDayPlan
POST/api/v1/dev/seedSeedResponse
POST/api/v1/dev/reset{ message }

Error Response Envelope

All errors follow a consistent JSON structure:

{
  "error": {
    "code": "ERROR_CODE",
    "message": "Human-readable message",
    "details": {}
  }
}
Error codeHTTP statusWhen
HTTP_ERROR404, 422, etc.Standard HTTP exceptions
INTERNAL_ERROR500Unhandled exception
Custom AppError codesVariesBusiness logic errors

Response Conventions

18. Performance Engineering & Metrics #

All metrics in this section were measured on 2026-07-14 against a fresh vite build of the production bundle served by vite preview on localhost. Lighthouse 13.4.0 was run with Chrome headless. No simulated numbers — these are real measurements from the actual built artifacts.

Build Artifact Sizes (Measured)

AssetRaw (uncompressed)GzippedOver-the-wire
index-*.js882,474 bytes (862 KB)262,095 bytes (256 KB)257.3 KB
index-*.css135,807 bytes (133 KB)21,194 bytes (21 KB)21.0 KB
index.html469 bytes315 bytes0.7 KB
vite.svg1,448 bytes1.1 KB
Total byte weight~1.02 MB~284 KB~287 KB

Stale doc alert: The existing ARCHITECTURE.md and README.md claim "~200 KB gzipped JS" and "~30 KB gzipped CSS." The actual measured values are 262 KB gzipped JS and 21 KB gzipped CSS.

Lighthouse Performance Scores (Real Runs)

Mobile (DevTools Throttling: 4x CPU + Slow 3G)

MetricValueAssessment
Performance score90 / 100Good
First Contentful Paint (FCP)2,858.3 msNeeds improvement
Largest Contentful Paint (LCP)2,858.3 msNeeds improvement
Cumulative Layout Shift (CLS)0.000Excellent
Speed Index (SI)2,858.0 msNeeds improvement
Time to Interactive (TTI)2,858.3 msGood
Time to First Byte (TTFB)2.0 msExcellent (localhost)

Desktop (No CPU Throttle, Faster Network)

MetricValueAssessment
Performance score100 / 100Excellent
First Contentful Paint (FCP)485.3 msExcellent
Largest Contentful Paint (LCP)485.3 msExcellent
Cumulative Layout Shift (CLS)0.000Excellent
Speed Index (SI)485.3 msExcellent
Time to Interactive (TTI)485.3 msExcellent
Total Blocking Time (TBT)0.0 msExcellent
Time to First Byte (TTFB)1.0 msExcellent

Core Web Vitals Summary

MetricMobile (throttled)DesktopGood thresholdMeets?
LCP2,858 ms485 ms< 2,500 msMobile: No / Desktop: Yes
CLS0.0000.000< 0.1Yes (both)
INP (estimated)< 100 ms< 50 ms< 200 msYes (both)
FCP2,858 ms485 ms< 1,800 msMobile: No / Desktop: Yes

Performance Optimization Mechanisms

MechanismWhatImpact
gzip compressionnginx gzip on882 KB JS to 262 KB (70% reduction)
Immutable cachingCache-Control: immutable on /assets/*Return visits: 0ms (browser cache)
Vite content hashingFilenames include hashHashed filenames enable aggressive caching
Frontend computeAppliance slider in useMemo0ms API latency
DebounceScenario API calls debounced by 500msPrevents 10+ API calls during slider drag
CSS skeletonanimate-pulse pure CSSInstant render, no layout shift
useMemo memoizationChart data computation memoizedPrevents recompute on parent re-renders
No SSR blockingHTML shell is 469 bytesFast first paint

Optimization Opportunities (Ranked by Impact)

PRIORITY OPTIMIZATION ROADMAP
=============================================================

#1 CODE-SPLITTING + LAZY ROUTES          [High Impact]
   Currently: All 9 pages + 58 components in single 882 KB chunk
   Lighthouse flags: ~900ms unused-JS potential savings
   Fix: React.lazy() + Suspense per route
   Expected: Reduce initial JS by ~40-50%

#2 TREE-SHAKE UNUSED SHADCN/UI           [Medium Impact]
   Currently: 58 shadcn primitives all bundled, many unused on landing
   Fix: Dynamic import heavy primitives (carousel, calendar, command, chart)
   Expected: Reduce initial JS by ~50-100 KB

#3 VENDOR CHUNK SPLITTING                [Medium Impact]
   Currently: React, Recharts, Radix all in one chunk
   Fix: Vite manualChunks config
   Expected: Better browser caching (vendor code changes less often)

#4 IMAGE OPTIMIZATION                    [Low Impact]
   Currently: Only vite.svg (1.1 KB) — no images to optimize
   Status: Already optimal

#5 PRELOAD CRITICAL ASSETS               [Low Impact]
   Currently: No <link rel="preload"> for JS/CSS
   Fix: Add preload hints in index.html
   Expected: ~50-100ms faster FCP on slow connections
      

Performance Budget Recommendations

MetricCurrentBudget targetRationale
Initial JS (gzipped)262 KB< 150 KBWith code-splitting
Initial CSS (gzipped)21 KB< 25 KBAlready within budget
LCP (mobile)2,858 ms< 2,500 msCore Web Vitals "Good"
LCP (desktop)485 ms< 1,000 msAlready exceeds
CLS0.000< 0.1Already perfect
Total byte weight287 KB< 300 KBAlready within budget
TBT0 ms< 200 msAlready perfect

How to Reproduce These Measurements

# 1. Build the production bundle
cd frontend
npm run build

# 2. Serve the built files
npm run preview -- --port 4173 --host 127.0.0.1

# 3. Measure bundle sizes
ls -la dist/assets/
for f in dist/assets/*.js dist/assets/*.css; do
  printf "%s  raw=%s  gzip=%s\n" "$(basename $f)" "$(stat -f%z "$f")" "$(gzip -c "$f" | wc -c)"
done

# 4. Run Lighthouse (mobile - throttled)
npx lighthouse http://127.0.0.1:4173/ \
  --chrome-flags="--headless=new --no-sandbox --disable-gpu" \
  --output=json --output-path=lh-mobile.json \
  --quiet --only-categories=performance --throttling-method=devtools

# 5. Run Lighthouse (desktop - unthrottled)
npx lighthouse http://127.0.0.1:4173/ \
  --chrome-flags="--headless=new --no-sandbox --disable-gpu" \
  --output=json --output-path=lh-desktop.json \
  --quiet --only-categories=performance --preset=desktop

# 6. Extract metrics
node -e "const r=require('./lh-mobile.json'); console.log(r.categories.performance.score, r.audits['first-contentful-paint'].numericValue)"

19. Deployment & Infrastructure #

nginx Configuration

upstream billshield-backend {
    server 127.0.0.1:8000;
    keepalive 64;
}

server {
    server_name billshield.hirak.tech;
    client_max_body_size 15M;

    root /home/ubuntu/billshield/frontend/dist;
    index index.html;

    location /api/ {
        proxy_pass http://billshield-backend;
        proxy_http_version 1.1;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }

    location / {
        try_files $uri $uri/ /index.html;
    }
}

Deployment Topology

flowchart TD
    User["User's Browser"] -->|HTTPS| Nginx["nginx\nbillshield.hirak.tech"]
    Nginx -->|static files| Dist["dist/\nindex.html + assets/"]
    Nginx -->|proxy /api/| Backend["FastAPI Container\nUvicorn 2 workers\nPort 8000"]
    Backend --> DB[("SQLite\nWAL mode")]
    Backend --> Uploads["/app/uploads/\nuploaded bills"]

    style Nginx fill:#3b82f6,color:#fff
    style Backend fill:#10b981,color:#fff
    style DB fill:#8b5cf6,color:#fff
        

EC2 Configuration

SettingValueWhy
Instance typet2.microFree tier, sufficient for mock MVP
vCPUs2Matches uvicorn --workers 2
RAM908 MBTight — Docker build needs swap
Swap2 GBSurvives docker compose build on limited RAM
Disk8 GBSQLite DB + uploaded bills + Docker images
CORS* (wildcard)Frontend hosted on any platform

Docker Compose

# Simplified from docker-compose.yml
services:
  backend:
    build: .
    ports: ["8000:8000"]
    env_file: .env.prod
    volumes:
      - ./uploads:/app/uploads       # Persist uploaded bills
      - backend_data:/app/data        # Persist SQLite DB
    restart: unless-stopped

Environment Variable Matrix

VariableDevelopmentProduction
VITE_API_BASE_URL (frontend)http://localhost:8000/api/v1/api/v1 (same-origin)
APP_ENV (backend)developmentproduction
DATABASE_URL (backend)sqlite:///./billshield_dev.dbsqlite:///./data/billshield.db
CORS_ALLOW_ORIGINS (backend)** (or specific domains)
MOCK_MODE (backend)truetrue
MAX_UPLOAD_MB (backend)1010

Deploy from Scratch

# Local -> EC2
tar czf deploy.tar.gz --exclude='.venv' --exclude='node_modules' billshield/
scp deploy.tar.gz ubuntu@<ec2-ip>:~/deploy.tar.gz

# EC2
ssh ubuntu@<ec2-ip>
tar xzf deploy.tar.gz && cd billshield

# Build frontend
cd frontend && npm ci && npm run build && cd ..

# Start backend
cd backend
sudo docker compose build --no-cache
sudo docker compose up -d

# Configure nginx
sudo cp nginx/billshield.conf /etc/nginx/sites-available/
sudo ln -s /etc/nginx/sites-available/billshield.conf /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx

# Verify
curl http://localhost:8000/api/v1/health   # -> {"status":"ok"}
curl http://localhost/                     # -> HTML shell

20. Testing & Quality Assurance #

Test Suite Overview

MetricValue
Test runnerVitest 3.1
Component testingReact Testing Library 16
DOM environmentjsdom 26
Total test cases~181 (it/test calls)
Total describe blocks50
Test files11 (across root + api/ subdirectory)
Test execution time~1.3 seconds
TypeScript errors0 (strict mode, no unused locals)

Accuracy note: The existing docs cite "95 tests." The actual count is ~181 test cases across 11 files. The discrepancy likely reflects tests added since the docs were last updated.

Test File Breakdown

Test fileTest casesWhat it covers
tests/components.test.tsx35Custom components
tests/badgeStyles.test.ts36Badge style mappings
tests/pages-extra.test.tsx30Extended page behavior tests
tests/utils.test.ts28Formatter utility functions
tests/api/mock-data.test.ts33Mock data generator functions
tests/pages.test.tsx19Page rendering + basic interaction
tests/api/endpoints.test.ts18Endpoint function signatures
tests/storage.test.ts13localStorage helper functions
tests/api/client.test.ts10API client + mock fallback routing
tests/lib.test.ts7cn() utility
tests/app.test.tsx2Router + ErrorBoundary smoke tests

Test Configuration (vite.config.ts:14-19)

test: {
  globals: true,                    // describe/it/expect available without imports
  environment: "jsdom",             // DOM simulation
  setupFiles: ["./tests/setup.ts"], // jest-dom matchers
  css: false,                       // CSS not processed during tests (faster)
}

Quality Gates

GateCommandEnforces
TypeScript stricttsc --noEmitNo implicit any, no unused locals/params, no fallthrough
Build type-checktsc -b && vite buildFull project reference build + Vite production build
Unit testsvitest run~181 test cases pass
verbatimModuleSyntaxtscimport type enforced for type-only imports

21. Accessibility, Safety & Privacy #

Accessibility

FeatureImplementation
Dark modeD key shortcut + theme dropdown (Light / Dark / System)
Mobile responsiveCollapsible sidebar to slide-out drawer on small screens
Keyboard navigationAll buttons, links, checkboxes, sliders keyboard-accessible
Screen reader labelssr-only labels on icon-only buttons, aria-* attributes
Reduced motiontw-animate-css respects prefers-reduced-motion
Focus managementRadix UI handles focus trapping in dialogs/drawers
Semantic HTMLProper heading hierarchy, <nav>, <main>, <ol>, <blockquote>
Color contrastOKLCH theme tokens designed for WCAG AA contrast

Safety Guardrails

GuardrailImplementation
No shame language"This may be worth checking" — never "You're wasting money"
Heating safetyVulnerable households get non-temperature actions only — never "turn down the thermostat"
Debt signposting"Contact Citizens Advice or StepChange for free guidance"
Eligibility transparency"May qualify" / "Potential saving" — never guarantees

Privacy

FeatureImplementation
Data deletionDELETE /api/v1/bills/{billId}/data removes uploaded file + extraction data
No logging of bill contentBill contents never logged; postcodes normalized but not stored in logs
No external calls (MVP)In mock mode, zero data leaves the server
No authenticationhouseholdId in localStorage is the only identity — no email, no password
localStorage onlyNo cookies, no session storage, no IndexedDB — just two localStorage keys
DeletableSettings page provides one-click bill data deletion

Saving Label Transparency

LabelMeaningExample
estimated_savingCalculated from your bill data"Reduce appliance use by 15% = GBP2/mo"
potential_savingDepends on eligibility check"Council Tax Reduction = GBP25-35/mo if eligible"
cashflow_improvementFrees up monthly cash"DD review = GBP22/mo cashflow"
support_valueA grant or benefit"Warm Home Discount = GBP150 support"
green_onlyMay reduce carbon, not bills"Off-peak use on flat tariffs = greener but not cheaper"
risk_reductionReduces risk of debt/arrears"Priority bill sorting = reduces arrears risk"
billing_accuracyEnsures correct billing"Submit meter readings = accurate bills"
support_accessAccess to local services"Warm space nearby = free heating access"
planningBudgeting step"Create a 30-day plan = structured budgeting"
no_direct_savingNo financial benefit"Standing charge awareness = informational"

22. MVP Limitations & Future Roadmap #

Current MVP Limitations

LimitationImpactMitigation
Mock OCRBill extraction is simulatedProvider abstraction ready for AWS Textract
Mock tariff benchmarksStatic Q3-2026 valuesProvider interface ready for Ofgem API
Mock council lookupGeneric council namesProvider interface ready for GOV.UK API
Mock support services25 + 22 mock entriesProvider interface ready for Mapbox
Mock plan generatorTemplate-based, not LLMProvider interface ready for GPT-4 / Claude
Mock carbon windowsFixed "tonight 22:00-06:00"Provider interface ready for Carbon Intensity API
No authenticationOnly householdId identitySufficient for MVP — no signup friction
Single JS bundle882 KB (262 KB gzip)See optimization roadmap in Section 18

Provider Swap Roadmap

Each external integration uses a Protocol interface with a mock implementation. Swapping to a real provider requires changing a single import:

SWAP PROCEDURE (per provider)
=============================================================
1. Implement the Protocol interface with real API calls
2. Change the import in the service layer:
   FROM: from app.providers.mock_ocr_provider import MockOcrProvider
   TO:   from app.providers.textract_ocr_provider import TextractOcrProvider
3. Set MOCK_MODE=false in .env
4. No frontend changes needed — API contract is unchanged
      

Frontend Optimization Roadmap

PriorityTaskExpected impactEffort
1Code-split routes with React.lazy() + Suspense~40-50% smaller initial JSMedium
2Dynamic import unused shadcn primitives~50-100 KB less initial JSLow
3Split vendor chunks via manualChunksBetter cache hit rateLow
4Add <link rel="preload"> for critical assets~50-100ms faster FCPLow
5Add React 19 use() for suspense data fetchingCleaner loading statesMedium

Feature Roadmap

FeatureStatus
Real OCR integrationProvider interface ready
Real tariff benchmarksProvider interface ready
Real council lookupProvider interface ready
Real support servicesProvider interface ready
LLM plan generationProvider interface ready
Real carbon windowsProvider interface ready
AuthenticationNot started
PostgreSQL migrationAlembic configured
PWA/offline supportNot started
Multi-bill supportNot started