feat: rewrite bootstrap, fix CloudKit sync, update canonical data, and UI fixes

- Rewrite BootstrapService: remove all legacy code paths (JSONStadium,
  JSONGame, bootstrapStadiumsLegacy, bootstrapGamesLegacy, venue aliases,
  createDefaultLeagueStructure), require canonical JSON files only
- Add clearCanonicalData() to handle partial bootstrap recovery (prevents
  duplicate key crashes from interrupted first-launch)
- Fix nullable stadium_canonical_id in games (4 MLS games have null)
- Fix CKModels: logoUrl case, conference/division field keys
- Fix CanonicalSyncService: sync conferenceCanonicalId/divisionCanonicalId
- Add sports_canonical.json and DemoMode.swift
- Delete legacy stadiums.json and games.json
- Update all canonical resource JSON files with latest data
- Fix TripWizardView horizontal scrolling with GeometryReader constraint
- Update RegionMapSelector, TripDetailView, TripOptionsView UI improvements
- Add DateRangePicker, PlanningModeStep, SportsStep enhancements
- Update UI tests and marketing-videos config

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-02-06 00:06:19 -06:00
parent 12f959ab8d
commit fdcecafaa3
29 changed files with 93279 additions and 157943 deletions

View File

@@ -34,9 +34,11 @@ struct CKTeam {
static let cityKey = "city" static let cityKey = "city"
static let stadiumRefKey = "stadiumRef" static let stadiumRefKey = "stadiumRef"
static let stadiumCanonicalIdKey = "stadiumCanonicalId" static let stadiumCanonicalIdKey = "stadiumCanonicalId"
static let logoURLKey = "logoURL" static let logoURLKey = "logoUrl"
static let primaryColorKey = "primaryColor" static let primaryColorKey = "primaryColor"
static let secondaryColorKey = "secondaryColor" static let secondaryColorKey = "secondaryColor"
static let conferenceCanonicalIdKey = "conferenceCanonicalId"
static let divisionCanonicalIdKey = "divisionCanonicalId"
let record: CKRecord let record: CKRecord
@@ -68,6 +70,16 @@ struct CKTeam {
record[CKTeam.stadiumCanonicalIdKey] as? String record[CKTeam.stadiumCanonicalIdKey] as? String
} }
/// The conference canonical ID string from CloudKit (e.g., "nba_eastern")
var conferenceCanonicalId: String? {
record[CKTeam.conferenceCanonicalIdKey] as? String
}
/// The division canonical ID string from CloudKit (e.g., "nba_southeast")
var divisionCanonicalId: String? {
record[CKTeam.divisionCanonicalIdKey] as? String
}
var team: Team? { var team: Team? {
// Use teamId field, or fall back to record name // Use teamId field, or fall back to record name
let id = (record[CKTeam.idKey] as? String) ?? record.recordID.recordName let id = (record[CKTeam.idKey] as? String) ?? record.recordID.recordName
@@ -99,6 +111,8 @@ struct CKTeam {
sport: sport, sport: sport,
city: city, city: city,
stadiumId: stadiumId, stadiumId: stadiumId,
conferenceId: record[CKTeam.conferenceCanonicalIdKey] as? String,
divisionId: record[CKTeam.divisionCanonicalIdKey] as? String,
logoURL: logoURL, logoURL: logoURL,
primaryColor: record[CKTeam.primaryColorKey] as? String, primaryColor: record[CKTeam.primaryColorKey] as? String,
secondaryColor: record[CKTeam.secondaryColorKey] as? String secondaryColor: record[CKTeam.secondaryColorKey] as? String

View File

@@ -564,4 +564,5 @@ nonisolated enum BundledDataTimestamp {
static let stadiums = ISO8601DateFormatter().date(from: "2026-01-08T00:00:00Z") ?? Date() static let stadiums = ISO8601DateFormatter().date(from: "2026-01-08T00:00:00Z") ?? Date()
static let games = ISO8601DateFormatter().date(from: "2026-01-08T00:00:00Z") ?? Date() static let games = ISO8601DateFormatter().date(from: "2026-01-08T00:00:00Z") ?? Date()
static let leagueStructure = ISO8601DateFormatter().date(from: "2026-01-08T00:00:00Z") ?? Date() static let leagueStructure = ISO8601DateFormatter().date(from: "2026-01-08T00:00:00Z") ?? Date()
static let sports = ISO8601DateFormatter().date(from: "2026-01-08T00:00:00Z") ?? Date()
} }

View File

@@ -33,13 +33,11 @@ actor BootstrapService {
// MARK: - JSON Models (match bundled JSON structure) // MARK: - JSON Models (match bundled JSON structure)
// MARK: - Canonical JSON Models (from canonicalization pipeline)
private struct JSONCanonicalStadium: Codable { private struct JSONCanonicalStadium: Codable {
let canonical_id: String let canonical_id: String
let name: String let name: String
let city: String let city: String
let state: String let state: String?
let latitude: Double let latitude: Double
let longitude: Double let longitude: Double
let capacity: Int let capacity: Int
@@ -67,12 +65,12 @@ actor BootstrapService {
let canonical_id: String let canonical_id: String
let sport: String let sport: String
let season: String let season: String
let game_datetime_utc: String? // ISO 8601 format (new canonical format) let game_datetime_utc: String? // ISO 8601 format
let date: String? // Legacy format (deprecated) let date: String? // Fallback date+time format
let time: String? // Legacy format (deprecated) let time: String? // Fallback date+time format
let home_team_canonical_id: String let home_team_canonical_id: String
let away_team_canonical_id: String let away_team_canonical_id: String
let stadium_canonical_id: String let stadium_canonical_id: String?
let is_playoff: Bool let is_playoff: Bool
let broadcast_info: String? let broadcast_info: String?
} }
@@ -84,38 +82,6 @@ actor BootstrapService {
let valid_until: String? let valid_until: String?
} }
// MARK: - Legacy JSON Models (for backward compatibility)
private struct JSONStadium: Codable {
let id: String
let name: String
let city: String
let state: String
let latitude: Double
let longitude: Double
let capacity: Int
let sport: String
let team_abbrevs: [String]
let source: String
let year_opened: Int?
}
private struct JSONGame: Codable {
let id: String
let sport: String
let season: String
let date: String
let time: String?
let home_team: String
let away_team: String
let home_team_abbrev: String
let away_team_abbrev: String
let venue: String
let source: String
let is_playoff: Bool
let broadcast: String?
}
private struct JSONLeagueStructure: Codable { private struct JSONLeagueStructure: Codable {
let id: String let id: String
let sport: String let sport: String
@@ -135,13 +101,21 @@ actor BootstrapService {
let valid_until: String? let valid_until: String?
} }
private struct JSONCanonicalSport: Codable {
let sport_id: String
let abbreviation: String
let display_name: String
let icon_name: String
let color_hex: String
let season_start_month: Int
let season_end_month: Int
let is_active: Bool
}
// MARK: - Public Methods // MARK: - Public Methods
/// Bootstrap canonical data from bundled JSON if not already done. /// Bootstrap canonical data from bundled JSON if not already done.
/// This is the main entry point called at app launch. /// This is the main entry point called at app launch.
///
/// Prefers new canonical format files (*_canonical.json) from the pipeline,
/// falls back to legacy format for backward compatibility.
@MainActor @MainActor
func bootstrapIfNeeded(context: ModelContext) async throws { func bootstrapIfNeeded(context: ModelContext) async throws {
let syncState = SyncState.current(in: context) let syncState = SyncState.current(in: context)
@@ -151,6 +125,9 @@ actor BootstrapService {
return return
} }
// Clear any partial bootstrap data from a previous failed attempt
try clearCanonicalData(context: context)
// Bootstrap in dependency order: // Bootstrap in dependency order:
// 1. Stadiums (no dependencies) // 1. Stadiums (no dependencies)
// 2. Stadium aliases (depends on stadiums) // 2. Stadium aliases (depends on stadiums)
@@ -165,6 +142,7 @@ actor BootstrapService {
try await bootstrapTeams(context: context) try await bootstrapTeams(context: context)
try await bootstrapTeamAliases(context: context) try await bootstrapTeamAliases(context: context)
try await bootstrapGames(context: context) try await bootstrapGames(context: context)
try await bootstrapSports(context: context)
// Mark bootstrap complete // Mark bootstrap complete
syncState.bootstrapCompleted = true syncState.bootstrapCompleted = true
@@ -182,18 +160,10 @@ actor BootstrapService {
@MainActor @MainActor
private func bootstrapStadiums(context: ModelContext) async throws { private func bootstrapStadiums(context: ModelContext) async throws {
// Try canonical format first, fall back to legacy guard let url = Bundle.main.url(forResource: "stadiums_canonical", withExtension: "json") else {
if let url = Bundle.main.url(forResource: "stadiums_canonical", withExtension: "json") { throw BootstrapError.bundledResourceNotFound("stadiums_canonical.json")
try await bootstrapStadiumsCanonical(url: url, context: context)
} else if let url = Bundle.main.url(forResource: "stadiums", withExtension: "json") {
try await bootstrapStadiumsLegacy(url: url, context: context)
} else {
throw BootstrapError.bundledResourceNotFound("stadiums_canonical.json or stadiums.json")
} }
}
@MainActor
private func bootstrapStadiumsCanonical(url: URL, context: ModelContext) async throws {
let data: Data let data: Data
let stadiums: [JSONCanonicalStadium] let stadiums: [JSONCanonicalStadium]
@@ -212,7 +182,7 @@ actor BootstrapService {
source: .bundled, source: .bundled,
name: jsonStadium.name, name: jsonStadium.name,
city: jsonStadium.city, city: jsonStadium.city,
state: jsonStadium.state.isEmpty ? stateFromCity(jsonStadium.city) : jsonStadium.state, state: (jsonStadium.state?.isEmpty ?? true) ? stateFromCity(jsonStadium.city) : jsonStadium.state!,
latitude: jsonStadium.latitude, latitude: jsonStadium.latitude,
longitude: jsonStadium.longitude, longitude: jsonStadium.longitude,
capacity: jsonStadium.capacity, capacity: jsonStadium.capacity,
@@ -225,52 +195,9 @@ actor BootstrapService {
} }
} }
@MainActor
private func bootstrapStadiumsLegacy(url: URL, context: ModelContext) async throws {
let data: Data
let stadiums: [JSONStadium]
do {
data = try Data(contentsOf: url)
stadiums = try JSONDecoder().decode([JSONStadium].self, from: data)
} catch {
throw BootstrapError.jsonDecodingFailed("stadiums.json", error)
}
for jsonStadium in stadiums {
let canonical = CanonicalStadium(
canonicalId: jsonStadium.id,
schemaVersion: SchemaVersion.current,
lastModified: BundledDataTimestamp.stadiums,
source: .bundled,
name: jsonStadium.name,
city: jsonStadium.city,
state: jsonStadium.state.isEmpty ? stateFromCity(jsonStadium.city) : jsonStadium.state,
latitude: jsonStadium.latitude,
longitude: jsonStadium.longitude,
capacity: jsonStadium.capacity,
yearOpened: jsonStadium.year_opened,
sport: jsonStadium.sport
)
context.insert(canonical)
// Legacy format: create stadium alias for the current name
let alias = StadiumAlias(
aliasName: jsonStadium.name,
stadiumCanonicalId: jsonStadium.id,
schemaVersion: SchemaVersion.current,
lastModified: BundledDataTimestamp.stadiums
)
alias.stadium = canonical
context.insert(alias)
}
}
@MainActor @MainActor
private func bootstrapStadiumAliases(context: ModelContext) async throws { private func bootstrapStadiumAliases(context: ModelContext) async throws {
// Stadium aliases are loaded from stadium_aliases.json (from canonical pipeline)
guard let url = Bundle.main.url(forResource: "stadium_aliases", withExtension: "json") else { guard let url = Bundle.main.url(forResource: "stadium_aliases", withExtension: "json") else {
// Aliases are optional - legacy format creates them inline
return return
} }
@@ -313,10 +240,7 @@ actor BootstrapService {
@MainActor @MainActor
private func bootstrapLeagueStructure(context: ModelContext) async throws { private func bootstrapLeagueStructure(context: ModelContext) async throws {
// Load league structure if file exists
guard let url = Bundle.main.url(forResource: "league_structure", withExtension: "json") else { guard let url = Bundle.main.url(forResource: "league_structure", withExtension: "json") else {
// League structure is optional for MVP - create basic structure from known sports
createDefaultLeagueStructure(context: context)
return return
} }
@@ -356,17 +280,10 @@ actor BootstrapService {
@MainActor @MainActor
private func bootstrapTeams(context: ModelContext) async throws { private func bootstrapTeams(context: ModelContext) async throws {
// Try canonical format first, fall back to legacy extraction from games guard let url = Bundle.main.url(forResource: "teams_canonical", withExtension: "json") else {
if let url = Bundle.main.url(forResource: "teams_canonical", withExtension: "json") { throw BootstrapError.bundledResourceNotFound("teams_canonical.json")
try await bootstrapTeamsCanonical(url: url, context: context)
} else {
// Legacy: Teams will be extracted from games during bootstrapGames
// This path is deprecated but maintained for backward compatibility
} }
}
@MainActor
private func bootstrapTeamsCanonical(url: URL, context: ModelContext) async throws {
let data: Data let data: Data
let teams: [JSONCanonicalTeam] let teams: [JSONCanonicalTeam]
@@ -395,180 +312,8 @@ actor BootstrapService {
} }
} }
@MainActor
private func bootstrapGames(context: ModelContext) async throws {
// Try canonical format first, fall back to legacy
if let url = Bundle.main.url(forResource: "games_canonical", withExtension: "json") {
try await bootstrapGamesCanonical(url: url, context: context)
} else if let url = Bundle.main.url(forResource: "games", withExtension: "json") {
try await bootstrapGamesLegacy(url: url, context: context)
} else {
throw BootstrapError.bundledResourceNotFound("games_canonical.json or games.json")
}
}
@MainActor
private func bootstrapGamesCanonical(url: URL, context: ModelContext) async throws {
let data: Data
let games: [JSONCanonicalGame]
do {
data = try Data(contentsOf: url)
games = try JSONDecoder().decode([JSONCanonicalGame].self, from: data)
} catch {
throw BootstrapError.jsonDecodingFailed("games_canonical.json", error)
}
var seenGameIds = Set<String>()
for jsonGame in games {
// Deduplicate
guard !seenGameIds.contains(jsonGame.canonical_id) else { continue }
seenGameIds.insert(jsonGame.canonical_id)
// Parse datetime: prefer ISO 8601 format, fall back to legacy date+time
let dateTime: Date?
if let iso8601String = jsonGame.game_datetime_utc {
dateTime = parseISO8601(iso8601String)
} else if let date = jsonGame.date {
dateTime = parseDateTime(date: date, time: jsonGame.time ?? "7:00p")
} else {
dateTime = nil
}
guard let dateTime else { continue }
let game = CanonicalGame(
canonicalId: jsonGame.canonical_id,
schemaVersion: SchemaVersion.current,
lastModified: BundledDataTimestamp.games,
source: .bundled,
homeTeamCanonicalId: jsonGame.home_team_canonical_id,
awayTeamCanonicalId: jsonGame.away_team_canonical_id,
stadiumCanonicalId: jsonGame.stadium_canonical_id,
dateTime: dateTime,
sport: jsonGame.sport,
season: jsonGame.season,
isPlayoff: jsonGame.is_playoff,
broadcastInfo: jsonGame.broadcast_info
)
context.insert(game)
}
}
@MainActor
private func bootstrapGamesLegacy(url: URL, context: ModelContext) async throws {
let data: Data
let games: [JSONGame]
do {
data = try Data(contentsOf: url)
games = try JSONDecoder().decode([JSONGame].self, from: data)
} catch {
throw BootstrapError.jsonDecodingFailed("games.json", error)
}
// Build stadium lookup for legacy venue matching
let stadiumDescriptor = FetchDescriptor<CanonicalStadium>()
let canonicalStadiums = (try? context.fetch(stadiumDescriptor)) ?? []
var stadiumsByVenue: [String: CanonicalStadium] = [:]
for stadium in canonicalStadiums {
stadiumsByVenue[stadium.name.lowercased()] = stadium
}
// Check if teams already exist (from teams_canonical.json)
let teamDescriptor = FetchDescriptor<CanonicalTeam>()
let existingTeams = (try? context.fetch(teamDescriptor)) ?? []
var teamsCreated: [String: CanonicalTeam] = Dictionary(
uniqueKeysWithValues: existingTeams.map { ($0.canonicalId, $0) }
)
let teamsAlreadyLoaded = !existingTeams.isEmpty
var seenGameIds = Set<String>()
for jsonGame in games {
let sport = jsonGame.sport.uppercased()
// Legacy team extraction (only if teams not already loaded)
if !teamsAlreadyLoaded {
let homeTeamCanonicalId = "team_\(sport.lowercased())_\(jsonGame.home_team_abbrev.lowercased())"
if teamsCreated[homeTeamCanonicalId] == nil {
let stadiumCanonicalId = findStadiumCanonicalId(
venue: jsonGame.venue,
sport: sport,
stadiumsByVenue: stadiumsByVenue
)
let team = CanonicalTeam(
canonicalId: homeTeamCanonicalId,
schemaVersion: SchemaVersion.current,
lastModified: BundledDataTimestamp.games,
source: .bundled,
name: extractTeamName(from: jsonGame.home_team),
abbreviation: jsonGame.home_team_abbrev,
sport: sport,
city: extractCity(from: jsonGame.home_team),
stadiumCanonicalId: stadiumCanonicalId
)
context.insert(team)
teamsCreated[homeTeamCanonicalId] = team
}
let awayTeamCanonicalId = "team_\(sport.lowercased())_\(jsonGame.away_team_abbrev.lowercased())"
if teamsCreated[awayTeamCanonicalId] == nil {
let team = CanonicalTeam(
canonicalId: awayTeamCanonicalId,
schemaVersion: SchemaVersion.current,
lastModified: BundledDataTimestamp.games,
source: .bundled,
name: extractTeamName(from: jsonGame.away_team),
abbreviation: jsonGame.away_team_abbrev,
sport: sport,
city: extractCity(from: jsonGame.away_team),
stadiumCanonicalId: "unknown"
)
context.insert(team)
teamsCreated[awayTeamCanonicalId] = team
}
}
// Deduplicate games
guard !seenGameIds.contains(jsonGame.id) else { continue }
seenGameIds.insert(jsonGame.id)
guard let dateTime = parseDateTime(date: jsonGame.date, time: jsonGame.time ?? "7:00p") else {
continue
}
let homeTeamCanonicalId = "team_\(sport.lowercased())_\(jsonGame.home_team_abbrev.lowercased())"
let awayTeamCanonicalId = "team_\(sport.lowercased())_\(jsonGame.away_team_abbrev.lowercased())"
let stadiumCanonicalId = findStadiumCanonicalId(
venue: jsonGame.venue,
sport: sport,
stadiumsByVenue: stadiumsByVenue
)
let game = CanonicalGame(
canonicalId: jsonGame.id,
schemaVersion: SchemaVersion.current,
lastModified: BundledDataTimestamp.games,
source: .bundled,
homeTeamCanonicalId: homeTeamCanonicalId,
awayTeamCanonicalId: awayTeamCanonicalId,
stadiumCanonicalId: stadiumCanonicalId,
dateTime: dateTime,
sport: sport,
season: jsonGame.season,
isPlayoff: jsonGame.is_playoff,
broadcastInfo: jsonGame.broadcast
)
context.insert(game)
}
}
@MainActor @MainActor
private func bootstrapTeamAliases(context: ModelContext) async throws { private func bootstrapTeamAliases(context: ModelContext) async throws {
// Team aliases are optional - load if file exists
guard let url = Bundle.main.url(forResource: "team_aliases", withExtension: "json") else { guard let url = Bundle.main.url(forResource: "team_aliases", withExtension: "json") else {
return return
} }
@@ -583,7 +328,8 @@ actor BootstrapService {
throw BootstrapError.jsonDecodingFailed("team_aliases.json", error) throw BootstrapError.jsonDecodingFailed("team_aliases.json", error)
} }
let dateFormatter = ISO8601DateFormatter() let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "yyyy-MM-dd"
for jsonAlias in aliases { for jsonAlias in aliases {
let aliasType: TeamAliasType let aliasType: TeamAliasType
@@ -608,88 +354,107 @@ actor BootstrapService {
} }
} }
@MainActor
private func bootstrapGames(context: ModelContext) async throws {
guard let url = Bundle.main.url(forResource: "games_canonical", withExtension: "json") else {
throw BootstrapError.bundledResourceNotFound("games_canonical.json")
}
let data: Data
let games: [JSONCanonicalGame]
do {
data = try Data(contentsOf: url)
games = try JSONDecoder().decode([JSONCanonicalGame].self, from: data)
} catch {
throw BootstrapError.jsonDecodingFailed("games_canonical.json", error)
}
var seenGameIds = Set<String>()
for jsonGame in games {
// Deduplicate
guard !seenGameIds.contains(jsonGame.canonical_id) else { continue }
seenGameIds.insert(jsonGame.canonical_id)
// Parse datetime: prefer ISO 8601 format, fall back to date+time
let dateTime: Date?
if let iso8601String = jsonGame.game_datetime_utc {
dateTime = parseISO8601(iso8601String)
} else if let date = jsonGame.date {
dateTime = parseDateTime(date: date, time: jsonGame.time ?? "7:00p")
} else {
dateTime = nil
}
guard let dateTime else { continue }
let game = CanonicalGame(
canonicalId: jsonGame.canonical_id,
schemaVersion: SchemaVersion.current,
lastModified: BundledDataTimestamp.games,
source: .bundled,
homeTeamCanonicalId: jsonGame.home_team_canonical_id,
awayTeamCanonicalId: jsonGame.away_team_canonical_id,
stadiumCanonicalId: jsonGame.stadium_canonical_id ?? "",
dateTime: dateTime,
sport: jsonGame.sport,
season: jsonGame.season,
isPlayoff: jsonGame.is_playoff,
broadcastInfo: jsonGame.broadcast_info
)
context.insert(game)
}
}
@MainActor
private func bootstrapSports(context: ModelContext) async throws {
guard let url = Bundle.main.url(forResource: "sports_canonical", withExtension: "json") else {
return
}
let data: Data
let sports: [JSONCanonicalSport]
do {
data = try Data(contentsOf: url)
sports = try JSONDecoder().decode([JSONCanonicalSport].self, from: data)
} catch {
throw BootstrapError.jsonDecodingFailed("sports_canonical.json", error)
}
for jsonSport in sports {
let sport = CanonicalSport(
id: jsonSport.sport_id,
abbreviation: jsonSport.abbreviation,
displayName: jsonSport.display_name,
iconName: jsonSport.icon_name,
colorHex: jsonSport.color_hex,
seasonStartMonth: jsonSport.season_start_month,
seasonEndMonth: jsonSport.season_end_month,
isActive: jsonSport.is_active,
lastModified: BundledDataTimestamp.sports,
schemaVersion: SchemaVersion.current,
source: .bundled
)
context.insert(sport)
}
}
// MARK: - Helpers // MARK: - Helpers
@MainActor @MainActor
private func createDefaultLeagueStructure(context: ModelContext) { private func clearCanonicalData(context: ModelContext) throws {
// Create minimal league structure for supported sports try context.delete(model: CanonicalStadium.self)
let timestamp = BundledDataTimestamp.leagueStructure try context.delete(model: StadiumAlias.self)
try context.delete(model: LeagueStructureModel.self)
// MLB try context.delete(model: CanonicalTeam.self)
context.insert(LeagueStructureModel( try context.delete(model: TeamAlias.self)
id: "mlb_league", try context.delete(model: CanonicalGame.self)
sport: "MLB", try context.delete(model: CanonicalSport.self)
structureType: .league,
name: "Major League Baseball",
abbreviation: "MLB",
displayOrder: 0,
schemaVersion: SchemaVersion.current,
lastModified: timestamp
))
// NBA
context.insert(LeagueStructureModel(
id: "nba_league",
sport: "NBA",
structureType: .league,
name: "National Basketball Association",
abbreviation: "NBA",
displayOrder: 0,
schemaVersion: SchemaVersion.current,
lastModified: timestamp
))
// NHL
context.insert(LeagueStructureModel(
id: "nhl_league",
sport: "NHL",
structureType: .league,
name: "National Hockey League",
abbreviation: "NHL",
displayOrder: 0,
schemaVersion: SchemaVersion.current,
lastModified: timestamp
))
}
// Venue name aliases for stadiums that changed names
private static let venueAliases: [String: String] = [
"daikin park": "minute maid park",
"rate field": "guaranteed rate field",
"george m. steinbrenner field": "tropicana field",
"loandepot park": "loandepot park",
]
nonisolated private func findStadiumCanonicalId(
venue: String,
sport: String,
stadiumsByVenue: [String: CanonicalStadium]
) -> String {
var venueLower = venue.lowercased()
// Check for known aliases
if let aliasedName = Self.venueAliases[venueLower] {
venueLower = aliasedName
}
// Try exact match
if let stadium = stadiumsByVenue[venueLower] {
return stadium.canonicalId
}
// Try partial match
for (name, stadium) in stadiumsByVenue {
if name.contains(venueLower) || venueLower.contains(name) {
return stadium.canonicalId
}
}
// Generate deterministic ID for unknown venues
return "venue_unknown_\(venue.lowercased().replacingOccurrences(of: " ", with: "_"))"
} }
nonisolated private func parseISO8601(_ string: String) -> Date? { nonisolated private func parseISO8601(_ string: String) -> Date? {
// Handle ISO 8601 format: "2026-03-01T18:05:00Z"
let formatter = ISO8601DateFormatter() let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime] formatter.formatOptions = [.withInternetDateTime]
return formatter.date(from: string) return formatter.date(from: string)
@@ -727,34 +492,6 @@ actor BootstrapService {
return Calendar.current.date(bySettingHour: hour, minute: minute, second: 0, of: dateOnly) return Calendar.current.date(bySettingHour: hour, minute: minute, second: 0, of: dateOnly)
} }
nonisolated private func extractTeamName(from fullName: String) -> String {
// "Boston Celtics" -> "Celtics"
let parts = fullName.split(separator: " ")
if parts.count > 1 {
return parts.dropFirst().joined(separator: " ")
}
return fullName
}
nonisolated private func extractCity(from fullName: String) -> String {
// "Boston Celtics" -> "Boston"
// "New York Knicks" -> "New York"
let knownCities = [
"New York", "Los Angeles", "San Francisco", "San Diego", "San Antonio",
"New Orleans", "Oklahoma City", "Salt Lake City", "Kansas City",
"St. Louis", "St Louis"
]
for city in knownCities {
if fullName.hasPrefix(city) {
return city
}
}
// Default: first word
return String(fullName.split(separator: " ").first ?? Substring(fullName))
}
nonisolated private func stateFromCity(_ city: String) -> String { nonisolated private func stateFromCity(_ city: String) -> String {
let cityToState: [String: String] = [ let cityToState: [String: String] = [
"Atlanta": "GA", "Boston": "MA", "Brooklyn": "NY", "Charlotte": "NC", "Atlanta": "GA", "Boston": "MA", "Brooklyn": "NY", "Charlotte": "NC",

View File

@@ -644,6 +644,8 @@ actor CanonicalSyncService {
existing.logoURL = remote.logoURL?.absoluteString existing.logoURL = remote.logoURL?.absoluteString
existing.primaryColor = remote.primaryColor existing.primaryColor = remote.primaryColor
existing.secondaryColor = remote.secondaryColor existing.secondaryColor = remote.secondaryColor
existing.conferenceId = remote.conferenceId
existing.divisionId = remote.divisionId
existing.source = .cloudKit existing.source = .cloudKit
existing.lastModified = Date() existing.lastModified = Date()
@@ -667,7 +669,9 @@ actor CanonicalSyncService {
stadiumCanonicalId: stadiumCanonicalId, stadiumCanonicalId: stadiumCanonicalId,
logoURL: remote.logoURL?.absoluteString, logoURL: remote.logoURL?.absoluteString,
primaryColor: remote.primaryColor, primaryColor: remote.primaryColor,
secondaryColor: remote.secondaryColor secondaryColor: remote.secondaryColor,
conferenceId: remote.conferenceId,
divisionId: remote.divisionId
) )
context.insert(canonical) context.insert(canonical)
return .applied return .applied

View File

@@ -598,11 +598,27 @@ actor CloudKitService {
try await publicDatabase.save(subscription) try await publicDatabase.save(subscription)
} }
func subscribeToSportUpdates() async throws {
let subscription = CKQuerySubscription(
recordType: CKRecordType.sport,
predicate: NSPredicate(value: true),
subscriptionID: "sport-updates",
options: [.firesOnRecordCreation, .firesOnRecordUpdate]
)
let notification = CKSubscription.NotificationInfo()
notification.shouldSendContentAvailable = true
subscription.notificationInfo = notification
try await publicDatabase.save(subscription)
}
/// Subscribe to all canonical data updates /// Subscribe to all canonical data updates
func subscribeToAllUpdates() async throws { func subscribeToAllUpdates() async throws {
try await subscribeToScheduleUpdates() try await subscribeToScheduleUpdates()
try await subscribeToLeagueStructureUpdates() try await subscribeToLeagueStructureUpdates()
try await subscribeToTeamAliasUpdates() try await subscribeToTeamAliasUpdates()
try await subscribeToStadiumAliasUpdates() try await subscribeToStadiumAliasUpdates()
try await subscribeToSportUpdates()
} }
} }

View File

@@ -0,0 +1,71 @@
//
// DemoMode.swift
// SportsTime
//
// Environment key and configuration for automated demo mode.
// When enabled, UI elements auto-select as they scroll into view.
//
import SwiftUI
// MARK: - Demo Mode Environment Key
private struct DemoModeKey: EnvironmentKey {
static let defaultValue = false
}
extension EnvironmentValues {
var isDemoMode: Bool {
get { self[DemoModeKey.self] }
set { self[DemoModeKey.self] = newValue }
}
}
// MARK: - Demo Mode Configuration
/// Configuration for the demo flow
enum DemoConfig {
/// Delay before auto-selecting an item (seconds)
static let selectionDelay: Double = 0.5
/// Demo mode date selections
static let demoStartDate: Date = {
var components = DateComponents()
components.year = 2026
components.month = 6
components.day = 11
return Calendar.current.date(from: components) ?? Date()
}()
static let demoEndDate: Date = {
var components = DateComponents()
components.year = 2026
components.month = 6
components.day = 16
return Calendar.current.date(from: components) ?? Date()
}()
/// Demo mode planning mode selection
static let demoPlanningMode: PlanningMode = .dateRange
/// Demo mode sport selection
static let demoSport: Sport = .mlb
/// Demo mode region selection
static let demoRegion: Region = .central
/// Demo mode sort option
static let demoSortOption: TripSortOption = .mostGames
/// Demo mode trip index to select (0-indexed)
static let demoTripIndex: Int = 3
}
// MARK: - Demo Mode Launch Argument
extension ProcessInfo {
/// Check if app was launched in demo mode
static var isDemoMode: Bool {
ProcessInfo.processInfo.arguments.contains("-DemoMode")
}
}

View File

@@ -93,6 +93,7 @@ struct HomeView: View {
.tint(Theme.warmOrange) .tint(Theme.warmOrange)
.sheet(isPresented: $showNewTrip) { .sheet(isPresented: $showNewTrip) {
TripWizardView() TripWizardView()
.environment(\.isDemoMode, ProcessInfo.isDemoMode)
} }
.onChange(of: showNewTrip) { _, isShowing in .onChange(of: showNewTrip) { _, isShowing in
if !isShowing { if !isShowing {

View File

@@ -80,6 +80,7 @@ struct HomeContent_Classic: View {
.foregroundStyle(.white) .foregroundStyle(.white)
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)) .clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium))
} }
.accessibilityIdentifier("home.startPlanningButton")
.pressableStyle() .pressableStyle()
.glowEffect(color: Theme.warmOrange, radius: 12) .glowEffect(color: Theme.warmOrange, radius: 12)
} }

View File

@@ -19,6 +19,8 @@ struct RegionMapSelector: View {
let onToggle: (Region) -> Void let onToggle: (Region) -> Void
@Environment(\.colorScheme) private var colorScheme @Environment(\.colorScheme) private var colorScheme
@Environment(\.isDemoMode) private var isDemoMode
@State private var hasAppliedDemoSelection = false
// Camera position centered on continental US // Camera position centered on continental US
@State private var cameraPosition: MapCameraPosition = .camera( @State private var cameraPosition: MapCameraPosition = .camera(
@@ -33,29 +35,44 @@ struct RegionMapSelector: View {
var body: some View { var body: some View {
VStack(spacing: Theme.Spacing.sm) { VStack(spacing: Theme.Spacing.sm) {
// Map with region overlays // Map with region overlays
MapReader { proxy in ZStack {
Map(position: $cameraPosition, interactionModes: []) { MapReader { proxy in
// West region polygon Map(position: $cameraPosition, interactionModes: []) {
MapPolygon(coordinates: RegionCoordinates.west) // West region polygon
.foregroundStyle(fillColor(for: .west)) MapPolygon(coordinates: RegionCoordinates.west)
.stroke(strokeColor(for: .west), lineWidth: strokeWidth(for: .west)) .foregroundStyle(fillColor(for: .west))
.stroke(strokeColor(for: .west), lineWidth: strokeWidth(for: .west))
// Central region polygon // Central region polygon
MapPolygon(coordinates: RegionCoordinates.central) MapPolygon(coordinates: RegionCoordinates.central)
.foregroundStyle(fillColor(for: .central)) .foregroundStyle(fillColor(for: .central))
.stroke(strokeColor(for: .central), lineWidth: strokeWidth(for: .central)) .stroke(strokeColor(for: .central), lineWidth: strokeWidth(for: .central))
// East region polygon // East region polygon
MapPolygon(coordinates: RegionCoordinates.east) MapPolygon(coordinates: RegionCoordinates.east)
.foregroundStyle(fillColor(for: .east)) .foregroundStyle(fillColor(for: .east))
.stroke(strokeColor(for: .east), lineWidth: strokeWidth(for: .east)) .stroke(strokeColor(for: .east), lineWidth: strokeWidth(for: .east))
}
.mapStyle(.standard(elevation: .flat, pointsOfInterest: .excludingAll))
.onTapGesture { location in
if let coordinate = proxy.convert(location, from: .local) {
let tappedRegion = regionForCoordinate(coordinate)
onToggle(tappedRegion)
} }
.mapStyle(.standard(elevation: .flat, pointsOfInterest: .excludingAll))
.onTapGesture { location in
if let coordinate = proxy.convert(location, from: .local) {
let tappedRegion = regionForCoordinate(coordinate)
onToggle(tappedRegion)
}
}
}
// Invisible button overlays for UI testing accessibility
HStack(spacing: 0) {
Button { onToggle(.west) } label: { Color.clear }
.accessibilityIdentifier("wizard.regions.west")
.frame(maxWidth: .infinity)
Button { onToggle(.central) } label: { Color.clear }
.accessibilityIdentifier("wizard.regions.central")
.frame(maxWidth: .infinity)
Button { onToggle(.east) } label: { Color.clear }
.accessibilityIdentifier("wizard.regions.east")
.frame(maxWidth: .infinity)
} }
} }
.frame(height: 160) .frame(height: 160)
@@ -71,6 +88,14 @@ struct RegionMapSelector: View {
// Selection footer // Selection footer
selectionFooter selectionFooter
} }
.onAppear {
if isDemoMode && !hasAppliedDemoSelection && selectedRegions.isEmpty {
hasAppliedDemoSelection = true
DispatchQueue.main.asyncAfter(deadline: .now() + DemoConfig.selectionDelay) {
onToggle(DemoConfig.demoRegion)
}
}
}
} }
// MARK: - Coordinate to Region // MARK: - Coordinate to Region

View File

@@ -12,6 +12,7 @@ import UniformTypeIdentifiers
struct TripDetailView: View { struct TripDetailView: View {
@Environment(\.modelContext) private var modelContext @Environment(\.modelContext) private var modelContext
@Environment(\.colorScheme) private var colorScheme @Environment(\.colorScheme) private var colorScheme
@Environment(\.isDemoMode) private var isDemoMode
let trip: Trip let trip: Trip
private let providedGames: [String: RichGame]? private let providedGames: [String: RichGame]?
@@ -33,6 +34,7 @@ struct TripDetailView: View {
@State private var isLoadingRoutes = false @State private var isLoadingRoutes = false
@State private var loadedGames: [String: RichGame] = [:] @State private var loadedGames: [String: RichGame] = [:]
@State private var isLoadingGames = false @State private var isLoadingGames = false
@State private var hasAppliedDemoSelection = false
// Itinerary items state // Itinerary items state
@State private var itineraryItems: [ItineraryItem] = [] @State private var itineraryItems: [ItineraryItem] = []
@@ -113,7 +115,18 @@ struct TripDetailView: View {
} message: { } message: {
Text("This trip has \(multiRouteChunks.flatMap { $0 }.count) stops, which exceeds Apple Maps' limit of 16. Open the route in parts?") Text("This trip has \(multiRouteChunks.flatMap { $0 }.count) stops, which exceeds Apple Maps' limit of 16. Open the route in parts?")
} }
.onAppear { checkIfSaved() } .onAppear {
checkIfSaved()
// Demo mode: auto-favorite the trip
if isDemoMode && !hasAppliedDemoSelection && !isSaved {
hasAppliedDemoSelection = true
DispatchQueue.main.asyncAfter(deadline: .now() + DemoConfig.selectionDelay + 0.5) {
if !isSaved {
saveTrip()
}
}
}
}
.task { .task {
await loadGamesIfNeeded() await loadGamesIfNeeded()
if allowCustomItems { if allowCustomItems {
@@ -348,6 +361,7 @@ struct TripDetailView: View {
.clipShape(Circle()) .clipShape(Circle())
.shadow(color: .black.opacity(0.2), radius: 4, y: 2) .shadow(color: .black.opacity(0.2), radius: 4, y: 2)
} }
.accessibilityIdentifier("tripDetail.favoriteButton")
.padding(.top, 12) .padding(.top, 12)
.padding(.trailing, 12) .padding(.trailing, 12)
} }

View File

@@ -143,6 +143,8 @@ struct TripOptionsView: View {
@State private var citiesFilter: CitiesFilter = .noLimit @State private var citiesFilter: CitiesFilter = .noLimit
@State private var paceFilter: TripPaceFilter = .all @State private var paceFilter: TripPaceFilter = .all
@Environment(\.colorScheme) private var colorScheme @Environment(\.colorScheme) private var colorScheme
@Environment(\.isDemoMode) private var isDemoMode
@State private var hasAppliedDemoSelection = false
// MARK: - Computed Properties // MARK: - Computed Properties
@@ -272,7 +274,7 @@ struct TripOptionsView: View {
} }
// Options in this group // Options in this group
ForEach(group.options) { option in ForEach(Array(group.options.enumerated()), id: \.element.id) { index, option in
TripOptionCard( TripOptionCard(
option: option, option: option,
games: games, games: games,
@@ -281,6 +283,7 @@ struct TripOptionsView: View {
showTripDetail = true showTripDetail = true
} }
) )
.accessibilityIdentifier("tripOptions.trip.\(index)")
.padding(.horizontal, Theme.Spacing.md) .padding(.horizontal, Theme.Spacing.md)
} }
} }
@@ -300,6 +303,26 @@ struct TripOptionsView: View {
selectedTrip = nil selectedTrip = nil
} }
} }
.onAppear {
if isDemoMode && !hasAppliedDemoSelection {
hasAppliedDemoSelection = true
// Auto-select "Most Games" sort after a delay
DispatchQueue.main.asyncAfter(deadline: .now() + DemoConfig.selectionDelay) {
withAnimation(.easeInOut(duration: 0.3)) {
sortOption = DemoConfig.demoSortOption
}
}
// Then navigate to the 4th trip (index 3)
DispatchQueue.main.asyncAfter(deadline: .now() + DemoConfig.selectionDelay + 0.8) {
let sortedOptions = filteredAndSortedOptions
if sortedOptions.count > DemoConfig.demoTripIndex {
let option = sortedOptions[DemoConfig.demoTripIndex]
selectedTrip = convertToTrip(option)
showTripDetail = true
}
}
}
}
} }
private var sortPicker: some View { private var sortPicker: some View {
@@ -312,6 +335,7 @@ struct TripOptionsView: View {
} label: { } label: {
Label(option.rawValue, systemImage: option.icon) Label(option.rawValue, systemImage: option.icon)
} }
.accessibilityIdentifier("tripOptions.sortOption.\(option.rawValue.lowercased().replacingOccurrences(of: " ", with: ""))")
} }
} label: { } label: {
HStack(spacing: 8) { HStack(spacing: 8) {
@@ -332,6 +356,7 @@ struct TripOptionsView: View {
.strokeBorder(Theme.textMuted(colorScheme).opacity(0.2), lineWidth: 1) .strokeBorder(Theme.textMuted(colorScheme).opacity(0.2), lineWidth: 1)
) )
} }
.accessibilityIdentifier("tripOptions.sortDropdown")
} }
// MARK: - Filters Section // MARK: - Filters Section

View File

@@ -11,9 +11,11 @@ struct DateRangePicker: View {
@Binding var startDate: Date @Binding var startDate: Date
@Binding var endDate: Date @Binding var endDate: Date
@Environment(\.colorScheme) private var colorScheme @Environment(\.colorScheme) private var colorScheme
@Environment(\.isDemoMode) private var isDemoMode
@State private var displayedMonth: Date = Date() @State private var displayedMonth: Date = Date()
@State private var selectionState: SelectionState = .none @State private var selectionState: SelectionState = .none
@State private var hasAppliedDemoSelection = false
enum SelectionState { enum SelectionState {
case none case none
@@ -89,6 +91,24 @@ struct DateRangePicker: View {
if endDate > startDate { if endDate > startDate {
selectionState = .complete selectionState = .complete
} }
// Demo mode: auto-select dates
if isDemoMode && !hasAppliedDemoSelection {
hasAppliedDemoSelection = true
DispatchQueue.main.asyncAfter(deadline: .now() + DemoConfig.selectionDelay) {
withAnimation(.easeInOut(duration: 0.3)) {
// Navigate to demo month
displayedMonth = DemoConfig.demoStartDate
}
}
DispatchQueue.main.asyncAfter(deadline: .now() + DemoConfig.selectionDelay + 0.5) {
withAnimation(.easeInOut(duration: 0.3)) {
startDate = DemoConfig.demoStartDate
endDate = DemoConfig.demoEndDate
selectionState = .complete
}
}
}
} }
.onChange(of: startDate) { oldValue, newValue in .onChange(of: startDate) { oldValue, newValue in
// Navigate calendar to show the new month when startDate changes externally // Navigate calendar to show the new month when startDate changes externally
@@ -159,12 +179,14 @@ struct DateRangePicker: View {
.background(Theme.warmOrange.opacity(0.15)) .background(Theme.warmOrange.opacity(0.15))
.clipShape(Circle()) .clipShape(Circle())
} }
.accessibilityIdentifier("wizard.dates.previousMonth")
Spacer() Spacer()
Text(monthYearString) Text(monthYearString)
.font(.headline) .font(.headline)
.foregroundStyle(Theme.textPrimary(colorScheme)) .foregroundStyle(Theme.textPrimary(colorScheme))
.accessibilityIdentifier("wizard.dates.monthLabel")
Spacer() Spacer()
@@ -180,6 +202,7 @@ struct DateRangePicker: View {
.background(Theme.warmOrange.opacity(0.15)) .background(Theme.warmOrange.opacity(0.15))
.clipShape(Circle()) .clipShape(Circle())
} }
.accessibilityIdentifier("wizard.dates.nextMonth")
} }
} }
@@ -287,6 +310,12 @@ struct DayCell: View {
calendar.startOfDay(for: date) < calendar.startOfDay(for: Date()) calendar.startOfDay(for: date) < calendar.startOfDay(for: Date())
} }
private var accessibilityId: String {
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd"
return "wizard.dates.day.\(formatter.string(from: date))"
}
var body: some View { var body: some View {
Button(action: onTap) { Button(action: onTap) {
ZStack { ZStack {
@@ -330,6 +359,7 @@ struct DayCell: View {
.frame(width: 36, height: 36) .frame(width: 36, height: 36)
} }
} }
.accessibilityIdentifier(accessibilityId)
.buttonStyle(.plain) .buttonStyle(.plain)
.disabled(isPast) .disabled(isPast)
.frame(height: 40) .frame(height: 40)

View File

@@ -9,6 +9,7 @@ import SwiftUI
struct PlanningModeStep: View { struct PlanningModeStep: View {
@Environment(\.colorScheme) private var colorScheme @Environment(\.colorScheme) private var colorScheme
@Environment(\.isDemoMode) private var isDemoMode
@Binding var selection: PlanningMode? @Binding var selection: PlanningMode?
var body: some View { var body: some View {
@@ -35,6 +36,15 @@ struct PlanningModeStep: View {
RoundedRectangle(cornerRadius: Theme.CornerRadius.large) RoundedRectangle(cornerRadius: Theme.CornerRadius.large)
.stroke(Theme.surfaceGlow(colorScheme), lineWidth: 1) .stroke(Theme.surfaceGlow(colorScheme), lineWidth: 1)
} }
.onAppear {
if isDemoMode && selection == nil {
DispatchQueue.main.asyncAfter(deadline: .now() + DemoConfig.selectionDelay) {
withAnimation(.easeInOut(duration: 0.3)) {
selection = DemoConfig.demoPlanningMode
}
}
}
}
} }
} }
@@ -80,6 +90,7 @@ private struct WizardModeCard: View {
.stroke(isSelected ? Theme.warmOrange : Theme.textMuted(colorScheme).opacity(0.3), lineWidth: isSelected ? 2 : 1) .stroke(isSelected ? Theme.warmOrange : Theme.textMuted(colorScheme).opacity(0.3), lineWidth: isSelected ? 2 : 1)
) )
} }
.accessibilityIdentifier("wizard.planningMode.\(mode.rawValue)")
.buttonStyle(.plain) .buttonStyle(.plain)
} }
} }

