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:
Trey t
2026-01-11 09:56:13 -06:00
parent 5fba9e6052
commit 3aef39adba

View 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