Files
SimpleFinanceDash/.planning/phases/07-setup-wizard/07-02-PLAN.md
Jean-Luc Makiola 55eca5dbe1 docs(07): create phase plans for setup wizard
Two plans covering the 3-step first-run wizard: state management + UI
components (Wave 1), then completion logic + routing + redirect (Wave 2).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 20:59:26 +02:00

393 lines
17 KiB
Markdown

---
phase: 07-setup-wizard
plan: 02
type: execute
wave: 2
depends_on: [07-01]
files_modified:
- src/components/setup/ReviewStep.tsx
- src/pages/SetupPage.tsx
- src/App.tsx
- src/pages/DashboardPage.tsx
autonomous: true
requirements: [SETUP-01, SETUP-03, SETUP-05]
must_haves:
truths:
- "ReviewStep shows read-only summary of income + selected items grouped by type + totals"
- "/setup route is protected and renders SetupPage outside AppLayout"
- "DashboardPage redirects first-run users to /setup via useFirstRunState"
- "Completing wizard creates categories then template items and redirects to dashboard"
- "Skip setup clears localStorage, marks setup_completed=true, redirects to dashboard"
- "Complete button is disabled during API calls to prevent double-submit"
artifacts:
- path: "src/components/setup/ReviewStep.tsx"
provides: "Read-only summary of wizard selections"
exports: ["ReviewStep"]
- path: "src/App.tsx"
provides: "/setup route registration"
contains: "path=\"/setup\""
- path: "src/pages/DashboardPage.tsx"
provides: "First-run redirect to /setup"
contains: "useFirstRunState"
key_links:
- from: "src/pages/DashboardPage.tsx"
to: "src/hooks/useFirstRunState.ts"
via: "useFirstRunState() -> isFirstRun -> Navigate to /setup"
pattern: "isFirstRun.*Navigate.*setup"
- from: "src/pages/SetupPage.tsx"
to: "src/hooks/useCategories.ts"
via: "create.mutateAsync on wizard completion"
pattern: "create\\.mutateAsync"
- from: "src/pages/SetupPage.tsx"
to: "src/hooks/useTemplate.ts"
via: "createItem.mutateAsync on wizard completion"
pattern: "createItem\\.mutateAsync"
- from: "src/App.tsx"
to: "src/pages/SetupPage.tsx"
via: "Route path=/setup element=SetupPage"
pattern: "path=.*/setup"
---
<objective>
Add the ReviewStep component, wire wizard completion logic (creating categories + template items), add skip functionality, register the /setup route, and add first-run redirect in DashboardPage.
Purpose: Completes the wizard end-to-end — a new user is redirected to /setup, can navigate all 3 steps, and either completes (creating template data) or skips.
Output: Fully functional setup wizard accessible via /setup with working completion and skip flows.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/07-setup-wizard/07-CONTEXT.md
@.planning/phases/07-setup-wizard/07-RESEARCH.md
@.planning/phases/07-setup-wizard/07-PATTERNS.md
@.planning/phases/07-setup-wizard/07-UI-SPEC.md
@.planning/phases/07-setup-wizard/07-01-SUMMARY.md
<interfaces>
From src/hooks/useWizardState.ts (created in Plan 01):
```typescript
export interface WizardState {
currentStep: 1 | 2 | 3
income: number
selectedItems: Record<string, { checked: boolean; amount: number }>
}
export function useWizardState(userId: string): {
state: WizardState
setStep: (step: 1 | 2 | 3) => void
setIncome: (income: number) => void
toggleItem: (slug: string) => void
setItemAmount: (slug: string, amount: number) => void
clearState: () => void
}
```
From src/hooks/useCategories.ts:
```typescript
// create.mutateAsync({ name: string, type: CategoryType, icon?: string }) => Category { id, ... }
```
From src/hooks/useTemplate.ts:
```typescript
// createItem.mutateAsync({ category_id: string, item_tier: "fixed"|"variable", budgeted_amount: number }) => TemplateItem
// Template auto-creates on first useTemplate() query
```
From src/hooks/useFirstRunState.ts:
```typescript
export function useFirstRunState(): { isFirstRun: boolean; loading: boolean }
```
From src/data/presets.ts:
```typescript
export const PRESETS: PresetItem[] // 19 items with slug, type, defaultAmount, item_tier
```
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Create ReviewStep + wire completion/skip logic into SetupPage</name>
<read_first>
- src/pages/SetupPage.tsx
- src/hooks/useCategories.ts
- src/hooks/useTemplate.ts
- src/hooks/useWizardState.ts
- src/data/presets.ts
- src/lib/supabase.ts
</read_first>
<files>src/components/setup/ReviewStep.tsx, src/pages/SetupPage.tsx</files>
<action>
**1. Create `src/components/setup/ReviewStep.tsx`:**
Props: `income: number`, `selectedItems: Record<string, { checked: boolean; amount: number }>`, `currency: string`
Layout:
- Income row: `flex justify-between py-2` — left: `t("setup.step3.incomeLabel")` (14px/600), right: formatted income (14px/600)
- `<Separator />`
- Group checked items by type (same order: income, bill, variable_expense, debt, saving, investment). Only show groups that have at least one checked item.
- For each group: render CategoryGroupHeader (import from `./CategoryGroupHeader`), then for each checked item in group: `<div className="flex justify-between py-1.5 px-1"><span className="text-sm">{t(\`presets.${type}.${slug}\`)}</span><span className="text-sm text-right">{formattedAmount}</span></div>`
- `<Separator />`
- Totals: `space-y-1 pt-2`
- Total expenses row: `flex justify-between` with label `t("setup.step3.totalLabel")` (14px/600) and sum of checked amounts
- Remaining row: `flex justify-between` with label `t("setup.step3.remainingLabel")` (16px/600), value colored `text-on-budget` if >= 0, `text-destructive` if < 0
- If no items checked: show `<p className="text-sm text-muted-foreground py-4">{t("setup.step3.empty")}</p>` instead of groups
Use `Intl.NumberFormat` with `style: "currency"` and the `currency` prop for all amount formatting.
Import PRESETS from `@/data/presets` to map slugs to types for grouping.
**2. Update `src/pages/SetupPage.tsx` to add:**
a) Import ReviewStep, useCategories, useTemplate, useQueryClient, toast (from sonner), supabase (from @/lib/supabase), Navigate (from react-router-dom), useNavigate
b) Add `completing` state: `const [completing, setCompleting] = useState(false)`
c) Replace the step 3 placeholder with `<ReviewStep income={state.income} selectedItems={state.selectedItems} currency={currency} />`
d) Change the right-side button on step 3 from "Next Step" to "Complete Setup" (`t("setup.complete")`), disabled when `completing`
e) Add completion handler `handleComplete`:
```typescript
async function handleComplete() {
setCompleting(true)
try {
const queryClient = useQueryClient() // already declared at top
const checkedItems = PRESETS.filter(p => state.selectedItems[p.slug]?.checked)
// 1. Determine unique category types needed
const typesNeeded = [...new Set(checkedItems.map(i => i.type))]
// 2. Create one category per type (use i18n label as name)
const categoryMap: Record<string, string> = {}
for (const type of typesNeeded) {
try {
const cat = await create.mutateAsync({ name: t(`categories.types.${type}`), type })
categoryMap[type] = cat.id
} catch (e: any) {
// Unique constraint violation (23505) = category already exists, fetch it
if (e?.code === "23505" || e?.message?.includes("duplicate")) {
const existing = categories.find(c => c.type === type)
if (existing) categoryMap[type] = existing.id
} else {
throw e
}
}
}
// 3. If categories were fetched from cache but not found, refetch
if (Object.keys(categoryMap).length < typesNeeded.length) {
await queryClient.invalidateQueries({ queryKey: ["categories"] })
// Remaining types need fresh data — handled by the existing entries
}
// 4. Create template items for each checked preset
let partialFailure = false
for (const item of checkedItems) {
try {
await createItem.mutateAsync({
category_id: categoryMap[item.type],
item_tier: item.item_tier,
budgeted_amount: state.selectedItems[item.slug].amount,
})
} catch {
partialFailure = true
}
}
// 5. Mark setup complete
await supabase.from("profiles").update({ setup_completed: true }).eq("id", user!.id)
// 6. Clear wizard state
clearState()
// 7. Invalidate queries to prevent redirect loop
await queryClient.invalidateQueries({ queryKey: ["categories"] })
await queryClient.invalidateQueries({ queryKey: ["template-items"] })
// 8. Toast and redirect
if (partialFailure) {
toast.error(t("setup.toast.partialError"))
} else {
toast.success(t("setup.toast.success"))
}
navigate("/", { replace: true })
} catch {
toast.error(t("setup.toast.error"))
} finally {
setCompleting(false)
}
}
```
f) Add skip handler `handleSkipSetup`:
```typescript
async function handleSkipSetup() {
clearState()
await supabase.from("profiles").update({ setup_completed: true }).eq("id", user!.id)
navigate("/", { replace: true })
}
```
g) Wire "Skip setup" button below card to call `handleSkipSetup`
h) Wire "Skip Step" on step 3 to also call `handleSkipSetup` (per UI-SPEC: "On step 3 skip: same as Skip setup")
i) Disable Go Back, Skip Step, and Complete Setup buttons when `completing` is true
j) Add `useCategories()` call at component top level to get `create` mutation and `categories` array. Add `useTemplate()` to ensure template row exists before completion (Pitfall 5).
k) Get `useQueryClient()` at component top level (not inside handler).
</action>
<verify>
<automated>grep -q "ReviewStep" src/components/setup/ReviewStep.tsx && grep -q "handleComplete" src/pages/SetupPage.tsx && grep -q "setup_completed.*true" src/pages/SetupPage.tsx && grep -q "clearState" src/pages/SetupPage.tsx && npx tsc --noEmit 2>&1 | head -20</automated>
</verify>
<acceptance_criteria>
- src/components/setup/ReviewStep.tsx contains `t("setup.step3.incomeLabel")` and `Intl.NumberFormat`
- src/components/setup/ReviewStep.tsx contains `text-on-budget` and `text-destructive` conditional
- src/pages/SetupPage.tsx contains `async function handleComplete`
- src/pages/SetupPage.tsx contains `create.mutateAsync` (category creation)
- src/pages/SetupPage.tsx contains `createItem.mutateAsync` (template item creation)
- src/pages/SetupPage.tsx contains `setup_completed: true` (profile update)
- src/pages/SetupPage.tsx contains `handleSkipSetup` function
- src/pages/SetupPage.tsx contains `setCompleting(true)` (double-click prevention)
- src/pages/SetupPage.tsx contains `toast.success` and `toast.error`
- src/pages/SetupPage.tsx contains `invalidateQueries.*categories` (redirect loop prevention)
- TypeScript compiles without errors
</acceptance_criteria>
<done>ReviewStep renders read-only grouped summary. Completion creates categories + template items, handles duplicates, clears state, marks setup_completed, toasts, and redirects. Skip exits without creating data.</done>
</task>
<task type="auto">
<name>Task 2: Register /setup route in App.tsx + add first-run redirect in DashboardPage</name>
<read_first>
- src/App.tsx
- src/pages/DashboardPage.tsx
- src/hooks/useFirstRunState.ts
</read_first>
<files>src/App.tsx, src/pages/DashboardPage.tsx</files>
<action>
**1. Update `src/App.tsx`:**
Add import at top:
```typescript
import SetupPage from "@/pages/SetupPage"
```
Add the /setup route AFTER the public routes (login, register) and BEFORE the AppLayout route. It must be inside a ProtectedRoute but NOT inside AppLayout:
```typescript
<Route
path="/setup"
element={
<ProtectedRoute>
<SetupPage />
</ProtectedRoute>
}
/>
```
Insert this between the `</Route>` closing the register route (around line 46) and the `<Route element={<ProtectedRoute><AppLayout /></ProtectedRoute>}>` line (around line 47).
**2. Update `src/pages/DashboardPage.tsx`:**
Add imports at top (after existing imports):
```typescript
import { useFirstRunState } from "@/hooks/useFirstRunState"
import { Navigate } from "react-router-dom"
```
Note: `Navigate` may already be imported — check before adding a duplicate.
Inside the DashboardPage component function, add early returns BEFORE any existing logic (before the existing useState/useMemo calls):
```typescript
const { isFirstRun, loading: firstRunLoading } = useFirstRunState()
if (firstRunLoading) return <DashboardSkeleton />
if (isFirstRun) return <Navigate to="/setup" replace />
```
This uses the existing `DashboardSkeleton` component (already imported) as the loading fallback.
</action>
<verify>
<automated>grep -q 'path="/setup"' src/App.tsx && grep -q "SetupPage" src/App.tsx && grep -q "useFirstRunState" src/pages/DashboardPage.tsx && grep -q 'Navigate to="/setup"' src/pages/DashboardPage.tsx && npx tsc --noEmit 2>&1 | head -20</automated>
</verify>
<acceptance_criteria>
- src/App.tsx contains `import SetupPage from "@/pages/SetupPage"`
- src/App.tsx contains `path="/setup"` within a `<ProtectedRoute>` wrapper
- src/App.tsx has /setup route BEFORE the AppLayout route (not nested inside it)
- src/pages/DashboardPage.tsx contains `import { useFirstRunState } from "@/hooks/useFirstRunState"`
- src/pages/DashboardPage.tsx contains `const { isFirstRun, loading: firstRunLoading } = useFirstRunState()`
- src/pages/DashboardPage.tsx contains `if (isFirstRun) return <Navigate to="/setup" replace />`
- src/pages/DashboardPage.tsx contains `if (firstRunLoading) return <DashboardSkeleton />`
- TypeScript compiles without errors
</acceptance_criteria>
<done>/setup route registered as protected standalone page. First-run users are redirected from dashboard to /setup. Existing users with categories/template data see the normal dashboard.</done>
</task>
<task type="checkpoint:human-verify" gate="blocking">
<what-built>Complete 3-step setup wizard with income entry, recurring items checklist (19 presets), review step, completion logic (category + template creation), skip functionality, and first-run redirect from dashboard.</what-built>
<how-to-verify>
1. Create a new test user (or clear an existing user's categories and template items in Supabase)
2. Log in with the test user — should be redirected to `/setup`
3. Step 1: Verify income input shows 3000 pre-filled with EUR suffix. Try Next with empty/0 value (should show validation error). Enter 4000, click Next.
4. Step 2: Verify Bills (4) and Variable Expenses (5) are pre-checked. Verify allocation bar shows "Remaining to allocate: X" (4000 - sum of checked). Uncheck an item — verify amount updates. Check an item — verify it enables the amount input.
5. Step 3: Verify read-only summary shows income, grouped checked items, totals, and remaining.
6. Click "Complete Setup" — verify toast appears, redirected to dashboard, template page shows created items.
7. Refresh dashboard — should NOT redirect back to /setup (setup_completed=true, and categories exist).
8. Test "Skip setup" link — create another fresh user, click "Skip setup" from step 1. Verify redirect to dashboard with no data created.
9. Test localStorage persistence: start wizard, enter income, advance to step 2, refresh page — verify wizard resumes at step 2 with entered income.
</how-to-verify>
<resume-signal>Type "approved" or describe any issues found</resume-signal>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| client -> Supabase | Category and template item inserts on completion |
| localStorage -> component | Wizard state restoration (validated in Plan 01) |
| React Query cache -> redirect logic | isFirstRun depends on cache freshness |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-07-04 | Denial of Service | handleComplete double-click | mitigate | `completing` state disables button immediately on click. DB unique constraints on categories prevent duplicate rows. |
| T-07-05 | Tampering | Supabase profile update (setup_completed) | accept | RLS policy restricts updates to own profile row. Client can only set own setup_completed. |
| T-07-06 | Elevation of Privilege | /setup route access | mitigate | Route wrapped in ProtectedRoute — unauthenticated users redirected to /login. |
| T-07-07 | Denial of Service | Redirect loop (dashboard <-> /setup) | mitigate | After completion, invalidate ["categories"] and ["template-items"] queries before redirect. useFirstRunState will read fresh data showing isFirstRun=false. |
</threat_model>
<verification>
- `npx tsc --noEmit` passes
- Navigating to /setup while unauthenticated redirects to /login
- A user with zero categories sees /setup after login
- Completing wizard creates categories + template items in database
- Skipping wizard sets setup_completed=true without creating data
- Refreshing mid-wizard restores state from localStorage
- No redirect loop after completion
</verification>
<success_criteria>
- New user flow: login -> auto-redirect to /setup -> 3 steps -> complete -> dashboard with template
- Skip flow: /setup -> skip -> dashboard (no data created, setup_completed=true)
- Existing user flow: login -> dashboard (no redirect to /setup)
- No duplicate categories on repeated completion attempts
- No infinite redirect loop between dashboard and /setup
</success_criteria>
<output>
After completion, create `.planning/phases/07-setup-wizard/07-02-SUMMARY.md`
</output>