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>
17 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 | 02 | execute | 2 |
|
|
true |
|
|
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.
<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 @.planning/phases/07-setup-wizard/07-01-SUMMARY.md From src/hooks/useWizardState.ts (created in Plan 01): ```typescript export interface WizardState { currentStep: 1 | 2 | 3 income: number selectedItems: Record } 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:
// create.mutateAsync({ name: string, type: CategoryType, icon?: string }) => Category { id, ... }
From src/hooks/useTemplate.ts:
// 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:
export function useFirstRunState(): { isFirstRun: boolean; loading: boolean }
From src/data/presets.ts:
export const PRESETS: PresetItem[] // 19 items with slug, type, defaultAmount, item_tier
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}`)}{formattedAmount}` <Separator />- Totals:
space-y-1 pt-2- Total expenses row:
flex justify-betweenwith labelt("setup.step3.totalLabel")(14px/600) and sum of checked amounts - Remaining row:
flex justify-betweenwith labelt("setup.step3.remainingLabel")(16px/600), value coloredtext-on-budgetif >= 0,text-destructiveif < 0
- Total expenses row:
- 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:
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:
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).
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
<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>
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.
Add import at top:
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:
<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):
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):
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.
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
<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>
/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.
<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> |
<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>