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>
306 lines
8.3 KiB
Markdown
306 lines
8.3 KiB
Markdown
# 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
|