View File

@@ -84,6 +84,7 @@ struct ReviewStep: View {
.foregroundStyle(.white) .foregroundStyle(.white)
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.large)) .clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.large))
} }
.accessibilityIdentifier("wizard.planTripButton")
.disabled(!canPlanTrip || isPlanning) .disabled(!canPlanTrip || isPlanning)
} }
.padding(Theme.Spacing.lg) .padding(Theme.Spacing.lg)

View File

@@ -9,10 +9,12 @@ import SwiftUI
struct SportsStep: View { struct SportsStep: View {
@Environment(\.colorScheme) private var colorScheme @Environment(\.colorScheme) private var colorScheme
@Environment(\.isDemoMode) private var isDemoMode
@Binding var selectedSports: Set<Sport> @Binding var selectedSports: Set<Sport>
let sportAvailability: [Sport: Bool] let sportAvailability: [Sport: Bool]
let isLoading: Bool let isLoading: Bool
let canSelectSport: (Sport) -> Bool let canSelectSport: (Sport) -> Bool
@State private var hasAppliedDemoSelection = false
private let columns = [ private let columns = [
GridItem(.flexible()), GridItem(.flexible()),
@@ -54,6 +56,16 @@ struct SportsStep: View {
RoundedRectangle(cornerRadius: Theme.CornerRadius.large) RoundedRectangle(cornerRadius: Theme.CornerRadius.large)
.stroke(Theme.surfaceGlow(colorScheme), lineWidth: 1) .stroke(Theme.surfaceGlow(colorScheme), lineWidth: 1)
} }
.onAppear {
if isDemoMode && !hasAppliedDemoSelection && selectedSports.isEmpty {
hasAppliedDemoSelection = true
DispatchQueue.main.asyncAfter(deadline: .now() + DemoConfig.selectionDelay) {
withAnimation(.easeInOut(duration: 0.3)) {
_ = selectedSports.insert(DemoConfig.demoSport)
}
}
}
}
} }
private func toggleSport(_ sport: Sport) { private func toggleSport(_ sport: Sport) {
@@ -100,6 +112,7 @@ private struct SportCard: View {
.stroke(borderColor, lineWidth: isSelected ? 2 : 1) .stroke(borderColor, lineWidth: isSelected ? 2 : 1)
) )
} }
.accessibilityIdentifier("wizard.sports.\(sport.rawValue.lowercased())")
.buttonStyle(.plain) .buttonStyle(.plain)
.opacity(isAvailable ? 1.0 : 0.5) .opacity(isAvailable ? 1.0 : 0.5)
.disabled(!isAvailable) .disabled(!isAvailable)

