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(python3:*)",
|
||||||
"Bash(cat:*)",
|
"Bash(cat:*)",
|
||||||
"Bash(ls:*)",
|
"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
|
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:
|
class CloudKit:
|
||||||
def __init__(self, key_id, private_key, container, env):
|
def __init__(self, key_id, private_key, container, env):
|
||||||
self.key_id = key_id
|
self.key_id = key_id
|
||||||
@@ -69,7 +85,7 @@ class CloudKit:
|
|||||||
except:
|
except:
|
||||||
return {'error': f"{r.status_code}: {r.text[:200]}"}
|
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."""
|
"""Query records of a given type."""
|
||||||
path = f"{self.path_base}/records/query"
|
path = f"{self.path_base}/records/query"
|
||||||
body = json.dumps({
|
body = json.dumps({
|
||||||
@@ -83,16 +99,28 @@ class CloudKit:
|
|||||||
'X-Apple-CloudKit-Request-ISO8601Date': date,
|
'X-Apple-CloudKit-Request-ISO8601Date': date,
|
||||||
'X-Apple-CloudKit-Request-SignatureV1': self._sign(date, body, path),
|
'X-Apple-CloudKit-Request-SignatureV1': self._sign(date, body, path),
|
||||||
}
|
}
|
||||||
r = requests.post(f"{HOST}{path}", headers=headers, data=body, timeout=60)
|
if verbose:
|
||||||
if r.status_code == 200:
|
print(f" Querying {record_type}...")
|
||||||
return r.json()
|
try:
|
||||||
return {'error': f"{r.status_code}: {r.text[:200]}"}
|
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):
|
def delete_all(self, record_type, verbose=False):
|
||||||
"""Delete all records of a given type."""
|
"""Delete all records of a given type."""
|
||||||
total_deleted = 0
|
total_deleted = 0
|
||||||
while True:
|
while True:
|
||||||
result = self.query(record_type)
|
result = self.query(record_type, verbose=verbose)
|
||||||
if 'error' in result:
|
if 'error' in result:
|
||||||
print(f" Query error: {result['error']}")
|
print(f" Query error: {result['error']}")
|
||||||
break
|
break
|
||||||
@@ -101,21 +129,38 @@ class CloudKit:
|
|||||||
if not records:
|
if not records:
|
||||||
break
|
break
|
||||||
|
|
||||||
# Build delete operations
|
# Build delete operations (recordChangeTag required for delete)
|
||||||
ops = [{
|
ops = [{
|
||||||
'operationType': 'delete',
|
'operationType': 'delete',
|
||||||
'record': {'recordName': r['recordName'], 'recordType': record_type}
|
'record': {
|
||||||
|
'recordName': r['recordName'],
|
||||||
|
'recordType': record_type,
|
||||||
|
'recordChangeTag': r.get('recordChangeTag', '')
|
||||||
|
}
|
||||||
} for r in records]
|
} for r in records]
|
||||||
|
|
||||||
|
if verbose:
|
||||||
|
print(f" Sending delete for {len(ops)} records...")
|
||||||
|
|
||||||
delete_result = self.modify(ops)
|
delete_result = self.modify(ops)
|
||||||
|
|
||||||
|
if verbose:
|
||||||
|
print(f" Delete response: {json.dumps(delete_result)[:500]}")
|
||||||
|
|
||||||
if 'error' in delete_result:
|
if 'error' in delete_result:
|
||||||
print(f" Delete error: {delete_result['error']}")
|
print(f" Delete error: {delete_result['error']}")
|
||||||
break
|
break
|
||||||
|
|
||||||
deleted = len(delete_result.get('records', []))
|
# Check for individual record errors
|
||||||
total_deleted += deleted
|
result_records = delete_result.get('records', [])
|
||||||
if verbose:
|
successful = [r for r in result_records if 'serverErrorCode' not in r]
|
||||||
print(f" Deleted {deleted} {record_type} records...")
|
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)
|
time.sleep(0.5)
|
||||||
|
|
||||||
@@ -222,13 +267,16 @@ def main():
|
|||||||
stats = {'stadiums': 0, 'teams': 0, 'games': 0}
|
stats = {'stadiums': 0, 'teams': 0, 'games': 0}
|
||||||
team_map = {}
|
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
|
# Import stadiums & teams
|
||||||
if not args.games_only:
|
if not args.games_only:
|
||||||
print("--- Stadiums ---")
|
print("--- Stadiums ---")
|
||||||
recs = [{
|
recs = [{
|
||||||
'recordType': 'Stadium', 'recordName': s['id'],
|
'recordType': 'Stadium', 'recordName': stadium_uuid_map[s['id']],
|
||||||
'fields': {
|
'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', '')},
|
'city': {'value': s['city']}, 'state': {'value': s.get('state', '')},
|
||||||
'sport': {'value': s['sport']}, 'source': {'value': s.get('source', '')},
|
'sport': {'value': s['sport']}, 'source': {'value': s.get('source', '')},
|
||||||
'teamAbbrevs': {'value': s.get('team_abbrevs', [])},
|
'teamAbbrevs': {'value': s.get('team_abbrevs', [])},
|
||||||
@@ -243,25 +291,39 @@ def main():
|
|||||||
teams = {}
|
teams = {}
|
||||||
for s in stadiums:
|
for s in stadiums:
|
||||||
for abbr in s.get('team_abbrevs', []):
|
for abbr in s.get('team_abbrevs', []):
|
||||||
if abbr not in teams:
|
team_key = f"{s['sport']}_{abbr}" # Match Swift: "{sport.rawValue}_{abbrev}"
|
||||||
teams[abbr] = {'city': s['city'], 'sport': s['sport']}
|
if team_key not in teams:
|
||||||
team_map[abbr] = f"team_{abbr.lower()}"
|
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 = [{
|
recs = [{
|
||||||
'recordType': 'Team', 'recordName': f"team_{abbr.lower()}",
|
'recordType': 'Team', 'recordName': deterministic_uuid(team_key),
|
||||||
'fields': {
|
'fields': {
|
||||||
'teamId': {'value': f"team_{abbr.lower()}"}, 'abbreviation': {'value': abbr},
|
'teamId': {'value': deterministic_uuid(team_key)},
|
||||||
'name': {'value': abbr}, 'city': {'value': info['city']}, 'sport': {'value': info['sport']},
|
'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)
|
stats['teams'] = import_data(ck, recs, 'teams', args.dry_run, args.verbose)
|
||||||
|
|
||||||
# Import games
|
# Import games
|
||||||
if not args.stadiums_only and games:
|
if not args.stadiums_only and games:
|
||||||
|
# Rebuild team_map if only importing games (--games-only flag)
|
||||||
if not team_map:
|
if not team_map:
|
||||||
for s in stadiums:
|
for s in stadiums:
|
||||||
for abbr in s.get('team_abbrevs', []):
|
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 ---")
|
print("--- Games ---")
|
||||||
|
|
||||||
@@ -278,20 +340,51 @@ def main():
|
|||||||
|
|
||||||
recs = []
|
recs = []
|
||||||
for g in unique_games:
|
for g in unique_games:
|
||||||
|
game_uuid = deterministic_uuid(g['id'])
|
||||||
|
sport = g['sport']
|
||||||
|
|
||||||
fields = {
|
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', '')},
|
'season': {'value': g.get('season', '')}, 'source': {'value': g.get('source', '')},
|
||||||
}
|
}
|
||||||
if g.get('date'):
|
if g.get('date'):
|
||||||
try:
|
try:
|
||||||
dt = datetime.strptime(f"{g['date']} {g.get('time', '19:00')}", '%Y-%m-%d %H:%M')
|
# Parse time like "7:30p" or "10:00a"
|
||||||
fields['dateTime'] = {'value': int(dt.timestamp() * 1000)}
|
time_str = g.get('time', '7:00p')
|
||||||
except: pass
|
hour, minute = 19, 0
|
||||||
if g.get('home_team_abbrev') in team_map:
|
if time_str:
|
||||||
fields['homeTeamRef'] = {'value': {'recordName': team_map[g['home_team_abbrev']], 'action': 'NONE'}}
|
clean_time = time_str.lower().replace(' ', '')
|
||||||
if g.get('away_team_abbrev') in team_map:
|
is_pm = 'p' in clean_time
|
||||||
fields['awayTeamRef'] = {'value': {'recordName': team_map[g['away_team_abbrev']], 'action': 'NONE'}}
|
time_parts = clean_time.replace('p', '').replace('a', '').split(':')
|
||||||
recs.append({'recordType': 'Game', 'recordName': g['id'], 'fields': fields})
|
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)
|
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.
|
Scrapes NBA, MLB, NHL schedules from multiple sources for cross-validation.
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
python scrape_schedules.py --sport nba --season 2025
|
python scrape_schedules.py --sport nba --season 2026
|
||||||
python scrape_schedules.py --sport all --season 2025
|
python scrape_schedules.py --sport all --season 2026
|
||||||
python scrape_schedules.py --stadiums-only
|
python scrape_schedules.py --stadiums-only
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -435,7 +435,7 @@ def scrape_mlb_statsapi(season: int) -> list[Game]:
|
|||||||
time_str = None
|
time_str = None
|
||||||
|
|
||||||
game = Game(
|
game = Game(
|
||||||
id=f"mlb_{game_data.get('gamePk', '')}",
|
id='', # Will be assigned by assign_stable_ids
|
||||||
sport='MLB',
|
sport='MLB',
|
||||||
season=str(season),
|
season=str(season),
|
||||||
date=game_date,
|
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]:
|
def assign_stable_ids(games: list[Game], sport: str, season: str) -> list[Game]:
|
||||||
"""
|
"""
|
||||||
Assign stable IDs based on matchup + occurrence number within season.
|
Assign IDs based on matchup + date.
|
||||||
Format: {sport}_{season}_{away}_{home}_{num}
|
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
|
from collections import defaultdict
|
||||||
|
|
||||||
# Group games by matchup (away @ home)
|
season_str = season.replace('-', '')
|
||||||
matchups = defaultdict(list)
|
|
||||||
for game in games:
|
|
||||||
key = f"{game.away_team_abbrev}_{game.home_team_abbrev}"
|
|
||||||
matchups[key].append(game)
|
|
||||||
|
|
||||||
# Sort each matchup by date and assign occurrence number
|
# Track how many times we've seen each base ID (for doubleheaders)
|
||||||
for key, matchup_games in matchups.items():
|
id_counts = defaultdict(int)
|
||||||
matchup_games.sort(key=lambda g: g.date)
|
|
||||||
for i, game in enumerate(matchup_games, 1):
|
for game in games:
|
||||||
away = game.away_team_abbrev.lower()
|
away = game.away_team_abbrev.lower()
|
||||||
home = game.home_team_abbrev.lower()
|
home = game.home_team_abbrev.lower()
|
||||||
# Normalize season format (e.g., "2024-25" -> "2024-25", "2025" -> "2025")
|
# Extract MMDD from date (YYYY-MM-DD)
|
||||||
season_str = season.replace('-', '')
|
date_parts = game.date.split('-')
|
||||||
game.id = f"{sport.lower()}_{season_str}_{away}_{home}_{i}"
|
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
|
return games
|
||||||
|
|
||||||
@@ -892,7 +898,7 @@ def export_to_json(games: list[Game], stadiums: list[Stadium], output_dir: Path)
|
|||||||
def main():
|
def main():
|
||||||
parser = argparse.ArgumentParser(description='Scrape sports schedules')
|
parser = argparse.ArgumentParser(description='Scrape sports schedules')
|
||||||
parser.add_argument('--sport', choices=['nba', 'mlb', 'nhl', 'all'], default='all')
|
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('--stadiums-only', action='store_true', help='Only scrape stadium data')
|
||||||
parser.add_argument('--output', type=str, default='./data', help='Output directory')
|
parser.add_argument('--output', type=str, default='./data', help='Output directory')
|
||||||
|
|
||||||
@@ -931,7 +937,7 @@ def main():
|
|||||||
print("="*60)
|
print("="*60)
|
||||||
|
|
||||||
mlb_games_api = scrape_mlb_statsapi(args.season)
|
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)
|
all_games.extend(mlb_games_api)
|
||||||
|
|
||||||
if args.sport in ['nhl', 'all']:
|
if args.sport in ['nhl', 'all']:
|
||||||
|
|||||||
@@ -51,18 +51,28 @@ struct CKTeam {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var team: Team? {
|
var team: Team? {
|
||||||
guard let idString = record[CKTeam.idKey] as? String,
|
// Use teamId field, or fall back to record name
|
||||||
let id = UUID(uuidString: idString),
|
let idString = (record[CKTeam.idKey] as? String) ?? record.recordID.recordName
|
||||||
let name = record[CKTeam.nameKey] as? String,
|
guard let id = UUID(uuidString: idString),
|
||||||
let abbreviation = record[CKTeam.abbreviationKey] as? String,
|
let abbreviation = record[CKTeam.abbreviationKey] as? String,
|
||||||
let sportRaw = record[CKTeam.sportKey] as? String,
|
let sportRaw = record[CKTeam.sportKey] as? String,
|
||||||
let sport = Sport(rawValue: sportRaw),
|
let sport = Sport(rawValue: sportRaw),
|
||||||
let city = record[CKTeam.cityKey] as? String,
|
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))
|
|
||||||
else { return nil }
|
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) }
|
let logoURL = (record[CKTeam.logoURLKey] as? String).flatMap { URL(string: $0) }
|
||||||
|
|
||||||
return Team(
|
return Team(
|
||||||
@@ -111,15 +121,17 @@ struct CKStadium {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var stadium: Stadium? {
|
var stadium: Stadium? {
|
||||||
guard let idString = record[CKStadium.idKey] as? String,
|
// Use stadiumId field, or fall back to record name
|
||||||
let id = UUID(uuidString: idString),
|
let idString = (record[CKStadium.idKey] as? String) ?? record.recordID.recordName
|
||||||
|
guard let id = UUID(uuidString: idString),
|
||||||
let name = record[CKStadium.nameKey] as? String,
|
let name = record[CKStadium.nameKey] as? String,
|
||||||
let city = record[CKStadium.cityKey] 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
|
|
||||||
else { return nil }
|
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) }
|
let imageURL = (record[CKStadium.imageURLKey] as? String).flatMap { URL(string: $0) }
|
||||||
|
|
||||||
return Stadium(
|
return Stadium(
|
||||||
@@ -127,8 +139,8 @@ struct CKStadium {
|
|||||||
name: name,
|
name: name,
|
||||||
city: city,
|
city: city,
|
||||||
state: state,
|
state: state,
|
||||||
latitude: location.coordinate.latitude,
|
latitude: location?.coordinate.latitude ?? 0,
|
||||||
longitude: location.coordinate.longitude,
|
longitude: location?.coordinate.longitude ?? 0,
|
||||||
capacity: capacity,
|
capacity: capacity,
|
||||||
yearOpened: record[CKStadium.yearOpenedKey] as? Int,
|
yearOpened: record[CKStadium.yearOpenedKey] as? Int,
|
||||||
imageURL: imageURL
|
imageURL: imageURL
|
||||||
|
|||||||
@@ -13,8 +13,6 @@ struct TravelSegment: Identifiable, Codable, Hashable {
|
|||||||
let travelMode: TravelMode
|
let travelMode: TravelMode
|
||||||
let distanceMeters: Double
|
let distanceMeters: Double
|
||||||
let durationSeconds: Double
|
let durationSeconds: Double
|
||||||
let departureTime: Date
|
|
||||||
let arrivalTime: Date
|
|
||||||
let scenicScore: Double
|
let scenicScore: Double
|
||||||
let evChargingStops: [EVChargingStop]
|
let evChargingStops: [EVChargingStop]
|
||||||
let routePolyline: String?
|
let routePolyline: String?
|
||||||
@@ -26,8 +24,6 @@ struct TravelSegment: Identifiable, Codable, Hashable {
|
|||||||
travelMode: TravelMode,
|
travelMode: TravelMode,
|
||||||
distanceMeters: Double,
|
distanceMeters: Double,
|
||||||
durationSeconds: Double,
|
durationSeconds: Double,
|
||||||
departureTime: Date,
|
|
||||||
arrivalTime: Date,
|
|
||||||
scenicScore: Double = 0.5,
|
scenicScore: Double = 0.5,
|
||||||
evChargingStops: [EVChargingStop] = [],
|
evChargingStops: [EVChargingStop] = [],
|
||||||
routePolyline: String? = nil
|
routePolyline: String? = nil
|
||||||
@@ -38,8 +34,6 @@ struct TravelSegment: Identifiable, Codable, Hashable {
|
|||||||
self.travelMode = travelMode
|
self.travelMode = travelMode
|
||||||
self.distanceMeters = distanceMeters
|
self.distanceMeters = distanceMeters
|
||||||
self.durationSeconds = durationSeconds
|
self.durationSeconds = durationSeconds
|
||||||
self.departureTime = departureTime
|
|
||||||
self.arrivalTime = arrivalTime
|
|
||||||
self.scenicScore = scenicScore
|
self.scenicScore = scenicScore
|
||||||
self.evChargingStops = evChargingStops
|
self.evChargingStops = evChargingStops
|
||||||
self.routePolyline = routePolyline
|
self.routePolyline = routePolyline
|
||||||
|
|||||||
@@ -104,17 +104,13 @@ struct Trip: Identifiable, Codable, Hashable {
|
|||||||
return currentDate >= arrivalDay && currentDate <= departureDay
|
return currentDate >= arrivalDay && currentDate <= departureDay
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show travel segments that depart on this day
|
// Travel segments are location-based, not date-based
|
||||||
// Travel TO the last city happens on the last game day (drive morning, watch game)
|
// The view handles inserting travel between cities when locations differ
|
||||||
let segmentsForDay = travelSegments.filter { segment in
|
|
||||||
calendar.startOfDay(for: segment.departureTime) == currentDate
|
|
||||||
}
|
|
||||||
|
|
||||||
days.append(ItineraryDay(
|
days.append(ItineraryDay(
|
||||||
dayNumber: dayNumber,
|
dayNumber: dayNumber,
|
||||||
date: currentDate,
|
date: currentDate,
|
||||||
stops: stopsForDay,
|
stops: stopsForDay,
|
||||||
travelSegments: segmentsForDay
|
travelSegments: [] // Travel handled by view based on location changes
|
||||||
))
|
))
|
||||||
|
|
||||||
currentDate = calendar.date(byAdding: .day, value: 1, to: currentDate)!
|
currentDate = calendar.date(byAdding: .day, value: 1, to: currentDate)!
|
||||||
|
|||||||
@@ -227,7 +227,6 @@ struct TripPreferences: Codable, Hashable {
|
|||||||
var lodgingType: LodgingType
|
var lodgingType: LodgingType
|
||||||
var numberOfDrivers: Int
|
var numberOfDrivers: Int
|
||||||
var maxDrivingHoursPerDriver: Double?
|
var maxDrivingHoursPerDriver: Double?
|
||||||
var catchOtherSports: Bool
|
|
||||||
|
|
||||||
init(
|
init(
|
||||||
planningMode: PlanningMode = .dateRange,
|
planningMode: PlanningMode = .dateRange,
|
||||||
@@ -247,8 +246,7 @@ struct TripPreferences: Codable, Hashable {
|
|||||||
needsEVCharging: Bool = false,
|
needsEVCharging: Bool = false,
|
||||||
lodgingType: LodgingType = .hotel,
|
lodgingType: LodgingType = .hotel,
|
||||||
numberOfDrivers: Int = 1,
|
numberOfDrivers: Int = 1,
|
||||||
maxDrivingHoursPerDriver: Double? = nil,
|
maxDrivingHoursPerDriver: Double? = nil
|
||||||
catchOtherSports: Bool = false
|
|
||||||
) {
|
) {
|
||||||
self.planningMode = planningMode
|
self.planningMode = planningMode
|
||||||
self.startLocation = startLocation
|
self.startLocation = startLocation
|
||||||
@@ -268,7 +266,6 @@ struct TripPreferences: Codable, Hashable {
|
|||||||
self.lodgingType = lodgingType
|
self.lodgingType = lodgingType
|
||||||
self.numberOfDrivers = numberOfDrivers
|
self.numberOfDrivers = numberOfDrivers
|
||||||
self.maxDrivingHoursPerDriver = maxDrivingHoursPerDriver
|
self.maxDrivingHoursPerDriver = maxDrivingHoursPerDriver
|
||||||
self.catchOtherSports = catchOtherSports
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var totalDriverHoursPerDay: Double {
|
var totalDriverHoursPerDay: Double {
|
||||||
|
|||||||
@@ -145,12 +145,19 @@ actor CloudKitService {
|
|||||||
|
|
||||||
guard let homeRef = record[CKGame.homeTeamRefKey] as? CKRecord.Reference,
|
guard let homeRef = record[CKGame.homeTeamRefKey] as? CKRecord.Reference,
|
||||||
let awayRef = record[CKGame.awayTeamRefKey] 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 homeId = UUID(uuidString: homeRef.recordID.recordName),
|
||||||
let awayId = UUID(uuidString: awayRef.recordID.recordName),
|
let awayId = UUID(uuidString: awayRef.recordID.recordName)
|
||||||
let stadiumId = UUID(uuidString: stadiumRef.recordID.recordName)
|
|
||||||
else { return nil }
|
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)
|
return ckGame.game(homeTeamId: homeId, awayTeamId: awayId, stadiumId: stadiumId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -193,30 +193,20 @@ struct PlanningProgressView: View {
|
|||||||
@Environment(\.colorScheme) private var colorScheme
|
@Environment(\.colorScheme) private var colorScheme
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 32) {
|
VStack(spacing: 24) {
|
||||||
// Animated route illustration
|
// Simple spinner
|
||||||
AnimatedRouteGraphic()
|
ProgressView()
|
||||||
.frame(height: 150)
|
.scaleEffect(1.5)
|
||||||
.padding(.horizontal, 40)
|
.tint(Theme.warmOrange)
|
||||||
|
|
||||||
// Current step text
|
// Current step text
|
||||||
Text(steps[currentStep])
|
Text(steps[currentStep])
|
||||||
.font(.system(size: 18, weight: .medium))
|
.font(.system(size: 16, weight: .medium))
|
||||||
.foregroundStyle(Theme.textSecondary(colorScheme))
|
.foregroundStyle(Theme.textSecondary(colorScheme))
|
||||||
.animation(.easeInOut, value: currentStep)
|
.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)
|
.frame(maxWidth: .infinity)
|
||||||
.padding(.vertical, 60)
|
.padding(.vertical, 40)
|
||||||
.task {
|
.task {
|
||||||
await animateSteps()
|
await animateSteps()
|
||||||
}
|
}
|
||||||
@@ -226,7 +216,7 @@ struct PlanningProgressView: View {
|
|||||||
while !Task.isCancelled {
|
while !Task.isCancelled {
|
||||||
try? await Task.sleep(for: .milliseconds(1500))
|
try? await Task.sleep(for: .milliseconds(1500))
|
||||||
guard !Task.isCancelled else { break }
|
guard !Task.isCancelled else { break }
|
||||||
withAnimation(Theme.Animation.spring) {
|
withAnimation(.easeInOut) {
|
||||||
currentStep = (currentStep + 1) % steps.count
|
currentStep = (currentStep + 1) % steps.count
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,7 +77,6 @@ final class TripCreationViewModel {
|
|||||||
var lodgingType: LodgingType = .hotel
|
var lodgingType: LodgingType = .hotel
|
||||||
var numberOfDrivers: Int = 1
|
var numberOfDrivers: Int = 1
|
||||||
var maxDrivingHoursPerDriver: Double = 8
|
var maxDrivingHoursPerDriver: Double = 8
|
||||||
var catchOtherSports: Bool = false
|
|
||||||
|
|
||||||
// MARK: - Dependencies
|
// MARK: - Dependencies
|
||||||
|
|
||||||
@@ -286,8 +285,7 @@ final class TripCreationViewModel {
|
|||||||
needsEVCharging: needsEVCharging,
|
needsEVCharging: needsEVCharging,
|
||||||
lodgingType: lodgingType,
|
lodgingType: lodgingType,
|
||||||
numberOfDrivers: numberOfDrivers,
|
numberOfDrivers: numberOfDrivers,
|
||||||
maxDrivingHoursPerDriver: maxDrivingHoursPerDriver,
|
maxDrivingHoursPerDriver: maxDrivingHoursPerDriver
|
||||||
catchOtherSports: catchOtherSports
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Build planning request
|
// Build planning request
|
||||||
@@ -467,8 +465,7 @@ final class TripCreationViewModel {
|
|||||||
needsEVCharging: needsEVCharging,
|
needsEVCharging: needsEVCharging,
|
||||||
lodgingType: lodgingType,
|
lodgingType: lodgingType,
|
||||||
numberOfDrivers: numberOfDrivers,
|
numberOfDrivers: numberOfDrivers,
|
||||||
maxDrivingHoursPerDriver: maxDrivingHoursPerDriver,
|
maxDrivingHoursPerDriver: maxDrivingHoursPerDriver
|
||||||
catchOtherSports: catchOtherSports
|
|
||||||
)
|
)
|
||||||
return convertToTrip(option: option, preferences: preferences)
|
return convertToTrip(option: option, preferences: preferences)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -451,9 +451,7 @@ struct HorizontalTimelineItemView: View {
|
|||||||
toLocation: LocationInput(name: "San Francisco"),
|
toLocation: LocationInput(name: "San Francisco"),
|
||||||
travelMode: .drive,
|
travelMode: .drive,
|
||||||
distanceMeters: 600000,
|
distanceMeters: 600000,
|
||||||
durationSeconds: 21600,
|
durationSeconds: 21600
|
||||||
departureTime: Date(),
|
|
||||||
arrivalTime: Date().addingTimeInterval(21600)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
let option = ItineraryOption(
|
let option = ItineraryOption(
|
||||||
|
|||||||
@@ -555,18 +555,6 @@ struct TripCreationView: View {
|
|||||||
.tint(Theme.warmOrange)
|
.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: - 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 {
|
struct TripOptionsView: View {
|
||||||
let options: [ItineraryOption]
|
let options: [ItineraryOption]
|
||||||
let games: [UUID: RichGame]
|
let games: [UUID: RichGame]
|
||||||
@@ -1045,8 +1055,31 @@ struct TripOptionsView: View {
|
|||||||
|
|
||||||
@State private var selectedTrip: Trip?
|
@State private var selectedTrip: Trip?
|
||||||
@State private var showTripDetail = false
|
@State private var showTripDetail = false
|
||||||
|
@State private var sortOption: TripSortOption = .recommended
|
||||||
@Environment(\.colorScheme) private var colorScheme
|
@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 {
|
var body: some View {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
LazyVStack(spacing: 20) {
|
LazyVStack(spacing: 20) {
|
||||||
@@ -1065,10 +1098,15 @@ struct TripOptionsView: View {
|
|||||||
.foregroundStyle(Theme.textSecondary(colorScheme))
|
.foregroundStyle(Theme.textSecondary(colorScheme))
|
||||||
}
|
}
|
||||||
.padding(.top, Theme.Spacing.xl)
|
.padding(.top, Theme.Spacing.xl)
|
||||||
.padding(.bottom, Theme.Spacing.md)
|
.padding(.bottom, Theme.Spacing.sm)
|
||||||
|
|
||||||
// Options list with staggered animation
|
// Sort picker
|
||||||
ForEach(Array(options.enumerated()), id: \.offset) { index, option in
|
sortPicker
|
||||||
|
.padding(.horizontal, Theme.Spacing.md)
|
||||||
|
.padding(.bottom, Theme.Spacing.sm)
|
||||||
|
|
||||||
|
// Options list
|
||||||
|
ForEach(Array(sortedOptions.enumerated()), id: \.element.id) { index, option in
|
||||||
TripOptionCard(
|
TripOptionCard(
|
||||||
option: option,
|
option: option,
|
||||||
rank: index + 1,
|
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
|
// MARK: - Trip Option Card
|
||||||
@@ -1524,7 +1594,7 @@ struct DateRangePicker: View {
|
|||||||
|
|
||||||
private var daysOfWeekHeader: some View {
|
private var daysOfWeekHeader: some View {
|
||||||
HStack(spacing: 0) {
|
HStack(spacing: 0) {
|
||||||
ForEach(daysOfWeek, id: \.self) { day in
|
ForEach(Array(daysOfWeek.enumerated()), id: \.offset) { _, day in
|
||||||
Text(day)
|
Text(day)
|
||||||
.font(.system(size: 12, weight: .semibold))
|
.font(.system(size: 12, weight: .semibold))
|
||||||
.foregroundStyle(Theme.textMuted(colorScheme))
|
.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] {
|
private var itinerarySections: [ItinerarySection] {
|
||||||
var sections: [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
|
let days = tripDays
|
||||||
|
|
||||||
for (index, dayDate) in days.enumerated() {
|
for (index, dayDate) in days.enumerated() {
|
||||||
let dayNum = index + 1
|
let dayNum = index + 1
|
||||||
let gamesOnDay = gamesOn(date: dayDate)
|
let gamesOnDay = gamesOn(date: dayDate)
|
||||||
|
|
||||||
if !gamesOnDay.isEmpty || index == 0 || index == days.count - 1 {
|
// Get city from games (preferred) or from stops as fallback
|
||||||
sections.append(.day(dayNumber: dayNum, date: dayDate, games: gamesOnDay))
|
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)
|
// Add the day section
|
||||||
for segment in travelAfterDay {
|
sections.append(.day(dayNumber: daySection.dayNumber, date: daySection.date, games: daySection.games))
|
||||||
sections.append(.travel(segment))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return sections
|
return sections
|
||||||
@@ -311,13 +334,27 @@ struct TripDetailView: View {
|
|||||||
}.sorted { $0.game.dateTime < $1.game.dateTime }
|
}.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 calendar = Calendar.current
|
||||||
let dayEnd = calendar.startOfDay(for: date)
|
let dayStart = calendar.startOfDay(for: date)
|
||||||
|
|
||||||
return trip.travelSegments.filter { segment in
|
return trip.stops.first { stop in
|
||||||
let segmentDay = calendar.startOfDay(for: segment.departureTime)
|
let arrivalDay = calendar.startOfDay(for: stop.arrivalDate)
|
||||||
return segmentDay == dayEnd
|
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
|
// 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.
|
/// 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)
|
/// - Parameter bufferSeconds: Time buffer before game start (default 1 hour)
|
||||||
/// - Returns: Validator closure
|
/// - Returns: Validator closure
|
||||||
///
|
///
|
||||||
static func arrivalBeforeGameStart(bufferSeconds: TimeInterval = 3600) -> SegmentValidator {
|
static func arrivalBeforeGameStart(bufferSeconds: TimeInterval = 3600) -> SegmentValidator {
|
||||||
return { segment, _, toStop in
|
return { segment, fromStop, toStop in
|
||||||
guard let gameStart = toStop.firstGameStart else {
|
guard let gameStart = toStop.firstGameStart else {
|
||||||
return true // No game = no constraint
|
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)
|
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 false
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
|
|||||||
@@ -226,63 +226,84 @@ final class ScenarioAPlanner: ScenarioPlanner {
|
|||||||
/// Stop 1: Los Angeles (contains game 1 and 2)
|
/// Stop 1: Los Angeles (contains game 1 and 2)
|
||||||
/// Stop 2: San Francisco (contains game 3)
|
/// 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(
|
private func buildStops(
|
||||||
from games: [Game],
|
from games: [Game],
|
||||||
stadiums: [UUID: Stadium]
|
stadiums: [UUID: Stadium]
|
||||||
) -> [ItineraryStop] {
|
) -> [ItineraryStop] {
|
||||||
|
guard !games.isEmpty else { return [] }
|
||||||
|
|
||||||
// Step 1: Group all games by their stadium
|
// Sort games chronologically
|
||||||
// This lets us find ALL games at a stadium when we create that stop
|
let sortedGames = games.sorted { $0.startTime < $1.startTime }
|
||||||
// Result: { stadiumId: [game1, game2, ...], ... }
|
|
||||||
var stadiumGames: [UUID: [Game]] = [:]
|
// Group consecutive games at the same stadium into stops
|
||||||
for game in games {
|
// If you visit A, then B, then A again, that's 3 stops (A, B, A)
|
||||||
stadiumGames[game.stadiumId, default: []].append(game)
|
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
|
// Don't forget the last group
|
||||||
// When we hit a stadium for the first time, create a stop with ALL games at that stadium
|
if let stadiumId = currentStadiumId, !currentGames.isEmpty {
|
||||||
var stops: [ItineraryStop] = []
|
if let stop = createStop(from: currentGames, stadiumId: stadiumId, stadiums: stadiums) {
|
||||||
var processedStadiums: Set<UUID> = [] // Track which stadiums we've already made stops for
|
stops.append(stop)
|
||||||
|
}
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return stops
|
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
|
// MARK: - Stop Building
|
||||||
|
|
||||||
/// Converts a list of games into itinerary stops.
|
/// 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(
|
private func buildStops(
|
||||||
from games: [Game],
|
from games: [Game],
|
||||||
stadiums: [UUID: Stadium]
|
stadiums: [UUID: Stadium]
|
||||||
) -> [ItineraryStop] {
|
) -> [ItineraryStop] {
|
||||||
|
guard !games.isEmpty else { return [] }
|
||||||
|
|
||||||
// Group games by stadium
|
// Sort games chronologically
|
||||||
var stadiumGames: [UUID: [Game]] = [:]
|
let sortedGames = games.sorted { $0.startTime < $1.startTime }
|
||||||
for game in games {
|
|
||||||
stadiumGames[game.stadiumId, default: []].append(game)
|
// 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)
|
// Don't forget the last group
|
||||||
var stops: [ItineraryStop] = []
|
if let stadiumId = currentStadiumId, !currentGames.isEmpty {
|
||||||
var processedStadiums: Set<UUID> = []
|
if let stop = createStop(from: currentGames, stadiumId: stadiumId, stadiums: stadiums) {
|
||||||
|
stops.append(stop)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return stops
|
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
|
// MARK: - Stop Building
|
||||||
|
|
||||||
/// Converts games to stops (used by GeographicRouteExplorer callback).
|
/// 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(
|
private func buildStops(
|
||||||
from games: [Game],
|
from games: [Game],
|
||||||
stadiums: [UUID: Stadium]
|
stadiums: [UUID: Stadium]
|
||||||
) -> [ItineraryStop] {
|
) -> [ItineraryStop] {
|
||||||
|
guard !games.isEmpty else { return [] }
|
||||||
|
|
||||||
var stadiumGames: [UUID: [Game]] = [:]
|
// Sort games chronologically
|
||||||
for game in games {
|
let sortedGames = games.sorted { $0.startTime < $1.startTime }
|
||||||
stadiumGames[game.stadiumId, default: []].append(game)
|
|
||||||
|
// 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] = []
|
// Don't forget the last group
|
||||||
var processedStadiums: Set<UUID> = []
|
if let stadiumId = currentStadiumId, !currentGames.isEmpty {
|
||||||
|
if let stop = createStop(from: currentGames, stadiumId: stadiumId, stadiums: stadiums) {
|
||||||
for game in games {
|
stops.append(stop)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return stops
|
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.
|
/// Builds stops with start and end location endpoints.
|
||||||
private func buildStopsWithEndpoints(
|
private func buildStopsWithEndpoints(
|
||||||
start: LocationInput,
|
start: LocationInput,
|
||||||
|
|||||||
@@ -36,18 +36,12 @@ enum TravelEstimator {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate times (assume 8 AM departure)
|
|
||||||
let departureTime = from.departureDate.addingTimeInterval(8 * 3600)
|
|
||||||
let arrivalTime = departureTime.addingTimeInterval(drivingHours * 3600)
|
|
||||||
|
|
||||||
return TravelSegment(
|
return TravelSegment(
|
||||||
fromLocation: from.location,
|
fromLocation: from.location,
|
||||||
toLocation: to.location,
|
toLocation: to.location,
|
||||||
travelMode: .drive,
|
travelMode: .drive,
|
||||||
distanceMeters: distanceMiles * 1609.34,
|
distanceMeters: distanceMiles * 1609.34,
|
||||||
durationSeconds: drivingHours * 3600,
|
durationSeconds: drivingHours * 3600
|
||||||
departureTime: departureTime,
|
|
||||||
arrivalTime: arrivalTime
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,17 +67,12 @@ enum TravelEstimator {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
let departureTime = Date()
|
|
||||||
let arrivalTime = departureTime.addingTimeInterval(drivingHours * 3600)
|
|
||||||
|
|
||||||
return TravelSegment(
|
return TravelSegment(
|
||||||
fromLocation: from,
|
fromLocation: from,
|
||||||
toLocation: to,
|
toLocation: to,
|
||||||
travelMode: .drive,
|
travelMode: .drive,
|
||||||
distanceMeters: distanceMeters * roadRoutingFactor,
|
distanceMeters: distanceMeters * roadRoutingFactor,
|
||||||
durationSeconds: drivingHours * 3600,
|
durationSeconds: drivingHours * 3600
|
||||||
departureTime: departureTime,
|
|
||||||
arrivalTime: arrivalTime
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -244,10 +244,10 @@ enum TimelineItem: Identifiable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var date: Date {
|
var date: Date? {
|
||||||
switch self {
|
switch self {
|
||||||
case .stop(let stop): return stop.arrivalDate
|
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
|
case .rest(let rest): return rest.date
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -337,16 +337,9 @@ extension ItineraryOption {
|
|||||||
// Add travel segment to next stop (if not last stop)
|
// Add travel segment to next stop (if not last stop)
|
||||||
if index < travelSegments.count {
|
if index < travelSegments.count {
|
||||||
let segment = travelSegments[index]
|
let segment = travelSegments[index]
|
||||||
|
// Travel is location-based - just add the segment
|
||||||
// Check if travel spans multiple days
|
// Multi-day travel indicated by durationHours > 8
|
||||||
let travelDays = calculateTravelDays(for: segment, calendar: calendar)
|
timeline.append(.travel(segment))
|
||||||
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))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -391,31 +384,16 @@ extension ItineraryOption {
|
|||||||
return restDays
|
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.
|
/// 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]] {
|
func timelineByDate() -> [Date: [TimelineItem]] {
|
||||||
let calendar = Calendar.current
|
let calendar = Calendar.current
|
||||||
var byDate: [Date: [TimelineItem]] = [:]
|
var byDate: [Date: [TimelineItem]] = [:]
|
||||||
|
|
||||||
for item in generateTimeline() {
|
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)
|
byDate[day, default: []].append(item)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1794,45 +1794,9 @@ struct ScenarioCPlannerTests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Departure time before arrival time")
|
// Note: "Departure time before arrival time" test removed
|
||||||
func plan_DepartureBeforeArrival() {
|
// Travel segments are now location-based, not time-based
|
||||||
let planner = ScenarioCPlanner()
|
// The user decides when to travel; segments only describe route info
|
||||||
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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("Games filtered to date range")
|
@Test("Games filtered to date range")
|
||||||
func plan_GamesFilteredToDateRange() {
|
func plan_GamesFilteredToDateRange() {
|
||||||
|
|||||||
@@ -326,18 +326,8 @@ struct TravelEstimatorTests {
|
|||||||
#expect(twoDrivers != nil, "Should succeed with two drivers - within 32h limit")
|
#expect(twoDrivers != nil, "Should succeed with two drivers - within 32h limit")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("estimate stops - calculates departure and arrival times")
|
// Note: departure/arrival time tests removed - travel is now location-based, not time-based
|
||||||
func estimateStops_CalculatesTimes() {
|
// The user decides when to travel; segments only describe route info (distance, duration)
|
||||||
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")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("estimate stops - distance and duration are consistent")
|
@Test("estimate stops - distance and duration are consistent")
|
||||||
func estimateStops_DistanceDurationConsistent() {
|
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 |