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>
This commit is contained in:
305
docs/plans/2026-01-11-follow-team-mode-design.md
Normal file
305
docs/plans/2026-01-11-follow-team-mode-design.md
Normal file
@@ -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<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:
|
||||
|
||||
```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
|
||||
Reference in New Issue
Block a user