Replace upfront loading of all games with lazy Sport → Team → Game hierarchy. Games are now fetched per-team when expanded and cached to prevent re-fetching. Also removes pagination workaround and pre-computes groupings in ScheduleViewModel to avoid per-render work. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
647 lines
23 KiB
Swift
647 lines
23 KiB
Swift
//
|
|
// TripCreationViewModel.swift
|
|
// SportsTime
|
|
//
|
|
|
|
import Foundation
|
|
import SwiftUI
|
|
import Observation
|
|
import CoreLocation
|
|
|
|
@Observable
|
|
final class TripCreationViewModel {
|
|
|
|
// MARK: - State
|
|
|
|
enum ViewState: Equatable {
|
|
case editing
|
|
case planning
|
|
case selectingOption([ItineraryOption]) // Multiple options to choose from
|
|
case completed(Trip)
|
|
case error(String)
|
|
|
|
static func == (lhs: ViewState, rhs: ViewState) -> Bool {
|
|
switch (lhs, rhs) {
|
|
case (.editing, .editing): return true
|
|
case (.planning, .planning): return true
|
|
case (.selectingOption(let o1), .selectingOption(let o2)): return o1.count == o2.count
|
|
case (.completed(let t1), .completed(let t2)): return t1.id == t2.id
|
|
case (.error(let e1), .error(let e2)): return e1 == e2
|
|
default: return false
|
|
}
|
|
}
|
|
}
|
|
|
|
var viewState: ViewState = .editing
|
|
|
|
// MARK: - Planning Mode
|
|
|
|
var planningMode: PlanningMode = .dateRange {
|
|
didSet {
|
|
// Reset state when mode changes to ensure clean UI transitions
|
|
if oldValue != planningMode {
|
|
viewState = .editing
|
|
// Clear mode-specific selections
|
|
switch planningMode {
|
|
case .dateRange, .gameFirst:
|
|
// Clear locations for date-based modes
|
|
startLocationText = ""
|
|
endLocationText = ""
|
|
startLocation = nil
|
|
endLocation = nil
|
|
case .locations:
|
|
// Keep locations (user needs to enter them)
|
|
break
|
|
case .followTeam:
|
|
// Clear locations and must-see games for follow team mode
|
|
startLocationText = ""
|
|
endLocationText = ""
|
|
startLocation = nil
|
|
endLocation = nil
|
|
mustSeeGameIds.removeAll()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Form Fields
|
|
|
|
// Locations (used in .locations mode)
|
|
var startLocationText: String = ""
|
|
var endLocationText: String = ""
|
|
var startLocation: LocationInput?
|
|
var endLocation: LocationInput?
|
|
|
|
// Sports
|
|
var selectedSports: Set<Sport> = [.mlb] {
|
|
didSet {
|
|
// Clear cached games when sports selection changes
|
|
if selectedSports != oldValue {
|
|
availableGames = []
|
|
games = []
|
|
}
|
|
}
|
|
}
|
|
|
|
// Dates
|
|
var startDate: Date = Date() {
|
|
didSet {
|
|
// Clear cached games when start date changes
|
|
// BUT: In gameFirst mode, games are the source of truth for dates,
|
|
// so don't clear them (fixes "date range required" error)
|
|
if !Calendar.current.isDate(startDate, inSameDayAs: oldValue) && planningMode != .gameFirst {
|
|
availableGames = []
|
|
games = []
|
|
}
|
|
}
|
|
}
|
|
var endDate: Date = Date().addingTimeInterval(86400 * 7) {
|
|
didSet {
|
|
// Clear cached games when end date changes
|
|
// BUT: In gameFirst mode, games are the source of truth for dates,
|
|
// so don't clear them (fixes "date range required" error)
|
|
if !Calendar.current.isDate(endDate, inSameDayAs: oldValue) && planningMode != .gameFirst {
|
|
availableGames = []
|
|
games = []
|
|
}
|
|
}
|
|
}
|
|
|
|
// Games
|
|
var mustSeeGameIds: Set<String> = []
|
|
var availableGames: [RichGame] = []
|
|
var isLoadingGames: Bool = false
|
|
|
|
// Travel
|
|
var travelMode: TravelMode = .drive
|
|
var routePreference: RoutePreference = .balanced
|
|
|
|
// Constraints
|
|
var useStopCount: Bool = true
|
|
var numberOfStops: Int = 5
|
|
var leisureLevel: LeisureLevel = .moderate
|
|
|
|
// Optional
|
|
var mustStopLocations: [LocationInput] = []
|
|
var preferredCities: [String] = []
|
|
var needsEVCharging: Bool = false
|
|
var lodgingType: LodgingType = .hotel
|
|
var numberOfDrivers: Int = 1
|
|
var maxDrivingHoursPerDriver: Double = 8
|
|
|
|
// Travel Preferences
|
|
var allowRepeatCities: Bool = true
|
|
var selectedRegions: Set<Region> = [.east, .central, .west]
|
|
|
|
// Follow Team Mode
|
|
var followTeamId: String?
|
|
var useHomeLocation: Bool = true
|
|
|
|
// Game First Mode - Trip duration for sliding windows
|
|
var gameFirstTripDuration: Int = 7
|
|
|
|
// MARK: - Dependencies
|
|
|
|
private let planningEngine = TripPlanningEngine()
|
|
private let locationService = LocationService.shared
|
|
private let dataProvider = AppDataProvider.shared
|
|
|
|
// MARK: - Cached Data
|
|
|
|
private var teams: [String: Team] = [:]
|
|
private var stadiums: [String: Stadium] = [:]
|
|
private var games: [Game] = []
|
|
private(set) var currentPreferences: TripPreferences?
|
|
|
|
// MARK: - Computed Properties
|
|
|
|
var isFormValid: Bool {
|
|
switch planningMode {
|
|
case .dateRange:
|
|
// Need: sports + valid date range
|
|
return !selectedSports.isEmpty && endDate > startDate
|
|
|
|
case .gameFirst:
|
|
// Need: at least one selected game + sports
|
|
return !mustSeeGameIds.isEmpty && !selectedSports.isEmpty
|
|
|
|
case .locations:
|
|
// Need: start + end locations + sports
|
|
return !startLocationText.isEmpty &&
|
|
!endLocationText.isEmpty &&
|
|
!selectedSports.isEmpty
|
|
|
|
case .followTeam:
|
|
// Need: team selected + valid date range
|
|
guard followTeamId != nil else { return false }
|
|
guard endDate > startDate else { return false }
|
|
// If using home location, need a valid start location
|
|
if useHomeLocation && startLocationText.isEmpty { return false }
|
|
return true
|
|
}
|
|
}
|
|
|
|
var formValidationMessage: String? {
|
|
switch planningMode {
|
|
case .dateRange:
|
|
if selectedSports.isEmpty { return "Select at least one sport" }
|
|
if endDate <= startDate { return "End date must be after start date" }
|
|
case .gameFirst:
|
|
if mustSeeGameIds.isEmpty { return "Select at least one game" }
|
|
if selectedSports.isEmpty { return "Select at least one sport" }
|
|
case .locations:
|
|
if startLocationText.isEmpty { return "Enter a starting location" }
|
|
if endLocationText.isEmpty { return "Enter an ending location" }
|
|
if selectedSports.isEmpty { return "Select at least one sport" }
|
|
|
|
case .followTeam:
|
|
if followTeamId == nil { return "Select a team to follow" }
|
|
if endDate <= startDate { return "End date must be after start date" }
|
|
if useHomeLocation && startLocationText.isEmpty { return "Enter your home location" }
|
|
}
|
|
return nil
|
|
}
|
|
|
|
var tripDurationDays: Int {
|
|
let days = Calendar.current.dateComponents([.day], from: startDate, to: endDate).day ?? 0
|
|
return max(1, days)
|
|
}
|
|
|
|
var selectedGamesCount: Int {
|
|
mustSeeGameIds.count
|
|
}
|
|
|
|
var selectedGames: [RichGame] {
|
|
availableGames.filter { mustSeeGameIds.contains($0.game.id) }
|
|
}
|
|
|
|
/// Computed date range for game-first mode based on selected games
|
|
var gameFirstDateRange: (start: Date, end: Date)? {
|
|
guard !selectedGames.isEmpty else { return nil }
|
|
let gameDates = selectedGames.map { $0.game.dateTime }
|
|
guard let earliest = gameDates.min(),
|
|
let latest = gameDates.max() else { return nil }
|
|
return (earliest, latest)
|
|
}
|
|
|
|
/// Teams grouped by sport for Follow Team picker
|
|
var teamsBySport: [Sport: [Team]] {
|
|
var grouped: [Sport: [Team]] = [:]
|
|
for team in dataProvider.teams {
|
|
grouped[team.sport, default: []].append(team)
|
|
}
|
|
// Sort teams alphabetically within each sport
|
|
for sport in grouped.keys {
|
|
grouped[sport]?.sort { $0.name < $1.name }
|
|
}
|
|
return grouped
|
|
}
|
|
|
|
/// The currently followed team (for display)
|
|
var followedTeam: Team? {
|
|
guard let teamId = followTeamId else { return nil }
|
|
return dataProvider.teams.first { $0.id == teamId }
|
|
}
|
|
|
|
// MARK: - Actions
|
|
|
|
func loadScheduleData() async {
|
|
do {
|
|
// Ensure initial data is loaded
|
|
if dataProvider.teams.isEmpty {
|
|
await dataProvider.loadInitialData()
|
|
}
|
|
|
|
// Use cached teams and stadiums from data provider
|
|
for team in dataProvider.teams {
|
|
teams[team.id] = team
|
|
}
|
|
for stadium in dataProvider.stadiums {
|
|
stadiums[stadium.id] = stadium
|
|
}
|
|
|
|
// Filter games within date range
|
|
games = try await dataProvider.filterGames(
|
|
sports: selectedSports,
|
|
startDate: startDate,
|
|
endDate: endDate
|
|
)
|
|
|
|
// Build rich games for display
|
|
availableGames = games.compactMap { game -> RichGame? in
|
|
guard let homeTeam = teams[game.homeTeamId],
|
|
let awayTeam = teams[game.awayTeamId],
|
|
let stadium = stadiums[game.stadiumId] else {
|
|
return nil
|
|
}
|
|
return RichGame(game: game, homeTeam: homeTeam, awayTeam: awayTeam, stadium: stadium)
|
|
}
|
|
} catch {
|
|
viewState = .error("Failed to load schedule data: \(error.localizedDescription)")
|
|
}
|
|
}
|
|
|
|
func resolveLocations() async {
|
|
do {
|
|
// Only resolve if we don't already have a location with coordinates
|
|
// (LocationSearchSheet already provides full LocationInput with coordinates)
|
|
if !startLocationText.isEmpty && startLocation?.coordinate == nil {
|
|
print("🔍 resolveLocations: Resolving startLocation from text '\(startLocationText)'")
|
|
startLocation = try await locationService.resolveLocation(
|
|
LocationInput(name: startLocationText, address: startLocationText)
|
|
)
|
|
} else if startLocation?.coordinate != nil {
|
|
print("🔍 resolveLocations: startLocation already has coordinates, skipping resolve")
|
|
}
|
|
|
|
if !endLocationText.isEmpty && endLocation?.coordinate == nil {
|
|
print("🔍 resolveLocations: Resolving endLocation from text '\(endLocationText)'")
|
|
endLocation = try await locationService.resolveLocation(
|
|
LocationInput(name: endLocationText, address: endLocationText)
|
|
)
|
|
} else if endLocation?.coordinate != nil {
|
|
print("🔍 resolveLocations: endLocation already has coordinates, skipping resolve")
|
|
}
|
|
} catch {
|
|
viewState = .error("Failed to resolve locations: \(error.localizedDescription)")
|
|
}
|
|
}
|
|
|
|
func planTrip() async {
|
|
guard isFormValid else { return }
|
|
|
|
viewState = .planning
|
|
|
|
// Mode-specific setup
|
|
var effectiveStartDate = startDate
|
|
var effectiveEndDate = endDate
|
|
var resolvedStartLocation: LocationInput?
|
|
var resolvedEndLocation: LocationInput?
|
|
|
|
switch planningMode {
|
|
case .dateRange:
|
|
// Use provided date range, no location needed
|
|
// Games will be found within the date range across all regions
|
|
effectiveStartDate = startDate
|
|
effectiveEndDate = endDate
|
|
|
|
case .gameFirst:
|
|
// Calculate date range from selected games + buffer
|
|
if let dateRange = gameFirstDateRange {
|
|
effectiveStartDate = dateRange.start
|
|
effectiveEndDate = dateRange.end
|
|
}
|
|
// Derive start/end locations from first/last game stadiums
|
|
if let firstGame = selectedGames.sorted(by: { $0.game.dateTime < $1.game.dateTime }).first,
|
|
let lastGame = selectedGames.sorted(by: { $0.game.dateTime < $1.game.dateTime }).last {
|
|
resolvedStartLocation = LocationInput(
|
|
name: firstGame.stadium.city,
|
|
coordinate: firstGame.stadium.coordinate,
|
|
address: "\(firstGame.stadium.city), \(firstGame.stadium.state)"
|
|
)
|
|
resolvedEndLocation = LocationInput(
|
|
name: lastGame.stadium.city,
|
|
coordinate: lastGame.stadium.coordinate,
|
|
address: "\(lastGame.stadium.city), \(lastGame.stadium.state)"
|
|
)
|
|
}
|
|
|
|
case .locations:
|
|
// Resolve provided locations
|
|
print("🔍 ViewModel.planTrip: .locations mode")
|
|
print(" - startLocationText: '\(startLocationText)'")
|
|
print(" - endLocationText: '\(endLocationText)'")
|
|
print(" - startLocation BEFORE resolve: \(startLocation?.name ?? "nil")")
|
|
print(" - endLocation BEFORE resolve: \(endLocation?.name ?? "nil")")
|
|
|
|
await resolveLocations()
|
|
resolvedStartLocation = startLocation
|
|
resolvedEndLocation = endLocation
|
|
|
|
print(" - startLocation AFTER resolve: \(startLocation?.name ?? "nil")")
|
|
print(" - endLocation AFTER resolve: \(endLocation?.name ?? "nil")")
|
|
print(" - resolvedStartLocation: \(resolvedStartLocation?.name ?? "nil")")
|
|
print(" - resolvedEndLocation: \(resolvedEndLocation?.name ?? "nil")")
|
|
|
|
guard resolvedStartLocation != nil, resolvedEndLocation != nil else {
|
|
viewState = .error("Could not resolve start or end location")
|
|
return
|
|
}
|
|
|
|
case .followTeam:
|
|
// Use provided date range
|
|
effectiveStartDate = startDate
|
|
effectiveEndDate = endDate
|
|
|
|
// If using home location, resolve it
|
|
if useHomeLocation && !startLocationText.isEmpty {
|
|
await resolveLocations()
|
|
resolvedStartLocation = startLocation
|
|
resolvedEndLocation = startLocation // Round trip - same start/end
|
|
}
|
|
// Otherwise, planner will use first/last game locations (fly-in/fly-out)
|
|
}
|
|
|
|
// Ensure we have games data
|
|
if games.isEmpty {
|
|
await loadScheduleData()
|
|
}
|
|
|
|
// Build preferences
|
|
let preferences = TripPreferences(
|
|
planningMode: planningMode,
|
|
startLocation: resolvedStartLocation,
|
|
endLocation: resolvedEndLocation,
|
|
sports: selectedSports,
|
|
mustSeeGameIds: mustSeeGameIds,
|
|
travelMode: travelMode,
|
|
startDate: effectiveStartDate,
|
|
endDate: effectiveEndDate,
|
|
numberOfStops: useStopCount ? numberOfStops : nil,
|
|
tripDuration: useStopCount ? nil : tripDurationDays,
|
|
leisureLevel: leisureLevel,
|
|
mustStopLocations: mustStopLocations,
|
|
preferredCities: preferredCities,
|
|
routePreference: routePreference,
|
|
needsEVCharging: needsEVCharging,
|
|
lodgingType: lodgingType,
|
|
numberOfDrivers: numberOfDrivers,
|
|
maxDrivingHoursPerDriver: maxDrivingHoursPerDriver,
|
|
allowRepeatCities: allowRepeatCities,
|
|
selectedRegions: selectedRegions,
|
|
followTeamId: followTeamId,
|
|
useHomeLocation: useHomeLocation,
|
|
gameFirstTripDuration: gameFirstTripDuration
|
|
)
|
|
|
|
// Build planning request
|
|
let request = PlanningRequest(
|
|
preferences: preferences,
|
|
availableGames: games,
|
|
teams: teams,
|
|
stadiums: stadiums
|
|
)
|
|
|
|
// Plan the trip
|
|
let result = planningEngine.planItineraries(request: request)
|
|
|
|
switch result {
|
|
case .success(var options):
|
|
guard !options.isEmpty else {
|
|
viewState = .error("No valid itinerary found")
|
|
return
|
|
}
|
|
|
|
// Enrich with EV chargers if requested and feature is enabled
|
|
if FeatureFlags.enableEVCharging && needsEVCharging {
|
|
options = await ItineraryBuilder.enrichWithEVChargers(options)
|
|
}
|
|
|
|
// Store preferences for later conversion
|
|
currentPreferences = preferences
|
|
|
|
if options.count == 1 {
|
|
// Only one option - go directly to detail
|
|
let trip = convertToTrip(option: options[0], preferences: preferences)
|
|
viewState = .completed(trip)
|
|
} else {
|
|
// Multiple options - show selection view
|
|
viewState = .selectingOption(options)
|
|
}
|
|
|
|
case .failure(let failure):
|
|
viewState = .error(failureMessage(for: failure))
|
|
}
|
|
}
|
|
|
|
func toggleMustSeeGame(_ gameId: String) {
|
|
if mustSeeGameIds.contains(gameId) {
|
|
mustSeeGameIds.remove(gameId)
|
|
} else {
|
|
mustSeeGameIds.insert(gameId)
|
|
}
|
|
}
|
|
|
|
func deselectAllGames() {
|
|
mustSeeGameIds.removeAll()
|
|
}
|
|
|
|
func switchPlanningMode(_ mode: PlanningMode) {
|
|
// Just set the mode - didSet observer handles state reset
|
|
planningMode = mode
|
|
}
|
|
|
|
/// Load games for browsing in game-first mode
|
|
func loadGamesForBrowsing() async {
|
|
isLoadingGames = true
|
|
do {
|
|
// Ensure initial data is loaded
|
|
if dataProvider.teams.isEmpty {
|
|
await dataProvider.loadInitialData()
|
|
}
|
|
|
|
// Use cached teams and stadiums from data provider
|
|
for team in dataProvider.teams {
|
|
teams[team.id] = team
|
|
}
|
|
for stadium in dataProvider.stadiums {
|
|
stadiums[stadium.id] = stadium
|
|
}
|
|
|
|
// Fetch all games for browsing (no date filter)
|
|
games = try await dataProvider.allGames(for: selectedSports)
|
|
|
|
// Build rich games for display
|
|
availableGames = games.compactMap { game -> RichGame? in
|
|
guard let homeTeam = teams[game.homeTeamId],
|
|
let awayTeam = teams[game.awayTeamId],
|
|
let stadium = stadiums[game.stadiumId] else {
|
|
return nil
|
|
}
|
|
return RichGame(game: game, homeTeam: homeTeam, awayTeam: awayTeam, stadium: stadium)
|
|
}.sorted { $0.game.dateTime < $1.game.dateTime }
|
|
|
|
} catch {
|
|
viewState = .error("Failed to load games: \(error.localizedDescription)")
|
|
}
|
|
isLoadingGames = false
|
|
}
|
|
|
|
func addMustStopLocation(_ location: LocationInput) {
|
|
guard !mustStopLocations.contains(where: { $0.name == location.name }) else { return }
|
|
mustStopLocations.append(location)
|
|
}
|
|
|
|
func removeMustStopLocation(_ location: LocationInput) {
|
|
mustStopLocations.removeAll { $0.name == location.name }
|
|
}
|
|
|
|
func addPreferredCity(_ city: String) {
|
|
guard !city.isEmpty, !preferredCities.contains(city) else { return }
|
|
preferredCities.append(city)
|
|
}
|
|
|
|
func removePreferredCity(_ city: String) {
|
|
preferredCities.removeAll { $0 == city }
|
|
}
|
|
|
|
func reset() {
|
|
viewState = .editing
|
|
planningMode = .dateRange
|
|
startLocationText = ""
|
|
endLocationText = ""
|
|
startLocation = nil
|
|
endLocation = nil
|
|
selectedSports = [.mlb]
|
|
startDate = Date()
|
|
endDate = Date().addingTimeInterval(86400 * 7)
|
|
mustSeeGameIds = []
|
|
numberOfStops = 5
|
|
leisureLevel = .moderate
|
|
mustStopLocations = []
|
|
preferredCities = []
|
|
availableGames = []
|
|
isLoadingGames = false
|
|
currentPreferences = nil
|
|
allowRepeatCities = true
|
|
selectedRegions = [.east, .central, .west]
|
|
}
|
|
|
|
/// Toggles region selection. Any combination is allowed.
|
|
func toggleRegion(_ region: Region) {
|
|
if selectedRegions.contains(region) {
|
|
selectedRegions.remove(region)
|
|
} else {
|
|
selectedRegions.insert(region)
|
|
}
|
|
}
|
|
|
|
/// Select a specific itinerary option and navigate to its detail
|
|
func selectOption(_ option: ItineraryOption) {
|
|
guard let preferences = currentPreferences else {
|
|
viewState = .error("Unable to load trip preferences")
|
|
return
|
|
}
|
|
let trip = convertToTrip(option: option, preferences: preferences)
|
|
viewState = .completed(trip)
|
|
}
|
|
|
|
/// Convert an itinerary option to a Trip (public for use by TripOptionsView)
|
|
func convertOptionToTrip(_ option: ItineraryOption) -> Trip {
|
|
let preferences = currentPreferences ?? TripPreferences(
|
|
planningMode: planningMode,
|
|
startLocation: nil,
|
|
endLocation: nil,
|
|
sports: selectedSports,
|
|
mustSeeGameIds: mustSeeGameIds,
|
|
travelMode: travelMode,
|
|
startDate: startDate,
|
|
endDate: endDate,
|
|
numberOfStops: useStopCount ? numberOfStops : nil,
|
|
tripDuration: useStopCount ? nil : tripDurationDays,
|
|
leisureLevel: leisureLevel,
|
|
mustStopLocations: mustStopLocations,
|
|
preferredCities: preferredCities,
|
|
routePreference: routePreference,
|
|
needsEVCharging: needsEVCharging,
|
|
lodgingType: lodgingType,
|
|
numberOfDrivers: numberOfDrivers,
|
|
maxDrivingHoursPerDriver: maxDrivingHoursPerDriver,
|
|
allowRepeatCities: allowRepeatCities,
|
|
selectedRegions: selectedRegions,
|
|
followTeamId: followTeamId,
|
|
useHomeLocation: useHomeLocation,
|
|
gameFirstTripDuration: gameFirstTripDuration
|
|
)
|
|
return convertToTrip(option: option, preferences: preferences)
|
|
}
|
|
|
|
/// Go back to option selection from trip detail
|
|
func backToOptions() {
|
|
if case .completed = viewState {
|
|
// We'd need to store options to go back - for now, restart planning
|
|
viewState = .editing
|
|
}
|
|
}
|
|
|
|
// MARK: - Conversion Helpers
|
|
|
|
private func convertToTrip(option: ItineraryOption, preferences: TripPreferences) -> Trip {
|
|
// Convert ItineraryStops to TripStops
|
|
let tripStops = option.stops.enumerated().map { index, stop in
|
|
TripStop(
|
|
stopNumber: index + 1,
|
|
city: stop.city,
|
|
state: stop.state,
|
|
coordinate: stop.coordinate,
|
|
arrivalDate: stop.arrivalDate,
|
|
departureDate: stop.departureDate,
|
|
games: stop.games,
|
|
isRestDay: stop.games.isEmpty
|
|
)
|
|
}
|
|
|
|
return Trip(
|
|
name: generateTripName(from: tripStops),
|
|
preferences: preferences,
|
|
stops: tripStops,
|
|
travelSegments: option.travelSegments,
|
|
totalGames: option.totalGames,
|
|
totalDistanceMeters: option.totalDistanceMiles * 1609.34,
|
|
totalDrivingSeconds: option.totalDrivingHours * 3600
|
|
)
|
|
}
|
|
|
|
private func generateTripName(from stops: [TripStop]) -> String {
|
|
let cities = stops.compactMap { $0.city }.prefix(3)
|
|
if cities.count <= 1 {
|
|
return cities.first ?? "Road Trip"
|
|
}
|
|
return cities.joined(separator: " → ")
|
|
}
|
|
|
|
private func failureMessage(for failure: PlanningFailure) -> String {
|
|
failure.message
|
|
}
|
|
}
|