Files
Sportstime/docs/plans/2026-01-12-trip-planning-enhancements.md
Trey t 9531ed1008 docs: add Trip Planning Enhancements implementation plan
12 tasks with TDD approach:
- TripWizardViewModel with reveal state logic
- 8 step components (PlanningMode, Sports, Dates, Regions, etc.)
- TripWizardView container with auto-scroll
- Settings toggle for classic vs wizard mode
- Integration tests

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 19:38:05 -06:00

52 KiB

Trip Planning Enhancements Implementation Plan

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

Goal: Transform trip creation into a progressive-reveal wizard flow where sections appear as the user makes selections, while keeping everything on a single scrolling screen.

Architecture: New TripWizardView container with TripWizardViewModel manages step visibility state. Each step is a small, focused view component (~100-150 lines). Existing TripCreationViewModel logic is reused—wizard just controls reveal timing. Settings toggle allows power users to use classic form.

Tech Stack: SwiftUI, @Observable, existing TripCreationViewModel, AppDataProvider


Task 1: Create TripWizardViewModel

Files:

  • Create: SportsTime/Features/Trip/ViewModels/TripWizardViewModel.swift
  • Test: SportsTimeTests/Trip/TripWizardViewModelTests.swift

Step 1: Write the failing test for reveal state tracking

// SportsTimeTests/Trip/TripWizardViewModelTests.swift
import XCTest
@testable import SportsTime

final class TripWizardViewModelTests: XCTestCase {

    func test_initialState_onlyPlanningModeStepVisible() {
        let viewModel = TripWizardViewModel()

        XCTAssertTrue(viewModel.isPlanningModeStepVisible)
        XCTAssertFalse(viewModel.isSportsStepVisible)
        XCTAssertFalse(viewModel.isDatesStepVisible)
        XCTAssertFalse(viewModel.isRegionsStepVisible)
    }

    func test_selectingPlanningMode_revealsSportsStep() {
        let viewModel = TripWizardViewModel()

        viewModel.planningMode = .dateRange

        XCTAssertTrue(viewModel.isSportsStepVisible)
    }

    func test_selectingSport_revealsDatesStep() {
        let viewModel = TripWizardViewModel()
        viewModel.planningMode = .dateRange

        viewModel.selectedSports = [.mlb]

        XCTAssertTrue(viewModel.isDatesStepVisible)
    }

    func test_changingPlanningMode_resetsDownstreamSelections() {
        let viewModel = TripWizardViewModel()
        viewModel.planningMode = .dateRange
        viewModel.selectedSports = [.mlb, .nba]
        viewModel.hasSetDates = true

        viewModel.planningMode = .gameFirst

        XCTAssertTrue(viewModel.selectedSports.isEmpty)
        XCTAssertFalse(viewModel.hasSetDates)
    }
}

Step 2: Run test to verify it fails

Run: xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' -only-testing:SportsTimeTests/TripWizardViewModelTests test 2>&1 | tail -20

Expected: FAIL - file not found

Step 3: Create test directory and file

mkdir -p SportsTimeTests/Trip

Then create the test file with content from Step 1.

Step 4: Write minimal TripWizardViewModel implementation

// SportsTime/Features/Trip/ViewModels/TripWizardViewModel.swift
import Foundation
import SwiftUI

@Observable
final class TripWizardViewModel {

    // MARK: - Planning Mode

    var planningMode: PlanningMode? = nil {
        didSet {
            if oldValue != nil && oldValue != planningMode {
                resetDownstreamFromPlanningMode()
            }
        }
    }

    // MARK: - Sports Selection

    var selectedSports: Set<Sport> = []

    // MARK: - Dates

    var startDate: Date = Date()
    var endDate: Date = Date().addingTimeInterval(86400 * 7)
    var hasSetDates: Bool = false

    // MARK: - Regions

    var selectedRegions: Set<USRegion> = []

    // MARK: - Route Preferences

    var routePreference: RoutePreference = .balanced
    var hasSetRoutePreference: Bool = false

    // MARK: - Repeat Cities

    var allowRepeatCities: Bool = false
    var hasSetRepeatCities: Bool = false

    // MARK: - Must Stops

    var mustStopLocations: [LocationInput] = []

    // MARK: - Reveal State (computed)

    var isPlanningModeStepVisible: Bool { true }

    var isSportsStepVisible: Bool {
        planningMode != nil
    }

    var isDatesStepVisible: Bool {
        isSportsStepVisible && !selectedSports.isEmpty
    }

    var isRegionsStepVisible: Bool {
        isDatesStepVisible && hasSetDates
    }

    var isRoutePreferenceStepVisible: Bool {
        isRegionsStepVisible && !selectedRegions.isEmpty
    }

    var isRepeatCitiesStepVisible: Bool {
        isRoutePreferenceStepVisible && hasSetRoutePreference
    }

    var isMustStopsStepVisible: Bool {
        isRepeatCitiesStepVisible && hasSetRepeatCities
    }

    var isReviewStepVisible: Bool {
        isMustStopsStepVisible
    }

    /// Combined state for animation tracking
    var revealState: Int {
        var state = 0
        if isSportsStepVisible { state += 1 }
        if isDatesStepVisible { state += 2 }
        if isRegionsStepVisible { state += 4 }
        if isRoutePreferenceStepVisible { state += 8 }
        if isRepeatCitiesStepVisible { state += 16 }
        if isMustStopsStepVisible { state += 32 }
        if isReviewStepVisible { state += 64 }
        return state
    }

    // MARK: - Reset Logic

    private func resetDownstreamFromPlanningMode() {
        selectedSports = []
        hasSetDates = false
        selectedRegions = []
        hasSetRoutePreference = false
        hasSetRepeatCities = false
        mustStopLocations = []
    }
}

Step 5: Run test to verify it passes

Run: xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' -only-testing:SportsTimeTests/TripWizardViewModelTests test 2>&1 | tail -20

Expected: PASS (4 tests)

Step 6: Commit

git add SportsTime/Features/Trip/ViewModels/TripWizardViewModel.swift SportsTimeTests/Trip/TripWizardViewModelTests.swift
git commit -m "feat(wizard): add TripWizardViewModel with reveal state logic"

