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

@@ -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: ", ")
}
}