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>
@@ -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:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
|
After Width: | Height: | Size: 540 KiB |
@@ -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)
|
||||
|
||||
|
||||
10070
Scripts/data/games.csv
138738
Scripts/data/games.json
@@ -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']:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)!
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
|
Before Width: | Height: | Size: 276 KiB |
|
Before Width: | Height: | Size: 126 KiB |
|
Before Width: | Height: | Size: 3.3 MiB After Width: | Height: | Size: 3.3 MiB |
|
Before Width: | Height: | Size: 1.8 MiB After Width: | Height: | Size: 1.8 MiB |