Fix game times with UTC data, restructure schedule by date

- Update games_canonical.json to use ISO 8601 UTC timestamps (game_datetime_utc)
- Fix BootstrapService timezone-aware parsing for venue-local fallback
- Fix thread-unsafe shared DateFormatter in RichGame local time display
- Bump SchemaVersion to 4 to force re-bootstrap with correct UTC data
- Restructure schedule view: group by date instead of sport, with sport
  icons on each row and date section headers showing game counts
- Fix schedule row backgrounds using Theme.cardBackground instead of black
- Sort games by UTC time with local-time tiebreaker for same-instant games

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-02-19 11:43:39 -06:00
parent e6c4b8e12b
commit 999b5a1190
12 changed files with 13387 additions and 26877 deletions

View File

@@ -32,8 +32,8 @@ final class ScheduleViewModel {
// MARK: - Pre-computed Groupings (avoid computed property overhead)
/// Games grouped by sport - pre-computed to avoid re-grouping on every render
private(set) var gamesBySport: [(sport: Sport, games: [RichGame])] = []
/// Games grouped by date - pre-computed to avoid re-grouping on every render
private(set) var gamesByDate: [(date: Date, games: [RichGame])] = []
/// All games matching current filters (before any display limiting)
private var filteredGames: [RichGame] = []
@@ -193,15 +193,20 @@ final class ScheduleViewModel {
}
}
// Step 2: Pre-compute grouping by sport (done once, not per-render)
let grouped = Dictionary(grouping: filteredGames) { $0.game.sport }
gamesBySport = grouped
.sorted { lhs, rhs in
let lhsIndex = Sport.allCases.firstIndex(of: lhs.key) ?? 0
let rhsIndex = Sport.allCases.firstIndex(of: rhs.key) ?? 0
return lhsIndex < rhsIndex
}
.map { (sport: $0.key, games: $0.value.sorted { $0.game.dateTime < $1.game.dateTime }) }
// Step 2: Pre-compute grouping by date (done once, not per-render)
let calendar = Calendar.current
let grouped = Dictionary(grouping: filteredGames) { calendar.startOfDay(for: $0.game.dateTime) }
gamesByDate = grouped
.sorted { $0.key < $1.key }
.map { (date: $0.key, games: $0.value.sorted { lhs, rhs in
if lhs.game.dateTime == rhs.game.dateTime {
// Same UTC time: sort by local display time (earlier local times first)
let lhsOffset = lhs.stadium.timeZone?.secondsFromGMT(for: lhs.game.dateTime) ?? 0
let rhsOffset = rhs.stadium.timeZone?.secondsFromGMT(for: rhs.game.dateTime) ?? 0
return lhsOffset < rhsOffset
}
return lhs.game.dateTime < rhs.game.dateTime
}) }
}
}

View File