Task 2: Create PlanningModeStep Component

Files:

  • Create: SportsTime/Features/Trip/Views/Wizard/Steps/PlanningModeStep.swift

Step 1: Create directory structure

mkdir -p SportsTime/Features/Trip/Views/Wizard/Steps

Step 2: Write PlanningModeStep view

// SportsTime/Features/Trip/Views/Wizard/Steps/PlanningModeStep.swift
import SwiftUI

struct PlanningModeStep: View {
    @Binding var selection: PlanningMode?

    var body: some View {
        VStack(alignment: .leading, spacing: Theme.Spacing.md) {
            StepHeader(
                title: "How do you want to plan?",
                subtitle: "Choose your starting point"
            )

            VStack(spacing: Theme.Spacing.sm) {
                ForEach(PlanningMode.allCases) { mode in
                    PlanningModeCard(
                        mode: mode,
                        isSelected: selection == mode,
                        onTap: { selection = mode }
                    )
                }
            }
        }
        .padding(Theme.Spacing.md)
        .background(Theme.Colors.cardBackground)
        .clipShape(RoundedRectangle(cornerRadius: Theme.Radius.medium))
    }
}

// MARK: - Planning Mode Card

private struct PlanningModeCard: View {
    let mode: PlanningMode
    let isSelected: Bool
    let onTap: () -> Void

    var body: some View {
        Button(action: onTap) {
            HStack(spacing: Theme.Spacing.md) {
                Image(systemName: mode.iconName)
                    .font(.title2)
                    .foregroundStyle(isSelected ? Theme.Colors.primary : .secondary)
                    .frame(width: 32)

                VStack(alignment: .leading, spacing: 2) {
                    Text(mode.displayName)
                        .font(.headline)
                        .foregroundStyle(.primary)

                    Text(mode.description)
                        .font(.caption)
                        .foregroundStyle(.secondary)
                }

                Spacer()

                if isSelected {
                    Image(systemName: "checkmark.circle.fill")
                        .foregroundStyle(Theme.Colors.primary)
                }
            }
            .padding(Theme.Spacing.md)
            .background(isSelected ? Theme.Colors.primary.opacity(0.1) : Color.clear)
            .clipShape(RoundedRectangle(cornerRadius: Theme.Radius.small))
            .overlay(
                RoundedRectangle(cornerRadius: Theme.Radius.small)
                    .stroke(isSelected ? Theme.Colors.primary : Color.gray.opacity(0.3), lineWidth: isSelected ? 2 : 1)
            )
        }
        .buttonStyle(.plain)
    }
}

// MARK: - Step Header (Reusable)

struct StepHeader: View {
    let title: String
    var subtitle: String? = nil

    var body: some View {
        VStack(alignment: .leading, spacing: 4) {
            Text(title)
                .font(.title3)
                .fontWeight(.semibold)
                .foregroundStyle(.primary)

            if let subtitle {
                Text(subtitle)
                    .font(.subheadline)
                    .foregroundStyle(.secondary)
            }
        }
    }
}

// MARK: - PlanningMode Extensions

extension PlanningMode {
    var iconName: String {
        switch self {
        case .dateRange: return "calendar"
        case .gameFirst: return "sportscourt.fill"
        case .locations: return "mappin.and.ellipse"
        case .followTeam: return "person.3.fill"
        }
    }

    var displayName: String {
        switch self {
        case .dateRange: return "By Date Range"
        case .gameFirst: return "By Games"
        case .locations: return "By Locations"
        case .followTeam: return "Follow a Team"
        }
    }

    var description: String {
        switch self {
        case .dateRange: return "Set dates first, find games in that window"
        case .gameFirst: return "Pick the games you want to see"
        case .locations: return "Choose start/end cities for your trip"
        case .followTeam: return "Follow one team's home and away games"
        }
    }
}

// MARK: - Preview

#Preview {
    PlanningModeStep(selection: .constant(.dateRange))
        .padding()
        .themedBackground()
}

Step 3: Build to verify compilation

Run: xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' build 2>&1 | tail -10

Expected: BUILD SUCCEEDED

Step 4: Commit

git add SportsTime/Features/Trip/Views/Wizard/Steps/PlanningModeStep.swift
git commit -m "feat(wizard): add PlanningModeStep component"

Task 3: Create SportsStep Component with Availability Graying

Files:

  • Create: SportsTime/Features/Trip/Views/Wizard/Steps/SportsStep.swift
  • Test: SportsTimeTests/Trip/TripWizardViewModelTests.swift (add sport availability tests)

Step 1: Add sport availability tests

Add to TripWizardViewModelTests.swift:

func test_sportAvailability_sportsWithNoGamesAreUnavailable() async {
    let viewModel = TripWizardViewModel()
    viewModel.planningMode = .dateRange
    viewModel.startDate = Date()
    viewModel.endDate = Date().addingTimeInterval(86400 * 7)

    // Simulate fetching availability (MLB has games, WNBA doesn't in January)
    await viewModel.fetchSportAvailability()

    // This test verifies the mechanism exists - actual availability depends on data
    XCTAssertNotNil(viewModel.sportAvailability)
}

func test_selectingUnavailableSport_isNotAllowed() {
    let viewModel = TripWizardViewModel()
    viewModel.sportAvailability = [.mlb: true, .wnba: false]

    let canSelect = viewModel.canSelectSport(.wnba)

    XCTAssertFalse(canSelect)
}

Step 2: Run tests to verify they fail

Run: xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' -only-testing:SportsTimeTests/TripWizardViewModelTests test 2>&1 | tail -20

Expected: FAIL - sportAvailability not defined

Step 3: Add sport availability to TripWizardViewModel

Add to TripWizardViewModel.swift:

// MARK: - Sport Availability

var sportAvailability: [Sport: Bool] = [:]
var isLoadingSportAvailability: Bool = false

func canSelectSport(_ sport: Sport) -> Bool {
    sportAvailability[sport] ?? true // Default to available if not checked
}

