- Add a11y label to ProgressMapView reset button and progress bar values - Fix CADisplayLink retain cycle in ItineraryTableViewController via deinit - Add [weak self] to PhotoGalleryViewModel Task closure - Add @MainActor to TripWizardViewModel, remove manual MainActor.run hop - Fix O(n²) rank lookup in PollDetailView/DebugPollPreviewView with enumerated() - Cache itinerarySections via ItinerarySectionBuilder static extraction + @State - Convert CanonicalSyncService/BootstrapService from actor to @MainActor final class - Add .accessibilityHidden(true) to RegionMapSelector Map to prevent duplicate VoiceOver Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
149 lines
5.6 KiB
Swift
149 lines
5.6 KiB
Swift
//
|
|
// 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<Int>? {
|
|
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
|
|
}
|
|
}
|