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 stadiumRefKey = "stadiumRef"
static let stadiumCanonicalIdKey = "stadiumCanonicalId"
static let logoURLKey = "logoURL"
static let logoURLKey = "logoUrl"
static let primaryColorKey = "primaryColor"
static let secondaryColorKey = "secondaryColor"
static let conferenceCanonicalIdKey = "conferenceCanonicalId"
static let divisionCanonicalIdKey = "divisionCanonicalId"
let record: CKRecord
@@ -68,6 +70,16 @@ struct CKTeam {
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? {
// Use teamId field, or fall back to record name
let id = (record[CKTeam.idKey] as? String) ?? record.recordID.recordName
@@ -99,6 +111,8 @@ struct CKTeam {
sport: sport,
city: city,
stadiumId: stadiumId,
conferenceId: record[CKTeam.conferenceCanonicalIdKey] as? String,
divisionId: record[CKTeam.divisionCanonicalIdKey] as? String,
logoURL: logoURL,
primaryColor: record[CKTeam.primaryColorKey] 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 games = 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: - Canonical JSON Models (from canonicalization pipeline)
private struct JSONCanonicalStadium: Codable {
let canonical_id: String
let name: String
let city: String
let state: String
let state: String?
let latitude: Double
let longitude: Double
let capacity: Int
@@ -67,12 +65,12 @@ actor BootstrapService {
let canonical_id: String
let sport: String
let season: String
let game_datetime_utc: String? // ISO 8601 format (new canonical format)
let date: String? // Legacy format (deprecated)
let time: String? // Legacy format (deprecated)
let game_datetime_utc: String? // ISO 8601 format
let date: String? // Fallback date+time format
let time: String? // Fallback date+time format
let home_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 broadcast_info: String?
}
@@ -84,38 +82,6 @@ actor BootstrapService {
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 {
let id: String
let sport: String
@@ -135,13 +101,21 @@ actor BootstrapService {
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
/// Bootstrap canonical data from bundled JSON if not already done.
/// 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
func bootstrapIfNeeded(context: ModelContext) async throws {
let syncState = SyncState.current(in: context)
@@ -151,6 +125,9 @@ actor BootstrapService {
return
}
// Clear any partial bootstrap data from a previous failed attempt
try clearCanonicalData(context: context)
// Bootstrap in dependency order:
// 1. Stadiums (no dependencies)
// 2. Stadium aliases (depends on stadiums)
@@ -165,6 +142,7 @@ actor BootstrapService {
try await bootstrapTeams(context: context)
try await bootstrapTeamAliases(context: context)
try await bootstrapGames(context: context)
try await bootstrapSports(context: context)
// Mark bootstrap complete
syncState.bootstrapCompleted = true
@@ -182,18 +160,10 @@ actor BootstrapService {
@MainActor
private func bootstrapStadiums(context: ModelContext) async throws {
// Try canonical format first, fall back to legacy
if let url = Bundle.main.url(forResource: "stadiums_canonical", withExtension: "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")
guard let url = Bundle.main.url(forResource: "stadiums_canonical", withExtension: "json") else {
throw BootstrapError.bundledResourceNotFound("stadiums_canonical.json")
}
}
@MainActor
private func bootstrapStadiumsCanonical(url: URL, context: ModelContext) async throws {
let data: Data
let stadiums: [JSONCanonicalStadium]
@@ -212,7 +182,7 @@ actor BootstrapService {
source: .bundled,
name: jsonStadium.name,
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,
longitude: jsonStadium.longitude,
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
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 {
// Aliases are optional - legacy format creates them inline
return
}
@@ -313,10 +240,7 @@ actor BootstrapService {
@MainActor
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 {
// League structure is optional for MVP - create basic structure from known sports
createDefaultLeagueStructure(context: context)
return
}
@@ -356,17 +280,10 @@ actor BootstrapService {
@MainActor
private func bootstrapTeams(context: ModelContext) async throws {
// Try canonical format first, fall back to legacy extraction from games
if let url = Bundle.main.url(forResource: "teams_canonical", withExtension: "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
guard let url = Bundle.main.url(forResource: "teams_canonical", withExtension: "json") else {
throw BootstrapError.bundledResourceNotFound("teams_canonical.json")
}
}
@MainActor
private func bootstrapTeamsCanonical(url: URL, context: ModelContext) async throws {
let data: Data
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
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 {
return
}
@@ -583,7 +328,8 @@ actor BootstrapService {
throw BootstrapError.jsonDecodingFailed("team_aliases.json", error)
}
let dateFormatter = ISO8601DateFormatter()
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "yyyy-MM-dd"
for jsonAlias in aliases {
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
@MainActor
private func createDefaultLeagueStructure(context: ModelContext) {
// Create minimal league structure for supported sports
let timestamp = BundledDataTimestamp.leagueStructure
// MLB
context.insert(LeagueStructureModel(
id: "mlb_league",
sport: "MLB",
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: "_"))"
private func clearCanonicalData(context: ModelContext) throws {
try context.delete(model: CanonicalStadium.self)
try context.delete(model: StadiumAlias.self)
try context.delete(model: LeagueStructureModel.self)
try context.delete(model: CanonicalTeam.self)
try context.delete(model: TeamAlias.self)
try context.delete(model: CanonicalGame.self)
try context.delete(model: CanonicalSport.self)
}
nonisolated private func parseISO8601(_ string: String) -> Date? {
// Handle ISO 8601 format: "2026-03-01T18:05:00Z"
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime]
return formatter.date(from: string)
@@ -727,34 +492,6 @@ actor BootstrapService {
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 {
let cityToState: [String: String] = [
"Atlanta": "GA", "Boston": "MA", "Brooklyn": "NY", "Charlotte": "NC",

View File

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

View File

@@ -598,11 +598,27 @@ actor CloudKitService {
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
func subscribeToAllUpdates() async throws {
try await subscribeToScheduleUpdates()
try await subscribeToLeagueStructureUpdates()
try await subscribeToTeamAliasUpdates()
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")
}
}