func fetchSportAvailability() async {
    guard hasSetDates else { return }

    isLoadingSportAvailability = true
    defer { isLoadingSportAvailability = false }

    var availability: [Sport: Bool] = [:]

    for sport in Sport.supported {
        do {
            let games = try await AppDataProvider.shared.filterGames(
                sports: [sport],
                startDate: startDate,
                endDate: endDate
            )
            availability[sport] = !games.isEmpty
        } catch {
            availability[sport] = true // Default to available on error
        }
    }

    await MainActor.run {
        self.sportAvailability = availability
    }
}

Step 4: Run tests to verify they pass

Run: xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' -only-testing:SportsTimeTests/TripWizardViewModelTests test 2>&1 | tail -20

Expected: PASS

Step 5: Write SportsStep view

// SportsTime/Features/Trip/Views/Wizard/Steps/SportsStep.swift
import SwiftUI

struct SportsStep: View {
    @Binding var selectedSports: Set<Sport>
    let sportAvailability: [Sport: Bool]
    let isLoading: Bool
    let canSelectSport: (Sport) -> Bool

    private let columns = [
        GridItem(.flexible()),
        GridItem(.flexible()),
        GridItem(.flexible())
    ]

    var body: some View {
        VStack(alignment: .leading, spacing: Theme.Spacing.md) {
            StepHeader(
                title: "Which sports interest you?",
                subtitle: "Select one or more leagues"
            )

            if isLoading {
                HStack {
                    ProgressView()
                        .scaleEffect(0.8)
                    Text("Checking game availability...")
                        .font(.caption)
                        .foregroundStyle(.secondary)
                }
                .padding(.vertical, Theme.Spacing.sm)
            }

            LazyVGrid(columns: columns, spacing: Theme.Spacing.sm) {
                ForEach(Sport.supported, id: \.self) { sport in
                    SportCard(
                        sport: sport,
                        isSelected: selectedSports.contains(sport),
                        isAvailable: canSelectSport(sport),
                        onTap: {
                            if canSelectSport(sport) {
                                toggleSport(sport)
                            }
                        }
                    )
                }
            }
        }
        .padding(Theme.Spacing.md)
        .background(Theme.Colors.cardBackground)
        .clipShape(RoundedRectangle(cornerRadius: Theme.Radius.medium))
    }

    private func toggleSport(_ sport: Sport) {
        if selectedSports.contains(sport) {
            selectedSports.remove(sport)
        } else {
            selectedSports.insert(sport)
        }
    }
}

// MARK: - Sport Card

private struct SportCard: View {
    let sport: Sport
    let isSelected: Bool
    let isAvailable: Bool
    let onTap: () -> Void

    var body: some View {
        Button(action: onTap) {
            VStack(spacing: Theme.Spacing.xs) {
                Image(systemName: sport.iconName)
                    .font(.title2)
                    .foregroundStyle(cardColor)

                Text(sport.rawValue)
                    .font(.caption)
                    .fontWeight(.medium)
                    .foregroundStyle(cardColor)

                if !isAvailable {
                    Text("No games")
                        .font(.caption2)
                        .foregroundStyle(.secondary)
                }
            }
            .frame(maxWidth: .infinity)
            .padding(.vertical, Theme.Spacing.md)
            .background(backgroundColor)
            .clipShape(RoundedRectangle(cornerRadius: Theme.Radius.small))
            .overlay(
                RoundedRectangle(cornerRadius: Theme.Radius.small)
                    .stroke(borderColor, lineWidth: isSelected ? 2 : 1)
            )
        }
        .buttonStyle(.plain)
        .opacity(isAvailable ? 1.0 : 0.5)
        .disabled(!isAvailable)
    }

    private var cardColor: Color {
        if !isAvailable { return .gray }
        return isSelected ? sport.color : .secondary
    }

    private var backgroundColor: Color {
        if !isAvailable { return Color.gray.opacity(0.1) }
        return isSelected ? sport.color.opacity(0.15) : Color.clear
    }

    private var borderColor: Color {
        if !isAvailable { return Color.gray.opacity(0.3) }
        return isSelected ? sport.color : Color.gray.opacity(0.3)
    }
}

// MARK: - Preview

#Preview {
    SportsStep(
        selectedSports: .constant([.mlb]),
        sportAvailability: [.mlb: true, .nba: true, .wnba: false],
        isLoading: false,
        canSelectSport: { sport in sport != .wnba }
    )
    .padding()
    .themedBackground()
}

Step 6: Build to verify compilation

Run: xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' build 2>&1 | tail -10

Expected: BUILD SUCCEEDED

Step 7: Commit

git add SportsTime/Features/Trip/Views/Wizard/Steps/SportsStep.swift SportsTime/Features/Trip/ViewModels/TripWizardViewModel.swift SportsTimeTests/Trip/TripWizardViewModelTests.swift
git commit -m "feat(wizard): add SportsStep with availability graying"

Task 4: Create DatesStep Component

Files:

  • Create: SportsTime/Features/Trip/Views/Wizard/Steps/DatesStep.swift

Step 1: Write DatesStep view

// SportsTime/Features/Trip/Views/Wizard/Steps/DatesStep.swift
import SwiftUI

struct DatesStep: View {
    @Binding var startDate: Date
    @Binding var endDate: Date
    @Binding var hasSetDates: Bool
    let onDatesChanged: () -> Void

    var body: some View {
        VStack(alignment: .leading, spacing: Theme.Spacing.md) {
            StepHeader(
                title: "When would you like to travel?",
                subtitle: "Pick your trip dates"
            )

            VStack(spacing: Theme.Spacing.md) {
                DatePicker(
                    "Start Date",
                    selection: $startDate,
                    in: Date()...,
                    displayedComponents: .date
                )
                .datePickerStyle(.compact)
                .onChange(of: startDate) { _, newValue in
                    // Ensure end date is after start date
                    if endDate < newValue {
                        endDate = newValue.addingTimeInterval(86400)
                    }
                    hasSetDates = true
                    onDatesChanged()
                }

                DatePicker(
                    "End Date",
                    selection: $endDate,
                    in: startDate...,
                    displayedComponents: .date
                )
                .datePickerStyle(.compact)
                .onChange(of: endDate) { _, _ in
                    hasSetDates = true
                    onDatesChanged()
                }
            }
            .padding(Theme.Spacing.sm)
            .background(Color(.secondarySystemBackground))
            .clipShape(RoundedRectangle(cornerRadius: Theme.Radius.small))

            // Trip duration indicator
            HStack {
                Image(systemName: "calendar.badge.clock")
                    .foregroundStyle(.secondary)
                Text(durationText)
                    .font(.subheadline)
                    .foregroundStyle(.secondary)
            }
        }
        .padding(Theme.Spacing.md)
        .background(Theme.Colors.cardBackground)
        .clipShape(RoundedRectangle(cornerRadius: Theme.Radius.medium))
    }

