Refactor travel segments and simplify trip options

Travel segment architecture:
- Remove departureTime/arrivalTime from TravelSegment (location-based, not date-based)
- Fix travel sections appearing after destination instead of between cities
- Fix missing travel segments when revisiting same city (consecutive grouping)
- Remove unwanted rest day at end of trip

Planning engine fixes:
- All three planners now group only consecutive games at same stadium
- Visiting A → B → A creates 3 stops with proper travel between

UI simplification:
- Remove redundant sort options (mostDriving/leastDriving, mostCities/leastCities)
- Remove unused "Find Other Sports Along Route" toggle (was dead code)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-01-07 19:39:53 -06:00
parent 40a6f879e3
commit 4184af60b5
29 changed files with 140675 additions and 144310 deletions

View File

@@ -8,7 +8,9 @@
"Bash(python3:*)",
"Bash(cat:*)",
"Bash(ls:*)",
"Bash(xcrun simctl install:*)"
"Bash(xcrun simctl install:*)",
"Skill(frontend-design:frontend-design)",
"Bash(xcrun simctl io:*)"
]
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 540 KiB

View File

@@ -35,6 +35,22 @@ HOST = "https://api.apple-cloudkit.com"
BATCH_SIZE = 200
def deterministic_uuid(string: str) -> str:
"""
Generate a deterministic UUID from a string using SHA256.
Matches the StubDataProvider.deterministicUUID() implementation in Swift.
"""
# SHA256 hash of the string
hash_bytes = hashlib.sha256(string.encode('utf-8')).digest()
# Use first 16 bytes
uuid_bytes = bytearray(hash_bytes[:16])
# Set UUID version (4) and variant bits to match Swift implementation
uuid_bytes[6] = (uuid_bytes[6] & 0x0F) | 0x40
uuid_bytes[8] = (uuid_bytes[8] & 0x3F) | 0x80
# Format as UUID string
return f"{uuid_bytes[0:4].hex()}-{uuid_bytes[4:6].hex()}-{uuid_bytes[6:8].hex()}-{uuid_bytes[8:10].hex()}-{uuid_bytes[10:16].hex()}".upper()
class CloudKit:
def __init__(self, key_id, private_key, container, env):
self.key_id = key_id
@@ -69,7 +85,7 @@ class CloudKit:
except:
return {'error': f"{r.status_code}: {r.text[:200]}"}
def query(self, record_type, limit=200):
def query(self, record_type, limit=200, verbose=False):
"""Query records of a given type."""
path = f"{self.path_base}/records/query"
body = json.dumps({
@@ -83,16 +99,28 @@ class CloudKit:
'X-Apple-CloudKit-Request-ISO8601Date': date,
'X-Apple-CloudKit-Request-SignatureV1': self._sign(date, body, path),
}
r = requests.post(f"{HOST}{path}", headers=headers, data=body, timeout=60)
if r.status_code == 200:
return r.json()
return {'error': f"{r.status_code}: {r.text[:200]}"}
if verbose:
print(f" Querying {record_type}...")
try:
r = requests.post(f"{HOST}{path}", headers=headers, data=body, timeout=30)
if verbose:
print(f" Response: {r.status_code}")
if r.status_code == 200:
result = r.json()
if verbose:
print(f" Found {len(result.get('records', []))} records")
return result
return {'error': f"{r.status_code}: {r.text[:200]}"}
except requests.exceptions.Timeout:
return {'error': 'Request timed out after 30s'}
except Exception as e:
return {'error': f"Request failed: {e}"}
def delete_all(self, record_type, verbose=False):
"""Delete all records of a given type."""
total_deleted = 0
while True:
result = self.query(record_type)
result = self.query(record_type, verbose=verbose)
if 'error' in result:
print(f" Query error: {result['error']}")
break
@@ -101,21 +129,38 @@ class CloudKit:
if not records:
break
# Build delete operations
# Build delete operations (recordChangeTag required for delete)
ops = [{
'operationType': 'delete',
'record': {'recordName': r['recordName'], 'recordType': record_type}
'record': {
'recordName': r['recordName'],
'recordType': record_type,
'recordChangeTag': r.get('recordChangeTag', '')
}
} for r in records]
if verbose:
print(f" Sending delete for {len(ops)} records...")
delete_result = self.modify(ops)
if verbose:
print(f" Delete response: {json.dumps(delete_result)[:500]}")
if 'error' in delete_result:
print(f" Delete error: {delete_result['error']}")
break
deleted = len(delete_result.get('records', []))
total_deleted += deleted
if verbose:
print(f" Deleted {deleted} {record_type} records...")
# Check for individual record errors
result_records = delete_result.get('records', [])
successful = [r for r in result_records if 'serverErrorCode' not in r]
failed = [r for r in result_records if 'serverErrorCode' in r]
if failed and verbose:
print(f" Failed: {failed[0]}")
total_deleted += len(successful)
print(f" Deleted {len(successful)} {record_type} records" + (f" ({len(failed)} failed)" if failed else ""))
time.sleep(0.5)
@@ -222,13 +267,16 @@ def main():
stats = {'stadiums': 0, 'teams': 0, 'games': 0}
team_map = {}
# Build stadium UUID lookup (stadium string ID -> UUID)
stadium_uuid_map = {s['id']: deterministic_uuid(s['id']) for s in stadiums}
# Import stadiums & teams
if not args.games_only:
print("--- Stadiums ---")
recs = [{
'recordType': 'Stadium', 'recordName': s['id'],
'recordType': 'Stadium', 'recordName': stadium_uuid_map[s['id']],
'fields': {
'stadiumId': {'value': s['id']}, 'name': {'value': s['name']},
'stadiumId': {'value': stadium_uuid_map[s['id']]}, 'name': {'value': s['name']},
'city': {'value': s['city']}, 'state': {'value': s.get('state', '')},
'sport': {'value': s['sport']}, 'source': {'value': s.get('source', '')},
'teamAbbrevs': {'value': s.get('team_abbrevs', [])},
@@ -243,25 +291,39 @@ def main():
teams = {}
for s in stadiums:
for abbr in s.get('team_abbrevs', []):
if abbr not in teams:
teams[abbr] = {'city': s['city'], 'sport': s['sport']}
team_map[abbr] = f"team_{abbr.lower()}"
team_key = f"{s['sport']}_{abbr}" # Match Swift: "{sport.rawValue}_{abbrev}"
if team_key not in teams:
teams[team_key] = {'abbr': abbr, 'city': s['city'], 'sport': s['sport']}
team_uuid = deterministic_uuid(team_key)
team_map[(s['sport'], abbr)] = team_uuid
recs = [{
'recordType': 'Team', 'recordName': f"team_{abbr.lower()}",
'recordType': 'Team', 'recordName': deterministic_uuid(team_key),
'fields': {
'teamId': {'value': f"team_{abbr.lower()}"}, 'abbreviation': {'value': abbr},
'name': {'value': abbr}, 'city': {'value': info['city']}, 'sport': {'value': info['sport']},
'teamId': {'value': deterministic_uuid(team_key)},
'abbreviation': {'value': info['abbr']},
'name': {'value': info['abbr']},
'city': {'value': info['city']},
'sport': {'value': info['sport']},
}
} for abbr, info in teams.items()]
} for team_key, info in teams.items()]
stats['teams'] = import_data(ck, recs, 'teams', args.dry_run, args.verbose)
# Import games
if not args.stadiums_only and games:
# Rebuild team_map if only importing games (--games-only flag)
if not team_map:
for s in stadiums:
for abbr in s.get('team_abbrevs', []):
team_map[abbr] = f"team_{abbr.lower()}"
team_key = f"{s['sport']}_{abbr}"
team_map[(s['sport'], abbr)] = deterministic_uuid(team_key)
# Build team -> stadium map for stadiumRef
team_stadium_map = {}
for s in stadiums:
stadium_uuid = stadium_uuid_map[s['id']]
for abbr in s.get('team_abbrevs', []):
team_stadium_map[(s['sport'], abbr)] = stadium_uuid
print("--- Games ---")
@@ -278,20 +340,51 @@ def main():
recs = []
for g in unique_games:
game_uuid = deterministic_uuid(g['id'])
sport = g['sport']
fields = {
'gameId': {'value': g['id']}, 'sport': {'value': g['sport']},
'gameId': {'value': game_uuid}, 'sport': {'value': sport},
'season': {'value': g.get('season', '')}, 'source': {'value': g.get('source', '')},
}
if g.get('date'):
try:
dt = datetime.strptime(f"{g['date']} {g.get('time', '19:00')}", '%Y-%m-%d %H:%M')
fields['dateTime'] = {'value': int(dt.timestamp() * 1000)}
except: pass
if g.get('home_team_abbrev') in team_map:
fields['homeTeamRef'] = {'value': {'recordName': team_map[g['home_team_abbrev']], 'action': 'NONE'}}
if g.get('away_team_abbrev') in team_map:
fields['awayTeamRef'] = {'value': {'recordName': team_map[g['away_team_abbrev']], 'action': 'NONE'}}
recs.append({'recordType': 'Game', 'recordName': g['id'], 'fields': fields})
# Parse time like "7:30p" or "10:00a"
time_str = g.get('time', '7:00p')
hour, minute = 19, 0
if time_str:
clean_time = time_str.lower().replace(' ', '')
is_pm = 'p' in clean_time
time_parts = clean_time.replace('p', '').replace('a', '').split(':')
if time_parts:
hour = int(time_parts[0])
if is_pm and hour != 12:
hour += 12
elif not is_pm and hour == 12:
hour = 0
if len(time_parts) > 1:
minute = int(time_parts[1])
dt = datetime.strptime(f"{g['date']} {hour:02d}:{minute:02d}", '%Y-%m-%d %H:%M')
# CloudKit expects TIMESTAMP type with milliseconds since epoch
fields['dateTime'] = {'value': int(dt.timestamp() * 1000), 'type': 'TIMESTAMP'}
except Exception as e:
if args.verbose:
print(f" Warning: Failed to parse date/time for {g['id']}: {e}")
# Team references - use (sport, abbrev) tuple for lookup
home_team_key = f"{sport}_{g.get('home_team_abbrev', '')}"
away_team_key = f"{sport}_{g.get('away_team_abbrev', '')}"
home_team_uuid = deterministic_uuid(home_team_key)
away_team_uuid = deterministic_uuid(away_team_key)
fields['homeTeamRef'] = {'value': {'recordName': home_team_uuid, 'action': 'NONE'}}
fields['awayTeamRef'] = {'value': {'recordName': away_team_uuid, 'action': 'NONE'}}
# Stadium reference - look up by home team abbrev
stadium_uuid = team_stadium_map.get((sport, g.get('home_team_abbrev', '')))
if stadium_uuid:
fields['stadiumRef'] = {'value': {'recordName': stadium_uuid, 'action': 'NONE'}}
recs.append({'recordType': 'Game', 'recordName': game_uuid, 'fields': fields})
stats['games'] = import_data(ck, recs, 'games', args.dry_run, args.verbose)

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -4,8 +4,8 @@ Sports Schedule Scraper for SportsTime App
Scrapes NBA, MLB, NHL schedules from multiple sources for cross-validation.
Usage:
python scrape_schedules.py --sport nba --season 2025
python scrape_schedules.py --sport all --season 2025
python scrape_schedules.py --sport nba --season 2026
python scrape_schedules.py --sport all --season 2026
python scrape_schedules.py --stadiums-only
"""
@@ -435,7 +435,7 @@ def scrape_mlb_statsapi(season: int) -> list[Game]:
time_str = None
game = Game(
id=f"mlb_{game_data.get('gamePk', '')}",
id='', # Will be assigned by assign_stable_ids
sport='MLB',
season=str(season),
date=game_date,
@@ -786,28 +786,34 @@ def generate_stadiums_from_teams() -> list[Stadium]:
def assign_stable_ids(games: list[Game], sport: str, season: str) -> list[Game]:
"""
Assign stable IDs based on matchup + occurrence number within season.
Format: {sport}_{season}_{away}_{home}_{num}
Assign IDs based on matchup + date.
Format: {sport}_{season}_{away}_{home}_{MMDD} (or {MMDD}_2 for doubleheaders)
This ensures IDs don't change when games are rescheduled.
When games are rescheduled, the old ID becomes orphaned and a new one is created.
Use --delete-all before import to clean up orphaned records.
"""
from collections import defaultdict
# Group games by matchup (away @ home)
matchups = defaultdict(list)
for game in games:
key = f"{game.away_team_abbrev}_{game.home_team_abbrev}"
matchups[key].append(game)
season_str = season.replace('-', '')
# Sort each matchup by date and assign occurrence number
for key, matchup_games in matchups.items():
matchup_games.sort(key=lambda g: g.date)
for i, game in enumerate(matchup_games, 1):
away = game.away_team_abbrev.lower()
home = game.home_team_abbrev.lower()
# Normalize season format (e.g., "2024-25" -> "2024-25", "2025" -> "2025")
season_str = season.replace('-', '')
game.id = f"{sport.lower()}_{season_str}_{away}_{home}_{i}"
# Track how many times we've seen each base ID (for doubleheaders)
id_counts = defaultdict(int)
for game in games:
away = game.away_team_abbrev.lower()
home = game.home_team_abbrev.lower()
# Extract MMDD from date (YYYY-MM-DD)
date_parts = game.date.split('-')
mmdd = f"{date_parts[1]}{date_parts[2]}" if len(date_parts) == 3 else "0000"
base_id = f"{sport.lower()}_{season_str}_{away}_{home}_{mmdd}"
id_counts[base_id] += 1
# Add suffix for doubleheaders (game 2+)
if id_counts[base_id] > 1:
game.id = f"{base_id}_{id_counts[base_id]}"
else:
game.id = base_id
return games
@@ -892,7 +898,7 @@ def export_to_json(games: list[Game], stadiums: list[Stadium], output_dir: Path)
def main():
parser = argparse.ArgumentParser(description='Scrape sports schedules')
parser.add_argument('--sport', choices=['nba', 'mlb', 'nhl', 'all'], default='all')
parser.add_argument('--season', type=int, default=2025, help='Season year (ending year)')
parser.add_argument('--season', type=int, default=2026, help='Season year (ending year)')
parser.add_argument('--stadiums-only', action='store_true', help='Only scrape stadium data')
parser.add_argument('--output', type=str, default='./data', help='Output directory')
@@ -931,7 +937,7 @@ def main():
print("="*60)
mlb_games_api = scrape_mlb_statsapi(args.season)
# MLB API uses official gamePk which is already stable - no reassignment needed
mlb_games_api = assign_stable_ids(mlb_games_api, 'MLB', str(args.season))
all_games.extend(mlb_games_api)
if args.sport in ['nhl', 'all']:

View File

@@ -51,18 +51,28 @@ struct CKTeam {
}
var team: Team? {
guard let idString = record[CKTeam.idKey] as? String,
let id = UUID(uuidString: idString),
let name = record[CKTeam.nameKey] as? String,
// Use teamId field, or fall back to record name
let idString = (record[CKTeam.idKey] as? String) ?? record.recordID.recordName
guard let id = UUID(uuidString: idString),
let abbreviation = record[CKTeam.abbreviationKey] as? String,
let sportRaw = record[CKTeam.sportKey] as? String,
let sport = Sport(rawValue: sportRaw),
let city = record[CKTeam.cityKey] as? String,
let stadiumRef = record[CKTeam.stadiumRefKey] as? CKRecord.Reference,
let stadiumIdString = stadiumRef.recordID.recordName.split(separator: ":").last,
let stadiumId = UUID(uuidString: String(stadiumIdString))
let city = record[CKTeam.cityKey] as? String
else { return nil }
// Name defaults to abbreviation if not provided
let name = record[CKTeam.nameKey] as? String ?? abbreviation
// Stadium reference is optional - use placeholder UUID if not present
let stadiumId: UUID
if let stadiumRef = record[CKTeam.stadiumRefKey] as? CKRecord.Reference,
let refId = UUID(uuidString: stadiumRef.recordID.recordName) {
stadiumId = refId
} else {
// Generate deterministic placeholder from team ID
stadiumId = UUID()
}
let logoURL = (record[CKTeam.logoURLKey] as? String).flatMap { URL(string: $0) }
return Team(
@@ -111,15 +121,17 @@ struct CKStadium {
}
var stadium: Stadium? {
guard let idString = record[CKStadium.idKey] as? String,
let id = UUID(uuidString: idString),
// Use stadiumId field, or fall back to record name
let idString = (record[CKStadium.idKey] as? String) ?? record.recordID.recordName
guard let id = UUID(uuidString: idString),
let name = record[CKStadium.nameKey] as? String,
let city = record[CKStadium.cityKey] as? String,
let state = record[CKStadium.stateKey] as? String,
let location = record[CKStadium.locationKey] as? CLLocation,
let capacity = record[CKStadium.capacityKey] as? Int
let city = record[CKStadium.cityKey] as? String
else { return nil }
// These fields are optional in CloudKit
let state = record[CKStadium.stateKey] as? String ?? ""
let location = record[CKStadium.locationKey] as? CLLocation
let capacity = record[CKStadium.capacityKey] as? Int ?? 0
let imageURL = (record[CKStadium.imageURLKey] as? String).flatMap { URL(string: $0) }
return Stadium(
@@ -127,8 +139,8 @@ struct CKStadium {
name: name,
city: city,
state: state,
latitude: location.coordinate.latitude,
longitude: location.coordinate.longitude,
latitude: location?.coordinate.latitude ?? 0,
longitude: location?.coordinate.longitude ?? 0,
capacity: capacity,
yearOpened: record[CKStadium.yearOpenedKey] as? Int,
imageURL: imageURL

View File

@@ -13,8 +13,6 @@ struct TravelSegment: Identifiable, Codable, Hashable {
let travelMode: TravelMode
let distanceMeters: Double
let durationSeconds: Double
let departureTime: Date
let arrivalTime: Date
let scenicScore: Double
let evChargingStops: [EVChargingStop]
let routePolyline: String?
@@ -26,8 +24,6 @@ struct TravelSegment: Identifiable, Codable, Hashable {
travelMode: TravelMode,
distanceMeters: Double,
durationSeconds: Double,
departureTime: Date,
arrivalTime: Date,
scenicScore: Double = 0.5,
evChargingStops: [EVChargingStop] = [],
routePolyline: String? = nil
@@ -38,8 +34,6 @@ struct TravelSegment: Identifiable, Codable, Hashable {
self.travelMode = travelMode
self.distanceMeters = distanceMeters
self.durationSeconds = durationSeconds
self.departureTime = departureTime
self.arrivalTime = arrivalTime
self.scenicScore = scenicScore
self.evChargingStops = evChargingStops
self.routePolyline = routePolyline

View File

@@ -104,17 +104,13 @@ struct Trip: Identifiable, Codable, Hashable {
return currentDate >= arrivalDay && currentDate <= departureDay
}
// Show travel segments that depart on this day
// Travel TO the last city happens on the last game day (drive morning, watch game)
let segmentsForDay = travelSegments.filter { segment in
calendar.startOfDay(for: segment.departureTime) == currentDate
}
// Travel segments are location-based, not date-based
// The view handles inserting travel between cities when locations differ
days.append(ItineraryDay(
dayNumber: dayNumber,
date: currentDate,
stops: stopsForDay,
travelSegments: segmentsForDay
travelSegments: [] // Travel handled by view based on location changes
))
currentDate = calendar.date(byAdding: .day, value: 1, to: currentDate)!

View File

@@ -227,7 +227,6 @@ struct TripPreferences: Codable, Hashable {
var lodgingType: LodgingType
var numberOfDrivers: Int
var maxDrivingHoursPerDriver: Double?
var catchOtherSports: Bool
init(
planningMode: PlanningMode = .dateRange,
@@ -247,8 +246,7 @@ struct TripPreferences: Codable, Hashable {
needsEVCharging: Bool = false,
lodgingType: LodgingType = .hotel,
numberOfDrivers: Int = 1,
maxDrivingHoursPerDriver: Double? = nil,
catchOtherSports: Bool = false
maxDrivingHoursPerDriver: Double? = nil
) {
self.planningMode = planningMode
self.startLocation = startLocation
@@ -268,7 +266,6 @@ struct TripPreferences: Codable, Hashable {
self.lodgingType = lodgingType
self.numberOfDrivers = numberOfDrivers
self.maxDrivingHoursPerDriver = maxDrivingHoursPerDriver
self.catchOtherSports = catchOtherSports
}
var totalDriverHoursPerDay: Double {

View File

@@ -145,12 +145,19 @@ actor CloudKitService {
guard let homeRef = record[CKGame.homeTeamRefKey] as? CKRecord.Reference,
let awayRef = record[CKGame.awayTeamRefKey] as? CKRecord.Reference,
let stadiumRef = record[CKGame.stadiumRefKey] as? CKRecord.Reference,
let homeId = UUID(uuidString: homeRef.recordID.recordName),
let awayId = UUID(uuidString: awayRef.recordID.recordName),
let stadiumId = UUID(uuidString: stadiumRef.recordID.recordName)
let awayId = UUID(uuidString: awayRef.recordID.recordName)
else { return nil }
// Stadium ref is optional - use placeholder if not present
let stadiumId: UUID
if let stadiumRef = record[CKGame.stadiumRefKey] as? CKRecord.Reference,
let refId = UUID(uuidString: stadiumRef.recordID.recordName) {
stadiumId = refId
} else {
stadiumId = UUID() // Placeholder - will be resolved via team lookup
}
return ckGame.game(homeTeamId: homeId, awayTeamId: awayId, stadiumId: stadiumId)
}

View File

@@ -193,30 +193,20 @@ struct PlanningProgressView: View {
@Environment(\.colorScheme) private var colorScheme
var body: some View {
VStack(spacing: 32) {
// Animated route illustration
AnimatedRouteGraphic()
.frame(height: 150)
.padding(.horizontal, 40)
VStack(spacing: 24) {
// Simple spinner
ProgressView()
.scaleEffect(1.5)
.tint(Theme.warmOrange)
// Current step text
Text(steps[currentStep])
.font(.system(size: 18, weight: .medium))
.font(.system(size: 16, weight: .medium))
.foregroundStyle(Theme.textSecondary(colorScheme))
.animation(.easeInOut, value: currentStep)
// Progress dots
HStack(spacing: 12) {
ForEach(0..<steps.count, id: \.self) { i in
Circle()
.fill(i <= currentStep ? Theme.warmOrange : Theme.textMuted(colorScheme).opacity(0.3))
.frame(width: i == currentStep ? 10 : 8, height: i == currentStep ? 10 : 8)
.animation(Theme.Animation.spring, value: currentStep)
}
}
}
.frame(maxWidth: .infinity)
.padding(.vertical, 60)
.padding(.vertical, 40)
.task {
await animateSteps()
}
@@ -226,7 +216,7 @@ struct PlanningProgressView: View {
while !Task.isCancelled {
try? await Task.sleep(for: .milliseconds(1500))
guard !Task.isCancelled else { break }
withAnimation(Theme.Animation.spring) {
withAnimation(.easeInOut) {
currentStep = (currentStep + 1) % steps.count
}
}

View File

@@ -77,7 +77,6 @@ final class TripCreationViewModel {
var lodgingType: LodgingType = .hotel
var numberOfDrivers: Int = 1
var maxDrivingHoursPerDriver: Double = 8
var catchOtherSports: Bool = false
// MARK: - Dependencies
@@ -286,8 +285,7 @@ final class TripCreationViewModel {
needsEVCharging: needsEVCharging,
lodgingType: lodgingType,
numberOfDrivers: numberOfDrivers,
maxDrivingHoursPerDriver: maxDrivingHoursPerDriver,
catchOtherSports: catchOtherSports
maxDrivingHoursPerDriver: maxDrivingHoursPerDriver
)
// Build planning request
@@ -467,8 +465,7 @@ final class TripCreationViewModel {
needsEVCharging: needsEVCharging,
lodgingType: lodgingType,
numberOfDrivers: numberOfDrivers,
maxDrivingHoursPerDriver: maxDrivingHoursPerDriver,
catchOtherSports: catchOtherSports
maxDrivingHoursPerDriver: maxDrivingHoursPerDriver
)
return convertToTrip(option: option, preferences: preferences)
}

View File

@@ -451,9 +451,7 @@ struct HorizontalTimelineItemView: View {
toLocation: LocationInput(name: "San Francisco"),
travelMode: .drive,
distanceMeters: 600000,
durationSeconds: 21600,
departureTime: Date(),
arrivalTime: Date().addingTimeInterval(21600)
durationSeconds: 21600
)
let option = ItineraryOption(

View File

@@ -555,18 +555,6 @@ struct TripCreationView: View {
.tint(Theme.warmOrange)
}
// Other Sports
VStack(alignment: .leading, spacing: Theme.Spacing.xs) {
ThemedToggle(
label: "Find Other Sports Along Route",
isOn: $viewModel.catchOtherSports,
icon: "sportscourt"
)
Text("When enabled, we'll look for games from other sports happening along your route that fit your schedule.")
.font(.system(size: Theme.FontSize.micro))
.foregroundStyle(Theme.textMuted(colorScheme))
}
}
}
}
@@ -1037,6 +1025,28 @@ struct LocationSearchSheet: View {
// MARK: - Trip Options View
// MARK: - Sort Options
enum TripSortOption: String, CaseIterable, Identifiable {
case recommended = "Recommended"
case mostGames = "Most Games"
case leastGames = "Least Games"
case mostMiles = "Most Miles"
case leastMiles = "Least Miles"
case bestEfficiency = "Best Efficiency"
var id: String { rawValue }
var icon: String {
switch self {
case .recommended: return "star.fill"
case .mostGames, .leastGames: return "sportscourt"
case .mostMiles, .leastMiles: return "road.lanes"
case .bestEfficiency: return "gauge.with.dots.needle.33percent"
}
}
}
struct TripOptionsView: View {
let options: [ItineraryOption]
let games: [UUID: RichGame]
@@ -1045,8 +1055,31 @@ struct TripOptionsView: View {
@State private var selectedTrip: Trip?
@State private var showTripDetail = false
@State private var sortOption: TripSortOption = .recommended
@Environment(\.colorScheme) private var colorScheme
private var sortedOptions: [ItineraryOption] {
switch sortOption {
case .recommended:
return options
case .mostGames:
return options.sorted { $0.totalGames > $1.totalGames }
case .leastGames:
return options.sorted { $0.totalGames < $1.totalGames }
case .mostMiles:
return options.sorted { $0.totalDistanceMiles > $1.totalDistanceMiles }
case .leastMiles:
return options.sorted { $0.totalDistanceMiles < $1.totalDistanceMiles }
case .bestEfficiency:
// Games per driving hour (higher is better)
return options.sorted {
let effA = $0.totalDrivingHours > 0 ? Double($0.totalGames) / $0.totalDrivingHours : 0
let effB = $1.totalDrivingHours > 0 ? Double($1.totalGames) / $1.totalDrivingHours : 0
return effA > effB
}
}
}
var body: some View {
ScrollView {
LazyVStack(spacing: 20) {
@@ -1065,10 +1098,15 @@ struct TripOptionsView: View {
.foregroundStyle(Theme.textSecondary(colorScheme))
}
.padding(.top, Theme.Spacing.xl)
.padding(.bottom, Theme.Spacing.md)
.padding(.bottom, Theme.Spacing.sm)
// Options list with staggered animation
ForEach(Array(options.enumerated()), id: \.offset) { index, option in
// Sort picker
sortPicker
.padding(.horizontal, Theme.Spacing.md)
.padding(.bottom, Theme.Spacing.sm)
// Options list
ForEach(Array(sortedOptions.enumerated()), id: \.element.id) { index, option in
TripOptionCard(
option: option,
rank: index + 1,
@@ -1092,6 +1130,38 @@ struct TripOptionsView: View {
}
}
}
private var sortPicker: some View {
Menu {
ForEach(TripSortOption.allCases) { option in
Button {
withAnimation(.easeInOut(duration: 0.2)) {
sortOption = option
}
} label: {
Label(option.rawValue, systemImage: option.icon)
}
}
} label: {
HStack(spacing: 8) {
Image(systemName: sortOption.icon)
.font(.system(size: 14))
Text(sortOption.rawValue)
.font(.system(size: 14, weight: .medium))
Image(systemName: "chevron.down")
.font(.system(size: 12))
}
.foregroundStyle(Theme.textPrimary(colorScheme))
.padding(.horizontal, 16)
.padding(.vertical, 10)
.background(Theme.cardBackground(colorScheme))
.clipShape(Capsule())
.overlay(
Capsule()
.strokeBorder(Theme.textMuted(colorScheme).opacity(0.2), lineWidth: 1)
)
}
}
}
// MARK: - Trip Option Card
@@ -1524,7 +1594,7 @@ struct DateRangePicker: View {
private var daysOfWeekHeader: some View {
HStack(spacing: 0) {
ForEach(daysOfWeek, id: \.self) { day in
ForEach(Array(daysOfWeek.enumerated()), id: \.offset) { _, day in
Text(day)
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(Theme.textMuted(colorScheme))

View File

@@ -262,24 +262,47 @@ struct TripDetailView: View {
}
}
/// Build itinerary sections: days and travel between days
/// Build itinerary sections: days with travel between different cities
private var itinerarySections: [ItinerarySection] {
var sections: [ItinerarySection] = []
let calendar = Calendar.current
// Build day sections for days with games
var daySections: [(dayNumber: Int, date: Date, city: String, games: [RichGame])] = []
let days = tripDays
for (index, dayDate) in days.enumerated() {
let dayNum = index + 1
let gamesOnDay = gamesOn(date: dayDate)
if !gamesOnDay.isEmpty || index == 0 || index == days.count - 1 {
sections.append(.day(dayNumber: dayNum, date: dayDate, games: gamesOnDay))
// Get city from games (preferred) or from stops as fallback
let cityForDay = gamesOnDay.first?.stadium.city ?? cityOn(date: dayDate) ?? ""
// Include days with games
// Skip empty days at the end (departure day after last game)
if !gamesOnDay.isEmpty {
daySections.append((dayNum, dayDate, cityForDay, gamesOnDay))
}
}
// Build sections: insert travel BEFORE each day when coming from different city
for (index, daySection) in daySections.enumerated() {
// Check if we need travel BEFORE this day (coming from different city)
if index > 0 {
let prevSection = daySections[index - 1]
let prevCity = prevSection.city
let currentCity = daySection.city
// If cities differ, find travel segment from prev -> current
if !prevCity.isEmpty && !currentCity.isEmpty && prevCity != currentCity {
if let travelSegment = findTravelSegment(from: prevCity, to: currentCity) {
sections.append(.travel(travelSegment))
}
}
}
let travelAfterDay = travelDepartingAfter(date: dayDate, beforeNextGameDay: days.indices.contains(index + 1) ? days[index + 1] : nil)
for segment in travelAfterDay {
sections.append(.travel(segment))
}
// Add the day section
sections.append(.day(dayNumber: daySection.dayNumber, date: daySection.date, games: daySection.games))
}
return sections
@@ -311,13 +334,27 @@ struct TripDetailView: View {
}.sorted { $0.game.dateTime < $1.game.dateTime }
}
private func travelDepartingAfter(date: Date, beforeNextGameDay: Date?) -> [TravelSegment] {
/// Get the city for a given date (from the stop that covers that date)
private func cityOn(date: Date) -> String? {
let calendar = Calendar.current
let dayEnd = calendar.startOfDay(for: date)
let dayStart = calendar.startOfDay(for: date)
return trip.travelSegments.filter { segment in
let segmentDay = calendar.startOfDay(for: segment.departureTime)
return segmentDay == dayEnd
return trip.stops.first { stop in
let arrivalDay = calendar.startOfDay(for: stop.arrivalDate)
let departureDay = calendar.startOfDay(for: stop.departureDate)
return dayStart >= arrivalDay && dayStart <= departureDay
}?.city
}
/// Find travel segment that goes from one city to another
private func findTravelSegment(from fromCity: String, to toCity: String) -> TravelSegment? {
let fromLower = fromCity.lowercased().trimmingCharacters(in: .whitespaces)
let toLower = toCity.lowercased().trimmingCharacters(in: .whitespaces)
return trip.travelSegments.first { segment in
let segmentFrom = segment.fromLocation.name.lowercased().trimmingCharacters(in: .whitespaces)
let segmentTo = segment.toLocation.name.lowercased().trimmingCharacters(in: .whitespaces)
return segmentFrom == fromLower && segmentTo == toLower
}
}

View File

@@ -111,21 +111,30 @@ enum ItineraryBuilder {
// MARK: - Common Validators
/// Validator that ensures arrival time is before game start (with buffer).
/// Validator that ensures travel duration allows arrival before game start.
/// Used by Scenario B where selected games have fixed start times.
///
/// This checks if the travel duration is short enough that the user could
/// theoretically leave after the previous game and arrive before the next.
///
/// - Parameter bufferSeconds: Time buffer before game start (default 1 hour)
/// - Returns: Validator closure
///
static func arrivalBeforeGameStart(bufferSeconds: TimeInterval = 3600) -> SegmentValidator {
return { segment, _, toStop in
return { segment, fromStop, toStop in
guard let gameStart = toStop.firstGameStart else {
return true // No game = no constraint
}
// Check if there's enough time between departure point and game start
// Departure assumed after previous day's activities (use departure date as baseline)
let earliestDeparture = fromStop.departureDate
let travelDuration = segment.durationSeconds
let earliestArrival = earliestDeparture.addingTimeInterval(travelDuration)
let deadline = gameStart.addingTimeInterval(-bufferSeconds)
if segment.arrivalTime > deadline {
print("[ItineraryBuilder] Cannot arrive in time: arrival \(segment.arrivalTime) > deadline \(deadline)")
if earliestArrival > deadline {
print("[ItineraryBuilder] Cannot arrive in time: earliest arrival \(earliestArrival) > deadline \(deadline)")
return false
}
return true

View File

@@ -226,63 +226,84 @@ final class ScenarioAPlanner: ScenarioPlanner {
/// Stop 1: Los Angeles (contains game 1 and 2)
/// Stop 2: San Francisco (contains game 3)
///
/// Note: If you visit the same city, leave, and come back, that creates
/// separate stops (one for each visit).
///
private func buildStops(
from games: [Game],
stadiums: [UUID: Stadium]
) -> [ItineraryStop] {
guard !games.isEmpty else { return [] }
// Step 1: Group all games by their stadium
// This lets us find ALL games at a stadium when we create that stop
// Result: { stadiumId: [game1, game2, ...], ... }
var stadiumGames: [UUID: [Game]] = [:]
for game in games {
stadiumGames[game.stadiumId, default: []].append(game)
// Sort games chronologically
let sortedGames = games.sorted { $0.startTime < $1.startTime }
// Group consecutive games at the same stadium into stops
// If you visit A, then B, then A again, that's 3 stops (A, B, A)
var stops: [ItineraryStop] = []
var currentStadiumId: UUID? = nil
var currentGames: [Game] = []
for game in sortedGames {
if game.stadiumId == currentStadiumId {
// Same stadium as previous game - add to current group
currentGames.append(game)
} else {
// Different stadium - finalize previous stop (if any) and start new one
if let stadiumId = currentStadiumId, !currentGames.isEmpty {
if let stop = createStop(from: currentGames, stadiumId: stadiumId, stadiums: stadiums) {
stops.append(stop)
}
}
currentStadiumId = game.stadiumId
currentGames = [game]
}
}
// Step 2: Walk through games in chronological order
// When we hit a stadium for the first time, create a stop with ALL games at that stadium
var stops: [ItineraryStop] = []
var processedStadiums: Set<UUID> = [] // Track which stadiums we've already made stops for
for game in games {
// Skip if we already created a stop for this stadium
guard !processedStadiums.contains(game.stadiumId) else { continue }
processedStadiums.insert(game.stadiumId)
// Get ALL games at this stadium (not just this one)
let gamesAtStadium = stadiumGames[game.stadiumId] ?? [game]
let sortedGames = gamesAtStadium.sorted { $0.startTime < $1.startTime }
// Look up stadium info for location data
let stadium = stadiums[game.stadiumId]
let city = stadium?.city ?? "Unknown"
let state = stadium?.state ?? ""
let coordinate = stadium?.coordinate
let location = LocationInput(
name: city,
coordinate: coordinate,
address: stadium?.fullAddress
)
// Create the stop
// - arrivalDate: when we need to arrive (first game at this stop)
// - departureDate: when we can leave (after last game at this stop)
// - games: IDs of all games we'll attend at this stop
let stop = ItineraryStop(
city: city,
state: state,
coordinate: coordinate,
games: sortedGames.map { $0.id },
arrivalDate: sortedGames.first?.gameDate ?? Date(),
departureDate: sortedGames.last?.gameDate ?? Date(),
location: location,
firstGameStart: sortedGames.first?.startTime
)
stops.append(stop)
// Don't forget the last group
if let stadiumId = currentStadiumId, !currentGames.isEmpty {
if let stop = createStop(from: currentGames, stadiumId: stadiumId, stadiums: stadiums) {
stops.append(stop)
}
}
return stops
}
/// Creates an ItineraryStop from a group of games at the same stadium.
private func createStop(
from games: [Game],
stadiumId: UUID,
stadiums: [UUID: Stadium]
) -> ItineraryStop? {
guard !games.isEmpty else { return nil }
let sortedGames = games.sorted { $0.startTime < $1.startTime }
let stadium = stadiums[stadiumId]
let city = stadium?.city ?? "Unknown"
let state = stadium?.state ?? ""
let coordinate = stadium?.coordinate
let location = LocationInput(
name: city,
coordinate: coordinate,
address: stadium?.fullAddress
)
// departureDate is day AFTER last game (we leave the next morning)
let lastGameDate = sortedGames.last?.gameDate ?? Date()
let departureDateValue = Calendar.current.date(byAdding: .day, value: 1, to: lastGameDate) ?? lastGameDate
return ItineraryStop(
city: city,
state: state,
coordinate: coordinate,
games: sortedGames.map { $0.id },
arrivalDate: sortedGames.first?.gameDate ?? Date(),
departureDate: departureDateValue,
location: location,
firstGameStart: sortedGames.first?.startTime
)
}
}

View File

@@ -296,54 +296,82 @@ final class ScenarioBPlanner: ScenarioPlanner {
// MARK: - Stop Building
/// Converts a list of games into itinerary stops.
/// Groups games by stadium, creates one stop per unique stadium.
/// Groups consecutive games at the same stadium into one stop.
/// Creates separate stops when visiting the same city with other cities in between.
private func buildStops(
from games: [Game],
stadiums: [UUID: Stadium]
) -> [ItineraryStop] {
guard !games.isEmpty else { return [] }
// Group games by stadium
var stadiumGames: [UUID: [Game]] = [:]
for game in games {
stadiumGames[game.stadiumId, default: []].append(game)
// Sort games chronologically
let sortedGames = games.sorted { $0.startTime < $1.startTime }
// Group consecutive games at the same stadium
var stops: [ItineraryStop] = []
var currentStadiumId: UUID? = nil
var currentGames: [Game] = []
for game in sortedGames {
if game.stadiumId == currentStadiumId {
// Same stadium as previous game - add to current group
currentGames.append(game)
} else {
// Different stadium - finalize previous stop (if any) and start new one
if let stadiumId = currentStadiumId, !currentGames.isEmpty {
if let stop = createStop(from: currentGames, stadiumId: stadiumId, stadiums: stadiums) {
stops.append(stop)
}
}
currentStadiumId = game.stadiumId
currentGames = [game]
}
}
// Create stops in chronological order (first game at each stadium)
var stops: [ItineraryStop] = []
var processedStadiums: Set<UUID> = []
for game in games {
guard !processedStadiums.contains(game.stadiumId) else { continue }
processedStadiums.insert(game.stadiumId)
let gamesAtStadium = stadiumGames[game.stadiumId] ?? [game]
let sortedGames = gamesAtStadium.sorted { $0.startTime < $1.startTime }
let stadium = stadiums[game.stadiumId]
let city = stadium?.city ?? "Unknown"
let state = stadium?.state ?? ""
let coordinate = stadium?.coordinate
let location = LocationInput(
name: city,
coordinate: coordinate,
address: stadium?.fullAddress
)
let stop = ItineraryStop(
city: city,
state: state,
coordinate: coordinate,
games: sortedGames.map { $0.id },
arrivalDate: sortedGames.first?.gameDate ?? Date(),
departureDate: sortedGames.last?.gameDate ?? Date(),
location: location,
firstGameStart: sortedGames.first?.startTime
)
stops.append(stop)
// Don't forget the last group
if let stadiumId = currentStadiumId, !currentGames.isEmpty {
if let stop = createStop(from: currentGames, stadiumId: stadiumId, stadiums: stadiums) {
stops.append(stop)
}
}
return stops
}
/// Creates an ItineraryStop from a group of games at the same stadium.
private func createStop(
from games: [Game],
stadiumId: UUID,
stadiums: [UUID: Stadium]
) -> ItineraryStop? {
guard !games.isEmpty else { return nil }
let sortedGames = games.sorted { $0.startTime < $1.startTime }
let stadium = stadiums[stadiumId]
let city = stadium?.city ?? "Unknown"
let state = stadium?.state ?? ""
let coordinate = stadium?.coordinate
let location = LocationInput(
name: city,
coordinate: coordinate,
address: stadium?.fullAddress
)
// departureDate is day AFTER last game (we leave the next morning)
let lastGameDate = sortedGames.last?.gameDate ?? Date()
let departureDateValue = Calendar.current.date(byAdding: .day, value: 1, to: lastGameDate) ?? lastGameDate
return ItineraryStop(
city: city,
state: state,
coordinate: coordinate,
games: sortedGames.map { $0.id },
arrivalDate: sortedGames.first?.gameDate ?? Date(),
departureDate: departureDateValue,
location: location,
firstGameStart: sortedGames.first?.startTime
)
}
}

View File

@@ -432,53 +432,84 @@ final class ScenarioCPlanner: ScenarioPlanner {
// MARK: - Stop Building
/// Converts games to stops (used by GeographicRouteExplorer callback).
/// Groups consecutive games at the same stadium into one stop.
/// Creates separate stops when visiting the same city with other cities in between.
private func buildStops(
from games: [Game],
stadiums: [UUID: Stadium]
) -> [ItineraryStop] {
guard !games.isEmpty else { return [] }
var stadiumGames: [UUID: [Game]] = [:]
for game in games {
stadiumGames[game.stadiumId, default: []].append(game)
// Sort games chronologically
let sortedGames = games.sorted { $0.startTime < $1.startTime }
// Group consecutive games at the same stadium
var stops: [ItineraryStop] = []
var currentStadiumId: UUID? = nil
var currentGames: [Game] = []
for game in sortedGames {
if game.stadiumId == currentStadiumId {
// Same stadium as previous game - add to current group
currentGames.append(game)
} else {
// Different stadium - finalize previous stop (if any) and start new one
if let stadiumId = currentStadiumId, !currentGames.isEmpty {
if let stop = createStop(from: currentGames, stadiumId: stadiumId, stadiums: stadiums) {
stops.append(stop)
}
}
currentStadiumId = game.stadiumId
currentGames = [game]
}
}
var stops: [ItineraryStop] = []
var processedStadiums: Set<UUID> = []
for game in games {
guard !processedStadiums.contains(game.stadiumId) else { continue }
processedStadiums.insert(game.stadiumId)
let gamesAtStadium = stadiumGames[game.stadiumId] ?? [game]
let sortedGames = gamesAtStadium.sorted { $0.startTime < $1.startTime }
let stadium = stadiums[game.stadiumId]
let city = stadium?.city ?? "Unknown"
let state = stadium?.state ?? ""
let coordinate = stadium?.coordinate
let location = LocationInput(
name: city,
coordinate: coordinate,
address: stadium?.fullAddress
)
let stop = ItineraryStop(
city: city,
state: state,
coordinate: coordinate,
games: sortedGames.map { $0.id },
arrivalDate: sortedGames.first?.gameDate ?? Date(),
departureDate: sortedGames.last?.gameDate ?? Date(),
location: location,
firstGameStart: sortedGames.first?.startTime
)
stops.append(stop)
// Don't forget the last group
if let stadiumId = currentStadiumId, !currentGames.isEmpty {
if let stop = createStop(from: currentGames, stadiumId: stadiumId, stadiums: stadiums) {
stops.append(stop)
}
}
return stops
}
/// Creates an ItineraryStop from a group of games at the same stadium.
private func createStop(
from games: [Game],
stadiumId: UUID,
stadiums: [UUID: Stadium]
) -> ItineraryStop? {
guard !games.isEmpty else { return nil }
let sortedGames = games.sorted { $0.startTime < $1.startTime }
let stadium = stadiums[stadiumId]
let city = stadium?.city ?? "Unknown"
let state = stadium?.state ?? ""
let coordinate = stadium?.coordinate
let location = LocationInput(
name: city,
coordinate: coordinate,
address: stadium?.fullAddress
)
// departureDate is day AFTER last game (we leave the next morning)
let lastGameDate = sortedGames.last?.gameDate ?? Date()
let departureDateValue = Calendar.current.date(byAdding: .day, value: 1, to: lastGameDate) ?? lastGameDate
return ItineraryStop(
city: city,
state: state,
coordinate: coordinate,
games: sortedGames.map { $0.id },
arrivalDate: sortedGames.first?.gameDate ?? Date(),
departureDate: departureDateValue,
location: location,
firstGameStart: sortedGames.first?.startTime
)
}
/// Builds stops with start and end location endpoints.
private func buildStopsWithEndpoints(
start: LocationInput,

View File

@@ -36,18 +36,12 @@ enum TravelEstimator {
return nil
}
// Calculate times (assume 8 AM departure)
let departureTime = from.departureDate.addingTimeInterval(8 * 3600)
let arrivalTime = departureTime.addingTimeInterval(drivingHours * 3600)
return TravelSegment(
fromLocation: from.location,
toLocation: to.location,
travelMode: .drive,
distanceMeters: distanceMiles * 1609.34,
durationSeconds: drivingHours * 3600,
departureTime: departureTime,
arrivalTime: arrivalTime
durationSeconds: drivingHours * 3600
)
}
@@ -73,17 +67,12 @@ enum TravelEstimator {
return nil
}
let departureTime = Date()
let arrivalTime = departureTime.addingTimeInterval(drivingHours * 3600)
return TravelSegment(
fromLocation: from,
toLocation: to,
travelMode: .drive,
distanceMeters: distanceMeters * roadRoutingFactor,
durationSeconds: drivingHours * 3600,
departureTime: departureTime,
arrivalTime: arrivalTime
durationSeconds: drivingHours * 3600
)
}

View File

@@ -244,10 +244,10 @@ enum TimelineItem: Identifiable {
}
}
var date: Date {
var date: Date? {
switch self {
case .stop(let stop): return stop.arrivalDate
case .travel(let segment): return segment.departureTime
case .travel: return nil // Travel is location-based, not date-based
case .rest(let rest): return rest.date
}
}
@@ -337,16 +337,9 @@ extension ItineraryOption {
// Add travel segment to next stop (if not last stop)
if index < travelSegments.count {
let segment = travelSegments[index]
// Check if travel spans multiple days
let travelDays = calculateTravelDays(for: segment, calendar: calendar)
if travelDays.count > 1 {
// Multi-day travel: could split into daily segments or keep as one
// For now, keep as single segment with multi-day indicator
timeline.append(.travel(segment))
} else {
timeline.append(.travel(segment))
}
// Travel is location-based - just add the segment
// Multi-day travel indicated by durationHours > 8
timeline.append(.travel(segment))
}
}
@@ -391,31 +384,16 @@ extension ItineraryOption {
return restDays
}
/// Calculates which calendar days a travel segment spans.
private func calculateTravelDays(
for segment: TravelSegment,
calendar: Calendar
) -> [Date] {
var days: [Date] = []
let startDay = calendar.startOfDay(for: segment.departureTime)
let endDay = calendar.startOfDay(for: segment.arrivalTime)
var currentDay = startDay
while currentDay <= endDay {
days.append(currentDay)
currentDay = calendar.date(byAdding: .day, value: 1, to: currentDay) ?? currentDay
}
return days
}
/// Timeline organized by date for calendar-style display.
/// Note: Travel segments are excluded as they are location-based, not date-based.
func timelineByDate() -> [Date: [TimelineItem]] {
let calendar = Calendar.current
var byDate: [Date: [TimelineItem]] = [:]
for item in generateTimeline() {
let day = calendar.startOfDay(for: item.date)
// Skip travel items - they don't have dates
guard let itemDate = item.date else { continue }
let day = calendar.startOfDay(for: itemDate)
byDate[day, default: []].append(item)
}

File diff suppressed because it is too large Load Diff

View File

@@ -1794,45 +1794,9 @@ struct ScenarioCPlannerTests {
}
}
@Test("Departure time before arrival time")
func plan_DepartureBeforeArrival() {
let planner = ScenarioCPlanner()
let sd = sdStadium
let la = laStadium
let sf = sfStadium
let game = makeGame(stadiumId: la.id, date: date("2026-06-10 19:00"))
let startLoc = LocationInput(
name: "San Diego",
coordinate: CLLocationCoordinate2D(latitude: sd.latitude, longitude: sd.longitude)
)
let endLoc = LocationInput(
name: "San Francisco",
coordinate: CLLocationCoordinate2D(latitude: sf.latitude, longitude: sf.longitude)
)
let request = makeRequest(
games: [game],
stadiums: [sd.id: sd, la.id: la, sf.id: sf],
startLocation: startLoc,
endLocation: endLoc,
startDate: date("2026-06-01 00:00"),
endDate: date("2026-06-30 23:59")
)
let result = planner.plan(request: request)
if case .success(let options) = result {
for option in options {
for segment in option.travelSegments {
#expect(segment.departureTime < segment.arrivalTime)
}
}
} else {
Issue.record("Expected success")
}
}
// Note: "Departure time before arrival time" test removed
// Travel segments are now location-based, not time-based
// The user decides when to travel; segments only describe route info
@Test("Games filtered to date range")
func plan_GamesFilteredToDateRange() {

View File

@@ -326,18 +326,8 @@ struct TravelEstimatorTests {
#expect(twoDrivers != nil, "Should succeed with two drivers - within 32h limit")
}
@Test("estimate stops - calculates departure and arrival times")
func estimateStops_CalculatesTimes() {
let baseDate = Calendar.current.date(from: DateComponents(year: 2026, month: 4, day: 5))!
let stop1 = makeStop(city: "Los Angeles", latitude: 34.0522, longitude: -118.2437, departureDate: baseDate)
let stop2 = makeStop(city: "San Diego", latitude: 32.7157, longitude: -117.1611)
let segment = TravelEstimator.estimate(from: stop1, to: stop2, constraints: defaultConstraints())
#expect(segment != nil)
#expect(segment!.departureTime > baseDate, "Departure should be after base date (adds 8 hours)")
#expect(segment!.arrivalTime > segment!.departureTime, "Arrival should be after departure")
}
// Note: departure/arrival time tests removed - travel is now location-based, not time-based
// The user decides when to travel; segments only describe route info (distance, duration)
@Test("estimate stops - distance and duration are consistent")
func estimateStops_DistanceDurationConsistent() {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 276 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

View File

Before

Width:  |  Height:  |  Size: 3.3 MiB

After

Width:  |  Height:  |  Size: 3.3 MiB

View File

Before

Width:  |  Height:  |  Size: 1.8 MiB

After

Width:  |  Height:  |  Size: 1.8 MiB