// // ItinerarySectionBuilder.swift // SportsTime // // Pure static builder for itinerary sections. // Extracted from TripDetailView to enable caching and testability. // import Foundation enum ItinerarySectionBuilder { /// Build itinerary sections from trip data. /// /// This is a pure function: same inputs always produce the same output. /// Callers should cache the result and recompute only when inputs change. static func build( trip: Trip, tripDays: [Date], games: [String: RichGame], travelOverrides: [String: TravelOverride], itineraryItems: [ItineraryItem], allowCustomItems: Bool ) -> [ItinerarySection] { var sections: [ItinerarySection] = [] // Use TravelPlacement for consistent day calculation (shared with tests). var travelByDay = TravelPlacement.computeTravelByDay(trip: trip, tripDays: tripDays) // Apply user overrides on top of computed defaults. for (segmentIndex, segment) in trip.travelSegments.enumerated() { let travelId = stableTravelAnchorId(segment, at: segmentIndex) guard let override = travelOverrides[travelId] else { continue } // Validate override is within valid day range if let validRange = validDayRange(for: travelId, trip: trip, tripDays: tripDays), validRange.contains(override.day) { // Remove from computed position (search all days) for key in travelByDay.keys { travelByDay[key]?.removeAll { $0.id == segment.id } } travelByDay.keys.forEach { key in if travelByDay[key]?.isEmpty == true { travelByDay[key] = nil } } // Place at overridden position travelByDay[override.day, default: []].append(segment) } } // Process ALL days for (index, dayDate) in tripDays.enumerated() { let dayNum = index + 1 let gamesOnDay = gamesOn(date: dayDate, trip: trip, games: games) // Travel for this day (if any) - appears before day header for travelSegment in (travelByDay[dayNum] ?? []) { let segIdx = trip.travelSegments.firstIndex(where: { $0.id == travelSegment.id }) ?? 0 sections.append(.travel(travelSegment, segmentIndex: segIdx)) } // Day section - shows games or minimal rest day display sections.append(.day(dayNumber: dayNum, date: dayDate, games: gamesOnDay)) // Custom items for this day (sorted by sortOrder) if allowCustomItems { // Add button first - always right after day header sections.append(.addButton(day: dayNum)) let dayItems = itineraryItems .filter { $0.day == dayNum && $0.isCustom } .sorted { $0.sortOrder < $1.sortOrder } for item in dayItems { sections.append(.customItem(item)) } } } return sections } // MARK: - Helpers static func stableTravelAnchorId(_ segment: TravelSegment, at index: Int) -> String { let from = TravelInfo.normalizeCityName(segment.fromLocation.name) let to = TravelInfo.normalizeCityName(segment.toLocation.name) return "travel:\(index):\(from)->\(to)" } static func gamesOn(date: Date, trip: Trip, games: [String: RichGame]) -> [RichGame] { let calendar = Calendar.current let dayStart = calendar.startOfDay(for: date) let allGameIds = trip.stops.flatMap { $0.games } let foundGames = allGameIds.compactMap { games[$0] } return foundGames.filter { richGame in calendar.startOfDay(for: richGame.game.dateTime) == dayStart }.sorted { $0.game.dateTime < $1.game.dateTime } } /// Parse the segment index from a travel anchor ID. /// Format: "travel:INDEX:from->to" → INDEX private static func parseSegmentIndex(from travelId: String) -> Int? { let stripped = travelId.replacingOccurrences(of: "travel:", with: "") let parts = stripped.components(separatedBy: ":") guard parts.count >= 2, let index = Int(parts[0]) else { return nil } return index } private static func validDayRange(for travelId: String, trip: Trip, tripDays: [Date]) -> ClosedRange? { guard let segmentIndex = parseSegmentIndex(from: travelId), segmentIndex < trip.stops.count - 1 else { return nil } let fromStop = trip.stops[segmentIndex] let toStop = trip.stops[segmentIndex + 1] let fromDayNum = dayNumber(for: fromStop.departureDate, tripDays: tripDays) let toDayNum = dayNumber(for: toStop.arrivalDate, tripDays: tripDays) let minDay = max(fromDayNum + 1, 1) let maxDay = min(toDayNum, tripDays.count) if minDay > maxDay { return nil } return minDay...maxDay } private static func dayNumber(for date: Date, 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 date is before trip, return 0; if after, return count + 1 if let first = tripDays.first, target < calendar.startOfDay(for: first) { return 0 } return tripDays.count + 1 } }