    private var durationText: String {
        let days = Calendar.current.dateComponents([.day], from: startDate, to: endDate).day ?? 0
        if days == 0 {
            return "Same day trip"
        } else if days == 1 {
            return "1 day trip"
        } else {
            return "\(days) day trip"
        }
    }
}

// MARK: - Preview

#Preview {
    DatesStep(
        startDate: .constant(Date()),
        endDate: .constant(Date().addingTimeInterval(86400 * 5)),
        hasSetDates: .constant(true),
        onDatesChanged: {}
    )
    .padding()
    .themedBackground()
}

Step 2: Build to verify compilation

Run: xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' build 2>&1 | tail -10

Expected: BUILD SUCCEEDED

Step 3: Commit

git add SportsTime/Features/Trip/Views/Wizard/Steps/DatesStep.swift
git commit -m "feat(wizard): add DatesStep component"

Task 5: Create RegionsStep Component

Files:

  • Create: SportsTime/Features/Trip/Views/Wizard/Steps/RegionsStep.swift

Step 1: Write RegionsStep view

// SportsTime/Features/Trip/Views/Wizard/Steps/RegionsStep.swift
import SwiftUI

struct RegionsStep: View {
    @Binding var selectedRegions: Set<USRegion>

    private let columns = [
        GridItem(.flexible()),
        GridItem(.flexible())
    ]

    var body: some View {
        VStack(alignment: .leading, spacing: Theme.Spacing.md) {
            StepHeader(
                title: "Where do you want to go?",
                subtitle: "Select one or more regions"
            )

            LazyVGrid(columns: columns, spacing: Theme.Spacing.sm) {
                ForEach(USRegion.allCases) { region in
                    RegionCard(
                        region: region,
                        isSelected: selectedRegions.contains(region),
                        onTap: { toggleRegion(region) }
                    )
                }
            }

            if !selectedRegions.isEmpty {
                HStack {
                    Image(systemName: "mappin.circle.fill")
                        .foregroundStyle(Theme.Colors.primary)
                    Text("\(selectedRegions.count) region\(selectedRegions.count == 1 ? "" : "s") selected")
                        .font(.subheadline)
                        .foregroundStyle(.secondary)
                }
            }
        }
        .padding(Theme.Spacing.md)
        .background(Theme.Colors.cardBackground)
        .clipShape(RoundedRectangle(cornerRadius: Theme.Radius.medium))
    }

    private func toggleRegion(_ region: USRegion) {
        if selectedRegions.contains(region) {
            selectedRegions.remove(region)
        } else {
            selectedRegions.insert(region)
        }
    }
}

// MARK: - Region Card

private struct RegionCard: View {
    let region: USRegion
    let isSelected: Bool
    let onTap: () -> Void

    var body: some View {
        Button(action: onTap) {
            VStack(spacing: Theme.Spacing.xs) {
                Text(region.emoji)
                    .font(.title)

                Text(region.displayName)
                    .font(.caption)
                    .fontWeight(.medium)
                    .foregroundStyle(isSelected ? Theme.Colors.primary : .primary)
                    .multilineTextAlignment(.center)
            }
            .frame(maxWidth: .infinity)
            .padding(.vertical, Theme.Spacing.md)
            .background(isSelected ? Theme.Colors.primary.opacity(0.1) : Color.clear)
            .clipShape(RoundedRectangle(cornerRadius: Theme.Radius.small))
            .overlay(
                RoundedRectangle(cornerRadius: Theme.Radius.small)
                    .stroke(isSelected ? Theme.Colors.primary : Color.gray.opacity(0.3), lineWidth: isSelected ? 2 : 1)
            )
        }
        .buttonStyle(.plain)
    }
}

// MARK: - USRegion Extensions

extension USRegion {
    var emoji: String {
        switch self {
        case .northeast: return "🗽"
        case .southeast: return "🌴"
        case .midwest: return "🌾"
        case .southwest: return "🌵"
        case .west: return "🌁"
        case .northwest: return "🌲"
        }
    }

    var displayName: String {
        switch self {
        case .northeast: return "Northeast"
        case .southeast: return "Southeast"
        case .midwest: return "Midwest"
        case .southwest: return "Southwest"
        case .west: return "West"
        case .northwest: return "Northwest"
        }
    }
}

// MARK: - Preview

#Preview {
    RegionsStep(selectedRegions: .constant([.northeast, .midwest]))
        .padding()
        .themedBackground()
}

Step 2: Build to verify compilation

Run: xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' build 2>&1 | tail -10

Expected: BUILD SUCCEEDED

Step 3: Commit

git add SportsTime/Features/Trip/Views/Wizard/Steps/RegionsStep.swift
git commit -m "feat(wizard): add RegionsStep component"

Task 6: Create RoutePreferenceStep Component

Files:

  • Create: SportsTime/Features/Trip/Views/Wizard/Steps/RoutePreferenceStep.swift

Step 1: Write RoutePreferenceStep view

// SportsTime/Features/Trip/Views/Wizard/Steps/RoutePreferenceStep.swift
import SwiftUI

struct RoutePreferenceStep: View {
    @Binding var routePreference: RoutePreference
    @Binding var hasSetRoutePreference: Bool

