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:
85
SportsTime/Features/Trip/Views/TravelPlacement.swift
Normal file
85
SportsTime/Features/Trip/Views/TravelPlacement.swift
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user