feat: fix travel placement bug, add theme-based alternate icons, fix animated background crash

- Fix repeat-city travel placement: use stop indices instead of global city name
  matching so Follow Team trips with repeat cities show travel correctly
- Add TravelPlacement helper and regression tests (7 tests)
- Add alternate app icons for each theme, auto-switch on theme change
- Fix index-out-of-range crash in AnimatedSportsBackground (19 configs, was iterating 20)
- Add marketing video configs, engine, and new video components
- Add docs and data exports

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-02-06 09:36:34 -06:00
parent fdcecafaa3
commit 8e937a5646
77 changed files with 143400 additions and 83 deletions

View File

@@ -0,0 +1,85 @@
//
// TravelPlacement.swift
// SportsTime
//
// Computes which day number each travel segment should be displayed on.
// Extracted from TripDetailView for testability.
//
import Foundation
enum TravelPlacement {
/// Result of computing travel placement for a trip.
struct Placement {
let day: Int
let segmentIndex: Int
}
/// Computes which day each travel segment belongs to.
///
/// Uses stop indices (not city name matching) so repeat cities work correctly.
/// `trip.travelSegments[i]` connects `trip.stops[i]` to `trip.stops[i+1]`.
///
/// - Parameters:
/// - trip: The trip containing stops and travel segments.
/// - tripDays: Array of dates (one per trip day, start-of-day normalized).
/// - Returns: Dictionary mapping day number (1-based) to TravelSegment.
static func computeTravelByDay(
trip: Trip,
tripDays: [Date]
) -> [Int: TravelSegment] {
var travelByDay: [Int: TravelSegment] = [:]
for (segmentIndex, segment) in trip.travelSegments.enumerated() {
let minDay: Int
let maxDay: Int
let defaultDay: Int
if segmentIndex < trip.stops.count - 1 {
let fromStop = trip.stops[segmentIndex]
let toStop = trip.stops[segmentIndex + 1]
let fromDayNum = dayNumber(for: fromStop.departureDate, in: tripDays)
let toDayNum = dayNumber(for: toStop.arrivalDate, in: tripDays)
minDay = max(fromDayNum + 1, 1)
maxDay = min(toDayNum, tripDays.count)
defaultDay = minDay
} else {
minDay = 1
maxDay = tripDays.count
defaultDay = 1
}
let clampedDefault: Int
if minDay <= maxDay {
clampedDefault = max(minDay, min(defaultDay, maxDay))
} else {
clampedDefault = minDay
}
travelByDay[clampedDefault] = segment
}
return travelByDay
}
/// Convert a date to a 1-based day number within the trip days array.
/// Returns 0 if before trip start, tripDays.count + 1 if after trip end.
static func dayNumber(for date: Date, in tripDays: [Date]) -> Int {
let calendar = Calendar.current
let target = calendar.startOfDay(for: date)
for (index, tripDay) in tripDays.enumerated() {
if calendar.startOfDay(for: tripDay) == target {
return index + 1
}
}
if let firstDay = tripDays.first, target < firstDay {
return 0
}
return tripDays.count + 1
}
}

View File