    var body: some View {
        VStack(alignment: .leading, spacing: Theme.Spacing.md) {
            StepHeader(
                title: "What's your route preference?",
                subtitle: "Balance efficiency vs. exploration"
            )

            VStack(spacing: Theme.Spacing.sm) {
                ForEach(RoutePreference.allCases) { preference in
                    RoutePreferenceCard(
                        preference: preference,
                        isSelected: routePreference == preference,
                        onTap: {
                            routePreference = preference
                            hasSetRoutePreference = true
                        }
                    )
                }
            }
        }
        .padding(Theme.Spacing.md)
        .background(Theme.Colors.cardBackground)
        .clipShape(RoundedRectangle(cornerRadius: Theme.Radius.medium))
    }
}

// MARK: - Route Preference Card

private struct RoutePreferenceCard: View {
    let preference: RoutePreference
    let isSelected: Bool
    let onTap: () -> Void

    var body: some View {
        Button(action: onTap) {
            HStack(spacing: Theme.Spacing.md) {
                Image(systemName: preference.iconName)
                    .font(.title2)
                    .foregroundStyle(isSelected ? Theme.Colors.primary : .secondary)
                    .frame(width: 32)

                VStack(alignment: .leading, spacing: 2) {
                    Text(preference.displayName)
                        .font(.headline)
                        .foregroundStyle(.primary)

                    Text(preference.description)
                        .font(.caption)
                        .foregroundStyle(.secondary)
                }

                Spacer()

                if isSelected {
                    Image(systemName: "checkmark.circle.fill")
                        .foregroundStyle(Theme.Colors.primary)
                }
            }
            .padding(Theme.Spacing.md)
            .background(isSelected ? Theme.Colors.primary.opacity(0.1) : Color.clear)
            .clipShape(RoundedRectangle(cornerRadius: Theme.Radius.small))
            .overlay(
                RoundedRectangle(cornerRadius: Theme.Radius.small)
                    .stroke(isSelected ? Theme.Colors.primary : Color.gray.opacity(0.3), lineWidth: isSelected ? 2 : 1)
            )
        }
        .buttonStyle(.plain)
    }
}

// MARK: - RoutePreference Extensions

extension RoutePreference {
    var iconName: String {
        switch self {
        case .efficient: return "bolt.fill"
        case .scenic: return "binoculars.fill"
        case .balanced: return "scale.3d"
        }
    }

    var displayName: String {
        switch self {
        case .efficient: return "Efficient"
        case .scenic: return "Scenic"
        case .balanced: return "Balanced"
        }
    }

    var description: String {
        switch self {
        case .efficient: return "Minimize driving time between games"
        case .scenic: return "Prioritize interesting stops and routes"
        case .balanced: return "Mix of efficiency and exploration"
        }
    }
}

// MARK: - Preview

#Preview {
    RoutePreferenceStep(
        routePreference: .constant(.balanced),
        hasSetRoutePreference: .constant(true)
    )
    .padding()
    .themedBackground()
}

Step 2: Build and commit

Run: xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' build 2>&1 | tail -10

git add SportsTime/Features/Trip/Views/Wizard/Steps/RoutePreferenceStep.swift
git commit -m "feat(wizard): add RoutePreferenceStep component"

Task 7: Create RepeatCitiesStep and MustStopsStep Components

Files:

  • Create: SportsTime/Features/Trip/Views/Wizard/Steps/RepeatCitiesStep.swift
  • Create: SportsTime/Features/Trip/Views/Wizard/Steps/MustStopsStep.swift

Step 1: Write RepeatCitiesStep

// SportsTime/Features/Trip/Views/Wizard/Steps/RepeatCitiesStep.swift
import SwiftUI

struct RepeatCitiesStep: View {
    @Binding var allowRepeatCities: Bool
    @Binding var hasSetRepeatCities: Bool

    var body: some View {
        VStack(alignment: .leading, spacing: Theme.Spacing.md) {
            StepHeader(
                title: "Visit cities more than once?",
                subtitle: "Some trips work better with return visits"
            )

            HStack(spacing: Theme.Spacing.md) {
                OptionButton(
                    title: "No, unique cities only",
                    icon: "arrow.right",
                    isSelected: hasSetRepeatCities && !allowRepeatCities,
                    onTap: {
                        allowRepeatCities = false
                        hasSetRepeatCities = true
                    }
                )

                OptionButton(
                    title: "Yes, allow repeats",
                    icon: "arrow.triangle.2.circlepath",
                    isSelected: hasSetRepeatCities && allowRepeatCities,
                    onTap: {
                        allowRepeatCities = true
                        hasSetRepeatCities = true
                    }
                )
            }
        }
        .padding(Theme.Spacing.md)
        .background(Theme.Colors.cardBackground)
        .clipShape(RoundedRectangle(cornerRadius: Theme.Radius.medium))
    }
}

// MARK: - Option Button

private struct OptionButton: View {
    let title: String
    let icon: String
    let isSelected: Bool
    let onTap: () -> Void

    var body: some View {
        Button(action: onTap) {
            VStack(spacing: Theme.Spacing.sm) {
                Image(systemName: icon)
                    .font(.title2)
                    .foregroundStyle(isSelected ? Theme.Colors.primary : .secondary)

                Text(title)
                    .font(.caption)
                    .fontWeight(.medium)
                    .foregroundStyle(isSelected ? Theme.Colors.primary : .primary)
                    .multilineTextAlignment(.center)
            }
            .frame(maxWidth: .infinity)
            .padding(Theme.Spacing.md)
            .background(isSelected ? Theme.Colors.primary.opacity(0.1) : Color.clear)
            .clipShape(RoundedRectangle(cornerRadius: Theme.Radius.small))
            .overlay(
                RoundedRectangle(cornerRadius: Theme.Radius.small)
                    .stroke(isSelected ? Theme.Colors.primary : Color.gray.opacity(0.3), lineWidth: isSelected ? 2 : 1)
            )
        }
        .buttonStyle(.plain)
    }
}

// MARK: - Preview

#Preview {
    RepeatCitiesStep(
        allowRepeatCities: .constant(false),
        hasSetRepeatCities: .constant(true)
    )
    .padding()
    .themedBackground()
}

Step 2: Write MustStopsStep

// SportsTime/Features/Trip/Views/Wizard/Steps/MustStopsStep.swift
import SwiftUI