View File

@@ -27,7 +27,8 @@ struct TripWizardView: View {
var body: some View { var body: some View {
NavigationStack { NavigationStack {
ScrollView { GeometryReader { geometry in
ScrollView(.vertical) {
VStack(spacing: Theme.Spacing.lg) { VStack(spacing: Theme.Spacing.lg) {
// Step 1: Planning Mode (always visible) // Step 1: Planning Mode (always visible)
PlanningModeStep(selection: $viewModel.planningMode) PlanningModeStep(selection: $viewModel.planningMode)
@@ -131,7 +132,9 @@ struct TripWizardView: View {
} }
} }
.padding(Theme.Spacing.md) .padding(Theme.Spacing.md)
.frame(width: geometry.size.width)
.animation(.easeInOut(duration: 0.2), value: viewModel.areStepsVisible) .animation(.easeInOut(duration: 0.2), value: viewModel.areStepsVisible)
}
} }
.themedBackground() .themedBackground()
.navigationTitle("Plan a Trip") .navigationTitle("Plan a Trip")

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -17,6 +17,33 @@
"parent_id": "mlb_league", "parent_id": "mlb_league",
"display_order": 1 "display_order": 1
}, },
{
"id": "mlb_al_east",
"sport": "MLB",
"type": "division",
"name": "AL East",
"abbreviation": null,
"parent_id": "mlb_al",
"display_order": 3
},
{
"id": "mlb_al_central",
"sport": "MLB",
"type": "division",
"name": "AL Central",
"abbreviation": null,
"parent_id": "mlb_al",
"display_order": 4
},
{
"id": "mlb_al_west",
"sport": "MLB",
"type": "division",
"name": "AL West",
"abbreviation": null,
"parent_id": "mlb_al",
"display_order": 5
},
{ {
"id": "mlb_nl", "id": "mlb_nl",
"sport": "MLB", "sport": "MLB",
@@ -26,33 +53,6 @@
"parent_id": "mlb_league", "parent_id": "mlb_league",
"display_order": 2 "display_order": 2
}, },
{
"id": "mlb_al_east",
"sport": "MLB",
"type": "division",
"name": "AL East",
"abbreviation": null,
"parent_id": "mlb_al",
"display_order": 1
},
{
"id": "mlb_al_central",
"sport": "MLB",
"type": "division",
"name": "AL Central",
"abbreviation": null,
"parent_id": "mlb_al",
"display_order": 2
},
{
"id": "mlb_al_west",
"sport": "MLB",
"type": "division",
"name": "AL West",
"abbreviation": null,
"parent_id": "mlb_al",
"display_order": 3
},
{ {
"id": "mlb_nl_east", "id": "mlb_nl_east",
"sport": "MLB", "sport": "MLB",
@@ -60,7 +60,7 @@
"name": "NL East", "name": "NL East",
"abbreviation": null, "abbreviation": null,
"parent_id": "mlb_nl", "parent_id": "mlb_nl",
"display_order": 1 "display_order": 6
}, },
{ {
"id": "mlb_nl_central", "id": "mlb_nl_central",
@@ -69,7 +69,7 @@
"name": "NL Central", "name": "NL Central",
"abbreviation": null, "abbreviation": null,
"parent_id": "mlb_nl", "parent_id": "mlb_nl",
"display_order": 2 "display_order": 7
}, },
{ {
"id": "mlb_nl_west", "id": "mlb_nl_west",
@@ -78,7 +78,43 @@
"name": "NL West", "name": "NL West",
"abbreviation": null, "abbreviation": null,
"parent_id": "mlb_nl", "parent_id": "mlb_nl",
"display_order": 3 "display_order": 8
},
{
"id": "mls_league",
"sport": "MLS",
"type": "league",
"name": "Major League Soccer",
"abbreviation": "MLS",
"parent_id": null,
"display_order": 1
},
{
"id": "mls_eastern",
"sport": "MLS",
"type": "conference",
"name": "Eastern Conference",
"abbreviation": "East",
"parent_id": "mls_league",
"display_order": 38
},
{
"id": "",
"sport": "MLS",
"type": "division",
"name": "Eastern",
"abbreviation": "East",
"parent_id": "mls_eastern",
"display_order": 0
},
{
"id": "mls_western",
"sport": "MLS",
"type": "conference",
"name": "Western Conference",
"abbreviation": "West",
"parent_id": "mls_league",
"display_order": 39
}, },
{ {
"id": "nba_league", "id": "nba_league",
@@ -87,7 +123,7 @@
"name": "National Basketball Association", "name": "National Basketball Association",
"abbreviation": "NBA", "abbreviation": "NBA",
"parent_id": null, "parent_id": null,
"display_order": 0 "display_order": 2
}, },
{ {
"id": "nba_eastern", "id": "nba_eastern",
@@ -96,16 +132,7 @@
"name": "Eastern Conference", "name": "Eastern Conference",
"abbreviation": "East", "abbreviation": "East",
"parent_id": "nba_league", "parent_id": "nba_league",
"display_order": 1 "display_order": 10
},
{
"id": "nba_western",
"sport": "NBA",
"type": "conference",
"name": "Western Conference",
"abbreviation": "West",
"parent_id": "nba_league",
"display_order": 2
}, },
{ {
"id": "nba_atlantic", "id": "nba_atlantic",
@@ -114,7 +141,7 @@
"name": "Atlantic", "name": "Atlantic",
"abbreviation": null, "abbreviation": null,
"parent_id": "nba_eastern", "parent_id": "nba_eastern",
"display_order": 1 "display_order": 12
}, },
{ {
"id": "nba_central", "id": "nba_central",
@@ -123,7 +150,7 @@
"name": "Central", "name": "Central",
"abbreviation": null, "abbreviation": null,
"parent_id": "nba_eastern", "parent_id": "nba_eastern",
"display_order": 2 "display_order": 13
}, },
{ {
"id": "nba_southeast", "id": "nba_southeast",
@@ -132,7 +159,16 @@
"name": "Southeast", "name": "Southeast",
"abbreviation": null, "abbreviation": null,
"parent_id": "nba_eastern", "parent_id": "nba_eastern",
"display_order": 3 "display_order": 14
},
{
"id": "nba_western",
"sport": "NBA",
"type": "conference",
"name": "Western Conference",
"abbreviation": "West",
"parent_id": "nba_league",
"display_order": 11
}, },
{ {
"id": "nba_northwest", "id": "nba_northwest",
@@ -141,7 +177,7 @@
"name": "Northwest", "name": "Northwest",
"abbreviation": null, "abbreviation": null,
"parent_id": "nba_western", "parent_id": "nba_western",
"display_order": 1 "display_order": 15
}, },
{ {
"id": "nba_pacific", "id": "nba_pacific",
@@ -150,7 +186,7 @@
"name": "Pacific", "name": "Pacific",
"abbreviation": null, "abbreviation": null,
"parent_id": "nba_western", "parent_id": "nba_western",
"display_order": 2 "display_order": 16
}, },
{ {
"id": "nba_southwest", "id": "nba_southwest",
@@ -159,8 +195,107 @@
"name": "Southwest", "name": "Southwest",
"abbreviation": null, "abbreviation": null,
"parent_id": "nba_western", "parent_id": "nba_western",
"display_order": 17
},
{
"id": "nfl_league",
"sport": "NFL",
"type": "league",
"name": "National Football League",
"abbreviation": "NFL",
"parent_id": null,
"display_order": 3 "display_order": 3
}, },
{
"id": "nfl_afc",
"sport": "NFL",
"type": "conference",
"name": "American Football Conference",
"abbreviation": "AFC",
"parent_id": "nfl_league",
"display_order": 19
},
{
"id": "nfl_afc_east",
"sport": "NFL",
"type": "division",
"name": "AFC East",
"abbreviation": null,
"parent_id": "nfl_afc",
"display_order": 21
},
{
"id": "nfl_afc_north",
"sport": "NFL",
"type": "division",
"name": "AFC North",
"abbreviation": null,
"parent_id": "nfl_afc",
"display_order": 22
},
{
"id": "nfl_afc_south",
"sport": "NFL",
"type": "division",
"name": "AFC South",
"abbreviation": null,
"parent_id": "nfl_afc",
"display_order": 23
},
{
"id": "nfl_afc_west",
"sport": "NFL",
"type": "division",
"name": "AFC West",
"abbreviation": null,
"parent_id": "nfl_afc",
"display_order": 24
},
{
"id": "nfl_nfc",
"sport": "NFL",
"type": "conference",
"name": "National Football Conference",
"abbreviation": "NFC",
"parent_id": "nfl_league",
"display_order": 20
},
{
"id": "nfl_nfc_east",
"sport": "NFL",
"type": "division",
"name": "NFC East",
"abbreviation": null,
"parent_id": "nfl_nfc",
"display_order": 25
},
{
"id": "nfl_nfc_north",
"sport": "NFL",
"type": "division",
"name": "NFC North",
"abbreviation": null,
"parent_id": "nfl_nfc",
"display_order": 26
},
{
"id": "nfl_nfc_south",
"sport": "NFL",
"type": "division",
"name": "NFC South",
"abbreviation": null,
"parent_id": "nfl_nfc",
"display_order": 27
},
{
"id": "nfl_nfc_west",
"sport": "NFL",
"type": "division",
"name": "NFC West",
"abbreviation": null,
"parent_id": "nfl_nfc",
"display_order": 28
},
{ {
"id": "nhl_league", "id": "nhl_league",
"sport": "NHL", "sport": "NHL",
@@ -168,7 +303,7 @@
"name": "National Hockey League", "name": "National Hockey League",
"abbreviation": "NHL", "abbreviation": "NHL",
"parent_id": null, "parent_id": null,
"display_order": 0 "display_order": 4
}, },
{ {
"id": "nhl_eastern", "id": "nhl_eastern",
@@ -177,16 +312,7 @@
"name": "Eastern Conference", "name": "Eastern Conference",
"abbreviation": "East", "abbreviation": "East",
"parent_id": "nhl_league", "parent_id": "nhl_league",
"display_order": 1 "display_order": 30
},
{
"id": "nhl_western",
"sport": "NHL",
"type": "conference",
"name": "Western Conference",
"abbreviation": "West",
"parent_id": "nhl_league",
"display_order": 2
}, },
{ {
"id": "nhl_atlantic", "id": "nhl_atlantic",
@@ -195,7 +321,7 @@
"name": "Atlantic", "name": "Atlantic",
"abbreviation": null, "abbreviation": null,
"parent_id": "nhl_eastern", "parent_id": "nhl_eastern",
"display_order": 1 "display_order": 32
}, },
{ {
"id": "nhl_metropolitan", "id": "nhl_metropolitan",
@@ -204,7 +330,16 @@
"name": "Metropolitan", "name": "Metropolitan",
"abbreviation": null, "abbreviation": null,
"parent_id": "nhl_eastern", "parent_id": "nhl_eastern",
"display_order": 2 "display_order": 33
},
{
"id": "nhl_western",
"sport": "NHL",
"type": "conference",
"name": "Western Conference",
"abbreviation": "West",
"parent_id": "nhl_league",
"display_order": 31
}, },
{ {
"id": "nhl_central", "id": "nhl_central",
@@ -213,7 +348,7 @@
"name": "Central", "name": "Central",
"abbreviation": null, "abbreviation": null,
"parent_id": "nhl_western", "parent_id": "nhl_western",
"display_order": 1 "display_order": 34
}, },
{ {
"id": "nhl_pacific", "id": "nhl_pacific",
@@ -222,6 +357,24 @@
"name": "Pacific", "name": "Pacific",
"abbreviation": null, "abbreviation": null,
"parent_id": "nhl_western", "parent_id": "nhl_western",
"display_order": 2 "display_order": 35
},
{
"id": "nwsl_league",
"sport": "NWSL",
"type": "league",
"name": "National Women's Soccer League",
"abbreviation": "NWSL",
"parent_id": null,
"display_order": 5
},
{
"id": "wnba_league",
"sport": "WNBA",
"type": "league",
"name": "Women's National Basketball Association",
"abbreviation": "WNBA",
"parent_id": null,
"display_order": 6
} }
] ]