@@ -12,7 +12,7 @@ struct ScheduleListView: View {
@State private var showDiagnostics = false
private var hasGames: Bool {
!viewModel.gamesBySport.isEmpty
!viewModel.gamesByDate.isEmpty
}
var body: some View {
@@ -109,16 +109,31 @@ struct ScheduleListView: View {
.listRowBackground(Color.clear)
}
ForEach(viewModel.gamesBySport, id: \.sport) { sportGroup in
ForEach(viewModel.gamesByDate, id: \.date) { dateGroup in
Section {
ForEach(sportGroup.games) { richGame in
GameRowView(game: richGame, showDate: true, showLocation: true)
ForEach(dateGroup.games) { richGame in
GameRowView(game: richGame, showSport: true, showLocation: true)
}
} header: {
HStack(spacing: 8) {
Image(systemName: sportGroup.sport.iconName)
.accessibilityHidden(true)
Text(sportGroup.sport.rawValue)
HStack {
Text(formatSectionDate(dateGroup.date))
if Calendar.current.isDateInToday(dateGroup.date) {
Text("TODAY")
.font(.caption2)
.fontWeight(.bold)
.foregroundStyle(.white)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(Color.orange)
.clipShape(Capsule())
}
Spacer()
Text("\(dateGroup.games.count) games")
.font(.caption)
.foregroundStyle(.secondary)
}
.font(.headline)
}
@@ -233,98 +248,70 @@ struct SportFilterChip: View {
// MARK: - Game Row View
struct GameRowView: View {
@Environment(\.colorScheme) private var colorScheme
let game: RichGame
var showDate: Bool = false
var showSport: Bool = false
var showLocation: Bool = false
// Static formatter to avoid allocation per row (significant performance improvement)
private static let dateFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateFormat = "EEE, MMM d"
return formatter
}()
// Cache isToday check to avoid repeated Calendar calls
private var isToday: Bool {
Calendar.current.isDateInToday(game.game.dateTime)
}
var body: some View {
VStack(alignment: .leading, spacing: 8) {
// Date (when grouped by sport)
if showDate {
HStack(spacing: 6) {
Text(Self.dateFormatter.string(from: game.game.dateTime))
.font(.caption)
.fontWeight(.medium)
.foregroundStyle(.secondary)
if isToday {
Text("TODAY")
.font(.caption2)
.fontWeight(.bold)
.foregroundStyle(.white)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(Color.orange)
.clipShape(Capsule())
}
}
HStack(spacing: 12) {
// Sport icon
if showSport {
Image(systemName: game.game.sport.iconName)
.font(.title3)
.foregroundStyle(game.game.sport.themeColor)
.frame(width: 28)
.accessibilityLabel(game.game.sport.rawValue)
}
// Teams
HStack {
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 8) {
TeamBadge(team: game.awayTeam, isHome: false)
Text("@")
VStack(alignment: .leading, spacing: 6) {
// Teams
HStack(spacing: 8) {
TeamBadge(team: game.awayTeam, isHome: false)
Text("@")
.font(.caption)
.foregroundStyle(.secondary)
TeamBadge(team: game.homeTeam, isHome: true)
}
// Game info
HStack(spacing: 12) {
Label(game.localGameTimeShort, systemImage: "clock")
Label(game.stadium.name, systemImage: "building.2")
.lineLimit(1)
if let broadcast = game.game.broadcastInfo {
Label(broadcast, systemImage: "tv")
}
}
.font(.caption)
.foregroundStyle(.secondary)
// Location
if showLocation {
HStack(spacing: 4) {
Image(systemName: "mappin.circle.fill")
.font(.caption2)
.foregroundStyle(.tertiary)
.accessibilityHidden(true)
Text(game.stadium.city)
.font(.caption)
.foregroundStyle(.secondary)
TeamBadge(team: game.homeTeam, isHome: true)
}
}
Spacer()
}
// Game info
HStack(spacing: 12) {
Label(game.localGameTimeShort, systemImage: "clock")
Label(game.stadium.name, systemImage: "building.2")
if let broadcast = game.game.broadcastInfo {
Label(broadcast, systemImage: "tv")
}
}
.font(.caption)
.foregroundStyle(.secondary)
// Location info (when grouped by game)
if showLocation {
HStack(spacing: 4) {
Image(systemName: "mappin.circle.fill")
.font(.caption2)
.foregroundStyle(.tertiary)
.accessibilityHidden(true)
Text(game.stadium.city)
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
.padding(.vertical, 4)
.listRowBackground(isToday ? Color.orange.opacity(0.1) : nil)
.accessibilityElement(children: .ignore)
.accessibilityLabel(gameAccessibilityLabel)
}
private var gameAccessibilityLabel: String {
var parts = ["\(game.awayTeam.name) at \(game.homeTeam.name)"]
parts.append(game.stadium.name)
var parts: [String] = []
if showSport { parts.append(game.game.sport.rawValue) }
parts.append("\(game.awayTeam.name) at \(game.homeTeam.name)")
parts.append(game.localGameTimeShort)
if showDate {
parts.append(Self.dateFormatter.string(from: game.game.dateTime))
}
parts.append(game.stadium.name)
return parts.joined(separator: ", ")
}
}