struct MustStopsStep: View {
    @Binding var mustStopLocations: [LocationInput]
    @State private var showLocationSearch = false

    var body: some View {
        VStack(alignment: .leading, spacing: Theme.Spacing.md) {
            StepHeader(
                title: "Any must-stop locations?",
                subtitle: "Optional - add cities you want to visit"
            )

            if !mustStopLocations.isEmpty {
                VStack(spacing: Theme.Spacing.xs) {
                    ForEach(mustStopLocations, id: \.name) { location in
                        HStack {
                            Image(systemName: "mappin.circle.fill")
                                .foregroundStyle(Theme.Colors.primary)

                            Text(location.name)
                                .font(.subheadline)

                            Spacer()

                            Button {
                                mustStopLocations.removeAll { $0.name == location.name }
                            } label: {
                                Image(systemName: "xmark.circle.fill")
                                    .foregroundStyle(.secondary)
                            }
                        }
                        .padding(Theme.Spacing.sm)
                        .background(Color(.secondarySystemBackground))
                        .clipShape(RoundedRectangle(cornerRadius: Theme.Radius.small))
                    }
                }
            }

            Button {
                showLocationSearch = true
            } label: {
                HStack {
                    Image(systemName: "plus.circle.fill")
                    Text(mustStopLocations.isEmpty ? "Add a location" : "Add another")
                }
                .font(.subheadline)
                .foregroundStyle(Theme.Colors.primary)
            }

            Text("Skip this step if you don't have specific cities in mind")
                .font(.caption)
                .foregroundStyle(.secondary)
        }
        .padding(Theme.Spacing.md)
        .background(Theme.Colors.cardBackground)
        .clipShape(RoundedRectangle(cornerRadius: Theme.Radius.medium))
        .sheet(isPresented: $showLocationSearch) {
            LocationSearchSheet(inputType: .mustStop) { location in
                mustStopLocations.append(location)
            }
        }
    }
}

// MARK: - Preview

#Preview {
    MustStopsStep(mustStopLocations: .constant([]))
        .padding()
        .themedBackground()
}

Step 3: Build and commit

Run: xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' build 2>&1 | tail -10

git add SportsTime/Features/Trip/Views/Wizard/Steps/RepeatCitiesStep.swift SportsTime/Features/Trip/Views/Wizard/Steps/MustStopsStep.swift
git commit -m "feat(wizard): add RepeatCitiesStep and MustStopsStep components"

Task 8: Create ReviewStep Component

Files:

  • Create: SportsTime/Features/Trip/Views/Wizard/Steps/ReviewStep.swift

Step 1: Write ReviewStep view

// SportsTime/Features/Trip/Views/Wizard/Steps/ReviewStep.swift
import SwiftUI

struct ReviewStep: View {
    let planningMode: PlanningMode
    let selectedSports: Set<Sport>
    let startDate: Date
    let endDate: Date
    let selectedRegions: Set<USRegion>
    let routePreference: RoutePreference
    let allowRepeatCities: Bool
    let mustStopLocations: [LocationInput]
    let isPlanning: Bool
    let onPlan: () -> Void

    var body: some View {
        VStack(alignment: .leading, spacing: Theme.Spacing.md) {
            StepHeader(
                title: "Ready to plan your trip!",
                subtitle: "Review your selections"
            )

            VStack(alignment: .leading, spacing: Theme.Spacing.sm) {
                ReviewRow(label: "Mode", value: planningMode.displayName)
                ReviewRow(label: "Sports", value: selectedSports.map(\.rawValue).sorted().joined(separator: ", "))
                ReviewRow(label: "Dates", value: dateRangeText)
                ReviewRow(label: "Regions", value: selectedRegions.map(\.displayName).sorted().joined(separator: ", "))
                ReviewRow(label: "Route", value: routePreference.displayName)
                ReviewRow(label: "Repeat cities", value: allowRepeatCities ? "Yes" : "No")

                if !mustStopLocations.isEmpty {
                    ReviewRow(label: "Must-stops", value: mustStopLocations.map(\.name).joined(separator: ", "))
                }
            }
            .padding(Theme.Spacing.sm)
            .background(Color(.secondarySystemBackground))
            .clipShape(RoundedRectangle(cornerRadius: Theme.Radius.small))

            Button(action: onPlan) {
                HStack {
                    if isPlanning {
                        ProgressView()
                            .scaleEffect(0.8)
                            .tint(.white)
                    }
                    Text(isPlanning ? "Planning..." : "Plan My Trip")
                        .fontWeight(.semibold)
                }
                .frame(maxWidth: .infinity)
                .padding(Theme.Spacing.md)
                .background(Theme.Colors.primary)
                .foregroundStyle(.white)
                .clipShape(RoundedRectangle(cornerRadius: Theme.Radius.medium))
            }
            .disabled(isPlanning)
        }
        .padding(Theme.Spacing.md)
        .background(Theme.Colors.cardBackground)
        .clipShape(RoundedRectangle(cornerRadius: Theme.Radius.medium))
    }

    private var dateRangeText: String {
        let formatter = DateFormatter()
        formatter.dateStyle = .medium
        return "\(formatter.string(from: startDate)) - \(formatter.string(from: endDate))"
    }
}

// MARK: - Review Row

private struct ReviewRow: View {
    let label: String
    let value: String

    var body: some View {
        HStack(alignment: .top) {
            Text(label)
                .font(.caption)
                .foregroundStyle(.secondary)
                .frame(width: 80, alignment: .leading)

            Text(value)
                .font(.subheadline)
                .foregroundStyle(.primary)

            Spacer()
        }
    }
}

// MARK: - Preview

#Preview {
    ReviewStep(
        planningMode: .dateRange,
        selectedSports: [.mlb, .nba],
        startDate: Date(),
        endDate: Date().addingTimeInterval(86400 * 7),
        selectedRegions: [.northeast, .midwest],
        routePreference: .balanced,
        allowRepeatCities: false,
        mustStopLocations: [],
        isPlanning: false,
        onPlan: {}
    )
    .padding()
    .themedBackground()
}

Step 2: Build and commit

git add SportsTime/Features/Trip/Views/Wizard/Steps/ReviewStep.swift
git commit -m "feat(wizard): add ReviewStep component"

