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
+15 -1
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
@@ -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()
} }
+122 -385
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",
@@ -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
@@ -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()
} }
} }
+71
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")
}
}
@@ -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 {
@@ -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)
} }
@@ -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,6 +35,7 @@ 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
ZStack {
MapReader { proxy in MapReader { proxy in
Map(position: $cameraPosition, interactionModes: []) { Map(position: $cameraPosition, interactionModes: []) {
// West region polygon // West region polygon
@@ -58,6 +61,20 @@ struct RegionMapSelector: View {
} }
} }
} }
// 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)
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)) .clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium))
.overlay( .overlay(
@@ -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
@@ -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)
} }
@@ -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
@@ -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)
@@ -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)
} }
} }
@@ -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)
@@ -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)
@@ -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,8 +132,10 @@ 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")
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+214 -61
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
} }
] ]
@@ -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
+210 -210
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
+1
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)
} }
+243 -9
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()
} }
+25 -1
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": {
+45
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>
</> </>
); );