docs(07): research phase domain for setup wizard
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
433
.planning/phases/07-setup-wizard/07-RESEARCH.md
Normal file
433
.planning/phases/07-setup-wizard/07-RESEARCH.md
Normal 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)
|
||||
Reference in New Issue
Block a user