Task 9: Create TripWizardView Container

Files:

  • Create: SportsTime/Features/Trip/Views/Wizard/TripWizardView.swift

Step 1: Write TripWizardView with progressive reveal

// SportsTime/Features/Trip/Views/Wizard/TripWizardView.swift
import SwiftUI

struct TripWizardView: View {
    @Environment(\.dismiss) private var dismiss
    @State private var viewModel = TripWizardViewModel()
    @State private var scrollProxy: ScrollViewProxy?
    @State private var showTripOptions = false
    @State private var tripOptions: [ItineraryOption] = []

    var body: some View {
        NavigationStack {
            ScrollViewReader { proxy in
                ScrollView {
                    VStack(spacing: Theme.Spacing.lg) {
                        // Step 1: Planning Mode (always visible)
                        PlanningModeStep(selection: $viewModel.planningMode)
                            .id("planningMode")

                        // Step 2: Sports (after mode selected)
                        if viewModel.isSportsStepVisible {
                            SportsStep(
                                selectedSports: $viewModel.selectedSports,
                                sportAvailability: viewModel.sportAvailability,
                                isLoading: viewModel.isLoadingSportAvailability,
                                canSelectSport: viewModel.canSelectSport
                            )
                            .id("sports")
                            .transition(.move(edge: .bottom).combined(with: .opacity))
                        }

                        // Step 3: Dates (after sport selected)
                        if viewModel.isDatesStepVisible {
                            DatesStep(
                                startDate: $viewModel.startDate,
                                endDate: $viewModel.endDate,
                                hasSetDates: $viewModel.hasSetDates,
                                onDatesChanged: {
                                    Task {
                                        await viewModel.fetchSportAvailability()
                                    }
                                }
                            )
                            .id("dates")
                            .transition(.move(edge: .bottom).combined(with: .opacity))
                        }

                        // Step 4: Regions (after dates set)
                        if viewModel.isRegionsStepVisible {
                            RegionsStep(selectedRegions: $viewModel.selectedRegions)
                                .id("regions")
                                .transition(.move(edge: .bottom).combined(with: .opacity))
                        }

                        // Step 5: Route Preference (after regions selected)
                        if viewModel.isRoutePreferenceStepVisible {
                            RoutePreferenceStep(
                                routePreference: $viewModel.routePreference,
                                hasSetRoutePreference: $viewModel.hasSetRoutePreference
                            )
                            .id("routePreference")
                            .transition(.move(edge: .bottom).combined(with: .opacity))
                        }

                        // Step 6: Repeat Cities (after route preference)
                        if viewModel.isRepeatCitiesStepVisible {
                            RepeatCitiesStep(
                                allowRepeatCities: $viewModel.allowRepeatCities,
                                hasSetRepeatCities: $viewModel.hasSetRepeatCities
                            )
                            .id("repeatCities")
                            .transition(.move(edge: .bottom).combined(with: .opacity))
                        }

                        // Step 7: Must Stops (after repeat cities)
                        if viewModel.isMustStopsStepVisible {
                            MustStopsStep(mustStopLocations: $viewModel.mustStopLocations)
                                .id("mustStops")
                                .transition(.move(edge: .bottom).combined(with: .opacity))
                        }

                        // Step 8: Review (after must stops visible)
                        if viewModel.isReviewStepVisible {
                            ReviewStep(
                                planningMode: viewModel.planningMode ?? .dateRange,
                                selectedSports: viewModel.selectedSports,
                                startDate: viewModel.startDate,
                                endDate: viewModel.endDate,
                                selectedRegions: viewModel.selectedRegions,
                                routePreference: viewModel.routePreference,
                                allowRepeatCities: viewModel.allowRepeatCities,
                                mustStopLocations: viewModel.mustStopLocations,
                                isPlanning: viewModel.isPlanning,
                                onPlan: { Task { await planTrip() } }
                            )
                            .id("review")
                            .transition(.move(edge: .bottom).combined(with: .opacity))
                        }
                    }
                    .padding(Theme.Spacing.md)
                    .animation(.easeInOut(duration: 0.3), value: viewModel.revealState)
                }
                .onAppear { scrollProxy = proxy }
                .onChange(of: viewModel.revealState) { _, _ in
                    // Auto-scroll to newly revealed section
                    DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
                        withAnimation {
                            scrollToLatestStep(proxy: proxy)
                        }
                    }
                }
            }
            .themedBackground()
            .navigationTitle("Plan a Trip")
            .navigationBarTitleDisplayMode(.inline)
            .toolbar {
                ToolbarItem(placement: .cancellationAction) {
                    Button("Cancel") { dismiss() }
                }
            }
            .navigationDestination(isPresented: $showTripOptions) {
                TripOptionsView(
                    options: tripOptions,
                    games: [:], // Will be populated from planning result
                    preferences: buildPreferences(),
                    convertToTrip: { _ in nil } // Placeholder
                )
            }
        }
    }

    // MARK: - Auto-scroll

    private func scrollToLatestStep(proxy: ScrollViewProxy) {
        if viewModel.isReviewStepVisible {
            proxy.scrollTo("review", anchor: .top)
        } else if viewModel.isMustStopsStepVisible {
            proxy.scrollTo("mustStops", anchor: .top)
        } else if viewModel.isRepeatCitiesStepVisible {
            proxy.scrollTo("repeatCities", anchor: .top)
        } else if viewModel.isRoutePreferenceStepVisible {
            proxy.scrollTo("routePreference", anchor: .top)
        } else if viewModel.isRegionsStepVisible {
            proxy.scrollTo("regions", anchor: .top)
        } else if viewModel.isDatesStepVisible {
            proxy.scrollTo("dates", anchor: .top)
        } else if viewModel.isSportsStepVisible {
            proxy.scrollTo("sports", anchor: .top)
        }
    }

    // MARK: - Planning

    private func planTrip() async {
        // TODO: Integrate with TripPlanningEngine
        // For now, this is a placeholder
        viewModel.isPlanning = true

        // Simulate planning delay
        try? await Task.sleep(nanoseconds: 2_000_000_000)

        viewModel.isPlanning = false
    }

    private func buildPreferences() -> TripPreferences {
        TripPreferences(
            sports: viewModel.selectedSports,
            startDate: viewModel.startDate,
            endDate: viewModel.endDate,
            regions: viewModel.selectedRegions,
            routePreference: viewModel.routePreference,
            allowRepeatCities: viewModel.allowRepeatCities,
            mustStopLocations: viewModel.mustStopLocations
        )
    }
}

