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

@@ -1,176 +0,0 @@
//
// CategoryPicker.swift
// SportsTime
//
// Polished category picker for custom itinerary items
//
import SwiftUI
/// Category picker with labeled pills in a grid layout
struct CategoryPicker: View {
@Binding var selectedCategory: ItemCategory
@Environment(\.colorScheme) private var colorScheme
/// Dynamic columns that adapt to accessibility text sizes
private var columns: [GridItem] {
[
GridItem(.flexible(), spacing: Theme.Spacing.sm),
GridItem(.flexible(), spacing: Theme.Spacing.sm),
GridItem(.flexible(), spacing: Theme.Spacing.sm)
]
}
var body: some View {
LazyVGrid(columns: columns, spacing: Theme.Spacing.sm) {
ForEach(ItemCategory.allCases, id: \.self) { category in
CategoryPill(
category: category,
isSelected: selectedCategory == category,
colorScheme: colorScheme
) {
Theme.Animation.withMotion(Theme.Animation.spring) {
selectedCategory = category
}
}
}
}
}
}
/// Individual category pill with emoji icon and label
private struct CategoryPill: View {
let category: ItemCategory
let isSelected: Bool
let colorScheme: ColorScheme
let onTap: () -> Void
var body: some View {
Button(action: onTap) {
VStack(spacing: Theme.Spacing.xs) {
// Emoji with background circle
ZStack {
Circle()
.fill(emojiBackground)
.frame(width: 44, height: 44)
Text(category.icon)
.font(.system(size: 24))
}
.shadow(
color: isSelected ? Theme.warmOrange.opacity(0.3) : .clear,
radius: 6,
y: 2
)
Text(category.label)
.font(.caption)
.fontWeight(isSelected ? .semibold : .medium)
.foregroundStyle(labelColor)
}
.frame(maxWidth: .infinity)
.padding(.vertical, Theme.Spacing.md)
.padding(.horizontal, Theme.Spacing.xs)
.background(pillBackground)
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium))
.overlay(
RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)
.strokeBorder(borderColor, lineWidth: isSelected ? 2 : 1)
)
.shadow(
color: isSelected ? Theme.warmOrange.opacity(0.15) : .clear,
radius: 8,
y: 4
)
}
.buttonStyle(CategoryPillButtonStyle())
.accessibilityLabel(category.label)
.accessibilityHint("Category for \(category.label.lowercased()) items")
.accessibilityAddTraits(isSelected ? [.isButton, .isSelected] : .isButton)
}
private var emojiBackground: Color {
if isSelected {
return Theme.warmOrange.opacity(0.2)
} else {
return Theme.cardBackgroundElevated(colorScheme)
}
}
private var pillBackground: Color {
if isSelected {
return Theme.warmOrange.opacity(0.08)
} else {
return Theme.cardBackground(colorScheme)
}
}
private var borderColor: Color {
if isSelected {
return Theme.warmOrange
} else {
return Theme.surfaceGlow(colorScheme)
}
}
private var labelColor: Color {
if isSelected {
return Theme.warmOrange
} else {
return Theme.textSecondary(colorScheme)
}
}
}
/// Custom button style with scale effect on press
private struct CategoryPillButtonStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
configuration.label
.scaleEffect(configuration.isPressed ? 0.95 : 1.0)
.animation(Theme.Animation.prefersReducedMotion ? nil : .spring(response: 0.3, dampingFraction: 0.7), value: configuration.isPressed)
}
}
// MARK: - Previews
#Preview("Light Mode") {
struct PreviewWrapper: View {
@State private var selected: ItemCategory = .restaurant
var body: some View {
VStack(spacing: 20) {
CategoryPicker(selectedCategory: $selected)
.padding()
Text("Selected: \(selected.label)")
.foregroundStyle(.secondary)
}
.padding()
.background(Color(.systemGroupedBackground))
}
}
return PreviewWrapper()
.preferredColorScheme(.light)
}
#Preview("Dark Mode") {
struct PreviewWrapper: View {
@State private var selected: ItemCategory = .attraction
var body: some View {
VStack(spacing: 20) {
CategoryPicker(selectedCategory: $selected)
.padding()
Text("Selected: \(selected.label)")
.foregroundStyle(.secondary)
}
.padding()
.background(Color(.systemGroupedBackground))
}
}
return PreviewWrapper()
.preferredColorScheme(.dark)
}

View File

