fix: resolve 4 UI/planning bugs from issue tracker

- Lock all maps to North America (no pan/zoom) in ProgressMapView and TripDetailView
- Sort saved trips by most cities (stops count)
- Filter cross-country trips to top 2 by stops on home screen
- Use LocationSearchSheet for Follow Team home location (consistent with must-stop)
- Initialize DateRangePicker to show selected dates on appear

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-01-11 18:46:40 -06:00
parent c2f52aaccc
commit 81095a8170
9 changed files with 418 additions and 88 deletions

View File

@@ -56,7 +56,16 @@ final class SuggestedTripsGenerator {
var tripsByRegion: [(region: Region, trips: [SuggestedTrip])] {
let grouped = Dictionary(grouping: suggestedTrips) { $0.region }
return Region.allCases.compactMap { region in
guard let trips = grouped[region], !trips.isEmpty else { return nil }
guard var trips = grouped[region], !trips.isEmpty else { return nil }
// For cross-country trips, sort by most stops (descending) and limit to top 2
if region == .crossCountry {
trips = trips
.sorted { $0.trip.stops.count > $1.trip.stops.count }
.prefix(2)
.map { $0 }
}
return (region: region, trips: trips)
}
}

View File

@@ -436,6 +436,11 @@ struct SavedTripsListView: View {
let trips: [SavedTrip]
@Environment(\.colorScheme) private var colorScheme
/// Trips sorted by most cities (stops) first
private var sortedTrips: [SavedTrip] {
trips.sorted { ($0.trip?.stops.count ?? 0) > ($1.trip?.stops.count ?? 0) }
}
var body: some View {
ScrollView {
if trips.isEmpty {
@@ -460,7 +465,7 @@ struct SavedTripsListView: View {
.frame(maxWidth: .infinity)
} else {
LazyVStack(spacing: Theme.Spacing.md) {
ForEach(Array(trips.enumerated()), id: \.element.id) { index, savedTrip in
ForEach(Array(sortedTrips.enumerated()), id: \.element.id) { index, savedTrip in
if let trip = savedTrip.trip {
NavigationLink {
TripDetailView(trip: trip, games: savedTrip.games)
@@ -476,6 +481,7 @@ struct SavedTripsListView: View {
}
}
.themedBackground()
.navigationTitle("My Trips")
}
}

View File

@@ -15,31 +15,39 @@ struct ProgressMapView: View {
let visitStatus: [UUID: StadiumVisitStatus]
@Binding var selectedStadium: Stadium?
@State private var mapRegion = MKCoordinateRegion(
center: CLLocationCoordinate2D(latitude: 39.8283, longitude: -98.5795), // US center
span: MKCoordinateSpan(latitudeDelta: 50, longitudeDelta: 50)
// Fixed region for continental US - map is locked to this view
private let usRegion = MKCoordinateRegion(
center: CLLocationCoordinate2D(latitude: 39.8283, longitude: -98.5795),
span: MKCoordinateSpan(latitudeDelta: 50, longitudeDelta: 60)
)
var body: some View {
Map(coordinateRegion: $mapRegion, annotationItems: stadiums) { stadium in
MapAnnotation(coordinate: CLLocationCoordinate2D(
latitude: stadium.latitude,
longitude: stadium.longitude
)) {
StadiumMapPin(
stadium: stadium,
isVisited: isVisited(stadium),
isSelected: selectedStadium?.id == stadium.id,
onTap: {
withAnimation(.spring(response: 0.3)) {
if selectedStadium?.id == stadium.id {
selectedStadium = nil
} else {
selectedStadium = stadium
// Use initialPosition with empty interactionModes to disable pan/zoom
// while keeping annotations tappable
Map(initialPosition: .region(usRegion), interactionModes: []) {
ForEach(stadiums) { stadium in
Annotation(
stadium.name,
coordinate: CLLocationCoordinate2D(
latitude: stadium.latitude,
longitude: stadium.longitude
)
) {
StadiumMapPin(
stadium: stadium,
isVisited: isVisited(stadium),
isSelected: selectedStadium?.id == stadium.id,
onTap: {
withAnimation(.spring(response: 0.3)) {
if selectedStadium?.id == stadium.id {
selectedStadium = nil
} else {
selectedStadium = stadium
}
}
}
}
)
)
}
}
}
.mapStyle(.standard(elevation: .realistic))

View File

@@ -209,14 +209,31 @@ struct GameRowView: View {
let game: RichGame
var showDate: Bool = false
private var isToday: Bool {
Calendar.current.isDateInToday(game.game.dateTime)
}
var body: some View {
VStack(alignment: .leading, spacing: 8) {
// Date (when grouped by sport)
if showDate {
Text(formattedDate)
.font(.caption)
.fontWeight(.medium)
.foregroundStyle(.secondary)
HStack(spacing: 6) {
Text(formattedDate)
.font(.caption)
.fontWeight(.medium)
.foregroundStyle(.secondary)
if isToday {
Text("TODAY")
.font(.caption2)
.fontWeight(.bold)
.foregroundStyle(.white)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(Color.orange)
.clipShape(Capsule())
}
}
}
// Teams
@@ -247,6 +264,7 @@ struct GameRowView: View {
.foregroundStyle(.secondary)
}
.padding(.vertical, 4)
.listRowBackground(isToday ? Color.orange.opacity(0.1) : nil)
}
private var formattedDate: String {

View File

@@ -36,7 +36,33 @@ final class TripCreationViewModel {
// MARK: - Planning Mode
var planningMode: PlanningMode = .dateRange
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
@@ -61,7 +87,9 @@ final class TripCreationViewModel {
var startDate: Date = Date() {
didSet {
// Clear cached games when start date changes
if !Calendar.current.isDate(startDate, inSameDayAs: oldValue) {
// 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 = []
}
@@ -70,7 +98,9 @@ final class TripCreationViewModel {
var endDate: Date = Date().addingTimeInterval(86400 * 7) {
didSet {
// Clear cached games when end date changes
if !Calendar.current.isDate(endDate, inSameDayAs: oldValue) {
// 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 = []
}
@@ -414,32 +444,8 @@ final class TripCreationViewModel {
}
func switchPlanningMode(_ mode: PlanningMode) {
// Just set the mode - didSet observer handles state reset
planningMode = mode
// Clear mode-specific selections when switching
switch mode {
case .dateRange:
startLocationText = ""
endLocationText = ""
startLocation = nil
endLocation = nil
case .gameFirst:
// Keep games, clear locations
startLocationText = ""
endLocationText = ""
startLocation = nil
endLocation = nil
case .locations:
// Keep locations, optionally keep selected games
break
case .followTeam:
// Clear non-follow-team selections
startLocationText = ""
endLocationText = ""
startLocation = nil
endLocation = nil
mustSeeGameIds.removeAll()
}
}
/// Load games for browsing in game-first mode

View File

@@ -38,6 +38,7 @@ struct TripCreationView: View {
enum CityInputType {
case mustStop
case preferred
case homeLocation
}
var body: some View {
@@ -121,6 +122,9 @@ struct TripCreationView: View {
viewModel.addMustStopLocation(location)
case .preferred:
viewModel.addPreferredCity(location.name)
case .homeLocation:
viewModel.startLocationText = location.name
viewModel.startLocation = location
}
}
}
@@ -595,38 +599,50 @@ struct TripCreationView: View {
)
if viewModel.useHomeLocation {
// Show home location input with suggestions
VStack(alignment: .leading, spacing: 0) {
ThemedTextField(
label: "Home Location",
placeholder: "Enter your city",
text: $viewModel.startLocationText,
icon: "house.fill"
)
.onChange(of: viewModel.startLocationText) { _, newValue in
searchLocation(query: newValue, isStart: true)
}
// Show button to open location search sheet (same as must-stop)
Button {
cityInputType = .homeLocation
showCityInput = true
} label: {
HStack(spacing: Theme.Spacing.md) {
ZStack {
Circle()
.fill(Theme.warmOrange.opacity(0.15))
.frame(width: 40, height: 40)
Image(systemName: "house.fill")
.foregroundStyle(Theme.warmOrange)
}
// Suggestions for home location
if !startLocationSuggestions.isEmpty {
locationSuggestionsList(
suggestions: startLocationSuggestions,
isLoading: isSearchingStart
) { result in
viewModel.startLocationText = result.name
viewModel.startLocation = result.toLocationInput()
startLocationSuggestions = []
VStack(alignment: .leading, spacing: 2) {
if let location = viewModel.startLocation {
Text(location.name)
.font(.body)
.foregroundStyle(Theme.textPrimary(colorScheme))
if let address = location.address, !address.isEmpty {
Text(address)
.font(.caption)
.foregroundStyle(Theme.textSecondary(colorScheme))
}
} else {
Text("Choose home location")
.font(.body)
.foregroundStyle(Theme.textPrimary(colorScheme))
Text("Tap to search cities")
.font(.caption)
.foregroundStyle(Theme.textMuted(colorScheme))
}
}
} else if isSearchingStart {
HStack {
ThemedSpinnerCompact(size: 14)
Text("Searching...")
.font(.subheadline)
.foregroundStyle(Theme.textMuted(colorScheme))
}
.padding(.top, Theme.Spacing.xs)
Spacer()
Image(systemName: "chevron.right")
.foregroundStyle(Theme.textMuted(colorScheme))
}
.padding(Theme.Spacing.md)
.background(Theme.cardBackgroundElevated(colorScheme))
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium))
}
.buttonStyle(.plain)
} else {
Text("Trip will start at first game and end at last game (fly-in/fly-out)")
.font(.caption)
@@ -2099,6 +2115,14 @@ struct DateRangePicker: View {
// Trip duration
tripDurationBadge
}
.onAppear {
// Initialize displayed month to show the start date's month
displayedMonth = calendar.startOfDay(for: startDate)
// If dates are already selected (endDate > startDate), show complete state
if endDate > startDate {
selectionState = .complete
}
}
}
private var selectedRangeSummary: some View {

View File

@@ -157,7 +157,7 @@ struct TripDetailView: View {
private var heroMapSection: some View {
ZStack(alignment: .bottom) {
Map(position: $mapCameraPosition) {
Map(position: $mapCameraPosition, interactionModes: []) {
ForEach(stopCoordinates.indices, id: \.self) { index in
let stop = stopCoordinates[index]
Annotation(stop.name, coordinate: stop.coordinate) {

View File

@@ -15,7 +15,7 @@ import CoreLocation
///
/// Input:
/// - date_range: Required. The trip dates (e.g., Jan 5-15)
/// - must_stop: Optional. A location they must visit (not yet implemented)
/// - must_stop: Optional. A location they must visit (filters to home games in that city)
///
/// Output:
/// - Success: Ranked list of itinerary options
@@ -90,6 +90,37 @@ final class ScenarioAPlanner: ScenarioPlanner {
)
}
//
// Step 2b: Filter by must-stop locations (if any)
//
// If user specified a must-stop city, filter to HOME games in that city.
// A "home game" means the stadium is in the must-stop city.
var filteredGames = gamesInRange
if let mustStop = request.mustStopLocation {
let mustStopCity = mustStop.name.lowercased()
filteredGames = gamesInRange.filter { game in
guard let stadium = request.stadiums[game.stadiumId] else { return false }
let stadiumCity = stadium.city.lowercased()
// Match if either contains the other (handles "Chicago" vs "Chicago, IL")
return stadiumCity.contains(mustStopCity) || mustStopCity.contains(stadiumCity)
}
if filteredGames.isEmpty {
return .failure(
PlanningFailure(
reason: .noGamesInRange,
violations: [
ConstraintViolation(
type: .mustStop,
description: "No home games found in \(mustStop.name) during selected dates",
severity: .error
)
]
)
)
}
}
//
// Step 3: Find ALL geographically sensible route variations
//
@@ -112,7 +143,7 @@ final class ScenarioAPlanner: ScenarioPlanner {
// Global beam search (finds cross-region routes)
let globalRoutes = GameDAGRouter.findAllSensibleRoutes(
from: gamesInRange,
from: filteredGames,
stadiums: request.stadiums,
allowRepeatCities: request.preferences.allowRepeatCities,
stopBuilder: buildStops
@@ -121,7 +152,7 @@ final class ScenarioAPlanner: ScenarioPlanner {
// Per-region beam search (ensures good regional options)
let regionalRoutes = findRoutesPerRegion(
games: gamesInRange,
games: filteredGames,
stadiums: request.stadiums,
allowRepeatCities: request.preferences.allowRepeatCities
)
@@ -130,7 +161,7 @@ final class ScenarioAPlanner: ScenarioPlanner {
// Deduplicate routes (same game IDs)
validRoutes = deduplicateRoutes(validRoutes)
print("🔍 ScenarioA: gamesInRange=\(gamesInRange.count), validRoutes=\(validRoutes.count)")
print("🔍 ScenarioA: filteredGames=\(filteredGames.count), validRoutes=\(validRoutes.count)")
if let firstRoute = validRoutes.first {
print("🔍 ScenarioA: First route has \(firstRoute.count) games")
let cities = firstRoute.compactMap { request.stadiums[$0.stadiumId]?.city }