21 KiB
Phase 7: Setup Wizard - Research
Researched: 2026-04-20 Domain: React multi-step wizard UI with localStorage persistence, Supabase writes Confidence: HIGH
Summary
Phase 7 builds a 3-step setup wizard that guides first-run users through budget template creation. The phase is primarily a frontend UI task: no new DB schema changes are needed (Phase 6 delivered setup_completed, presets, and useFirstRunState). The wizard must render a new /setup route outside the AppLayout (standalone page like login/register), persist state to localStorage, and on completion create categories + template items via existing hooks.
The codebase already provides every data layer primitive needed: useFirstRunState for redirect gating, useCategories().create for category creation, useTemplate().createItem for template item creation, PRESETS array with 19 items, and full i18n keys for preset names. The work is purely component authoring, routing, and orchestration.
Primary recommendation: Build the wizard as a standalone page component with internal step state management using React useState + localStorage sync. No additional libraries needed -- the existing stack (React 19, React Router 7, shadcn/ui, Tailwind, React Query, Supabase) handles everything.
<user_constraints>
User Constraints (from CONTEXT.md)
Locked Decisions
- Numbered horizontal stepper (1-2-3 bar at top) -- minimal, clear progress indicator
- Centered card layout (max-w-2xl) on clean background -- consistent with auth pages
- Next/Back buttons at bottom + step indicator is clickable for going back
- Single "monthly net income" number input pre-filled with 3000, required > 0
- Full checklist: all 19 PRESETS shown, grouped by category type, with editable amounts
- Bills (4) + variable_expense (5) pre-checked by default
- Sticky bar: "Remaining to allocate: Income - sum(checked) = X" -- turns red when negative
- Read-only review step with grouped summary
- "Complete" creates categories + template items. Does NOT create first month's budget.
- After completion: redirect to
/with success toast - "Skip" on each step + global "Skip setup" to exit entirely
- localStorage keyed by user_id for persistence across refresh
- On complete or skip: clear localStorage, mark profiles.setup_completed = true
- No animated transitions between steps (instant swap)
Claude's Discretion
- Exact component decomposition (how many sub-components for the wizard)
- Animation/transition between steps (decided: none)
- Exact styling of category badges and grouping headers
- Toast message wording variations
- Error handling UX for failed API calls during completion
Deferred Ideas (OUT OF SCOPE)
None -- discussion stayed within phase scope. </user_constraints>
<phase_requirements>
Phase Requirements
| ID | Description | Research Support |
|---|---|---|
| SETUP-01 | New user is guided through a 3-step wizard: income, recurring items, review | useFirstRunState hook for redirect gating, /setup route outside AppLayout |
| SETUP-02 | User sees pre-filled common budget items with sensible default amounts (~15-20 items) | PRESETS array (19 items) with defaultAmount + i18n keys already exist |
| SETUP-03 | User can skip any wizard step or the entire wizard | Skip Step per step + Skip setup global link, marks setup_completed=true |
| SETUP-04 | User sees a live "remaining to allocate" balance updating as items are selected | Computed from useState: income - sum(checked items amounts), re-renders on change |
| SETUP-05 | User's template is created from wizard selections on completion | useCategories().create + useTemplate().createItem mutations in sequence |
| </phase_requirements> |
Architectural Responsibility Map
| Capability | Primary Tier | Secondary Tier | Rationale |
|---|---|---|---|
| Wizard UI & navigation | Browser / Client | -- | Pure client-side multi-step form, no SSR |
| State persistence | Browser / Client | -- | localStorage for mid-session persistence |
| First-run redirect | Browser / Client | -- | useFirstRunState reads cached React Query data |
| Category + template creation | API / Backend (Supabase) | Browser / Client | Supabase handles insert; client calls via existing hooks |
| setup_completed flag update | API / Backend (Supabase) | Browser / Client | Direct Supabase update from client |
Standard Stack
Core (already installed)
| Library | Version | Purpose | Why Standard |
|---|---|---|---|
| React | ^19.2.4 | Component rendering | Project framework [VERIFIED: package.json] |
| React Router DOM | ^7.13.1 | /setup route, Navigate redirect | Project router [VERIFIED: package.json] |
| @tanstack/react-query | ^5.90.21 | Data fetching/mutations via hooks | Project data layer [VERIFIED: package.json] |
| @supabase/supabase-js | ^2.99.1 | DB writes (categories, template_items, profiles) | Project backend [VERIFIED: package.json] |
| react-i18next | ^16.5.8 | Bilingual copy (EN/DE) | Project i18n [VERIFIED: package.json] |
| sonner | ^2.0.7 | Toast notifications on completion/error | Project toast library [VERIFIED: package.json] |
| lucide-react | ^0.577.0 | Check icon for stepper | Project icon library [VERIFIED: package.json] |
Supporting (already installed)
| Library | Version | Purpose | When to Use |
|---|---|---|---|
| radix-ui | ^1.4.3 | Checkbox primitive (via shadcn) | Step 2 item selection |
| tailwind-merge + clsx + cva | various | Conditional styling | All components |
New shadcn Component to Install
| Component | Purpose |
|---|---|
| checkbox | Item selection in step 2 (not yet in /src/components/ui/) |
Installation:
npx shadcn@latest add checkbox
Alternatives Considered
| Instead of | Could Use | Tradeoff |
|---|---|---|
| useState + localStorage | react-hook-form + persist | Overkill for 2 fields (income + selections); useState simpler |
| Custom stepper | @shadcn/stepper or third-party | Not available in shadcn registry; custom is trivial for 3 steps |
| Zustand for wizard state | useState + localStorage | No global state needed; wizard is single-page isolated |
Architecture Patterns
System Architecture Diagram
[First Login] --> [AppLayout / DashboardPage]
|
useFirstRunState() --> isFirstRun?
| |
NO YES
| |
Dashboard <Navigate to="/setup">
|
[SetupWizard Page]
|
localStorage read --> restore step & data
|
+----------+----------+----------+
| | | |
Step 1 Step 2 Step 3 Skip Setup
(Income) (Items) (Review) |
| | | |
+----------+----------+ |
| |
[Complete] [Skip]
| |
create categories clear localStorage
create template items set setup_completed=true
clear localStorage redirect to /
set setup_completed=true
redirect to / with toast
Recommended Project Structure
src/
├── pages/
│ └── SetupPage.tsx # Page component, orchestrates wizard
├── components/
│ └── setup/
│ ├── WizardStepper.tsx # Horizontal 1-2-3 stepper bar
│ ├── IncomeStep.tsx # Step 1: income input
│ ├── RecurringItemsStep.tsx # Step 2: checklist with amounts
│ ├── ReviewStep.tsx # Step 3: read-only summary
│ ├── AllocationBar.tsx # Sticky remaining balance
│ ├── PresetItemRow.tsx # Single checkbox row
│ └── CategoryGroupHeader.tsx # Section divider with colored dot
Pattern 1: Wizard State in localStorage
What: Single useState object synced to localStorage on every change When to use: Multi-step forms that must survive page refresh Example:
// Source: project convention + React docs
interface WizardState {
currentStep: 1 | 2 | 3
income: number
selectedItems: Record<string, { checked: boolean; amount: number }>
}
const STORAGE_KEY = (userId: string) => `setup-wizard-${userId}`
function useWizardState(userId: string) {
const [state, setState] = useState<WizardState>(() => {
const saved = localStorage.getItem(STORAGE_KEY(userId))
if (saved) return JSON.parse(saved)
return getDefaultState() // builds from PRESETS defaults
})
useEffect(() => {
localStorage.setItem(STORAGE_KEY(userId), JSON.stringify(state))
}, [state, userId])
return [state, setState] as const
}
Pattern 2: Completion Sequence (Category + Template Item Creation)
What: Sequential creation of categories then template items, handling duplicates When to use: Wizard completion step Example:
// Source: existing useCategories + useTemplate hooks
async function completeWizard(
selectedItems: SelectedItem[],
income: number,
createCategory: MutateAsync,
createItem: MutateAsync,
) {
// 1. Determine unique category types needed
const typesNeeded = [...new Set(selectedItems.map(i => i.type))]
// 2. Create categories (skip if already exist -- DB has unique constraint)
const categoryMap: Record<string, string> = {}
for (const type of typesNeeded) {
try {
const cat = await createCategory({ name: categoryLabel(type), type })
categoryMap[type] = cat.id
} catch (e) {
// Unique constraint violation = category exists, fetch it
// Handle gracefully
}
}
// 3. Create template items for each selected preset
for (const item of selectedItems) {
await createItem({
category_id: categoryMap[item.type],
item_tier: item.item_tier,
budgeted_amount: item.amount,
})
}
}
Pattern 3: Route Structure (Standalone Page Outside AppLayout)
What: /setup route as a sibling to login/register, not nested in AppLayout When to use: Wizard must be full-screen without sidebar nav Example:
// Source: existing App.tsx routing pattern
<Routes>
{/* Public routes */}
<Route path="/login" element={<PublicRoute><LoginPage /></PublicRoute>} />
<Route path="/register" element={<PublicRoute><RegisterPage /></PublicRoute>} />
{/* Setup wizard -- protected but outside AppLayout */}
<Route path="/setup" element={<ProtectedRoute><SetupPage /></ProtectedRoute>} />
{/* Main app layout */}
<Route element={<ProtectedRoute><AppLayout /></ProtectedRoute>}>
<Route index element={<DashboardPage />} />
...
</Route>
</Routes>
Anti-Patterns to Avoid
- Nested wizard inside AppLayout: Wizard must be standalone (no sidebar). Keep it as a separate protected route.
- Creating template without categories first: Template items require
category_id-- categories must be created first in completion flow. - Using mutateAsync without error handling: Each Supabase insert can fail. Wrap in try/catch, show partial-error toast if some items fail.
- Reading profile.setup_completed for redirect: Use
useFirstRunState(checks categories/template data), not setup_completed flag. The flag is set on completion, not read for gating.
Don't Hand-Roll
| Problem | Don't Build | Use Instead | Why |
|---|---|---|---|
| Checkbox UI | Custom div with click handlers | shadcn Checkbox (Radix) | Keyboard accessibility, aria states, focus ring |
| Toast notifications | Custom alert/banner | sonner (already integrated) | Consistent with app, auto-dismiss, queue |
| Number formatting | Manual toFixed/locale | Intl.NumberFormat with profile currency | Handles EUR/USD/CHF, locale-aware separators |
| i18n key resolution | String concatenation | t(\presets.${type}.${slug}`)` |
Already set up in en.json/de.json |
Key insight: Every UI primitive and data operation is already available in the project. This phase is purely about composition and orchestration.
Common Pitfalls
Pitfall 1: Redirect Loop on Dashboard
What goes wrong: User completes wizard, lands on dashboard, useFirstRunState still returns isFirstRun=true because React Query cache is stale.
Why it happens: Categories/template items just created but query cache not invalidated yet.
How to avoid: After completion, invalidate both ["categories"] and ["template-items"] query keys before redirecting. Or use setup_completed flag for the redirect condition (check profiles table).
Warning signs: User bounces between dashboard and /setup infinitely.
Pitfall 2: Category Name Collision on Completion
What goes wrong: User already has a "Bills" category (edge case: skipped wizard, created manually, re-enters). DB unique constraint rejects the insert.
Why it happens: Phase 6 added UNIQUE(user_id, name) constraint on categories.
How to avoid: Use upsert or catch constraint violation errors and fetch existing category ID instead. The completion logic must handle "category already exists" gracefully.
Warning signs: Wizard completion fails silently or shows generic error.
Pitfall 3: localStorage Orphan After Logout
What goes wrong: User starts wizard, logs out, different user logs in -- sees previous user's wizard state.
Why it happens: localStorage key must include user_id.
How to avoid: Key is setup-wizard-${userId} (already specified in CONTEXT). Always validate userId matches on load.
Warning signs: Wrong income/selections appearing for a different user.
Pitfall 4: Race Condition in Completion
What goes wrong: User double-clicks "Complete Setup" and creates duplicate template items.
Why it happens: Button not disabled during async operation.
How to avoid: Disable Complete button immediately on click, show spinner. Use a completing state flag.
Warning signs: Duplicate rows in template_items table.
Pitfall 5: useTemplate() Needs Template to Exist Before createItem
What goes wrong: createItem throws "Template not loaded" because templateId is undefined.
Why it happens: useTemplate() auto-creates a template row on first query, but the query must complete before createItem works.
How to avoid: Ensure useTemplate() is called early in the wizard (mount time) so the template row exists by completion time. Or call getOrCreateTemplate() directly in the completion flow.
Warning signs: "Template not loaded" error on wizard completion.
Code Examples
Redirect Logic in Dashboard (or AppLayout)
// Source: existing useFirstRunState pattern + CONTEXT decisions
import { useFirstRunState } from "@/hooks/useFirstRunState"
import { Navigate } from "react-router-dom"
// Inside DashboardPage or a redirect wrapper:
const { isFirstRun, loading } = useFirstRunState()
if (loading) return <Skeleton /> // or null
if (isFirstRun) return <Navigate to="/setup" replace />
Updating setup_completed on Skip/Complete
// Source: existing SettingsPage pattern for profile updates
import { supabase } from "@/lib/supabase"
async function markSetupComplete(userId: string) {
await supabase
.from("profiles")
.update({ setup_completed: true })
.eq("id", userId)
}
Live Allocation Calculation
// Source: derived from CONTEXT decisions
function computeRemaining(income: number, items: Record<string, { checked: boolean; amount: number }>) {
const totalExpenses = Object.values(items)
.filter(i => i.checked)
.reduce((sum, i) => sum + i.amount, 0)
return income - totalExpenses
}
Default Selected Items State
// Source: CONTEXT + PRESETS data structure
import { PRESETS } from "@/data/presets"
function getDefaultSelectedItems(): Record<string, { checked: boolean; amount: number }> {
const result: Record<string, { checked: boolean; amount: number }> = {}
for (const preset of PRESETS) {
result[preset.slug] = {
checked: preset.type === "bill" || preset.type === "variable_expense",
amount: preset.defaultAmount,
}
}
return result
}
State of the Art
| Old Approach | Current Approach | When Changed | Impact |
|---|---|---|---|
| Multi-page wizard with URL-per-step | Single-page with internal state | Standard for SPA wizards | Simpler routing, better state control |
| Form library (react-hook-form) for wizards | useState for simple forms | When forms have < 5 fields | Less boilerplate for simple cases |
| Redux/Zustand for wizard state | localStorage + useState | For isolated flows | No global store pollution |
Assumptions Log
| # | Claim | Section | Risk if Wrong |
|---|---|---|---|
| A1 | shadcn checkbox installs via npx shadcn@latest add checkbox |
Standard Stack | Low -- standard shadcn CLI pattern, easily correctable |
| A2 | Category names for auto-creation should use i18n labels (e.g., t("categories.types.bill")) | Patterns | Medium -- if hardcoded English names used, DE users get English category names |
Open Questions
-
Category naming on creation
- What we know: Categories need a
namefield. Presets are grouped bytype. - What's unclear: Should each preset create its own category (19 categories) or one category per type (6 categories)?
- Recommendation: One category per type (6 max). The CONTEXT says "Create categories (from checked item types that don't already exist)" -- this confirms one-per-type. Multiple template items share the same category.
- What we know: Categories need a
-
Where to place the first-run redirect
- What we know:
useFirstRunStatereturnsisFirstRunbased on categories/template data. - What's unclear: Should redirect live in DashboardPage, AppLayout, or a wrapper component?
- Recommendation: Place in DashboardPage (the index route) since that's where first-run users land. Avoids affecting other routes.
- What we know:
Validation Architecture
Test Framework
| Property | Value |
|---|---|
| Framework | None detected (no test config or test files found) |
| Config file | none -- Wave 0 must create |
| Quick run command | N/A |
| Full suite command | N/A |
Phase Requirements to Test Map
| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|---|---|---|---|---|
| SETUP-01 | First-run user sees wizard, 3 steps | manual | Manual browser test | N/A |
| SETUP-02 | 19 pre-filled items with amounts | manual | Visual verification | N/A |
| SETUP-03 | Skip step/wizard works | manual | Click through wizard | N/A |
| SETUP-04 | Remaining balance updates live | manual | Check/uncheck items | N/A |
| SETUP-05 | Template created from selections | manual | Complete wizard, check /template | N/A |
Wave 0 Gaps
No test framework exists in this project. All verification is manual/visual. This is acceptable per the project's established pattern -- no test files exist anywhere in src/.
Security Domain
Applicable ASVS Categories
| ASVS Category | Applies | Standard Control |
|---|---|---|
| V2 Authentication | no | Already handled by ProtectedRoute |
| V3 Session Management | no | Supabase session management |
| V4 Access Control | yes | RLS policies on categories/template_items (already in place) |
| V5 Input Validation | yes | Income > 0 validation, amounts must be positive numbers |
| V6 Cryptography | no | No crypto needed |
Known Threat Patterns for This Phase
| Pattern | STRIDE | Standard Mitigation |
|---|---|---|
| localStorage tampering | Tampering | Validate localStorage data shape on load; sanitize numbers |
| Mass insert abuse (spam completion) | Denial of Service | setup_completed flag prevents re-entry; RLS limits to own user |
| XSS via preset slug injection | Spoofing | Presets are hardcoded in source, not user-input; i18n keys are safe |
Sources
Primary (HIGH confidence)
- Project source code:
src/hooks/useFirstRunState.ts,src/hooks/useCategories.ts,src/hooks/useTemplate.ts,src/data/presets.ts,src/App.tsx,src/lib/types.ts-- verified via direct file read package.json-- verified all dependency versions- Phase 6 outputs:
supabase/migrations/007_setup_completed.sql-- confirms DB schema ready
Secondary (MEDIUM confidence)
- UI-SPEC.md (07-UI-SPEC.md) -- authored design contract for this phase
- CONTEXT.md (07-CONTEXT.md) -- user decisions from discuss phase
Metadata
Confidence breakdown:
- Standard stack: HIGH - all dependencies verified in package.json, no new installs except shadcn checkbox
- Architecture: HIGH - follows exact patterns established in existing pages (Login, Register, Settings)
- Pitfalls: HIGH - derived from reading actual hook implementations and understanding race conditions
Research date: 2026-04-20 Valid until: 2026-05-20 (stable -- no moving targets, all deps are pinned)