Files
Sportstime/docs/plans/2026-01-11-follow-team-mode-design.md
Trey t 3aef39adba docs: add Follow Team mode design
New 4th planning mode for fans to build trips around their team's
schedule (home + away games). Includes region/date filtering,
flexible start/end location, and repeat city handling.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 09:56:13 -06:00

8.3 KiB

Follow Team Mode Design

Overview

Add a 4th trip planning mode called "Follow Team" where users select a team, regions, and date range to build trips around that team's schedule (both home and away games).

User Flow

  1. Select "Follow Team" mode
  2. Pick a team (grouped by sport for browsing)
  3. Select regions (East/Central/West)
  4. Pick date range
  5. Choose whether to start/end from home location or fly-in/fly-out
  6. Plan trip

Scope

Included:

  • Single team selection
  • Both home and away games
  • Region filtering (same as existing modes)
  • Date range filtering
  • Flexible start/end: home location OR first-game-to-last-game
  • Respect allowRepeatCities toggle

Not included (future):

  • Multiple teams (rivalry trips)
  • Opponent filtering
  • Win/loss context display

Data Model Changes

PlanningMode Extension

enum PlanningMode: String, Codable, CaseIterable, Identifiable {
    case dateRange      // Scenario A - dates first
    case gameFirst      // Scenario B - pick games
    case locations      // Scenario C - start/end cities
    case followTeam     // Scenario D - follow one team

    var displayName: String {
        switch self {
        case .dateRange: return "By Dates"
        case .gameFirst: return "By Games"
        case .locations: return "By Route"
        case .followTeam: return "Follow Team"
        }
    }

    var description: String {
        switch self {
        case .dateRange: return "Shows a curated sample of possible routes"
        case .gameFirst: return "Build trip around specific games"
        case .locations: return "Plan route between locations"
        case .followTeam: return "Follow your team on the road"
        }
    }

    var iconName: String {
        switch self {
        case .dateRange: return "calendar"
        case .gameFirst: return "sportscourt"
        case .locations: return "map"
        case .followTeam: return "person.3.fill"
        }
    }
}

TripPreferences Addition

struct TripPreferences {
    // ... existing properties ...

    /// Team to follow (for Follow Team mode)
    var followTeamId: UUID?

    /// Whether to start/end from a home location (vs fly-in/fly-out)
    var useHomeLocation: Bool = true
}

PlanningScenario Extension

enum PlanningScenario: Equatable {
    case scenarioA  // Date range
    case scenarioB  // Selected games
    case scenarioC  // Start/end locations
    case scenarioD  // Follow team
}

ScenarioDPlanner Logic

Core Algorithm

actor ScenarioDPlanner {
    func plan(request: PlanningRequest) async throws -> ItineraryResult {
        // 1. Filter games to selected team only
        let teamGames = filterToTeam(
            request.allGames,
            teamId: request.preferences.followTeamId
        )

        // 2. Apply region filter
        let regionalGames = filterByRegion(
            teamGames,
            regions: request.preferences.selectedRegions,
            stadiums: request.stadiums
        )

        // 3. Apply date range
        let dateFilteredGames = filterByDateRange(
            regionalGames,
            start: request.preferences.startDate,
            end: request.preferences.endDate
        )

        // 4. Apply repeat city constraint
        let finalGames = applyRepeatCityFilter(
            dateFilteredGames,
            allowRepeat: request.preferences.allowRepeatCities,
            stadiums: request.stadiums
        )

        // 5. Build route (with or without home location)
        if request.preferences.useHomeLocation,
           let startLocation = request.startLocation {
            // Round-trip from home
            return buildRouteFromHome(finalGames, start: startLocation)
        } else {
            // Fly-in / fly-out (first game to last game)
            return buildPointToPointRoute(finalGames)
        }
    }
}

Team Game Filtering

A game belongs to the followed team if they're home OR away:

