docs(07): research phase domain for setup wizard

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-20 20:51:44 +02:00
parent 3c937e68bc
commit 07823081bb

View File

@@ -0,0 +1,433 @@
# 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:**
```bash
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:**
```typescript
// 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:**
```typescript
// 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:**
```typescript
// 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)
```typescript
// 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
```typescript
// 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
```typescript
// 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
```typescript
// 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
1. **Category naming on creation**
- What we know: Categories need a `name` field. Presets are grouped by `type`.
- 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.
2. **Where to place the first-run redirect**
- What we know: `useFirstRunState` returns `isFirstRun` based 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.
## 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)