View File

@@ -0,0 +1,72 @@
[
{
"sport_id": "MLB",
"abbreviation": "MLB",
"display_name": "Major League Baseball",
"icon_name": "baseball.fill",
"color_hex": "#FF0000",
"season_start_month": 3,
"season_end_month": 10,
"is_active": true
},
{
"sport_id": "MLS",
"abbreviation": "MLS",
"display_name": "Major League Soccer",
"icon_name": "soccerball",
"color_hex": "#34C759",
"season_start_month": 2,
"season_end_month": 12,
"is_active": true
},
{
"sport_id": "NBA",
"abbreviation": "NBA",
"display_name": "National Basketball Association",
"icon_name": "basketball.fill",
"color_hex": "#FF8C00",
"season_start_month": 10,
"season_end_month": 6,
"is_active": true
},
{
"sport_id": "NFL",
"abbreviation": "NFL",
"display_name": "National Football League",
"icon_name": "football.fill",
"color_hex": "#8B4513",
"season_start_month": 9,
"season_end_month": 2,
"is_active": true
},
{
"sport_id": "NHL",
"abbreviation": "NHL",
"display_name": "National Hockey League",
"icon_name": "hockey.puck.fill",
"color_hex": "#007AFF",
"season_start_month": 10,
"season_end_month": 6,
"is_active": true
},
{
"sport_id": "NWSL",
"abbreviation": "NWSL",
"display_name": "National Women's Soccer League",
"icon_name": "soccerball",
"color_hex": "#5AC8FA",
"season_start_month": 3,
"season_end_month": 11,
"is_active": true
},
{
"sport_id": "WNBA",
"abbreviation": "WNBA",
"display_name": "Women's National Basketball Association",
"icon_name": "basketball.fill",
"color_hex": "#AF52DE",
"season_start_month": 5,
"season_end_month": 10,
"is_active": true
}
]

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,27 +1,83 @@
[ [
{ {
"id": "alias_mlb_1", "id": "alias_mlb_10",
"team_canonical_id": "team_mlb_wsn", "team_canonical_id": "team_mlb_cle",
"alias_type": "name", "alias_type": "name",
"alias_value": "Montreal Expos", "alias_value": "Cleveland Indians",
"valid_from": "1969-01-01", "valid_from": "1915-01-01",
"valid_until": "2021-12-31"
},
{
"id": "alias_mlb_23",
"team_canonical_id": "team_mlb_hou",
"alias_type": "name",
"alias_value": "Houston Colt .45s",
"valid_from": "1962-01-01",
"valid_until": "1964-12-31"
},
{
"id": "alias_mlb_14",
"team_canonical_id": "team_mlb_laa",
"alias_type": "name",
"alias_value": "Anaheim Angels",
"valid_from": "1997-01-01",
"valid_until": "2004-12-31" "valid_until": "2004-12-31"
}, },
{ {
"id": "alias_mlb_2", "id": "alias_mlb_15",
"team_canonical_id": "team_mlb_wsn", "team_canonical_id": "team_mlb_laa",
"alias_type": "abbreviation", "alias_type": "name",
"alias_value": "MON", "alias_value": "Los Angeles Angels of Anaheim",
"valid_from": "1969-01-01", "valid_from": "2005-01-01",
"valid_until": "2004-12-31" "valid_until": "2015-12-31"
}, },
{ {
"id": "alias_mlb_3", "id": "alias_mlb_16",
"team_canonical_id": "team_mlb_wsn", "team_canonical_id": "team_mlb_laa",
"alias_type": "name",
"alias_value": "California Angels",
"valid_from": "1965-01-01",
"valid_until": "1996-12-31"
},
{
"id": "alias_mlb_12",
"team_canonical_id": "team_mlb_mia",
"alias_type": "name",
"alias_value": "Florida Marlins",
"valid_from": "1993-01-01",
"valid_until": "2011-12-31"
},
{
"id": "alias_mlb_13",
"team_canonical_id": "team_mlb_mia",
"alias_type": "city", "alias_type": "city",
"alias_value": "Montreal", "alias_value": "Florida",
"valid_from": "1993-01-01",
"valid_until": "2011-12-31"
},
{
"id": "alias_mlb_20",
"team_canonical_id": "team_mlb_mil",
"alias_type": "name",
"alias_value": "Seattle Pilots",
"valid_from": "1969-01-01", "valid_from": "1969-01-01",
"valid_until": "2004-12-31" "valid_until": "1969-12-31"
},
{
"id": "alias_mlb_21",
"team_canonical_id": "team_mlb_mil",
"alias_type": "abbreviation",
"alias_value": "SEP",
"valid_from": "1969-01-01",
"valid_until": "1969-12-31"
},
{
"id": "alias_mlb_22",
"team_canonical_id": "team_mlb_mil",
"alias_type": "city",
"alias_value": "Seattle",
"valid_from": "1969-01-01",
"valid_until": "1969-12-31"
}, },
{ {
"id": "alias_mlb_4", "id": "alias_mlb_4",
@@ -71,14 +127,6 @@
"valid_from": "1901-01-01", "valid_from": "1901-01-01",
"valid_until": "1954-12-31" "valid_until": "1954-12-31"
}, },
{
"id": "alias_mlb_10",
"team_canonical_id": "team_mlb_cle",
"alias_type": "name",
"alias_value": "Cleveland Indians",
"valid_from": "1915-01-01",
"valid_until": "2021-12-31"
},
{ {
"id": "alias_mlb_11", "id": "alias_mlb_11",
"team_canonical_id": "team_mlb_tbr", "team_canonical_id": "team_mlb_tbr",
@@ -87,46 +135,6 @@
"valid_from": "1998-01-01", "valid_from": "1998-01-01",
"valid_until": "2007-12-31" "valid_until": "2007-12-31"
}, },
{
"id": "alias_mlb_12",
"team_canonical_id": "team_mlb_mia",
"alias_type": "name",
"alias_value": "Florida Marlins",
"valid_from": "1993-01-01",
"valid_until": "2011-12-31"
},
{
"id": "alias_mlb_13",
"team_canonical_id": "team_mlb_mia",
"alias_type": "city",
"alias_value": "Florida",
"valid_from": "1993-01-01",
"valid_until": "2011-12-31"
},
{
"id": "alias_mlb_14",
"team_canonical_id": "team_mlb_laa",
"alias_type": "name",
"alias_value": "Anaheim Angels",
"valid_from": "1997-01-01",
"valid_until": "2004-12-31"
},
{
"id": "alias_mlb_15",
"team_canonical_id": "team_mlb_laa",
"alias_type": "name",
"alias_value": "Los Angeles Angels of Anaheim",
"valid_from": "2005-01-01",
"valid_until": "2015-12-31"
},
{
"id": "alias_mlb_16",
"team_canonical_id": "team_mlb_laa",
"alias_type": "name",
"alias_value": "California Angels",
"valid_from": "1965-01-01",
"valid_until": "1996-12-31"
},
{ {
"id": "alias_mlb_17", "id": "alias_mlb_17",
"team_canonical_id": "team_mlb_tex", "team_canonical_id": "team_mlb_tex",
@@ -152,36 +160,28 @@
"valid_until": "1971-12-31" "valid_until": "1971-12-31"
}, },
{ {
"id": "alias_mlb_20", "id": "alias_mlb_1",
"team_canonical_id": "team_mlb_mil", "team_canonical_id": "team_mlb_wsn",
"alias_type": "name", "alias_type": "name",
"alias_value": "Seattle Pilots", "alias_value": "Montreal Expos",
"valid_from": "1969-01-01", "valid_from": "1969-01-01",
"valid_until": "1969-12-31" "valid_until": "2004-12-31"
}, },
{ {
"id": "alias_mlb_21", "id": "alias_mlb_2",
"team_canonical_id": "team_mlb_mil", "team_canonical_id": "team_mlb_wsn",
"alias_type": "abbreviation", "alias_type": "abbreviation",
"alias_value": "SEP", "alias_value": "MON",
"valid_from": "1969-01-01", "valid_from": "1969-01-01",
"valid_until": "1969-12-31" "valid_until": "2004-12-31"
}, },
{ {
"id": "alias_mlb_22", "id": "alias_mlb_3",
"team_canonical_id": "team_mlb_mil", "team_canonical_id": "team_mlb_wsn",
"alias_type": "city", "alias_type": "city",
"alias_value": "Seattle", "alias_value": "Montreal",
"valid_from": "1969-01-01", "valid_from": "1969-01-01",
"valid_until": "1969-12-31" "valid_until": "2004-12-31"
},
{
"id": "alias_mlb_23",
"team_canonical_id": "team_mlb_hou",
"alias_type": "name",
"alias_value": "Houston Colt .45s",
"valid_from": "1962-01-01",
"valid_until": "1964-12-31"
}, },
{ {
"id": "alias_nba_24", "id": "alias_nba_24",
@@ -215,78 +215,6 @@
"valid_from": "1968-01-01", "valid_from": "1968-01-01",
"valid_until": "1977-12-31" "valid_until": "1977-12-31"
}, },
{
"id": "alias_nba_28",
"team_canonical_id": "team_nba_okc",
"alias_type": "name",
"alias_value": "Seattle SuperSonics",
"valid_from": "1967-01-01",
"valid_until": "2008-07-01"
},
{
"id": "alias_nba_29",
"team_canonical_id": "team_nba_okc",
"alias_type": "abbreviation",
"alias_value": "SEA",
"valid_from": "1967-01-01",
"valid_until": "2008-07-01"
},
{
"id": "alias_nba_30",
"team_canonical_id": "team_nba_okc",
"alias_type": "city",
"alias_value": "Seattle",
"valid_from": "1967-01-01",
"valid_until": "2008-07-01"
},
{
"id": "alias_nba_31",
"team_canonical_id": "team_nba_mem",
"alias_type": "name",
"alias_value": "Vancouver Grizzlies",
"valid_from": "1995-01-01",
"valid_until": "2001-05-31"
},
{
"id": "alias_nba_32",
"team_canonical_id": "team_nba_mem",
"alias_type": "abbreviation",
"alias_value": "VAN",
"valid_from": "1995-01-01",
"valid_until": "2001-05-31"
},
{
"id": "alias_nba_33",
"team_canonical_id": "team_nba_mem",
"alias_type": "city",
"alias_value": "Vancouver",
"valid_from": "1995-01-01",
"valid_until": "2001-05-31"
},
{
"id": "alias_nba_34",
"team_canonical_id": "team_nba_nop",
"alias_type": "name",
"alias_value": "New Orleans Hornets",
"valid_from": "2002-01-01",
"valid_until": "2013-04-30"
},
{
"id": "alias_nba_35",
"team_canonical_id": "team_nba_nop",
"alias_type": "abbreviation",
"alias_value": "NOH",
"valid_from": "2002-01-01",
"valid_until": "2013-04-30"
},
{
"id": "alias_nba_36",
"team_canonical_id": "team_nba_nop",
"alias_type": "name",
"alias_value": "New Orleans/Oklahoma City Hornets",
"valid_from": "2005-01-01",
"valid_until": "2007-12-31"
},
{ {
"id": "alias_nba_37", "id": "alias_nba_37",
"team_canonical_id": "team_nba_cho", "team_canonical_id": "team_nba_cho",
@@ -303,30 +231,6 @@
"valid_from": "2004-01-01", "valid_from": "2004-01-01",
"valid_until": "2014-04-30" "valid_until": "2014-04-30"
}, },
{
"id": "alias_nba_39",
"team_canonical_id": "team_nba_was",
"alias_type": "name",
"alias_value": "Washington Bullets",
"valid_from": "1974-01-01",
"valid_until": "1997-05-31"
},
{
"id": "alias_nba_40",
"team_canonical_id": "team_nba_was",
"alias_type": "name",
"alias_value": "Capital Bullets",
"valid_from": "1973-01-01",
"valid_until": "1973-12-31"
},
{
"id": "alias_nba_41",
"team_canonical_id": "team_nba_was",
"alias_type": "name",
"alias_value": "Baltimore Bullets",
"valid_from": "1963-01-01",
"valid_until": "1972-12-31"
},
{ {
"id": "alias_nba_42", "id": "alias_nba_42",
"team_canonical_id": "team_nba_lac", "team_canonical_id": "team_nba_lac",
@@ -375,6 +279,78 @@
"valid_from": "1970-01-01", "valid_from": "1970-01-01",
"valid_until": "1978-05-31" "valid_until": "1978-05-31"
}, },
{
"id": "alias_nba_31",
"team_canonical_id": "team_nba_mem",
"alias_type": "name",
"alias_value": "Vancouver Grizzlies",
"valid_from": "1995-01-01",
"valid_until": "2001-05-31"
},
{
"id": "alias_nba_32",
"team_canonical_id": "team_nba_mem",
"alias_type": "abbreviation",
"alias_value": "VAN",
"valid_from": "1995-01-01",
"valid_until": "2001-05-31"
},
{
"id": "alias_nba_33",
"team_canonical_id": "team_nba_mem",
"alias_type": "city",
"alias_value": "Vancouver",
"valid_from": "1995-01-01",
"valid_until": "2001-05-31"
},
{
"id": "alias_nba_34",
"team_canonical_id": "team_nba_nop",
"alias_type": "name",
"alias_value": "New Orleans Hornets",
"valid_from": "2002-01-01",
"valid_until": "2013-04-30"
},
{
"id": "alias_nba_35",
"team_canonical_id": "team_nba_nop",
"alias_type": "abbreviation",
"alias_value": "NOH",
"valid_from": "2002-01-01",
"valid_until": "2013-04-30"
},
{
"id": "alias_nba_36",
"team_canonical_id": "team_nba_nop",
"alias_type": "name",
"alias_value": "New Orleans/Oklahoma City Hornets",
"valid_from": "2005-01-01",
"valid_until": "2007-12-31"
},
{
"id": "alias_nba_28",
"team_canonical_id": "team_nba_okc",
"alias_type": "name",
"alias_value": "Seattle SuperSonics",
"valid_from": "1967-01-01",
"valid_until": "2008-07-01"
},
{
"id": "alias_nba_29",
"team_canonical_id": "team_nba_okc",
"alias_type": "abbreviation",
"alias_value": "SEA",
"valid_from": "1967-01-01",
"valid_until": "2008-07-01"
},
{
"id": "alias_nba_30",
"team_canonical_id": "team_nba_okc",
"alias_type": "city",
"alias_value": "Seattle",
"valid_from": "1967-01-01",
"valid_until": "2008-07-01"
},
{ {
"id": "alias_nba_48", "id": "alias_nba_48",
"team_canonical_id": "team_nba_sac", "team_canonical_id": "team_nba_sac",
@@ -415,6 +391,54 @@
"valid_from": "1974-01-01", "valid_from": "1974-01-01",
"valid_until": "1979-05-31" "valid_until": "1979-05-31"
}, },
{
"id": "alias_nba_39",
"team_canonical_id": "team_nba_was",
"alias_type": "name",
"alias_value": "Washington Bullets",
"valid_from": "1974-01-01",
"valid_until": "1997-05-31"
},
{
"id": "alias_nba_40",
"team_canonical_id": "team_nba_was",
"alias_type": "name",
"alias_value": "Capital Bullets",
"valid_from": "1973-01-01",
"valid_until": "1973-12-31"
},
{
"id": "alias_nba_41",
"team_canonical_id": "team_nba_was",
"alias_type": "name",
"alias_value": "Baltimore Bullets",
"valid_from": "1963-01-01",
"valid_until": "1972-12-31"
},
{
"id": "alias_nfl_77",
"team_canonical_id": "team_nfl_was",
"alias_type": "name",
"alias_value": "Washington Redskins",
"valid_from": "1937-01-01",
"valid_until": "2020-07-13"
},
{
"id": "alias_nfl_78",
"team_canonical_id": "team_nfl_was",
"alias_type": "name",
"alias_value": "Washington Football Team",
"valid_from": "2020-07-13",
"valid_until": "2022-02-02"
},
{
"id": "alias_nfl_79",
"team_canonical_id": "team_nfl_was",
"alias_type": "abbreviation",
"alias_value": "WFT",
"valid_from": "2020-07-13",
"valid_until": "2022-02-02"
},
{ {
"id": "alias_nhl_53", "id": "alias_nhl_53",
"team_canonical_id": "team_nhl_ari", "team_canonical_id": "team_nhl_ari",
@@ -527,6 +551,14 @@
"valid_from": "1967-01-01", "valid_from": "1967-01-01",
"valid_until": "1993-05-31" "valid_until": "1993-05-31"
}, },
{
"id": "alias_nhl_76",
"team_canonical_id": "team_nhl_fla",
"alias_type": "city",
"alias_value": "Miami",
"valid_from": "1993-01-01",
"valid_until": "1998-12-31"
},
{ {
"id": "alias_nhl_67", "id": "alias_nhl_67",
"team_canonical_id": "team_nhl_njd", "team_canonical_id": "team_nhl_njd",
@@ -598,37 +630,5 @@
"alias_value": "Atlanta", "alias_value": "Atlanta",
"valid_from": "1999-01-01", "valid_from": "1999-01-01",
"valid_until": "2011-05-31" "valid_until": "2011-05-31"
},
{
"id": "alias_nhl_76",
"team_canonical_id": "team_nhl_fla",
"alias_type": "city",
"alias_value": "Miami",
"valid_from": "1993-01-01",
"valid_until": "1998-12-31"
},
{
"id": "alias_nfl_77",
"team_canonical_id": "team_nfl_was",
"alias_type": "name",
"alias_value": "Washington Redskins",
"valid_from": "1937-01-01",
"valid_until": "2020-07-13"
},
{
"id": "alias_nfl_78",
"team_canonical_id": "team_nfl_was",
"alias_type": "name",
"alias_value": "Washington Football Team",
"valid_from": "2020-07-13",
"valid_until": "2022-02-02"
},
{
"id": "alias_nfl_79",
"team_canonical_id": "team_nfl_was",
"alias_type": "abbreviation",
"alias_value": "WFT",
"valid_from": "2020-07-13",
"valid_until": "2022-02-02"
} }
] ]

