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>
20 KiB
phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
| phase | plan | type | wave | depends_on | files_modified | autonomous | requirements | must_haves | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 07-setup-wizard | 01 | execute | 1 |
|
true |
|
|
Purpose: Establishes the wizard's visual structure, localStorage persistence, and all interactive components. Plan 02 will add the ReviewStep and completion/redirect logic on top of this foundation. Output: A navigable 3-step wizard at /setup (not yet routed) with working state persistence and live allocation calculation.
<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_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 From src/lib/types.ts: ```typescript export type CategoryType = "income" | "bill" | "variable_expense" | "debt" | "saving" | "investment" export type ItemTier = "fixed" | "variable" | "one_off" export interface Profile { id: string; display_name: string | null; locale: string; currency: string; setup_completed: boolean; ... } ```From src/data/presets.ts:
export interface PresetItem {
slug: string
type: CategoryType
defaultAmount: number
item_tier: "fixed" | "variable"
}
export const PRESETS: PresetItem[] = [ /* 19 items: 4 income, 4 bill, 5 variable_expense, 2 debt, 2 saving, 2 investment */ ]
From src/hooks/useAuth.ts:
// Returns { user, profile, loading, signIn, signOut, signUp }
// profile.currency = "EUR" | "USD" | "CHF" etc.
From src/hooks/useCategories.ts:
export function useCategories(): { categories: Category[]; loading: boolean; create: UseMutationResult; ... }
// create.mutateAsync({ name: string, type: CategoryType, icon?: string }) => Category
From src/hooks/useTemplate.ts:
export function useTemplate(): { template: Template | null; items: TemplateItem[]; loading: boolean; createItem: UseMutationResult; ... }
// createItem.mutateAsync({ category_id: string, item_tier: "fixed"|"variable", budgeted_amount: number }) => TemplateItem
- Create
src/hooks/useWizardState.ts:
import { useState, useEffect } from "react"
import { PRESETS } from "@/data/presets"
export interface WizardState {
currentStep: 1 | 2 | 3
income: number
selectedItems: Record<string, { checked: boolean; amount: number }>
}
const STORAGE_KEY = (userId: string) => `setup-wizard-${userId}`
function getDefaultState(): WizardState {
const selectedItems: Record<string, { checked: boolean; amount: number }> = {}
for (const preset of PRESETS) {
selectedItems[preset.slug] = {
checked: preset.type === "bill" || preset.type === "variable_expense",
amount: preset.defaultAmount,
}
}
return { currentStep: 1, income: 3000, selectedItems }
}
export function useWizardState(userId: string) {
const [state, setState] = useState<WizardState>(() => {
try {
const saved = localStorage.getItem(STORAGE_KEY(userId))
if (saved) {
const parsed = JSON.parse(saved)
// Validate shape
if (parsed && typeof parsed.currentStep === "number" && typeof parsed.income === "number" && parsed.selectedItems) {
return parsed as WizardState
}
}
} catch { /* ignore corrupt data */ }
return getDefaultState()
})
useEffect(() => {
localStorage.setItem(STORAGE_KEY(userId), JSON.stringify(state))
}, [state, userId])
const setStep = (step: 1 | 2 | 3) => setState(s => ({ ...s, currentStep: step }))
const setIncome = (income: number) => setState(s => ({ ...s, income }))
const toggleItem = (slug: string) => setState(s => ({
...s,
selectedItems: {
...s.selectedItems,
[slug]: { ...s.selectedItems[slug], checked: !s.selectedItems[slug].checked },
},
}))
const setItemAmount = (slug: string, amount: number) => setState(s => ({
...s,
selectedItems: {
...s.selectedItems,
[slug]: { ...s.selectedItems[slug], amount },
},
}))
const clearState = () => {
localStorage.removeItem(STORAGE_KEY(userId))
}
return { state, setStep, setIncome, toggleItem, setItemAmount, clearState }
}
- Add i18n keys to
src/i18n/en.json— add a"setup"object at the top level:
"setup": {
"title": "Set up your budget",
"step1": {
"title": "Monthly Income",
"description": "How much do you earn each month?",
"incomeLabel": "Monthly net income",
"helper": "Enter your total monthly take-home pay",
"validation": "Please enter a positive income amount"
},
"step2": {
"title": "Recurring Items",
"description": "Select your regular monthly expenses",
"remaining": "Remaining to allocate"
},
"step3": {
"title": "Review",
"description": "Confirm your budget template",
"incomeLabel": "Monthly income",
"totalLabel": "Total expenses",
"remainingLabel": "Remaining",
"empty": "No items selected. You can add items to your template later."
},
"steps": {
"1": "Income",
"2": "Items",
"3": "Review"
},
"next": "Next Step",
"back": "Go Back",
"skip": "Skip Step",
"skipSetup": "Skip setup",
"complete": "Complete Setup",
"toast": {
"success": "Template created! Your first budget will appear automatically.",
"error": "Could not save your template. Please try again.",
"partialError": "Some items could not be saved. Check your template page."
}
}
- Add equivalent German keys to
src/i18n/de.json:
"setup": {
"title": "Budget einrichten",
"step1": {
"title": "Monatliches Einkommen",
"description": "Wie viel verdienst du pro Monat?",
"incomeLabel": "Monatliches Nettoeinkommen",
"helper": "Gib dein monatliches Nettoeinkommen ein",
"validation": "Bitte gib einen positiven Betrag ein"
},
"step2": {
"title": "Wiederkehrende Posten",
"description": "Wahle deine regelmaigen monatlichen Ausgaben",
"remaining": "Verbleibend zu verteilen"
},
"step3": {
"title": "Ubersicht",
"description": "Bestatige deine Budgetvorlage",
"incomeLabel": "Monatliches Einkommen",
"totalLabel": "Gesamtausgaben",
"remainingLabel": "Verbleibend",
"empty": "Keine Posten ausgewahlt. Du kannst spater Posten zu deiner Vorlage hinzufugen."
},
"steps": {
"1": "Einkommen",
"2": "Posten",
"3": "Ubersicht"
},
"next": "Nachster Schritt",
"back": "Zuruck",
"skip": "Schritt uberspringen",
"skipSetup": "Einrichtung uberspringen",
"complete": "Einrichtung abschlieen",
"toast": {
"success": "Vorlage erstellt! Dein erstes Budget wird automatisch erscheinen.",
"error": "Vorlage konnte nicht gespeichert werden. Bitte versuche es erneut.",
"partialError": "Einige Posten konnten nicht gespeichert werden. Prufe deine Vorlagenseite."
}
}
1. WizardStepper.tsx:
- Props:
currentStep: 1|2|3,onStepClick: (step: 1|2|3) => void - Renders 3 circles in a row connected by lines
- Active step:
bg-primary text-primary-foregroundwith number (14px/600) - Completed step (step < currentStep):
bg-primary text-primary-foregroundwith lucideCheckicon (16px) - Upcoming step (step > currentStep):
bg-muted text-muted-foreground border border-border - Connector line between:
h-px w-16— completed segmentbg-primary, upcomingbg-border - Step labels below circles: text-xs font-semibold, active
text-foreground, otherstext-muted-foreground - Labels from i18n:
t("setup.steps.1"),t("setup.steps.2"),t("setup.steps.3") - Clicking a completed step or current step calls onStepClick. Future steps: no action, cursor-default.
- Wrap in
role="navigation" aria-label="Setup progress" - Circle dimensions:
w-8 h-8(32px) - Overall layout:
flex items-center justify-center gap-0with step groupsflex flex-col items-center gap-1separated by connector lines - Margin below: handled by parent (mb-6)
2. IncomeStep.tsx:
- Props:
income: number,onIncomeChange: (val: number) => void,currency: string,error: string | null - Renders card content for step 1
- Label:
t("setup.step1.incomeLabel")(14px/600) - Input row:
flex items-center gap-2- Input:
type="number",className="w-full text-right text-lg", value={income}, onChange converts to number - Currency suffix:
<span className="text-muted-foreground text-sm">{currency}</span>
- Input:
- Helper text:
t("setup.step1.helper")intext-sm text-muted-foreground - If
erroris truthy: show<p className="text-sm text-destructive">{error}</p>below input aria-describedbyon input pointing to helper and error elements
3. AllocationBar.tsx:
- Props:
remaining: number,currency: string - Layout:
sticky top-0 z-10 flex justify-between items-center py-3 px-4 bg-muted border-b border-border rounded-none - Left:
<span className="text-sm font-semibold">{t("setup.step2.remaining")}</span> - Right: formatted amount using
Intl.NumberFormatwith currencyremaining >= 0:className="text-base font-semibold text-on-budget"remaining < 0:className="text-base font-semibold text-destructive"
- Add
aria-live="polite"on the amount element
4. CategoryGroupHeader.tsx:
- Props:
type: CategoryType,label: string,count: number - Layout:
flex items-center gap-2 pt-4 pb-2 - Colored dot:
w-2.5 h-2.5 rounded-fullwith inline stylebackgroundColor: var(--color-${type.replace('_', '-')})- Map types to CSS vars: income ->
--color-income, bill ->--color-bill, variable_expense ->--color-variable-expense, debt ->--color-debt, saving ->--color-saving, investment ->--color-investment
- Map types to CSS vars: income ->
- Label:
<span className="text-sm font-semibold">{label}</span> - Count:
<span className="text-xs text-muted-foreground">({count} items)</span> - Below:
<Separator />from shadcn
5. PresetItemRow.tsx:
- Props:
slug: string,type: CategoryType,checked: boolean,amount: number,onToggle: () => void,onAmountChange: (val: number) => void - Layout:
flex items-center gap-3 py-2.5 px-1 - Checkbox: shadcn
<Checkbox checked={checked} onCheckedChange={onToggle} /> - Item name:
<span className="flex-1 text-sm">{t(\presets.${type}.${slug}`)}` - Category badge:
<Badge variant="outline" className="text-xs" style={{ borderLeftWidth: '3px', borderLeftColor: \var(--color-${type.replace('_', '-')})` }}>{t(`categories.types.${type}`)}` - Amount input:
<Input type="number" className="w-24 text-right" value={amount} onChange={...} disabled={!checked} />- When disabled: add
bg-muted text-muted-foreground opacity-50classes
- When disabled: add
6. RecurringItemsStep.tsx:
- Props:
selectedItems: Record<string, { checked: boolean; amount: number }>,income: number,currency: string,onToggle: (slug: string) => void,onAmountChange: (slug: string, amount: number) => void - Groups PRESETS by type using:
Object.groupBy(PRESETS, (p) => p.type)— if Object.groupBy not available, use manual reduce - Order groups:
["income", "bill", "variable_expense", "debt", "saving", "investment"] - Renders AllocationBar at top (sticky)
- For each group: CategoryGroupHeader + list of PresetItemRow
- Wrap each group in
<fieldset>with<legend className="sr-only">{groupLabel}</legend> - Compute remaining:
income - sum of all checked items' amounts
7. SetupPage.tsx (page orchestrator):
- Imports useAuth, useWizardState, useTranslation, WizardStepper, IncomeStep, RecurringItemsStep
- Gets userId from
useAuth()->user.id - Gets currency from profile:
profile?.currency ?? "EUR" - Uses
useWizardState(userId)for state management - Layout:
<div className="flex min-h-screen items-center justify-center bg-background p-4"><div className="w-full max-w-2xl"> - Renders WizardStepper above the card
- Card:
<Card className="w-full border border-border shadow-sm">- CardHeader: step title (heading 20px/600) + step description (body 14px/400 text-muted-foreground)
- CardContent: renders active step component based on
state.currentStep - Bottom nav:
flex justify-between items-center pt-4 border-t border-border- Left side: Go Back button (variant="ghost", hidden on step 1) + Skip Step button (variant="ghost" text-muted-foreground)
- Right side: Next Step button (variant="default")
- Step 1 Next: validates income > 0, shows error if invalid, otherwise advances to step 2
- Step 2 Next: no validation, advances to step 3
- Step 3: shows placeholder text "Review step coming in next plan" (will be replaced in Plan 02)
- Below card: "Skip setup" link:
<button className="mt-4 text-sm text-muted-foreground underline block mx-auto">{t("setup.skipSetup")}</button> - Skip step on step 1: advance to step 2. Skip step on step 2: advance to step 3.
- Clicking completed stepper steps calls
setStep(step)ls src/components/setup/WizardStepper.tsx src/components/setup/IncomeStep.tsx src/components/setup/AllocationBar.tsx src/components/setup/CategoryGroupHeader.tsx src/components/setup/PresetItemRow.tsx src/components/setup/RecurringItemsStep.tsx src/pages/SetupPage.tsx && npx tsc --noEmit 2>&1 | head -30 <acceptance_criteria>- src/pages/SetupPage.tsx contains
useWizardStateandmax-w-2xl - src/pages/SetupPage.tsx contains
currentStepconditional rendering - src/components/setup/WizardStepper.tsx contains
role="navigation"andaria-label - src/components/setup/WizardStepper.tsx contains
w-8 h-8 - src/components/setup/IncomeStep.tsx contains
type="number"andtext-lg - src/components/setup/AllocationBar.tsx contains
aria-live="polite"andsticky top-0 - src/components/setup/CategoryGroupHeader.tsx contains
w-2.5 h-2.5 rounded-full - src/components/setup/PresetItemRow.tsx contains
Checkboximport andw-24 - src/components/setup/RecurringItemsStep.tsx imports
PRESETSfrom@/data/presets - TypeScript compiles without errors (
npx tsc --noEmitexits 0) </acceptance_criteria> All 7 wizard UI components render correctly, TypeScript compiles without errors, wizard navigates between steps 1 and 2 with working state persistence
- src/pages/SetupPage.tsx contains
<threat_model>
Trust Boundaries
| Boundary | Description |
|---|---|
| localStorage -> component | Persisted wizard data read back into React state |
| client -> Supabase (future Plan 02) | Category/template item creation on completion |
STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|---|---|---|---|---|
| T-07-01 | Tampering | useWizardState localStorage read | mitigate | Validate JSON shape on load: check currentStep is 1-3, income is number, selectedItems is object with expected structure. Fall back to defaults on invalid data. |
| T-07-02 | Information Disclosure | localStorage key | accept | Key includes userId, preventing cross-user data leakage. Data is non-sensitive (income amount, item selections). |
| T-07-03 | Spoofing | Preset data | accept | PRESETS are hardcoded source constants, not user input. i18n keys resolve from bundled JSON, no injection vector. |
| </threat_model> |
<success_criteria>
- SetupPage renders with WizardStepper showing 3 steps
- Step 1 shows income input pre-filled with 3000 and currency suffix
- Step 2 shows all 19 PRESETS grouped by type with checkboxes and editable amounts
- Bills (4) and variable_expense (5) items are checked by default
- AllocationBar shows remaining = 3000 - sum(checked) and turns red when negative
- Navigating between steps preserves state
- Refreshing the page restores wizard at correct step (localStorage) </success_criteria>