@@ -8,35 +8,6 @@
import SwiftUI
import MapKit
/// Category for custom itinerary items with emoji icons
enum ItemCategory: String, CaseIterable {
case restaurant
case attraction
case fuel
case hotel
case other
var icon: String {
switch self {
case .restaurant: return "\u{1F37D}" // 🍽
case .attraction: return "\u{1F3A2}" // 🎢
case .fuel: return "\u{26FD}" //
case .hotel: return "\u{1F3E8}" // 🏨
case .other: return "\u{1F4CC}" // 📌
}
}
var label: String {
switch self {
case .restaurant: return "Eat"
case .attraction: return "See"
case .fuel: return "Fuel"
case .hotel: return "Stay"
case .other: return "Other"
}
}
}
/// Legacy sheet for adding/editing custom itinerary items.
/// - Note: Use `QuickAddItemSheet` instead for new code.
@available(*, deprecated, message: "Use QuickAddItemSheet instead")
@@ -56,7 +27,6 @@ struct AddItemSheet: View {
}
@State private var entryMode: EntryMode = .searchPlaces
@State private var selectedCategory: ItemCategory = .restaurant
@State private var title: String = ""
@State private var isSaving = false
@@ -71,9 +41,6 @@ struct AddItemSheet: View {
var body: some View {
NavigationStack {
VStack(spacing: Theme.Spacing.lg) {
// Category picker
categoryPicker
// Entry mode picker (only for new items)
if !isEditing {
Picker("Entry Mode", selection: $entryMode) {
@@ -113,8 +80,6 @@ struct AddItemSheet: View {
}
.onAppear {
if let existing = existingItem, let info = existing.customInfo {
// Find matching category by icon, default to .other
selectedCategory = ItemCategory.allCases.first { $0.icon == info.icon } ?? .other
title = info.title
// If editing a mappable item, switch to custom mode
entryMode = .custom
@@ -264,20 +229,6 @@ struct AddItemSheet: View {
return components.isEmpty ? nil : components.joined(separator: ", ")
}
@ViewBuilder
private var categoryPicker: some View {
HStack(spacing: Theme.Spacing.md) {
ForEach(ItemCategory.allCases, id: \.self) { category in
CategoryButton(
category: category,
isSelected: selectedCategory == category
) {
selectedCategory = category
}
}
}
}
private func saveItem() {
isSaving = true
@@ -290,7 +241,7 @@ struct AddItemSheet: View {
let customInfo = CustomInfo(
title: trimmedTitle,
icon: selectedCategory.icon,
icon: "\u{1F4CC}",
time: existingInfo.time,
latitude: existingInfo.latitude,
longitude: existingInfo.longitude,
@@ -312,7 +263,7 @@ struct AddItemSheet: View {
let customInfo = CustomInfo(
title: placeName,
icon: selectedCategory.icon,
icon: "\u{1F4CC}",
time: nil,
latitude: coordinate.latitude,
longitude: coordinate.longitude,
@@ -333,7 +284,7 @@ struct AddItemSheet: View {
let customInfo = CustomInfo(
title: trimmedTitle,
icon: selectedCategory.icon,
icon: "\u{1F4CC}",
time: nil
)
@@ -403,36 +354,6 @@ private struct PlaceResultRow: View {
}
}
// MARK: - Category Button
private struct CategoryButton: View {
let category: ItemCategory
let isSelected: Bool
let action: () -> Void
var body: some View {
Button(action: action) {
VStack(spacing: 4) {
Text(category.icon)
.font(.title2)
Text(category.label)
.font(.caption2)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
.background(isSelected ? Theme.warmOrange.opacity(0.2) : Color.clear)
.cornerRadius(8)
.overlay(
RoundedRectangle(cornerRadius: 8)
.stroke(isSelected ? Theme.warmOrange : Color.secondary.opacity(0.3), lineWidth: isSelected ? 2 : 1)
)
}
.buttonStyle(.plain)
.accessibilityValue(isSelected ? "Selected" : "Not selected")
.accessibilityAddTraits(isSelected ? .isSelected : [])
}
}
#Preview {
AddItemSheet(
tripId: UUID(),

View File

@@ -269,7 +269,8 @@ struct TripDetailView: View {
Task { await deleteItineraryItem(item) }
},
onAddButtonTapped: { day in
addItemAnchor = AddItemAnchor(day: day)
let coord = coordinateForDay(day)
addItemAnchor = AddItemAnchor(day: day, regionCoordinate: coord)
}
)
.ignoresSafeArea(edges: .bottom)
@@ -692,7 +693,8 @@ struct TripDetailView: View {
DropTargetIndicator()
}
InlineAddButton {
addItemAnchor = AddItemAnchor(day: day)
let coord = coordinateForDay(day)
addItemAnchor = AddItemAnchor(day: day, regionCoordinate: coord)
}
}
.onDrop(of: [.text, .plainText, .utf8PlainText], isTargeted: Binding(
@@ -1285,6 +1287,12 @@ struct TripDetailView: View {
return allItems
}
/// Returns the coordinate of the first stop on the given day, for location-biased search.
private func coordinateForDay(_ day: Int) -> CLLocationCoordinate2D? {
let tripDay = trip.itineraryDays().first { $0.dayNumber == day }
return tripDay?.stops.first?.coordinate
}
private func toggleSaved() {
if isSaved {
unsaveTrip()
@@ -1615,6 +1623,7 @@ enum ItinerarySection {
struct AddItemAnchor: Identifiable {
let id = UUID()
let day: Int
let regionCoordinate: CLLocationCoordinate2D?
}
// MARK: - Inline Add Button
@@ -2100,7 +2109,8 @@ private struct SheetModifiers: ViewModifier {
QuickAddItemSheet(
tripId: tripId,
day: anchor.day,
existingItem: nil
existingItem: nil,
regionCoordinate: anchor.regionCoordinate
) { item in
Task { await saveItineraryItem(item) }
}