diff --git a/docs/plans/2026-01-11-follow-team-mode-design.md b/docs/plans/2026-01-11-follow-team-mode-design.md new file mode 100644 index 0000000..7909b47 --- /dev/null +++ b/docs/plans/2026-01-11-follow-team-mode-design.md @@ -0,0 +1,305 @@ +# 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 + +```swift +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 + +```swift +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 + +```swift +enum PlanningScenario: Equatable { + case scenarioA // Date range + case scenarioB // Selected games + case scenarioC // Start/end locations + case scenarioD // Follow team +} +``` + +## ScenarioDPlanner Logic + +### Core Algorithm + +```swift +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: + +```swift +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: + +```swift +func applyRepeatCityFilter(_ games: [Game], allowRepeat: Bool, stadiums: [UUID: Stadium]) -> [Game] { + guard !allowRepeat else { return games } + + var seenCities: Set = [] + 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: + +```swift +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 + +```swift +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 + +```swift +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 + +```swift +var homeLocationToggle: some View { + Toggle("Start and end from home", isOn: $viewModel.useHomeLocation) + .toggleStyle(.switch) +} +``` + +## Validation Rules + +```swift +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