@@ -821,32 +821,38 @@ struct TripDetailView: View {
var sections: [ItinerarySection] = []
let days = tripDays
// Pre-calculate which day each travel segment belongs to
// Default: day after last game in departure city, or use validated override
// Pre-calculate which day each travel segment belongs to.
// Uses stop indices (not city name matching) so repeat cities work correctly.
// trip.travelSegments[i] connects trip.stops[i] trip.stops[i+1].
var travelByDay: [Int: TravelSegment] = [:]
for segment in trip.travelSegments {
for (segmentIndex, segment) in trip.travelSegments.enumerated() {
let travelId = stableTravelAnchorId(segment)
let fromCity = segment.fromLocation.name
let toCity = segment.toLocation.name
// Calculate valid range for this travel
// Travel can only happen AFTER the last game in departure city
let lastGameInFromCity = findLastGameDay(in: fromCity)
let firstGameInToCity = findFirstGameDay(in: toCity)
let minDay = max(lastGameInFromCity + 1, 1)
let maxDay = min(firstGameInToCity, tripDays.count)
let validRange = minDay <= maxDay ? minDay...maxDay : minDay...minDay
// Calculate default day (day after last game in departure city)
// Use stop dates for precise placement (handles repeat cities)
let minDay: Int
let maxDay: Int
let defaultDay: Int
if lastGameInFromCity > 0 && lastGameInFromCity + 1 <= tripDays.count {
defaultDay = lastGameInFromCity + 1
} else if lastGameInFromCity > 0 {
defaultDay = lastGameInFromCity
if segmentIndex < trip.stops.count - 1 {
let fromStop = trip.stops[segmentIndex]
let toStop = trip.stops[segmentIndex + 1]
let fromDayNum = dayNumber(for: fromStop.departureDate)
let toDayNum = dayNumber(for: toStop.arrivalDate)
// Travel goes after the from stop's last day, up to the to stop's first day
minDay = max(fromDayNum + 1, 1)
maxDay = min(toDayNum, days.count)
defaultDay = minDay
} else {
// Fallback: segment doesn't align with stops
minDay = 1
maxDay = days.count
defaultDay = 1
}
let validRange = minDay <= maxDay ? minDay...maxDay : minDay...minDay
// Check for user override - only use if within valid range
if let override = travelOverrides[travelId],
validRange.contains(override.day) {
@@ -928,60 +934,46 @@ struct TripDetailView: View {
}?.city
}
/// Find the last day number that has a game in the given city
private func findLastGameDay(in city: String) -> Int {
let cityLower = city.lowercased()
let days = tripDays
var lastDay = 0
for (index, dayDate) in days.enumerated() {
let dayNum = index + 1
let gamesOnDay = gamesOn(date: dayDate)
if gamesOnDay.contains(where: { $0.stadium.city.lowercased() == cityLower }) {
lastDay = dayNum
}
}
return lastDay
}
/// Find the first day number that has a game in the given city
private func findFirstGameDay(in city: String) -> Int {
let cityLower = city.lowercased()
/// Convert a date to a 1-based day number within the trip.
/// Returns 0 if the date is before the trip, or tripDays.count + 1 if after.
private func dayNumber(for date: Date) -> Int {
let calendar = Calendar.current
let target = calendar.startOfDay(for: date)
let days = tripDays
for (index, dayDate) in days.enumerated() {
let dayNum = index + 1
let gamesOnDay = gamesOn(date: dayDate)
if gamesOnDay.contains(where: { $0.stadium.city.lowercased() == cityLower }) {
return dayNum
for (index, tripDay) in days.enumerated() {
if calendar.startOfDay(for: tripDay) == target {
return index + 1
}
}
return tripDays.count // Default to last day if no games found
// Date is outside the trip range
if let firstDay = days.first, target < firstDay {
return 0
}
return days.count + 1
}
/// Get valid day range for a travel segment
/// Travel can be displayed from the day of last departure game to the day of first arrival game
/// Get valid day range for a travel segment using stop indices.
/// Uses the from/to stop dates so repeat cities don't confuse placement.
private func validDayRange(for travelId: String) -> ClosedRange<Int>? {
// Find the segment matching this travel ID
guard let segment = trip.travelSegments.first(where: { stableTravelAnchorId($0) == travelId }) else {
// Find the segment index matching this travel ID
guard let segmentIndex = trip.travelSegments.firstIndex(where: { stableTravelAnchorId($0) == travelId }),
segmentIndex < trip.stops.count - 1 else {
return nil
}
let fromCity = segment.fromLocation.name
let toCity = segment.toLocation.name
let fromStop = trip.stops[segmentIndex]
let toStop = trip.stops[segmentIndex + 1]
// Travel can only happen AFTER the last game in departure city
// So the earliest travel day is the day AFTER the last game
let lastGameInFromCity = findLastGameDay(in: fromCity)
let minDay = max(lastGameInFromCity + 1, 1)
let fromDayNum = dayNumber(for: fromStop.departureDate)
let toDayNum = dayNumber(for: toStop.arrivalDate)
// Travel must happen BEFORE or ON the first game day in arrival city
let firstGameInToCity = findFirstGameDay(in: toCity)
let maxDay = min(firstGameInToCity, tripDays.count)
let minDay = max(fromDayNum + 1, 1)
let maxDay = min(toDayNum, tripDays.count)
// Handle edge case where minDay > maxDay shouldn't happen, but safeguard
if minDay > maxDay {
return minDay...minDay
return nil
}
return minDay...maxDay