// MARK: - Preview

#Preview {
    TripWizardView()
}

Step 2: Add isPlanning to TripWizardViewModel

Add to TripWizardViewModel.swift:

// MARK: - Planning State

var isPlanning: Bool = false

Step 3: Build to verify compilation

Run: xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' build 2>&1 | tail -10

Step 4: Commit

git add SportsTime/Features/Trip/Views/Wizard/TripWizardView.swift SportsTime/Features/Trip/ViewModels/TripWizardViewModel.swift
git commit -m "feat(wizard): add TripWizardView container with progressive reveal"

Task 10: Add Settings Toggle and Wire Up Navigation

Files:

  • Modify: SportsTime/Features/Settings/Views/SettingsView.swift
  • Modify: SportsTime/Features/Settings/ViewModels/SettingsViewModel.swift
  • Modify: SportsTime/Features/Home/Views/HomeView.swift

Step 1: Add useClassicTripCreation setting to SettingsViewModel

Check if SettingsViewModel exists and add:

// Add to SettingsViewModel
var useClassicTripCreation: Bool {
    get { UserDefaults.standard.bool(forKey: "useClassicTripCreation") }
    set { UserDefaults.standard.set(newValue, forKey: "useClassicTripCreation") }
}

Step 2: Add toggle to SettingsView

Add a new section to SettingsView:

// Add after existing sections in SettingsView
private var tripPlanningSection: some View {
    Section {
        Toggle("Use Classic Trip Form", isOn: $viewModel.useClassicTripCreation)
    } header: {
        Text("Trip Planning")
    } footer: {
        Text("Enable to use the original all-in-one trip creation form instead of the guided wizard.")
    }
}

Add tripPlanningSection to the List body.

Step 3: Update HomeView to conditionally show wizard or classic

Modify HomeView navigation to check the setting:

// In HomeView, update the sheet presentation
.sheet(isPresented: $showTripCreation) {
    if UserDefaults.standard.bool(forKey: "useClassicTripCreation") {
        TripCreationView(viewModel: tripCreationViewModel, initialSport: selectedSport)
    } else {
        TripWizardView()
    }
}

Step 4: Build and test

Run: xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' build 2>&1 | tail -10

Step 5: Commit

git add SportsTime/Features/Settings/Views/SettingsView.swift SportsTime/Features/Settings/ViewModels/SettingsViewModel.swift SportsTime/Features/Home/Views/HomeView.swift
git commit -m "feat(wizard): add Settings toggle and wire up navigation"

Task 11: Integration Testing

Files:

  • Test: SportsTimeTests/Trip/TripWizardViewModelTests.swift (add integration tests)

Step 1: Add full flow integration tests

Add to TripWizardViewModelTests.swift:

func test_fullDateRangeFlow_allStepsRevealInOrder() {
    let viewModel = TripWizardViewModel()

    // Initially only planning mode visible
    XCTAssertTrue(viewModel.isPlanningModeStepVisible)
    XCTAssertFalse(viewModel.isSportsStepVisible)

    // Select planning mode
    viewModel.planningMode = .dateRange
    XCTAssertTrue(viewModel.isSportsStepVisible)
    XCTAssertFalse(viewModel.isDatesStepVisible)

    // Select sport
    viewModel.selectedSports = [.mlb]
    XCTAssertTrue(viewModel.isDatesStepVisible)
    XCTAssertFalse(viewModel.isRegionsStepVisible)

    // Set dates
    viewModel.hasSetDates = true
    XCTAssertTrue(viewModel.isRegionsStepVisible)
    XCTAssertFalse(viewModel.isRoutePreferenceStepVisible)

    // Select region
    viewModel.selectedRegions = [.northeast]
    XCTAssertTrue(viewModel.isRoutePreferenceStepVisible)
    XCTAssertFalse(viewModel.isRepeatCitiesStepVisible)

    // Set route preference
    viewModel.hasSetRoutePreference = true
    XCTAssertTrue(viewModel.isRepeatCitiesStepVisible)
    XCTAssertFalse(viewModel.isMustStopsStepVisible)

    // Set repeat cities
    viewModel.hasSetRepeatCities = true
    XCTAssertTrue(viewModel.isMustStopsStepVisible)
    XCTAssertTrue(viewModel.isReviewStepVisible)
}

func test_revealState_changesWithEachStep() {
    let viewModel = TripWizardViewModel()
    let initialState = viewModel.revealState

    viewModel.planningMode = .dateRange
    XCTAssertNotEqual(viewModel.revealState, initialState)

    let afterModeState = viewModel.revealState
    viewModel.selectedSports = [.mlb]
    XCTAssertNotEqual(viewModel.revealState, afterModeState)
}

Step 2: Run all wizard tests

Run: xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' -only-testing:SportsTimeTests/TripWizardViewModelTests test 2>&1 | tail -30

Expected: All tests PASS

Step 3: Commit

git add SportsTimeTests/Trip/TripWizardViewModelTests.swift
git commit -m "test(wizard): add integration tests for full flow"

Task 12: Final Build and Full Test Suite

Step 1: Run full test suite

Run: xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' test 2>&1 | tail -50

Expected: All tests PASS

Step 2: Final commit if any cleanup needed

git status
# If any uncommitted changes, commit them

Summary

This plan creates:

  • TripWizardViewModel - State management for progressive reveal
  • 8 step components (PlanningModeStep, SportsStep, DatesStep, RegionsStep, RoutePreferenceStep, RepeatCitiesStep, MustStopsStep, ReviewStep)
  • TripWizardView - Container with auto-scroll
  • Settings toggle for classic vs wizard mode
  • Comprehensive test coverage

Total: ~12 tasks, ~40 steps