File diff suppressed because it is too large Load Diff

View File

@@ -67,6 +67,7 @@ struct SportsTimeApp: App {
var body: some Scene { var body: some Scene {
WindowGroup { WindowGroup {
BootstrappedContentView(modelContainer: sharedModelContainer) BootstrappedContentView(modelContainer: sharedModelContainer)
.environment(\.isDemoMode, ProcessInfo.isDemoMode)
} }
.modelContainer(sharedModelContainer) .modelContainer(sharedModelContainer)
} }

View File

@@ -10,30 +10,264 @@ import XCTest
final class SportsTimeUITests: XCTestCase { final class SportsTimeUITests: XCTestCase {
override func setUpWithError() throws { override func setUpWithError() throws {
// Put setup code here. This method is called before the invocation of each test method in the class.
// In UI tests it is usually best to stop immediately when a failure occurs. // In UI tests it is usually best to stop immediately when a failure occurs.
continueAfterFailure = false continueAfterFailure = false
// In UI tests its important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this.
} }
override func tearDownWithError() throws { override func tearDownWithError() throws {
// Put teardown code here. This method is called after the invocation of each test method in the class. // Put teardown code here.
} }
// MARK: - Demo Flow Test (Continuous Scroll Mode)
/// Complete trip planning demo with continuous smooth scrolling.
///
/// In demo mode, the app auto-selects each step as it appears on screen:
/// - Planning Mode: "By Dates"
/// - Dates: June 11-16, 2026
/// - Sport: MLB
/// - Region: Central US
/// - Sort: Most Games
/// - Trip: 4th option
/// - Action: Auto-favorite
///
/// The test just needs to:
/// 1. Launch with -DemoMode argument
/// 2. Tap "Start Planning"
/// 3. Continuously scroll - items auto-select as they appear
/// 4. Wait for transitions to complete
@MainActor @MainActor
func testExample() throws { func testTripPlanningDemoFlow() throws {
// UI tests must launch the application that they test. let app = XCUIApplication()
app.launchArguments = ["-DemoMode"]
app.launch()
// Wait for app to fully load
sleep(2)
// MARK: Step 1 - Tap "Start Planning"
let startPlanningButton = app.buttons["home.startPlanningButton"]
XCTAssertTrue(startPlanningButton.waitForExistence(timeout: 10), "Start Planning button should exist")
startPlanningButton.tap()
// Wait for demo mode to auto-select planning mode
sleep(2)
// MARK: Step 2 - Continuous scroll through wizard
// Demo mode auto-selects: Date Range June 11-16 MLB Central
// Each step auto-selects 0.5s after appearing, so we scroll slowly
for _ in 1...8 {
slowSwipeUp(app: app)
sleep(2) // Give time for auto-selections
}
// MARK: Step 3 - Click "Plan My Trip"
let planTripButton = app.buttons["wizard.planTripButton"]
XCTAssertTrue(planTripButton.waitForExistence(timeout: 5), "Plan My Trip button should exist")
planTripButton.tap()
// Wait for planning to complete
sleep(6)
// MARK: Step 4 - Demo mode auto-selects "Most Games" and navigates to 4th trip
// Wait for TripOptionsView to load and auto-selections to complete
let sortDropdown = app.buttons["tripOptions.sortDropdown"]
XCTAssertTrue(sortDropdown.waitForExistence(timeout: 15), "Sort dropdown should exist")
// Wait for demo mode to auto-select sort and navigate to trip detail
sleep(3)
// MARK: Step 5 - Scroll through trip detail (demo mode auto-favorites)
// Wait for TripDetailView to appear
let favoriteButton = app.buttons["tripDetail.favoriteButton"]
if favoriteButton.waitForExistence(timeout: 10) {
// Demo mode will auto-favorite, but we scroll to show the itinerary
for _ in 1...6 {
slowSwipeUp(app: app)
sleep(2)
}
// Scroll back up to show the favorited state
for _ in 1...4 {
slowSwipeDown(app: app)
sleep(1)
}
}
// Wait to display final state
sleep(3)
// Test complete - demo flow finished with trip favorited
}
// MARK: - Manual Demo Flow Test (Original)
/// Original manual test flow for comparison or when demo mode is not desired
@MainActor
func testTripPlanningManualFlow() throws {
let app = XCUIApplication() let app = XCUIApplication()
app.launch() app.launch()
// Use XCTAssert and related functions to verify your tests produce the correct results. // Wait for app to fully load
sleep(2)
// MARK: Step 1 - Tap "Start Planning"
let startPlanningButton = app.buttons["home.startPlanningButton"]
XCTAssertTrue(startPlanningButton.waitForExistence(timeout: 10), "Start Planning button should exist")
startPlanningButton.tap()
sleep(1)
// MARK: Step 2 - Choose "By Dates" mode
let dateRangeMode = app.buttons["wizard.planningMode.dateRange"]
XCTAssertTrue(dateRangeMode.waitForExistence(timeout: 5), "Date Range mode should exist")
dateRangeMode.tap()
sleep(1)
// Scroll down to see dates step
app.swipeUp()
sleep(1)
// MARK: Step 3 - Select June 11-16, 2026
// Navigate to June 2026
let nextMonthButton = app.buttons["wizard.dates.nextMonth"]
XCTAssertTrue(nextMonthButton.waitForExistence(timeout: 5), "Next month button should exist")
let monthLabel = app.staticTexts["wizard.dates.monthLabel"]
var attempts = 0
while !monthLabel.label.contains("June 2026") && attempts < 12 {
nextMonthButton.tap()
Thread.sleep(forTimeInterval: 0.3)
attempts += 1
}
// Select June 11
let june11 = app.buttons["wizard.dates.day.2026-06-11"]
XCTAssertTrue(june11.waitForExistence(timeout: 5), "June 11 should exist")
june11.tap()
Thread.sleep(forTimeInterval: 0.5)
// Select June 16
let june16 = app.buttons["wizard.dates.day.2026-06-16"]
XCTAssertTrue(june16.waitForExistence(timeout: 5), "June 16 should exist")
june16.tap()
sleep(1)
// Scroll down to see sports step
app.swipeUp(velocity: .slow)
sleep(1)
// MARK: Step 4 - Pick MLB
let mlbButton = app.buttons["wizard.sports.mlb"]
XCTAssertTrue(mlbButton.waitForExistence(timeout: 5), "MLB button should exist")
mlbButton.tap()
sleep(1)
// Scroll down to see regions step
app.swipeUp(velocity: .slow)
sleep(1)
// MARK: Step 5 - Select Central US region
let centralRegion = app.buttons["wizard.regions.central"]
XCTAssertTrue(centralRegion.waitForExistence(timeout: 5), "Central region should exist")
centralRegion.tap()
sleep(1)
// Scroll down to see remaining steps
app.swipeUp(velocity: .slow)
sleep(1)
// Keep scrolling for defaults
app.swipeUp(velocity: .slow)
sleep(1)
// MARK: Step 8 - Click "Plan My Trip"
let planTripButton = app.buttons["wizard.planTripButton"]
XCTAssertTrue(planTripButton.waitForExistence(timeout: 5), "Plan My Trip button should exist")
planTripButton.tap()
// Wait for planning to complete
sleep(5)
// MARK: Step 9 - Select "Most Games" from dropdown
let sortDropdown = app.buttons["tripOptions.sortDropdown"]
XCTAssertTrue(sortDropdown.waitForExistence(timeout: 15), "Sort dropdown should exist")
sortDropdown.tap()
Thread.sleep(forTimeInterval: 0.5)
let mostGamesOption = app.buttons["tripOptions.sortOption.mostgames"]
if mostGamesOption.waitForExistence(timeout: 3) {
mostGamesOption.tap()
} else {
app.buttons["Most Games"].tap()
}
Thread.sleep(forTimeInterval: 1)
// MARK: Step 10 - Scroll and select 4th trip
for _ in 1...3 {
slowSwipeUp(app: app)
sleep(1)
}
let fourthTrip = app.buttons["tripOptions.trip.3"]
if fourthTrip.waitForExistence(timeout: 5) {
fourthTrip.tap()
} else {
slowSwipeUp(app: app)
sleep(1)
if fourthTrip.waitForExistence(timeout: 3) {
fourthTrip.tap()
} else {
let anyTrip = app.buttons.matching(NSPredicate(format: "identifier BEGINSWITH 'tripOptions.trip.'")).firstMatch
XCTAssertTrue(anyTrip.waitForExistence(timeout: 5), "At least one trip option should exist")
anyTrip.tap()
}
}
sleep(2)
// MARK: Step 12 - Scroll through itinerary
for _ in 1...5 {
slowSwipeUp(app: app)
Thread.sleep(forTimeInterval: 1.5)
}
// MARK: Step 13 - Favorite the trip
for _ in 1...5 {
slowSwipeDown(app: app)
Thread.sleep(forTimeInterval: 0.5)
}
let favoriteButton = app.buttons["tripDetail.favoriteButton"]
XCTAssertTrue(favoriteButton.waitForExistence(timeout: 5), "Favorite button should exist")
favoriteButton.tap()
sleep(2)
}
// MARK: - Helper Methods
/// Performs a slow swipe up gesture for smooth scrolling
private func slowSwipeUp(app: XCUIApplication) {
let start = app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.7))
let end = app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.3))
start.press(forDuration: 0.1, thenDragTo: end, withVelocity: .slow, thenHoldForDuration: 0.1)
}
/// Performs a slow swipe down gesture for smooth scrolling
private func slowSwipeDown(app: XCUIApplication) {
let start = app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.3))
let end = app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.7))
start.press(forDuration: 0.1, thenDragTo: end, withVelocity: .slow, thenHoldForDuration: 0.1)
}
// MARK: - Basic Tests
@MainActor
func testExample() throws {
let app = XCUIApplication()
app.launch()
} }
@MainActor @MainActor
func testLaunchPerformance() throws { func testLaunchPerformance() throws {
// This measures how long it takes to launch your application.
measure(metrics: [XCTApplicationLaunchMetric()]) { measure(metrics: [XCTApplicationLaunchMetric()]) {
XCUIApplication().launch() XCUIApplication().launch()
} }

View File

@@ -10,7 +10,31 @@
"render:bucketlist": "remotion render TheBucketList out/the-bucket-list.mp4", "render:bucketlist": "remotion render TheBucketList out/the-bucket-list.mp4",
"render:squad": "remotion render TheSquad out/the-squad.mp4", "render:squad": "remotion render TheSquad out/the-squad.mp4",
"render:handoff": "remotion render TheHandoff out/the-handoff.mp4", "render:handoff": "remotion render TheHandoff out/the-handoff.mp4",
"render:all": "npm run render:route && npm run render:checklist && npm run render:bucketlist && npm run render:squad && npm run render:handoff" "render:fantest": "remotion render TheFanTest out/the-fan-test.mp4",
"render:groupchat": "remotion render TheGroupChat out/the-group-chat.mp4",
"render:all-originals": "npm run render:route && npm run render:checklist && npm run render:bucketlist && npm run render:squad && npm run render:handoff && npm run render:fantest && npm run render:groupchat",
"render:V03_H01": "remotion render V03_H01 out/week1/V03_H01.mp4",
"render:V10_H01": "remotion render V10_H01 out/week1/V10_H01.mp4",
"render:V03_H02": "remotion render V03_H02 out/week1/V03_H02.mp4",
"render:V10_H02": "remotion render V10_H02 out/week1/V10_H02.mp4",
"render:V03_H03": "remotion render V03_H03 out/week1/V03_H03.mp4",
"render:V17_H01": "remotion render V17_H01 out/week1/V17_H01.mp4",
"render:V17_H02": "remotion render V17_H02 out/week1/V17_H02.mp4",
"render:V06_H01": "remotion render V06_H01 out/week1/V06_H01.mp4",
"render:V08_H01": "remotion render V08_H01 out/week1/V08_H01.mp4",
"render:V05_LA_01": "remotion render V05_LA_01 out/week1/V05_LA_01.mp4",
"render:V05_NY_01": "remotion render V05_NY_01 out/week1/V05_NY_01.mp4",
"render:V05_TX_01": "remotion render V05_TX_01 out/week1/V05_TX_01.mp4",
"render:V05_CA_01": "remotion render V05_CA_01 out/week1/V05_CA_01.mp4",
"render:V08_LA_01": "remotion render V08_LA_01 out/week1/V08_LA_01.mp4",
"render:V04_H01": "remotion render V04_H01 out/week1/V04_H01.mp4",
"render:V20_H01": "remotion render V20_H01 out/week1/V20_H01.mp4",
"render:V14_H01": "remotion render V14_H01 out/week1/V14_H01.mp4",
"render:V04_H02": "remotion render V04_H02 out/week1/V04_H02.mp4",
"render:V02_H01": "remotion render V02_H01 out/week1/V02_H01.mp4",
"render:V19_H01": "remotion render V19_H01 out/week1/V19_H01.mp4",
"render:week1": "bash scripts/render-week1.sh",
"render:all": "npm run render:all-originals && npm run render:week1"
}, },
"type": "module", "type": "module",
"dependencies": { "dependencies": {

View File

@@ -1,3 +1,4 @@
import React from "react";
import { Composition, Folder } from "remotion"; import { Composition, Folder } from "remotion";
import { TheRoute } from "./videos/TheRoute"; import { TheRoute } from "./videos/TheRoute";
@@ -5,6 +6,12 @@ import { TheChecklist } from "./videos/TheChecklist";
import { TheBucketList } from "./videos/TheBucketList"; import { TheBucketList } from "./videos/TheBucketList";
import { TheSquad } from "./videos/TheSquad"; import { TheSquad } from "./videos/TheSquad";
import { TheHandoff } from "./videos/TheHandoff"; import { TheHandoff } from "./videos/TheHandoff";
import { TheFanTest } from "./videos/TheFanTest";
import { TheGroupChat } from "./videos/TheGroupChat";
import { VideoFromConfig } from "./engine";
import type { VideoConfig } from "./engine";
import week1Configs from "./configs/week1.json";
/** /**
* SportsTime Marketing Videos * SportsTime Marketing Videos
@@ -17,8 +24,11 @@ export const RemotionRoot: React.FC = () => {
const WIDTH = 1080; const WIDTH = 1080;
const HEIGHT = 1920; const HEIGHT = 1920;
const configs = week1Configs as VideoConfig[];
return ( return (
<> <>
{/* Original hand-crafted marketing videos */}
<Folder name="SportsTime-Marketing"> <Folder name="SportsTime-Marketing">
{/* Video 1: The Route - Map animation showcasing trip planning */} {/* Video 1: The Route - Map animation showcasing trip planning */}
<Composition <Composition
@@ -69,6 +79,41 @@ export const RemotionRoot: React.FC = () => {
width={WIDTH} width={WIDTH}
height={HEIGHT} height={HEIGHT}
/> />
{/* Video 6: The Fan Test - Viral identity challenge */}
<Composition
id="TheFanTest"
component={TheFanTest}
durationInFrames={18 * FPS} // 18 seconds = 540 frames
fps={FPS}
width={WIDTH}
height={HEIGHT}
/>
{/* Video 7: The Group Chat - Viral group chat chaos */}
<Composition
id="TheGroupChat"
component={TheGroupChat}
durationInFrames={16 * FPS} // 16 seconds = 480 frames
fps={FPS}
width={WIDTH}
height={HEIGHT}
/>
</Folder>
{/* Week 1: 20 config-driven TikTok/Reels videos */}
<Folder name="Week1-Reels">
{configs.map((config) => (
<Composition
key={config.id}
id={config.id}
component={() => <VideoFromConfig config={config} />}
durationInFrames={Math.round(config.targetLengthSec * FPS)}
fps={FPS}
width={WIDTH}
height={HEIGHT}
/>
))}
</Folder> </Folder>
</> </>
); );