func filterToTeam(_ games: [Game], teamId: UUID?) -> [Game] {
    guard let teamId else { return [] }
    return games.filter { game in
        game.homeTeamId == teamId || game.awayTeamId == teamId
    }
}

Repeat City Handling

When allowRepeatCities = false, keep only one game per city:

func applyRepeatCityFilter(_ games: [Game], allowRepeat: Bool, stadiums: [UUID: Stadium]) -> [Game] {
    guard !allowRepeat else { return games }

    var seenCities: Set<String> = []
    return games.filter { game in
        guard let stadium = stadiums[game.stadiumId] else { return false }
        if seenCities.contains(stadium.city) { return false }
        seenCities.insert(stadium.city)
        return true
    }
}

UI Changes

Mode Selector

Expand to 4 modes in a 2x2 grid:

var planningModeSection: some View {
    LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 12) {
        ForEach(PlanningMode.allCases) { mode in
            PlanningModeCard(
                mode: mode,
                isSelected: viewModel.planningMode == mode,
                action: { viewModel.planningMode = mode }
            )
        }
    }
}

Follow Team Mode Sections

case .followTeam:
    teamPickerSection      // Select team (grouped by sport)
    regionSection          // East/Central/West
    datesSection           // Start/end dates
    homeLocationToggle     // "Start from home?" toggle
    locationSection        // Conditional: Only if toggle is on

Team Picker Section

var teamPickerSection: some View {
    Section("Select Team") {
        Picker("Team", selection: $viewModel.followTeamId) {
            Text("Choose a team").tag(nil as UUID?)
            ForEach(teamsByLeague) { league in
                Section(league.name) {
                    ForEach(league.teams) { team in
                        Text(team.name).tag(team.id as UUID?)
                    }
                }
            }
        }
    }
}

Home Location Toggle

var homeLocationToggle: some View {
    Toggle("Start and end from home", isOn: $viewModel.useHomeLocation)
        .toggleStyle(.switch)
}

Validation Rules

var followTeamValidation: String? {
    guard viewModel.followTeamId != nil else {
        return "Select a team to follow"
    }

    guard viewModel.endDate > viewModel.startDate else {
        return "End date must be after start date"
    }

    if viewModel.useHomeLocation && viewModel.startLocation == nil {
        return "Enter your home location"
    }

    return nil
}

Edge Cases

Case Behavior
No games in range Show: "No [Team] games found in [Region] between [dates]"
All games in one city Valid single-stop trip
Repeat city + toggle off Keep first game in each city, skip duplicates
Back-to-back different cities Existing driving constraints handle feasibility
No start location but toggle on Block submission with validation message

Planning Failures

Reuse existing PlanningFailure reasons:

  • .noGamesInRange — No team games in date/region
  • .noValidRoutes — Can't build feasible route
  • .repeatCityViolation — Would require repeat city but toggle is off

Implementation Plan

Files to Create

SportsTime/Planning/Engine/ScenarioDPlanner.swift
SportsTimeTests/Planning/ScenarioDPlannerTests.swift

Files to Modify

SportsTime/Core/Models/Domain/TripPreferences.swift
SportsTime/Planning/Models/PlanningModels.swift
SportsTime/Planning/Engine/TripPlanningEngine.swift
SportsTime/Features/Trip/Views/TripCreationView.swift
SportsTime/Features/Trip/ViewModels/TripCreationViewModel.swift

Implementation Order

  1. Data model — Add properties to TripPreferences, add scenarioD
  2. Planner — Create ScenarioDPlanner
  3. Engine routing — Update TripPlanningEngine to route to Scenario D
  4. ViewModel — Add state and validation
  5. UI — Add team picker, toggle, mode card
  6. Tests — Cover all edge cases

Test Cases

  • Happy path: Team with 5 games in region → valid route
  • No games: Team has no games in range → proper failure
  • Single city: All home games, repeat allowed → valid single-stop
  • Repeat city blocked: Multiple home games, repeat off → keeps one
  • With home location: Round-trip from user's city
  • Without home location: Point-to-point first to last game