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
| Step | Page | What the user does | Time | Concrete benefit |
|---|---|---|---|---|
| 1 | Onboarding | Enter postcode, household type, income band, monthly costs | 3 min | Personalised calculations tuned to your household profile |
| 2 | Bill Upload | Upload an energy bill PDF (or skip to demo) | 1 click | Instant extraction of tariff rates, DD amount, annual usage |
| 3 | Dashboard | View monthly pressure summary, forecast chart, bill breakdown | Instant | Top-3 ranked actions with exact savings estimates |
| 4 | Action | Simulate scenarios, find local support, generate a 30-day plan | 5 min | Call scripts, step-by-step instructions, Google Maps directions |
Design Principles
- No shame: "Start with this low-effort step" — never "You're wasting money"
- No false promises: "May qualify" / "Potential saving" — never "You qualify" unless verified
- No analysis paralysis: Top-3 actions only. Extra actions below the fold.
- Safety first: Heating advice suppressed for vulnerable households
- Accessible: Dark/light/system theme,
Dkey shortcut, mobile-responsive
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
| Layer | Frontend | Backend |
|---|---|---|
| Framework | React 19.2 | FastAPI 0.115 |
| Language | TypeScript 5.9 (strict) | Python 3.12 |
| Build tool | Vite 7.3 | setuptools |
| Routing | React Router 6.30 | FastAPI routers |
| Styling | Tailwind CSS 4.2 + shadcn/ui (new-york) | — |
| Charts | Recharts 3.8 | — |
| Icons | Lucide React 1.6 | — |
| Toasts | Sonner 2.0 | — |
| Forms | React Hook Form 7.72 + Zod 4.3 | Pydantic v2 |
| ORM | — | SQLAlchemy 2.x |
| Migrations | — | Alembic 1.14 |
| Database | — | SQLite (dev) / PostgreSQL (prod) |
| Server | nginx (static + proxy) | Uvicorn (2 workers) |
| Validation | Zod schemas | Pydantic v2 |
| Testing | Vitest 3.1 + RTL 16 | Pytest 8.3 + HTTPX |
| Linting | tsc (strict, no unused locals) | Ruff |
| Logging | — | structlog (JSON) |
| Rate limiting | — | slowapi |
| Container | Docker (node:22-alpine) | Docker (python:3.12-slim) |
| CORS | — | Wildcard * (configurable) |
Key Frontend Dependencies
| Package | Version | Purpose |
|---|---|---|
react / react-dom | ^19.2 | UI framework (concurrent features) |
react-router-dom | ^6.30 | Client-side routing |
radix-ui | ^1.4.3 | Accessible primitives (shadcn/ui base) |
tailwindcss | ^4.2 | Utility-first CSS (Vite plugin) |
recharts | ^3.8 | ComposedChart, BarChart for data viz |
react-hook-form | ^7.72 | Form state management |
@hookform/resolvers | ^5.2 | Zod resolver for RHF |
zod | ^4.3 | Schema validation |
sonner | ^2.0 | Toast notifications |
lucide-react | ^1.6 | Icon set |
class-variance-authority | ^0.7 | Component variant typing |
clsx + tailwind-merge | ^2.1 / ^3.5 | cn() class merging utility |
date-fns | ^4.1 | Date 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
| Alias | Resolves to | Configured 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:
| Setting | Value | Effect |
|---|---|---|
strict | true | All strict checks (null, no-implicit-any, etc.) |
noUnusedLocals | true | Error on unused local variables |
noUnusedParameters | true | Error on unused function parameters |
noFallthroughCasesInSwitch | true | Error on switch fallthrough |
verbatimModuleSyntax | true | Enforces import type for type-only imports |
moduleDetection | force | All files treated as modules |
target | ES2022 | Modern JS output |
module | ESNext | Native ESM |
moduleResolution | bundler | Vite-style resolution |
jsx | react-jsx | Automatic runtime (no React import needed) |
erasableSyntaxOnly | true | Only TS syntax that erases cleanly |
NPM Scripts
| Script | Command | Purpose |
|---|---|---|
dev | vite | Dev server at http://localhost:5173 |
build | tsc -b && vite build | Type-check + production build to dist/ |
typecheck | tsc --noEmit | Type validation only (no output) |
preview | vite preview | Serve built dist/ locally |
test | vitest run | Run test suite once |
test:watch | vitest | Run tests in watch mode |
Environment Variables
| File | VITE_API_BASE_URL | Used for |
|---|---|---|
.env | http://localhost:8000/api/v1 | Local development (separate backend) |
.env.production | /api/v1 | Same-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:
- StrictMode enables double-render detection in development (catches side-effect bugs)
- ThemeProvider wraps the entire app — theme is available before first render
- Toaster (Sonner) is rendered at root level so any component can call
toast.error()/toast.success()
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
| Path | Component | Key API Call | Purpose |
|---|---|---|---|
/ | LandingPage | POST /dev/seed (demo button only) | Marketing hero + demo entry |
/onboarding | OnboardingPage | POST /households | 3-step wizard, 20 fields |
/upload | BillUploadPage | POST /bills/upload (multipart) | Drag-drop bill PDF upload |
/review/:billId | BillReviewPage | GET /bills/{id} then PATCH /bills/{id}/confirm | Editable OCR extraction review |
/dashboard | DashboardPage | GET /dashboard/{householdId} | Summary + chart + top-3 actions |
/scenarios | ScenarioSimulatorPage | POST /scenarios/simulate | Savings simulator (sliders + toggles) |
/support | SupportMapPage | GET /support-services | Local support service finder |
/plan | ThirtyDayPlanPage | POST /plans/30-day | Week-by-week action plan |
/settings | SettingsPage | GET/PATCH /households/{id} + DELETE /bills/{id}/data | Profile 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
| Feature | Implementation | Location |
|---|---|---|
| Theme modes | light / dark / system | theme-provider.tsx:4 |
| System detection | window.matchMedia('(prefers-color-scheme: dark)') | theme-provider.tsx:34-40 |
| Persistence | localStorage.setItem(storageKey, theme) | theme-provider.tsx:98 |
| Keyboard shortcut | D key toggles dark/light (ignores modifiers + editable fields) | theme-provider.tsx:142-180 |
| Cross-tab sync | window.addEventListener('storage', ...) listener | theme-provider.tsx:182-205 |
| Transition suppression | Injects *{transition:none!important} during switch, removed after 2 RAFs | theme-provider.tsx:42-59 |
| Editable field guard | isEditableTarget() checks input/textarea/select/contenteditable | theme-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)
| Category | Components |
|---|---|
| Form inputs | button, input, textarea, select, native-select, checkbox, radio-group, switch, slider, toggle, toggle-group, input-otp, input-group, field, label |
| Layout | card, separator, aspect-ratio, resizable, scroll-area, sidebar, tabs, collapsible |
| Overlays | dialog, alert-dialog, sheet, drawer, popover, tooltip, hover-card, dropdown-menu, context-menu, menubar, navigation-menu, command |
| Feedback | alert, badge, progress, skeleton, spinner, sonner (toaster), loading-skeleton, empty-state, empty, error-banner |
| Data display | table, avatar, breadcrumb, pagination, chart, calendar |
| Utility | kbd, direction, item, carousel, button-group, accordion |
Accuracy note: The existing
ARCHITECTURE.mdandREADME.mdreference "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:
| Variant | Border color | Use case |
|---|---|---|
default | border-border | Neutral stats |
success | emerald border | Positive metrics (savings) |
warning | amber border | Caution metrics |
danger | orange border | Risk 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:
| Element | Recharts component | Configuration |
|---|---|---|
| 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>():
| Function | Method | Path | Returns |
|---|---|---|---|
healthCheck() | GET | /health | HealthResponse |
seedDemoData() | POST | /dev/seed | SeedResponse |
resetDemoData() | POST | /dev/reset | { message } |
createHousehold(payload) | POST | /households | Household |
getHousehold(id) | GET | /households/{id} | Household |
updateHousehold(id, payload) | PATCH | /households/{id} | Household |
uploadBill(...) | POST | /bills/upload | BillUploadResponse |
getBillExtraction(billId) | GET | /bills/{id} | BillExtraction |
confirmBillFields(...) | PATCH | /bills/{id}/confirm | BillConfirmResponse |
getDashboard(householdId) | GET | /dashboard/{id} | DashboardData |
getRecommendations(householdId) | GET | /recommendations/{id} | { recommendations } |
simulateScenario(payload) | POST | /scenarios/simulate | ScenarioResult |
getSupportServices(...) | GET | /support-services?... | SupportServicesResponse |
generateThirtyDayPlan(payload) | POST | /plans/30-day | ThirtyDayPlan |
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)
| Function | Input | Output | Example |
|---|---|---|---|
formatCurrency(n) | number | GBP{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) | string | First letter upper | capitalize("low") → Low |
formatEnumLabel(v) | snake_case string | Title Case | formatEnumLabel("low_effort") → Low Effort |
formatRiskLabel(risk) | risk enum | Display label | formatRiskLabel("medium_high") → Medium-High |
Note:
GBPin 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)
| Type | Values |
|---|---|
HouseholdType | single_adult, couple, family_with_children, pensioner_household, shared_household |
IncomeBand | under_15k, 15k_25k, 25k_40k, 40k_60k, 60k_plus |
PaymentMethod | direct_debit, prepayment_meter, standard_credit, unknown |
Confidence | high, medium, low, needs_review, only_if_eligible, only_if_tariff_eligible |
Effort | low, medium, high |
Urgency | low, medium, high |
SafetyRisk | none, low, medium, high |
SavingLabel | estimated_saving, potential_saving, cashflow_improvement, support_value, green_only, no_direct_saving, billing_accuracy, risk_reduction, support_access, planning |
EnergyBillRisk | low, medium, medium_high, high |
SupportServiceType | warm_space, food_bank, citizens_advice, library, council_emergency_support, debt_advice, energy_help |
OpeningStatus | open_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
| Decision | Rationale |
|---|---|
| No Redux/Zustand | 9 pages, each self-contained. useState + localStorage is simpler than a global store |
| localStorage for IDs | householdId + billId persisted across sessions. Read by every page. No auth tokens needed |
| No API cache | Fresh fetch on every page visit (API is <300ms, no cache invalidation complexity) |
| Single form state | OnboardingPage has 20 fields in one useState<HouseholdPayload>, updated via a single update() helper |
| Debounce for sliders | ScenarioSimulator debounces API calls by 500ms. Appliance slider bypasses API entirely |
| useMemo for compute | Appliance saving computed via useMemo — 0ms API latency |
| ErrorBoundary | Class 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
| # | Engine | What it checks | How it calculates |
|---|---|---|---|
| 1 | Ranking | All generated candidates | Composite score (see formula below) |
| 2 | Tariff Checker | User rates vs Ofgem benchmark | % above benchmark x estimated annual saving |
| 3 | DD Health | Monthly DD vs forecast usage | annualDD = monthlyDD x 12; forecast = usage x unit rates + standing charges |
| 4 | Meter Reading | Days from price-cap change (1 Jan/Apr/Jul/Oct) | if today within 7 days of cap date: remind |
| 5 | Heating | Vulnerability flags (children, pensioner, health, disability) | if vulnerable: non-temperature actions only |
| 6 | Appliance | Tariff type (flat vs Economy 7) | if flat: green_only; if Economy 7: potential GBP7/mo |
| 7 | Standing Charge | Fixed daily costs | (elecSC + gasSC) x 365 / 12 / 100 |
| 8 | Eligibility | Benefits, health, disability, pensioner | match flags to: Warm Home Discount, PSR, hardship grants |
| 9 | Council Tax | Income band + council tax ratio | if income < GBP25k and councilTax > 8% of income: recommend CTR |
| 10 | Broadband | Qualifying benefits + cost > GBP25/mo | if receives benefits and cost > GBP25: recommend social tariff |
| 11 | Water | Metered status + occupants vs bedrooms | if unmetered and occupants < bedrooms: suggest water meter check |
| 12 | Debt Triage | Disposable 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
| Provider | Interface method | Mock implementation | Future real |
|---|---|---|---|
| OCR | extract_energy_bill(file_path) | Pre-configured BrightSpark Energy data | AWS Textract / Google Vision |
| Tariff | get_benchmark(postcode, payment_method) | Q3-2026 benchmark values | Ofgem price-cap API |
| Council | get_council_for_postcode(postcode) | "{Area} City Council" | GOV.UK local authority lookup |
| Support | find_services(postcode, filters, radius) | 25 wildcard + 22 area-specific | Mapbox Places API |
| Plan | generate_plan(household, recommendations) | Template-based deterministic plan | OpenAI GPT-4 / Claude |
| Carbon | get_low_carbon_windows(postcode) | Tonight 22:00-06:00 | UK 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
| Method | Path | Request Body | Response |
|---|---|---|---|
| GET | /api/v1/health | — | HealthResponse |
| POST | /api/v1/households | HouseholdPayload | Household |
| GET | /api/v1/households/{id} | — | Household |
| PATCH | /api/v1/households/{id} | Partial<HouseholdPayload> | Household |
| POST | /api/v1/bills/upload | FormData (multipart) | BillUploadResponse |
| GET | /api/v1/bills/{id} | — | BillExtraction |
| PATCH | /api/v1/bills/{id}/confirm | Record<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/simulate | ScenarioPayload | ScenarioResult |
| GET | /api/v1/support-services | Query params | SupportServicesResponse |
| POST | /api/v1/plans/30-day | { householdId, includeCompletedActions?, tone? } | ThirtyDayPlan |
| POST | /api/v1/dev/seed | — | SeedResponse |
| 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 code | HTTP status | When |
|---|---|---|
HTTP_ERROR | 404, 422, etc. | Standard HTTP exceptions |
INTERNAL_ERROR | 500 | Unhandled exception |
Custom AppError codes | Varies | Business logic errors |
Response Conventions
- All responses use camelCase JSON keys (matching frontend TypeScript interfaces)
- Dates are ISO 8601 strings
- Monetary values are numbers in GBP (pounds, not pence)
- Energy rates are in pence (per kWh or per day)
18. Performance Engineering & Metrics #
All metrics in this section were measured on 2026-07-14 against a fresh
vite buildof the production bundle served byvite previewon 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)
| Asset | Raw (uncompressed) | Gzipped | Over-the-wire |
|---|---|---|---|
index-*.js | 882,474 bytes (862 KB) | 262,095 bytes (256 KB) | 257.3 KB |
index-*.css | 135,807 bytes (133 KB) | 21,194 bytes (21 KB) | 21.0 KB |
index.html | 469 bytes | 315 bytes | 0.7 KB |
vite.svg | 1,448 bytes | — | 1.1 KB |
| Total byte weight | ~1.02 MB | ~284 KB | ~287 KB |
Stale doc alert: The existing
ARCHITECTURE.mdandREADME.mdclaim "~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)
| Metric | Value | Assessment |
|---|---|---|
| Performance score | 90 / 100 | Good |
| First Contentful Paint (FCP) | 2,858.3 ms | Needs improvement |
| Largest Contentful Paint (LCP) | 2,858.3 ms | Needs improvement |
| Cumulative Layout Shift (CLS) | 0.000 | Excellent |
| Speed Index (SI) | 2,858.0 ms | Needs improvement |
| Time to Interactive (TTI) | 2,858.3 ms | Good |
| Time to First Byte (TTFB) | 2.0 ms | Excellent (localhost) |
Desktop (No CPU Throttle, Faster Network)
| Metric | Value | Assessment |
|---|---|---|
| Performance score | 100 / 100 | Excellent |
| First Contentful Paint (FCP) | 485.3 ms | Excellent |
| Largest Contentful Paint (LCP) | 485.3 ms | Excellent |
| Cumulative Layout Shift (CLS) | 0.000 | Excellent |
| Speed Index (SI) | 485.3 ms | Excellent |
| Time to Interactive (TTI) | 485.3 ms | Excellent |
| Total Blocking Time (TBT) | 0.0 ms | Excellent |
| Time to First Byte (TTFB) | 1.0 ms | Excellent |
Core Web Vitals Summary
| Metric | Mobile (throttled) | Desktop | Good threshold | Meets? |
|---|---|---|---|---|
| LCP | 2,858 ms | 485 ms | < 2,500 ms | Mobile: No / Desktop: Yes |
| CLS | 0.000 | 0.000 | < 0.1 | Yes (both) |
| INP (estimated) | < 100 ms | < 50 ms | < 200 ms | Yes (both) |
| FCP | 2,858 ms | 485 ms | < 1,800 ms | Mobile: No / Desktop: Yes |
Performance Optimization Mechanisms
| Mechanism | What | Impact |
|---|---|---|
| gzip compression | nginx gzip on | 882 KB JS to 262 KB (70% reduction) |
| Immutable caching | Cache-Control: immutable on /assets/* | Return visits: 0ms (browser cache) |
| Vite content hashing | Filenames include hash | Hashed filenames enable aggressive caching |
| Frontend compute | Appliance slider in useMemo | 0ms API latency |
| Debounce | Scenario API calls debounced by 500ms | Prevents 10+ API calls during slider drag |
| CSS skeleton | animate-pulse pure CSS | Instant render, no layout shift |
| useMemo memoization | Chart data computation memoized | Prevents recompute on parent re-renders |
| No SSR blocking | HTML shell is 469 bytes | Fast 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
| Metric | Current | Budget target | Rationale |
|---|---|---|---|
| Initial JS (gzipped) | 262 KB | < 150 KB | With code-splitting |
| Initial CSS (gzipped) | 21 KB | < 25 KB | Already within budget |
| LCP (mobile) | 2,858 ms | < 2,500 ms | Core Web Vitals "Good" |
| LCP (desktop) | 485 ms | < 1,000 ms | Already exceeds |
| CLS | 0.000 | < 0.1 | Already perfect |
| Total byte weight | 287 KB | < 300 KB | Already within budget |
| TBT | 0 ms | < 200 ms | Already 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
| Setting | Value | Why |
|---|---|---|
| Instance type | t2.micro | Free tier, sufficient for mock MVP |
| vCPUs | 2 | Matches uvicorn --workers 2 |
| RAM | 908 MB | Tight — Docker build needs swap |
| Swap | 2 GB | Survives docker compose build on limited RAM |
| Disk | 8 GB | SQLite 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
| Variable | Development | Production |
|---|---|---|
VITE_API_BASE_URL (frontend) | http://localhost:8000/api/v1 | /api/v1 (same-origin) |
APP_ENV (backend) | development | production |
DATABASE_URL (backend) | sqlite:///./billshield_dev.db | sqlite:///./data/billshield.db |
CORS_ALLOW_ORIGINS (backend) | * | * (or specific domains) |
MOCK_MODE (backend) | true | true |
MAX_UPLOAD_MB (backend) | 10 | 10 |
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
| Metric | Value |
|---|---|
| Test runner | Vitest 3.1 |
| Component testing | React Testing Library 16 |
| DOM environment | jsdom 26 |
| Total test cases | ~181 (it/test calls) |
Total describe blocks | 50 |
| Test files | 11 (across root + api/ subdirectory) |
| Test execution time | ~1.3 seconds |
| TypeScript errors | 0 (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 file | Test cases | What it covers |
|---|---|---|
tests/components.test.tsx | 35 | Custom components |
tests/badgeStyles.test.ts | 36 | Badge style mappings |
tests/pages-extra.test.tsx | 30 | Extended page behavior tests |
tests/utils.test.ts | 28 | Formatter utility functions |
tests/api/mock-data.test.ts | 33 | Mock data generator functions |
tests/pages.test.tsx | 19 | Page rendering + basic interaction |
tests/api/endpoints.test.ts | 18 | Endpoint function signatures |
tests/storage.test.ts | 13 | localStorage helper functions |
tests/api/client.test.ts | 10 | API client + mock fallback routing |
tests/lib.test.ts | 7 | cn() utility |
tests/app.test.tsx | 2 | Router + 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
| Gate | Command | Enforces |
|---|---|---|
| TypeScript strict | tsc --noEmit | No implicit any, no unused locals/params, no fallthrough |
| Build type-check | tsc -b && vite build | Full project reference build + Vite production build |
| Unit tests | vitest run | ~181 test cases pass |
verbatimModuleSyntax | tsc | import type enforced for type-only imports |
21. Accessibility, Safety & Privacy #
Accessibility
| Feature | Implementation |
|---|---|
| Dark mode | D key shortcut + theme dropdown (Light / Dark / System) |
| Mobile responsive | Collapsible sidebar to slide-out drawer on small screens |
| Keyboard navigation | All buttons, links, checkboxes, sliders keyboard-accessible |
| Screen reader labels | sr-only labels on icon-only buttons, aria-* attributes |
| Reduced motion | tw-animate-css respects prefers-reduced-motion |
| Focus management | Radix UI handles focus trapping in dialogs/drawers |
| Semantic HTML | Proper heading hierarchy, <nav>, <main>, <ol>, <blockquote> |
| Color contrast | OKLCH theme tokens designed for WCAG AA contrast |
Safety Guardrails
| Guardrail | Implementation |
|---|---|
| No shame language | "This may be worth checking" — never "You're wasting money" |
| Heating safety | Vulnerable 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
| Feature | Implementation |
|---|---|
| Data deletion | DELETE /api/v1/bills/{billId}/data removes uploaded file + extraction data |
| No logging of bill content | Bill contents never logged; postcodes normalized but not stored in logs |
| No external calls (MVP) | In mock mode, zero data leaves the server |
| No authentication | householdId in localStorage is the only identity — no email, no password |
| localStorage only | No cookies, no session storage, no IndexedDB — just two localStorage keys |
| Deletable | Settings page provides one-click bill data deletion |
Saving Label Transparency
| Label | Meaning | Example |
|---|---|---|
estimated_saving | Calculated from your bill data | "Reduce appliance use by 15% = GBP2/mo" |
potential_saving | Depends on eligibility check | "Council Tax Reduction = GBP25-35/mo if eligible" |
cashflow_improvement | Frees up monthly cash | "DD review = GBP22/mo cashflow" |
support_value | A grant or benefit | "Warm Home Discount = GBP150 support" |
green_only | May reduce carbon, not bills | "Off-peak use on flat tariffs = greener but not cheaper" |
risk_reduction | Reduces risk of debt/arrears | "Priority bill sorting = reduces arrears risk" |
billing_accuracy | Ensures correct billing | "Submit meter readings = accurate bills" |
support_access | Access to local services | "Warm space nearby = free heating access" |
planning | Budgeting step | "Create a 30-day plan = structured budgeting" |
no_direct_saving | No financial benefit | "Standing charge awareness = informational" |
22. MVP Limitations & Future Roadmap #
Current MVP Limitations
| Limitation | Impact | Mitigation |
|---|---|---|
| Mock OCR | Bill extraction is simulated | Provider abstraction ready for AWS Textract |
| Mock tariff benchmarks | Static Q3-2026 values | Provider interface ready for Ofgem API |
| Mock council lookup | Generic council names | Provider interface ready for GOV.UK API |
| Mock support services | 25 + 22 mock entries | Provider interface ready for Mapbox |
| Mock plan generator | Template-based, not LLM | Provider interface ready for GPT-4 / Claude |
| Mock carbon windows | Fixed "tonight 22:00-06:00" | Provider interface ready for Carbon Intensity API |
| No authentication | Only householdId identity | Sufficient for MVP — no signup friction |
| Single JS bundle | 882 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
| Priority | Task | Expected impact | Effort |
|---|---|---|---|
| 1 | Code-split routes with React.lazy() + Suspense | ~40-50% smaller initial JS | Medium |
| 2 | Dynamic import unused shadcn primitives | ~50-100 KB less initial JS | Low |
| 3 | Split vendor chunks via manualChunks | Better cache hit rate | Low |
| 4 | Add <link rel="preload"> for critical assets | ~50-100ms faster FCP | Low |
| 5 | Add React 19 use() for suspense data fetching | Cleaner loading states | Medium |
Feature Roadmap
| Feature | Status |
|---|---|
| Real OCR integration | Provider interface ready |
| Real tariff benchmarks | Provider interface ready |
| Real council lookup | Provider interface ready |
| Real support services | Provider interface ready |
| LLM plan generation | Provider interface ready |
| Real carbon windows | Provider interface ready |
| Authentication | Not started |
| PostgreSQL migration | Alembic configured |
| PWA/offline support | Not started |
| Multi-bill support | Not started |