From bbcb07ff386d0190c3d82ca6082711d3f4d05c7c Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 20 Apr 2026 21:04:34 +0200 Subject: [PATCH 1/3] feat(07-01): add useWizardState hook, i18n keys, and shadcn checkbox - Create useWizardState hook with localStorage persistence keyed by userId - Add all setup wizard i18n keys to en.json and de.json - Install shadcn checkbox component for step 2 item selection --- src/components/ui/checkbox.tsx | 30 ++++++++++++ src/hooks/useWizardState.ts | 85 ++++++++++++++++++++++++++++++++++ src/i18n/de.json | 38 +++++++++++++++ src/i18n/en.json | 38 +++++++++++++++ 4 files changed, 191 insertions(+) create mode 100644 src/components/ui/checkbox.tsx create mode 100644 src/hooks/useWizardState.ts diff --git a/src/components/ui/checkbox.tsx b/src/components/ui/checkbox.tsx new file mode 100644 index 0000000..98ac83e --- /dev/null +++ b/src/components/ui/checkbox.tsx @@ -0,0 +1,30 @@ +import * as React from "react" +import { CheckIcon } from "lucide-react" +import { Checkbox as CheckboxPrimitive } from "radix-ui" + +import { cn } from "@/lib/utils" + +function Checkbox({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + + + ) +} + +export { Checkbox } diff --git a/src/hooks/useWizardState.ts b/src/hooks/useWizardState.ts new file mode 100644 index 0000000..141c8b5 --- /dev/null +++ b/src/hooks/useWizardState.ts @@ -0,0 +1,85 @@ +import { useState, useEffect } from "react" +import { PRESETS } from "@/data/presets" + +export interface WizardState { + currentStep: 1 | 2 | 3 + income: number + selectedItems: Record +} + +const STORAGE_KEY = (userId: string) => `setup-wizard-${userId}` + +function getDefaultState(): WizardState { + const selectedItems: Record = {} + 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(() => { + try { + const saved = localStorage.getItem(STORAGE_KEY(userId)) + if (saved) { + const parsed = JSON.parse(saved) + // Validate shape (T-07-01 mitigation: validate JSON on load) + if ( + parsed && + typeof parsed.currentStep === "number" && + parsed.currentStep >= 1 && + parsed.currentStep <= 3 && + typeof parsed.income === "number" && + parsed.income >= 0 && + parsed.selectedItems && + typeof parsed.selectedItems === "object" + ) { + 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 } +} diff --git a/src/i18n/de.json b/src/i18n/de.json index cf7e00f..3352cd1 100644 --- a/src/i18n/de.json +++ b/src/i18n/de.json @@ -121,6 +121,44 @@ "error": "Etwas ist schiefgelaufen", "confirm": "Bestätigen" }, + "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": "Wähle deine regelmäßigen monatlichen Ausgaben", + "remaining": "Verbleibend zu verteilen" + }, + "step3": { + "title": "Übersicht", + "description": "Bestätige deine Budgetvorlage", + "incomeLabel": "Monatliches Einkommen", + "totalLabel": "Gesamtausgaben", + "remainingLabel": "Verbleibend", + "empty": "Keine Posten ausgewählt. Du kannst später Posten zu deiner Vorlage hinzufügen." + }, + "steps": { + "1": "Einkommen", + "2": "Posten", + "3": "Übersicht" + }, + "next": "Nächster Schritt", + "back": "Zurück", + "skip": "Schritt überspringen", + "skipSetup": "Einrichtung überspringen", + "complete": "Einrichtung abschließen", + "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. Prüfe deine Vorlagenseite." + } + }, "presets": { "income": { "salary": "Gehalt", diff --git a/src/i18n/en.json b/src/i18n/en.json index a38a475..5531496 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -121,6 +121,44 @@ "error": "Something went wrong", "confirm": "Confirm" }, + "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." + } + }, "presets": { "income": { "salary": "Salary", From e1411976dd0a7ca93afd53d300d2212fb15fdf90 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 20 Apr 2026 21:06:29 +0200 Subject: [PATCH 2/3] feat(07-01): create wizard UI components and SetupPage - WizardStepper: horizontal 1-2-3 stepper with clickable completed steps - IncomeStep: number input with currency suffix and validation - AllocationBar: sticky remaining balance with live polite announcements - CategoryGroupHeader: colored dot section divider per category type - PresetItemRow: checkbox + name + badge + editable amount input - RecurringItemsStep: groups 19 PRESETS by type with allocation calculation - SetupPage: page orchestrator with step navigation and state persistence --- src/components/setup/AllocationBar.tsx | 31 ++++ src/components/setup/CategoryGroupHeader.tsx | 36 ++++ src/components/setup/IncomeStep.tsx | 45 +++++ src/components/setup/PresetItemRow.tsx | 59 ++++++ src/components/setup/RecurringItemsStep.tsx | 81 +++++++++ src/components/setup/WizardStepper.tsx | 62 +++++++ src/pages/SetupPage.tsx | 179 +++++++++++++++++++ 7 files changed, 493 insertions(+) create mode 100644 src/components/setup/AllocationBar.tsx create mode 100644 src/components/setup/CategoryGroupHeader.tsx create mode 100644 src/components/setup/IncomeStep.tsx create mode 100644 src/components/setup/PresetItemRow.tsx create mode 100644 src/components/setup/RecurringItemsStep.tsx create mode 100644 src/components/setup/WizardStepper.tsx create mode 100644 src/pages/SetupPage.tsx diff --git a/src/components/setup/AllocationBar.tsx b/src/components/setup/AllocationBar.tsx new file mode 100644 index 0000000..cb6b515 --- /dev/null +++ b/src/components/setup/AllocationBar.tsx @@ -0,0 +1,31 @@ +import { useTranslation } from "react-i18next" + +interface AllocationBarProps { + remaining: number + currency: string +} + +export function AllocationBar({ remaining, currency }: AllocationBarProps) { + const { t } = useTranslation() + + const formatted = new Intl.NumberFormat(undefined, { + style: "currency", + currency, + minimumFractionDigits: 0, + maximumFractionDigits: 0, + }).format(remaining) + + return ( +
+ {t("setup.step2.remaining")} + = 0 ? "text-on-budget" : "text-destructive" + }`} + > + {formatted} + +
+ ) +} diff --git a/src/components/setup/CategoryGroupHeader.tsx b/src/components/setup/CategoryGroupHeader.tsx new file mode 100644 index 0000000..18a80d8 --- /dev/null +++ b/src/components/setup/CategoryGroupHeader.tsx @@ -0,0 +1,36 @@ +import type { CategoryType } from "@/lib/types" +import { Separator } from "@/components/ui/separator" + +interface CategoryGroupHeaderProps { + type: CategoryType + label: string + count: number +} + +const colorVar = (type: CategoryType): string => { + const map: Record = { + income: "var(--color-income)", + bill: "var(--color-bill)", + variable_expense: "var(--color-variable-expense)", + debt: "var(--color-debt)", + saving: "var(--color-saving)", + investment: "var(--color-investment)", + } + return map[type] +} + +export function CategoryGroupHeader({ type, label, count }: CategoryGroupHeaderProps) { + return ( +
+
+ + {label} + ({count} items) +
+ +
+ ) +} diff --git a/src/components/setup/IncomeStep.tsx b/src/components/setup/IncomeStep.tsx new file mode 100644 index 0000000..ab8fb34 --- /dev/null +++ b/src/components/setup/IncomeStep.tsx @@ -0,0 +1,45 @@ +import { useTranslation } from "react-i18next" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" + +interface IncomeStepProps { + income: number + onIncomeChange: (val: number) => void + currency: string + error: string | null +} + +export function IncomeStep({ income, onIncomeChange, currency, error }: IncomeStepProps) { + const { t } = useTranslation() + + const helperId = "income-helper" + const errorId = "income-error" + + return ( +
+ +
+ onIncomeChange(Number(e.target.value))} + min={0} + aria-describedby={`${helperId}${error ? ` ${errorId}` : ""}`} + /> + {currency} +
+

+ {t("setup.step1.helper")} +

+ {error && ( +

+ {error} +

+ )} +
+ ) +} diff --git a/src/components/setup/PresetItemRow.tsx b/src/components/setup/PresetItemRow.tsx new file mode 100644 index 0000000..63387f5 --- /dev/null +++ b/src/components/setup/PresetItemRow.tsx @@ -0,0 +1,59 @@ +import { useTranslation } from "react-i18next" +import type { CategoryType } from "@/lib/types" +import { Checkbox } from "@/components/ui/checkbox" +import { Badge } from "@/components/ui/badge" +import { Input } from "@/components/ui/input" + +interface PresetItemRowProps { + slug: string + type: CategoryType + checked: boolean + amount: number + onToggle: () => void + onAmountChange: (val: number) => void +} + +const colorVar = (type: CategoryType): string => { + const map: Record = { + income: "var(--color-income)", + bill: "var(--color-bill)", + variable_expense: "var(--color-variable-expense)", + debt: "var(--color-debt)", + saving: "var(--color-saving)", + investment: "var(--color-investment)", + } + return map[type] +} + +export function PresetItemRow({ + slug, + type, + checked, + amount, + onToggle, + onAmountChange, +}: PresetItemRowProps) { + const { t } = useTranslation() + + return ( +
+ + {t(`presets.${type}.${slug}`)} + + {t(`categories.types.${type}`)} + + onAmountChange(Number(e.target.value))} + disabled={!checked} + min={0} + /> +
+ ) +} diff --git a/src/components/setup/RecurringItemsStep.tsx b/src/components/setup/RecurringItemsStep.tsx new file mode 100644 index 0000000..33bf9ac --- /dev/null +++ b/src/components/setup/RecurringItemsStep.tsx @@ -0,0 +1,81 @@ +import { useTranslation } from "react-i18next" +import { PRESETS } from "@/data/presets" +import type { CategoryType } from "@/lib/types" +import { AllocationBar } from "./AllocationBar" +import { CategoryGroupHeader } from "./CategoryGroupHeader" +import { PresetItemRow } from "./PresetItemRow" + +interface RecurringItemsStepProps { + selectedItems: Record + income: number + currency: string + onToggle: (slug: string) => void + onAmountChange: (slug: string, amount: number) => void +} + +const GROUP_ORDER: CategoryType[] = [ + "income", + "bill", + "variable_expense", + "debt", + "saving", + "investment", +] + +export function RecurringItemsStep({ + selectedItems, + income, + currency, + onToggle, + onAmountChange, +}: RecurringItemsStepProps) { + const { t } = useTranslation() + + // Group presets by type + const grouped = GROUP_ORDER.reduce( + (acc, type) => { + acc[type] = PRESETS.filter((p) => p.type === type) + return acc + }, + {} as Record + ) + + // Compute remaining + const totalChecked = Object.values(selectedItems) + .filter((i) => i.checked) + .reduce((sum, i) => sum + i.amount, 0) + const remaining = income - totalChecked + + return ( +
+ +
+ {GROUP_ORDER.map((type) => { + const items = grouped[type] + if (!items || items.length === 0) return null + return ( +
+ {t(`categories.types.${type}`)} + + {items.map((preset) => ( + onToggle(preset.slug)} + onAmountChange={(val) => onAmountChange(preset.slug, val)} + /> + ))} +
+ ) + })} +
+
+ ) +} diff --git a/src/components/setup/WizardStepper.tsx b/src/components/setup/WizardStepper.tsx new file mode 100644 index 0000000..55bd17e --- /dev/null +++ b/src/components/setup/WizardStepper.tsx @@ -0,0 +1,62 @@ +import { useTranslation } from "react-i18next" +import { Check } from "lucide-react" + +interface WizardStepperProps { + currentStep: 1 | 2 | 3 + onStepClick: (step: 1 | 2 | 3) => void +} + +const steps = [1, 2, 3] as const + +export function WizardStepper({ currentStep, onStepClick }: WizardStepperProps) { + const { t } = useTranslation() + + return ( + + ) +} diff --git a/src/pages/SetupPage.tsx b/src/pages/SetupPage.tsx new file mode 100644 index 0000000..b682a55 --- /dev/null +++ b/src/pages/SetupPage.tsx @@ -0,0 +1,179 @@ +import { useState } from "react" +import { useTranslation } from "react-i18next" +import { useAuth } from "@/hooks/useAuth" +import { useWizardState } from "@/hooks/useWizardState" +import { supabase } from "@/lib/supabase" +import type { Profile } from "@/lib/types" +import { WizardStepper } from "@/components/setup/WizardStepper" +import { IncomeStep } from "@/components/setup/IncomeStep" +import { RecurringItemsStep } from "@/components/setup/RecurringItemsStep" +import { Button } from "@/components/ui/button" +import { + Card, + CardContent, + CardHeader, + CardTitle, +} from "@/components/ui/card" +import { Skeleton } from "@/components/ui/skeleton" +import { useEffect } from "react" + +export default function SetupPage() { + const { t } = useTranslation() + const { user, loading: authLoading } = useAuth() + const [profile, setProfile] = useState(null) + const [profileLoading, setProfileLoading] = useState(true) + + useEffect(() => { + if (!user) return + supabase + .from("profiles") + .select("*") + .eq("id", user.id) + .single() + .then(({ data }) => { + if (data) setProfile(data) + setProfileLoading(false) + }) + }, [user]) + + const userId = user?.id ?? "" + const currency = profile?.currency ?? "EUR" + const { state, setStep, setIncome, toggleItem, setItemAmount } = + useWizardState(userId) + + const [incomeError, setIncomeError] = useState(null) + + const loading = authLoading || profileLoading + + if (loading) { + return ( +
+
+ + +
+
+ ) + } + + const stepTitles: Record<1 | 2 | 3, string> = { + 1: t("setup.step1.title"), + 2: t("setup.step2.title"), + 3: t("setup.step3.title"), + } + + const stepDescriptions: Record<1 | 2 | 3, string> = { + 1: t("setup.step1.description"), + 2: t("setup.step2.description"), + 3: t("setup.step3.description"), + } + + function handleNext() { + if (state.currentStep === 1) { + if (!state.income || state.income <= 0) { + setIncomeError(t("setup.step1.validation")) + return + } + setIncomeError(null) + setStep(2) + } else if (state.currentStep === 2) { + setStep(3) + } + } + + function handleBack() { + if (state.currentStep === 2) setStep(1) + else if (state.currentStep === 3) setStep(2) + } + + function handleSkipStep() { + if (state.currentStep === 1) setStep(2) + else if (state.currentStep === 2) setStep(3) + } + + function handleStepClick(step: 1 | 2 | 3) { + if (step <= state.currentStep) { + setStep(step) + } + } + + return ( +
+
+ + + + + + {stepTitles[state.currentStep]} + +

+ {stepDescriptions[state.currentStep]} +

+
+ + {state.currentStep === 1 && ( + + )} + + {state.currentStep === 2 && ( + + )} + + {state.currentStep === 3 && ( +
+

Review step coming in next plan

+
+ )} + + {/* Bottom navigation */} +
+
+ {state.currentStep > 1 && ( + + )} + {state.currentStep < 3 && ( + + )} +
+
+ {state.currentStep < 3 && ( + + )} + {state.currentStep === 3 && ( + + )} +
+
+
+
+ + +
+
+ ) +} From fada289774f2a7478da5bf8d588c9bb7a3f53eb7 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 20 Apr 2026 21:07:23 +0200 Subject: [PATCH 3/3] docs(07-01): complete setup wizard UI shell plan - SUMMARY.md with all task commits, decisions, and known stubs --- .../phases/07-setup-wizard/07-01-SUMMARY.md | 86 +++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 .planning/phases/07-setup-wizard/07-01-SUMMARY.md diff --git a/.planning/phases/07-setup-wizard/07-01-SUMMARY.md b/.planning/phases/07-setup-wizard/07-01-SUMMARY.md new file mode 100644 index 0000000..c606119 --- /dev/null +++ b/.planning/phases/07-setup-wizard/07-01-SUMMARY.md @@ -0,0 +1,86 @@ +--- +phase: 07-setup-wizard +plan: 01 +subsystem: frontend/setup-wizard +tags: [wizard, ui, state-management, i18n] +dependency_graph: + requires: [src/data/presets.ts, src/lib/types.ts, src/hooks/useAuth.ts] + provides: [src/hooks/useWizardState.ts, src/pages/SetupPage.tsx, src/components/setup/*] + affects: [src/i18n/en.json, src/i18n/de.json] +tech_stack: + added: [shadcn/checkbox] + patterns: [localStorage-synced-state, multi-step-wizard, grouped-preset-checklist] +key_files: + created: + - src/hooks/useWizardState.ts + - 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 + - src/components/ui/checkbox.tsx + modified: + - src/i18n/en.json + - src/i18n/de.json +decisions: + - "Profile fetched inline in SetupPage (same pattern as SettingsPage) since useAuth does not expose profile" + - "Used manual reduce for grouping PRESETS by type for broad browser compatibility" +metrics: + duration: 178s + completed: 2026-04-20T19:06:36Z + tasks_completed: 2 + tasks_total: 2 + files_created: 9 + files_modified: 2 +--- + +# Phase 07 Plan 01: Setup Wizard UI Shell Summary + +LocalStorage-synced 3-step wizard with income input, grouped preset checklist (19 items), live allocation bar, and horizontal stepper navigation. + +## Commits + +| Task | Commit | Description | +|------|--------|-------------| +| 1 | bbcb07f | useWizardState hook, i18n keys (EN+DE), shadcn checkbox | +| 2 | e141197 | All 7 wizard UI components and SetupPage orchestrator | + +## What Was Built + +1. **useWizardState hook** - Manages wizard step, income, and item selections in localStorage keyed by userId. Validates JSON shape on load (T-07-01 mitigation). Bills and variable_expense items pre-checked by default. + +2. **WizardStepper** - Horizontal 1-2-3 stepper with circles, connector lines, and step labels. Completed steps are clickable; future steps are disabled. + +3. **IncomeStep** - Number input pre-filled with 3000, currency suffix from profile, helper text, and inline validation error display. + +4. **AllocationBar** - Sticky bar showing remaining = income - sum(checked amounts). Green when positive, red (destructive) when negative. aria-live="polite" for screen readers. + +5. **CategoryGroupHeader** - Colored dot + label + item count for each of the 6 category types. + +6. **PresetItemRow** - Checkbox + preset name (i18n) + category badge with colored border + editable amount input (disabled when unchecked). + +7. **RecurringItemsStep** - Groups all 19 PRESETS into 6 category sections with AllocationBar at top. + +8. **SetupPage** - Page orchestrator: centered card layout (max-w-2xl), loads profile for currency, renders stepper + active step, handles Next/Back/Skip navigation with income validation. + +## i18n + +All wizard copy added under `setup.*` namespace in both `en.json` and `de.json` (35 keys each). German translations use proper umlauts and natural phrasing. + +## Deviations from Plan + +None - plan executed exactly as written. + +## Known Stubs + +| File | Line | Stub | Reason | +|------|------|------|--------| +| src/pages/SetupPage.tsx | Step 3 content | "Review step coming in next plan" placeholder | Plan 02 will implement ReviewStep and completion logic | +| src/pages/SetupPage.tsx | Skip setup button | No-op onClick | Plan 02 will wire skip to mark setup_completed and redirect | +| src/pages/SetupPage.tsx | Complete button | Disabled, no handler | Plan 02 will implement completion sequence | + +These stubs are intentional -- Plan 02 explicitly owns the ReviewStep, completion logic, routing, and redirect. + +## Self-Check: PASSED