From 92d808caf5dca92f6ad709091dda53798e7a55f0 Mon Sep 17 00:00:00 2001 From: Trey t Date: Thu, 8 Jan 2026 20:20:03 -0600 Subject: [PATCH] Add Stadium Progress system and themed loading spinners MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stadium Progress & Achievements: - Add StadiumVisit and Achievement SwiftData models - Create Progress tab with interactive map view - Implement photo-based visit import with GPS/date matching - Add achievement badges (count-based, regional, journey) - Create shareable progress cards for social media - Add canonical data infrastructure (stadium identities, team aliases) - Implement score resolution from free APIs (MLB, NBA, NHL stats) UI Improvements: - Add ThemedSpinner and ThemedSpinnerCompact components - Replace all ProgressView() with themed spinners throughout app - Fix sport selection state not persisting when navigating away Bug Fixes: - Fix Coast to Coast trips showing only 1 city (validation issue) - Fix stadium progress showing 0/0 (filtering issue) - Remove "Stadium Quest" title from progress view 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- Scripts/cloudkit_import.py | 156 +++- Scripts/data/league_structure.json | 227 +++++ Scripts/data/team_aliases.json | 610 +++++++++++++ Scripts/generate_canonical_data.py | 405 +++++++++ .../Core/Models/CloudKit/CKModels.swift | 127 +++ .../Domain/AchievementDefinitions.swift | 647 ++++++++++++++ SportsTime/Core/Models/Domain/Division.swift | 119 +++ SportsTime/Core/Models/Domain/Progress.swift | 232 +++++ SportsTime/Core/Models/Domain/Stadium.swift | 3 + SportsTime/Core/Models/Domain/Trip.swift | 10 +- .../Core/Models/Local/CanonicalModels.swift | 492 ++++++++++ .../Core/Models/Local/StadiumProgress.swift | 364 ++++++++ .../Core/Services/AchievementEngine.swift | 444 +++++++++ .../Core/Services/BootstrapService.swift | 512 +++++++++++ .../Core/Services/CanonicalDataProvider.swift | 234 +++++ .../Core/Services/CanonicalSyncService.swift | 634 +++++++++++++ .../Core/Services/CloudKitService.swift | 120 ++- SportsTime/Core/Services/FreeScoreAPI.swift | 298 +++++++ SportsTime/Core/Services/GameMatcher.swift | 324 +++++++ .../Services/PhotoMetadataExtractor.swift | 200 +++++ SportsTime/Core/Services/RateLimiter.swift | 208 +++++ .../ScoreAPIProviders/MLBStatsProvider.swift | 173 ++++ .../ScoreAPIProviders/NBAStatsProvider.swift | 215 +++++ .../ScoreAPIProviders/NHLStatsProvider.swift | 172 ++++ .../Core/Services/ScoreResolutionCache.swift | 312 +++++++ .../Services/StadiumIdentityService.swift | 273 ++++++ .../Services/StadiumProximityMatcher.swift | 348 ++++++++ .../Core/Services/StubDataProvider.swift | 1 + .../Services/SuggestedTripsGenerator.swift | 32 +- .../Core/Services/VisitPhotoService.swift | 410 +++++++++ .../Core/Theme/AnimatedComponents.swift | 185 +++- .../Services/ProgressCardGenerator.swift | 598 +++++++++++++ SportsTime/Features/Home/Views/HomeView.swift | 15 +- .../ViewModels/PhotoImportViewModel.swift | 173 ++++ .../ViewModels/ProgressViewModel.swift | 204 +++++ .../Progress/Views/AchievementsListView.swift | 526 +++++++++++ .../Views/GameMatchConfirmationView.swift | 341 +++++++ .../Progress/Views/PhotoImportView.swift | 548 ++++++++++++ .../Progress/Views/ProgressMapView.swift | 186 ++++ .../Progress/Views/ProgressTabView.swift | 685 ++++++++++++++ .../Progress/Views/StadiumVisitSheet.swift | 357 ++++++++ .../Progress/Views/VisitDetailView.swift | 538 +++++++++++ .../Schedule/Views/ScheduleListView.swift | 6 +- .../Settings/Views/SettingsView.swift | 9 +- .../ViewModels/TripCreationViewModel.swift | 4 + .../Trip/Views/TripCreationView.swift | 221 ++++- .../Features/Trip/Views/TripDetailView.swift | 3 +- SportsTime/Resources/league_structure.json | 227 +++++ SportsTime/Resources/team_aliases.json | 306 +++++++ SportsTime/SportsTimeApp.swift | 108 ++- .../ScenarioAPlannerSwiftTests.swift | 6 +- SportsTimeTests/ScenarioBPlannerTests.swift | 6 +- SportsTimeTests/ScenarioCPlannerTests.swift | 6 +- SportsTimeTests/SportsTimeTests.swift | 8 +- docs/STADIUM_PROGRESS_SPEC.md | 841 ++++++++++++++++++ 55 files changed, 14348 insertions(+), 61 deletions(-) create mode 100644 Scripts/data/league_structure.json create mode 100644 Scripts/data/team_aliases.json create mode 100644 Scripts/generate_canonical_data.py create mode 100644 SportsTime/Core/Models/Domain/AchievementDefinitions.swift create mode 100644 SportsTime/Core/Models/Domain/Division.swift create mode 100644 SportsTime/Core/Models/Domain/Progress.swift create mode 100644 SportsTime/Core/Models/Local/CanonicalModels.swift create mode 100644 SportsTime/Core/Models/Local/StadiumProgress.swift create mode 100644 SportsTime/Core/Services/AchievementEngine.swift create mode 100644 SportsTime/Core/Services/BootstrapService.swift create mode 100644 SportsTime/Core/Services/CanonicalDataProvider.swift create mode 100644 SportsTime/Core/Services/CanonicalSyncService.swift create mode 100644 SportsTime/Core/Services/FreeScoreAPI.swift create mode 100644 SportsTime/Core/Services/GameMatcher.swift create mode 100644 SportsTime/Core/Services/PhotoMetadataExtractor.swift create mode 100644 SportsTime/Core/Services/RateLimiter.swift create mode 100644 SportsTime/Core/Services/ScoreAPIProviders/MLBStatsProvider.swift create mode 100644 SportsTime/Core/Services/ScoreAPIProviders/NBAStatsProvider.swift create mode 100644 SportsTime/Core/Services/ScoreAPIProviders/NHLStatsProvider.swift create mode 100644 SportsTime/Core/Services/ScoreResolutionCache.swift create mode 100644 SportsTime/Core/Services/StadiumIdentityService.swift create mode 100644 SportsTime/Core/Services/StadiumProximityMatcher.swift create mode 100644 SportsTime/Core/Services/VisitPhotoService.swift create mode 100644 SportsTime/Export/Services/ProgressCardGenerator.swift create mode 100644 SportsTime/Features/Progress/ViewModels/PhotoImportViewModel.swift create mode 100644 SportsTime/Features/Progress/ViewModels/ProgressViewModel.swift create mode 100644 SportsTime/Features/Progress/Views/AchievementsListView.swift create mode 100644 SportsTime/Features/Progress/Views/GameMatchConfirmationView.swift create mode 100644 SportsTime/Features/Progress/Views/PhotoImportView.swift create mode 100644 SportsTime/Features/Progress/Views/ProgressMapView.swift create mode 100644 SportsTime/Features/Progress/Views/ProgressTabView.swift create mode 100644 SportsTime/Features/Progress/Views/StadiumVisitSheet.swift create mode 100644 SportsTime/Features/Progress/Views/VisitDetailView.swift create mode 100644 SportsTime/Resources/league_structure.json create mode 100644 SportsTime/Resources/team_aliases.json create mode 100644 docs/STADIUM_PROGRESS_SPEC.md diff --git a/Scripts/cloudkit_import.py b/Scripts/cloudkit_import.py index ee2da62..dc0e1b4 100755 --- a/Scripts/cloudkit_import.py +++ b/Scripts/cloudkit_import.py @@ -34,6 +34,40 @@ CONTAINER = "iCloud.com.sportstime.app" HOST = "https://api.apple-cloudkit.com" BATCH_SIZE = 200 +# Hardcoded credentials +DEFAULT_KEY_ID = "152be0715e0276e31aaea5cbfe79dc872f298861a55c70fae14e5fe3e026cff9" +DEFAULT_KEY_FILE = "eckey.pem" + + +def show_menu(): + """Show interactive menu and return selected action.""" + print("\n" + "="*50) + print("CloudKit Import - Select Action") + print("="*50) + print("\n 1. Import all (stadiums, teams, games, league structure, team aliases)") + print(" 2. Stadiums only") + print(" 3. Games only") + print(" 4. League structure only") + print(" 5. Team aliases only") + print(" 6. Canonical only (league structure + team aliases)") + print(" 7. Delete all then import") + print(" 8. Delete only (no import)") + print(" 9. Dry run (preview only)") + print(" 0. Exit") + print() + + while True: + try: + choice = input("Enter choice [1-9, 0 to exit]: ").strip() + if choice == '0': + return None + if choice in ['1', '2', '3', '4', '5', '6', '7', '8', '9']: + return int(choice) + print("Invalid choice. Please enter 1-9 or 0.") + except (EOFError, KeyboardInterrupt): + print("\nExiting.") + return None + def deterministic_uuid(string: str) -> str: """ @@ -214,19 +248,55 @@ def import_data(ck, records, name, dry_run, verbose): def main(): p = argparse.ArgumentParser(description='Import JSON to CloudKit') - p.add_argument('--key-id', default=os.environ.get('CLOUDKIT_KEY_ID')) - p.add_argument('--key-file', default=os.environ.get('CLOUDKIT_KEY_FILE')) + p.add_argument('--key-id', default=DEFAULT_KEY_ID) + p.add_argument('--key-file', default=DEFAULT_KEY_FILE) p.add_argument('--container', default=CONTAINER) p.add_argument('--env', choices=['development', 'production'], default='development') p.add_argument('--data-dir', default='./data') p.add_argument('--stadiums-only', action='store_true') p.add_argument('--games-only', action='store_true') + p.add_argument('--league-structure-only', action='store_true', help='Import only league structure') + p.add_argument('--team-aliases-only', action='store_true', help='Import only team aliases') + p.add_argument('--canonical-only', action='store_true', help='Import only canonical data (league structure + team aliases)') p.add_argument('--delete-all', action='store_true', help='Delete all records before importing') p.add_argument('--delete-only', action='store_true', help='Only delete records, do not import') p.add_argument('--dry-run', action='store_true') p.add_argument('--verbose', '-v', action='store_true') + p.add_argument('--interactive', '-i', action='store_true', help='Show interactive menu') args = p.parse_args() + # Show interactive menu if no action flags provided or --interactive + has_action_flag = any([ + args.stadiums_only, args.games_only, args.league_structure_only, + args.team_aliases_only, args.canonical_only, args.delete_all, + args.delete_only, args.dry_run + ]) + + if args.interactive or not has_action_flag: + choice = show_menu() + if choice is None: + return + + # Map menu choice to flags + if choice == 1: # Import all + pass # Default behavior + elif choice == 2: # Stadiums only + args.stadiums_only = True + elif choice == 3: # Games only + args.games_only = True + elif choice == 4: # League structure only + args.league_structure_only = True + elif choice == 5: # Team aliases only + args.team_aliases_only = True + elif choice == 6: # Canonical only + args.canonical_only = True + elif choice == 7: # Delete all then import + args.delete_all = True + elif choice == 8: # Delete only + args.delete_only = True + elif choice == 9: # Dry run + args.dry_run = True + print(f"\n{'='*50}") print(f"CloudKit Import {'(DRY RUN)' if args.dry_run else ''}") print(f"{'='*50}") @@ -236,14 +306,16 @@ def main(): data_dir = Path(args.data_dir) stadiums = json.load(open(data_dir / 'stadiums.json')) games = json.load(open(data_dir / 'games.json')) if (data_dir / 'games.json').exists() else [] - print(f"Loaded {len(stadiums)} stadiums, {len(games)} games\n") + league_structure = json.load(open(data_dir / 'league_structure.json')) if (data_dir / 'league_structure.json').exists() else [] + team_aliases = json.load(open(data_dir / 'team_aliases.json')) if (data_dir / 'team_aliases.json').exists() else [] + print(f"Loaded {len(stadiums)} stadiums, {len(games)} games, {len(league_structure)} league structures, {len(team_aliases)} team aliases\n") ck = None if not args.dry_run: if not HAS_CRYPTO: sys.exit("Error: pip install cryptography") - if not args.key_id or not args.key_file: - sys.exit("Error: --key-id and --key-file required (or use --dry-run)") + if not os.path.exists(args.key_file): + sys.exit(f"Error: Key file not found: {args.key_file}") ck = CloudKit(args.key_id, open(args.key_file, 'rb').read(), args.container, args.env) # Handle deletion @@ -252,8 +324,8 @@ def main(): sys.exit("Error: --key-id and --key-file required for deletion") print("--- Deleting Existing Records ---") - # Delete in order: Games first (has references), then Teams, then Stadiums - for record_type in ['Game', 'Team', 'Stadium']: + # Delete in order: dependent records first, then base records + for record_type in ['Game', 'TeamAlias', 'Team', 'LeagueStructure', 'Stadium']: print(f" Deleting {record_type} records...") deleted = ck.delete_all(record_type, verbose=args.verbose) print(f" Deleted {deleted} {record_type} records") @@ -264,14 +336,21 @@ def main(): print() return - stats = {'stadiums': 0, 'teams': 0, 'games': 0} + stats = {'stadiums': 0, 'teams': 0, 'games': 0, 'league_structures': 0, 'team_aliases': 0} team_map = {} + # Determine what to import based on flags + import_stadiums = not args.games_only and not args.league_structure_only and not args.team_aliases_only and not args.canonical_only + import_teams = not args.games_only and not args.league_structure_only and not args.team_aliases_only and not args.canonical_only + import_games = not args.stadiums_only and not args.league_structure_only and not args.team_aliases_only and not args.canonical_only + import_league_structure = args.league_structure_only or args.canonical_only or (not args.stadiums_only and not args.games_only and not args.team_aliases_only) + import_team_aliases = args.team_aliases_only or args.canonical_only or (not args.stadiums_only and not args.games_only and not args.league_structure_only) + # 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: + if import_stadiums: print("--- Stadiums ---") recs = [{ 'recordType': 'Stadium', 'recordName': stadium_uuid_map[s['id']], @@ -310,7 +389,7 @@ def main(): stats['teams'] = import_data(ck, recs, 'teams', args.dry_run, args.verbose) # Import games - if not args.stadiums_only and games: + if import_games and games: # Rebuild team_map if only importing games (--games-only flag) if not team_map: for s in stadiums: @@ -388,8 +467,63 @@ def main(): stats['games'] = import_data(ck, recs, 'games', args.dry_run, args.verbose) + # Import league structure + if import_league_structure and league_structure: + print("--- League Structure ---") + now_ms = int(datetime.now(timezone.utc).timestamp() * 1000) + recs = [{ + 'recordType': 'LeagueStructure', + 'recordName': ls['id'], # Use the id as recordName + 'fields': { + 'structureId': {'value': ls['id']}, + 'sport': {'value': ls['sport']}, + 'type': {'value': ls['type']}, + 'name': {'value': ls['name']}, + 'displayOrder': {'value': ls['display_order']}, + 'schemaVersion': {'value': 1}, + 'lastModified': {'value': now_ms, 'type': 'TIMESTAMP'}, + **({'abbreviation': {'value': ls['abbreviation']}} if ls.get('abbreviation') else {}), + **({'parentId': {'value': ls['parent_id']}} if ls.get('parent_id') else {}), + } + } for ls in league_structure] + stats['league_structures'] = import_data(ck, recs, 'league structures', args.dry_run, args.verbose) + + # Import team aliases + if import_team_aliases and team_aliases: + print("--- Team Aliases ---") + now_ms = int(datetime.now(timezone.utc).timestamp() * 1000) + recs = [] + for ta in team_aliases: + fields = { + 'aliasId': {'value': ta['id']}, + 'teamCanonicalId': {'value': ta['team_canonical_id']}, + 'aliasType': {'value': ta['alias_type']}, + 'aliasValue': {'value': ta['alias_value']}, + 'schemaVersion': {'value': 1}, + 'lastModified': {'value': now_ms, 'type': 'TIMESTAMP'}, + } + # Add optional date fields + if ta.get('valid_from'): + try: + dt = datetime.strptime(ta['valid_from'], '%Y-%m-%d') + fields['validFrom'] = {'value': int(dt.timestamp() * 1000), 'type': 'TIMESTAMP'} + except: + pass + if ta.get('valid_until'): + try: + dt = datetime.strptime(ta['valid_until'], '%Y-%m-%d') + fields['validUntil'] = {'value': int(dt.timestamp() * 1000), 'type': 'TIMESTAMP'} + except: + pass + recs.append({ + 'recordType': 'TeamAlias', + 'recordName': ta['id'], # Use the id as recordName + 'fields': fields + }) + stats['team_aliases'] = import_data(ck, recs, 'team aliases', args.dry_run, args.verbose) + print(f"\n{'='*50}") - print(f"COMPLETE: {stats['stadiums']} stadiums, {stats['teams']} teams, {stats['games']} games") + print(f"COMPLETE: {stats['stadiums']} stadiums, {stats['teams']} teams, {stats['games']} games, {stats['league_structures']} league structures, {stats['team_aliases']} team aliases") if args.dry_run: print("[DRY RUN - nothing imported]") print() diff --git a/Scripts/data/league_structure.json b/Scripts/data/league_structure.json new file mode 100644 index 0000000..94a57dd --- /dev/null +++ b/Scripts/data/league_structure.json @@ -0,0 +1,227 @@ +[ + { + "id": "mlb_league", + "sport": "MLB", + "type": "league", + "name": "Major League Baseball", + "abbreviation": "MLB", + "parent_id": null, + "display_order": 0 + }, + { + "id": "mlb_al", + "sport": "MLB", + "type": "conference", + "name": "American League", + "abbreviation": "AL", + "parent_id": "mlb_league", + "display_order": 1 + }, + { + "id": "mlb_nl", + "sport": "MLB", + "type": "conference", + "name": "National League", + "abbreviation": "NL", + "parent_id": "mlb_league", + "display_order": 2 + }, + { + "id": "mlb_al_east", + "sport": "MLB", + "type": "division", + "name": "AL East", + "abbreviation": null, + "parent_id": "mlb_al", + "display_order": 3 + }, + { + "id": "mlb_al_central", + "sport": "MLB", + "type": "division", + "name": "AL Central", + "abbreviation": null, + "parent_id": "mlb_al", + "display_order": 4 + }, + { + "id": "mlb_al_west", + "sport": "MLB", + "type": "division", + "name": "AL West", + "abbreviation": null, + "parent_id": "mlb_al", + "display_order": 5 + }, + { + "id": "mlb_nl_east", + "sport": "MLB", + "type": "division", + "name": "NL East", + "abbreviation": null, + "parent_id": "mlb_nl", + "display_order": 6 + }, + { + "id": "mlb_nl_central", + "sport": "MLB", + "type": "division", + "name": "NL Central", + "abbreviation": null, + "parent_id": "mlb_nl", + "display_order": 7 + }, + { + "id": "mlb_nl_west", + "sport": "MLB", + "type": "division", + "name": "NL West", + "abbreviation": null, + "parent_id": "mlb_nl", + "display_order": 8 + }, + { + "id": "nba_league", + "sport": "NBA", + "type": "league", + "name": "National Basketball Association", + "abbreviation": "NBA", + "parent_id": null, + "display_order": 9 + }, + { + "id": "nba_eastern", + "sport": "NBA", + "type": "conference", + "name": "Eastern Conference", + "abbreviation": "East", + "parent_id": "nba_league", + "display_order": 10 + }, + { + "id": "nba_western", + "sport": "NBA", + "type": "conference", + "name": "Western Conference", + "abbreviation": "West", + "parent_id": "nba_league", + "display_order": 11 + }, + { + "id": "nba_atlantic", + "sport": "NBA", + "type": "division", + "name": "Atlantic", + "abbreviation": null, + "parent_id": "nba_eastern", + "display_order": 12 + }, + { + "id": "nba_central", + "sport": "NBA", + "type": "division", + "name": "Central", + "abbreviation": null, + "parent_id": "nba_eastern", + "display_order": 13 + }, + { + "id": "nba_southeast", + "sport": "NBA", + "type": "division", + "name": "Southeast", + "abbreviation": null, + "parent_id": "nba_eastern", + "display_order": 14 + }, + { + "id": "nba_northwest", + "sport": "NBA", + "type": "division", + "name": "Northwest", + "abbreviation": null, + "parent_id": "nba_western", + "display_order": 15 + }, + { + "id": "nba_pacific", + "sport": "NBA", + "type": "division", + "name": "Pacific", + "abbreviation": null, + "parent_id": "nba_western", + "display_order": 16 + }, + { + "id": "nba_southwest", + "sport": "NBA", + "type": "division", + "name": "Southwest", + "abbreviation": null, + "parent_id": "nba_western", + "display_order": 17 + }, + { + "id": "nhl_league", + "sport": "NHL", + "type": "league", + "name": "National Hockey League", + "abbreviation": "NHL", + "parent_id": null, + "display_order": 18 + }, + { + "id": "nhl_eastern", + "sport": "NHL", + "type": "conference", + "name": "Eastern Conference", + "abbreviation": "East", + "parent_id": "nhl_league", + "display_order": 19 + }, + { + "id": "nhl_western", + "sport": "NHL", + "type": "conference", + "name": "Western Conference", + "abbreviation": "West", + "parent_id": "nhl_league", + "display_order": 20 + }, + { + "id": "nhl_atlantic", + "sport": "NHL", + "type": "division", + "name": "Atlantic", + "abbreviation": null, + "parent_id": "nhl_eastern", + "display_order": 21 + }, + { + "id": "nhl_metropolitan", + "sport": "NHL", + "type": "division", + "name": "Metropolitan", + "abbreviation": null, + "parent_id": "nhl_eastern", + "display_order": 22 + }, + { + "id": "nhl_central", + "sport": "NHL", + "type": "division", + "name": "Central", + "abbreviation": null, + "parent_id": "nhl_western", + "display_order": 23 + }, + { + "id": "nhl_pacific", + "sport": "NHL", + "type": "division", + "name": "Pacific", + "abbreviation": null, + "parent_id": "nhl_western", + "display_order": 24 + } +] \ No newline at end of file diff --git a/Scripts/data/team_aliases.json b/Scripts/data/team_aliases.json new file mode 100644 index 0000000..6e4206d --- /dev/null +++ b/Scripts/data/team_aliases.json @@ -0,0 +1,610 @@ +[ + { + "id": "alias_mlb_1", + "team_canonical_id": "team_mlb_wsn", + "alias_type": "name", + "alias_value": "Montreal Expos", + "valid_from": "1969-01-01", + "valid_until": "2004-12-31" + }, + { + "id": "alias_mlb_2", + "team_canonical_id": "team_mlb_wsn", + "alias_type": "abbreviation", + "alias_value": "MON", + "valid_from": "1969-01-01", + "valid_until": "2004-12-31" + }, + { + "id": "alias_mlb_3", + "team_canonical_id": "team_mlb_wsn", + "alias_type": "city", + "alias_value": "Montreal", + "valid_from": "1969-01-01", + "valid_until": "2004-12-31" + }, + { + "id": "alias_mlb_4", + "team_canonical_id": "team_mlb_oak", + "alias_type": "name", + "alias_value": "Kansas City Athletics", + "valid_from": "1955-01-01", + "valid_until": "1967-12-31" + }, + { + "id": "alias_mlb_5", + "team_canonical_id": "team_mlb_oak", + "alias_type": "abbreviation", + "alias_value": "KCA", + "valid_from": "1955-01-01", + "valid_until": "1967-12-31" + }, + { + "id": "alias_mlb_6", + "team_canonical_id": "team_mlb_oak", + "alias_type": "city", + "alias_value": "Kansas City", + "valid_from": "1955-01-01", + "valid_until": "1967-12-31" + }, + { + "id": "alias_mlb_7", + "team_canonical_id": "team_mlb_oak", + "alias_type": "name", + "alias_value": "Philadelphia Athletics", + "valid_from": "1901-01-01", + "valid_until": "1954-12-31" + }, + { + "id": "alias_mlb_8", + "team_canonical_id": "team_mlb_oak", + "alias_type": "abbreviation", + "alias_value": "PHA", + "valid_from": "1901-01-01", + "valid_until": "1954-12-31" + }, + { + "id": "alias_mlb_9", + "team_canonical_id": "team_mlb_oak", + "alias_type": "city", + "alias_value": "Philadelphia", + "valid_from": "1901-01-01", + "valid_until": "1954-12-31" + }, + { + "id": "alias_mlb_10", + "team_canonical_id": "team_mlb_cle", + "alias_type": "name", + "alias_value": "Cleveland Indians", + "valid_from": "1915-01-01", + "valid_until": "2021-12-31" + }, + { + "id": "alias_mlb_11", + "team_canonical_id": "team_mlb_tbr", + "alias_type": "name", + "alias_value": "Tampa Bay Devil Rays", + "valid_from": "1998-01-01", + "valid_until": "2007-12-31" + }, + { + "id": "alias_mlb_12", + "team_canonical_id": "team_mlb_mia", + "alias_type": "name", + "alias_value": "Florida Marlins", + "valid_from": "1993-01-01", + "valid_until": "2011-12-31" + }, + { + "id": "alias_mlb_13", + "team_canonical_id": "team_mlb_mia", + "alias_type": "city", + "alias_value": "Florida", + "valid_from": "1993-01-01", + "valid_until": "2011-12-31" + }, + { + "id": "alias_mlb_14", + "team_canonical_id": "team_mlb_laa", + "alias_type": "name", + "alias_value": "Anaheim Angels", + "valid_from": "1997-01-01", + "valid_until": "2004-12-31" + }, + { + "id": "alias_mlb_15", + "team_canonical_id": "team_mlb_laa", + "alias_type": "name", + "alias_value": "Los Angeles Angels of Anaheim", + "valid_from": "2005-01-01", + "valid_until": "2015-12-31" + }, + { + "id": "alias_mlb_16", + "team_canonical_id": "team_mlb_laa", + "alias_type": "name", + "alias_value": "California Angels", + "valid_from": "1965-01-01", + "valid_until": "1996-12-31" + }, + { + "id": "alias_mlb_17", + "team_canonical_id": "team_mlb_tex", + "alias_type": "name", + "alias_value": "Washington Senators", + "valid_from": "1961-01-01", + "valid_until": "1971-12-31" + }, + { + "id": "alias_mlb_18", + "team_canonical_id": "team_mlb_tex", + "alias_type": "abbreviation", + "alias_value": "WS2", + "valid_from": "1961-01-01", + "valid_until": "1971-12-31" + }, + { + "id": "alias_mlb_19", + "team_canonical_id": "team_mlb_tex", + "alias_type": "city", + "alias_value": "Washington", + "valid_from": "1961-01-01", + "valid_until": "1971-12-31" + }, + { + "id": "alias_mlb_20", + "team_canonical_id": "team_mlb_mil", + "alias_type": "name", + "alias_value": "Seattle Pilots", + "valid_from": "1969-01-01", + "valid_until": "1969-12-31" + }, + { + "id": "alias_mlb_21", + "team_canonical_id": "team_mlb_mil", + "alias_type": "abbreviation", + "alias_value": "SEP", + "valid_from": "1969-01-01", + "valid_until": "1969-12-31" + }, + { + "id": "alias_mlb_22", + "team_canonical_id": "team_mlb_mil", + "alias_type": "city", + "alias_value": "Seattle", + "valid_from": "1969-01-01", + "valid_until": "1969-12-31" + }, + { + "id": "alias_mlb_23", + "team_canonical_id": "team_mlb_hou", + "alias_type": "name", + "alias_value": "Houston Colt .45s", + "valid_from": "1962-01-01", + "valid_until": "1964-12-31" + }, + { + "id": "alias_nba_24", + "team_canonical_id": "team_nba_brk", + "alias_type": "name", + "alias_value": "New Jersey Nets", + "valid_from": "1977-01-01", + "valid_until": "2012-04-30" + }, + { + "id": "alias_nba_25", + "team_canonical_id": "team_nba_brk", + "alias_type": "abbreviation", + "alias_value": "NJN", + "valid_from": "1977-01-01", + "valid_until": "2012-04-30" + }, + { + "id": "alias_nba_26", + "team_canonical_id": "team_nba_brk", + "alias_type": "city", + "alias_value": "New Jersey", + "valid_from": "1977-01-01", + "valid_until": "2012-04-30" + }, + { + "id": "alias_nba_27", + "team_canonical_id": "team_nba_brk", + "alias_type": "name", + "alias_value": "New York Nets", + "valid_from": "1968-01-01", + "valid_until": "1977-12-31" + }, + { + "id": "alias_nba_28", + "team_canonical_id": "team_nba_okc", + "alias_type": "name", + "alias_value": "Seattle SuperSonics", + "valid_from": "1967-01-01", + "valid_until": "2008-07-01" + }, + { + "id": "alias_nba_29", + "team_canonical_id": "team_nba_okc", + "alias_type": "abbreviation", + "alias_value": "SEA", + "valid_from": "1967-01-01", + "valid_until": "2008-07-01" + }, + { + "id": "alias_nba_30", + "team_canonical_id": "team_nba_okc", + "alias_type": "city", + "alias_value": "Seattle", + "valid_from": "1967-01-01", + "valid_until": "2008-07-01" + }, + { + "id": "alias_nba_31", + "team_canonical_id": "team_nba_mem", + "alias_type": "name", + "alias_value": "Vancouver Grizzlies", + "valid_from": "1995-01-01", + "valid_until": "2001-05-31" + }, + { + "id": "alias_nba_32", + "team_canonical_id": "team_nba_mem", + "alias_type": "abbreviation", + "alias_value": "VAN", + "valid_from": "1995-01-01", + "valid_until": "2001-05-31" + }, + { + "id": "alias_nba_33", + "team_canonical_id": "team_nba_mem", + "alias_type": "city", + "alias_value": "Vancouver", + "valid_from": "1995-01-01", + "valid_until": "2001-05-31" + }, + { + "id": "alias_nba_34", + "team_canonical_id": "team_nba_nop", + "alias_type": "name", + "alias_value": "New Orleans Hornets", + "valid_from": "2002-01-01", + "valid_until": "2013-04-30" + }, + { + "id": "alias_nba_35", + "team_canonical_id": "team_nba_nop", + "alias_type": "abbreviation", + "alias_value": "NOH", + "valid_from": "2002-01-01", + "valid_until": "2013-04-30" + }, + { + "id": "alias_nba_36", + "team_canonical_id": "team_nba_nop", + "alias_type": "name", + "alias_value": "New Orleans/Oklahoma City Hornets", + "valid_from": "2005-01-01", + "valid_until": "2007-12-31" + }, + { + "id": "alias_nba_37", + "team_canonical_id": "team_nba_cho", + "alias_type": "name", + "alias_value": "Charlotte Bobcats", + "valid_from": "2004-01-01", + "valid_until": "2014-04-30" + }, + { + "id": "alias_nba_38", + "team_canonical_id": "team_nba_cho", + "alias_type": "abbreviation", + "alias_value": "CHA", + "valid_from": "2004-01-01", + "valid_until": "2014-04-30" + }, + { + "id": "alias_nba_39", + "team_canonical_id": "team_nba_was", + "alias_type": "name", + "alias_value": "Washington Bullets", + "valid_from": "1974-01-01", + "valid_until": "1997-05-31" + }, + { + "id": "alias_nba_40", + "team_canonical_id": "team_nba_was", + "alias_type": "name", + "alias_value": "Capital Bullets", + "valid_from": "1973-01-01", + "valid_until": "1973-12-31" + }, + { + "id": "alias_nba_41", + "team_canonical_id": "team_nba_was", + "alias_type": "name", + "alias_value": "Baltimore Bullets", + "valid_from": "1963-01-01", + "valid_until": "1972-12-31" + }, + { + "id": "alias_nba_42", + "team_canonical_id": "team_nba_lac", + "alias_type": "name", + "alias_value": "San Diego Clippers", + "valid_from": "1978-01-01", + "valid_until": "1984-05-31" + }, + { + "id": "alias_nba_43", + "team_canonical_id": "team_nba_lac", + "alias_type": "abbreviation", + "alias_value": "SDC", + "valid_from": "1978-01-01", + "valid_until": "1984-05-31" + }, + { + "id": "alias_nba_44", + "team_canonical_id": "team_nba_lac", + "alias_type": "city", + "alias_value": "San Diego", + "valid_from": "1978-01-01", + "valid_until": "1984-05-31" + }, + { + "id": "alias_nba_45", + "team_canonical_id": "team_nba_lac", + "alias_type": "name", + "alias_value": "Buffalo Braves", + "valid_from": "1970-01-01", + "valid_until": "1978-05-31" + }, + { + "id": "alias_nba_46", + "team_canonical_id": "team_nba_lac", + "alias_type": "abbreviation", + "alias_value": "BUF", + "valid_from": "1970-01-01", + "valid_until": "1978-05-31" + }, + { + "id": "alias_nba_47", + "team_canonical_id": "team_nba_lac", + "alias_type": "city", + "alias_value": "Buffalo", + "valid_from": "1970-01-01", + "valid_until": "1978-05-31" + }, + { + "id": "alias_nba_48", + "team_canonical_id": "team_nba_sac", + "alias_type": "name", + "alias_value": "Kansas City Kings", + "valid_from": "1975-01-01", + "valid_until": "1985-05-31" + }, + { + "id": "alias_nba_49", + "team_canonical_id": "team_nba_sac", + "alias_type": "abbreviation", + "alias_value": "KCK", + "valid_from": "1975-01-01", + "valid_until": "1985-05-31" + }, + { + "id": "alias_nba_50", + "team_canonical_id": "team_nba_sac", + "alias_type": "city", + "alias_value": "Kansas City", + "valid_from": "1975-01-01", + "valid_until": "1985-05-31" + }, + { + "id": "alias_nba_51", + "team_canonical_id": "team_nba_uta", + "alias_type": "name", + "alias_value": "New Orleans Jazz", + "valid_from": "1974-01-01", + "valid_until": "1979-05-31" + }, + { + "id": "alias_nba_52", + "team_canonical_id": "team_nba_uta", + "alias_type": "city", + "alias_value": "New Orleans", + "valid_from": "1974-01-01", + "valid_until": "1979-05-31" + }, + { + "id": "alias_nhl_53", + "team_canonical_id": "team_nhl_ari", + "alias_type": "name", + "alias_value": "Arizona Coyotes", + "valid_from": "2014-01-01", + "valid_until": "2024-04-30" + }, + { + "id": "alias_nhl_54", + "team_canonical_id": "team_nhl_ari", + "alias_type": "name", + "alias_value": "Phoenix Coyotes", + "valid_from": "1996-01-01", + "valid_until": "2013-12-31" + }, + { + "id": "alias_nhl_55", + "team_canonical_id": "team_nhl_ari", + "alias_type": "abbreviation", + "alias_value": "PHX", + "valid_from": "1996-01-01", + "valid_until": "2013-12-31" + }, + { + "id": "alias_nhl_56", + "team_canonical_id": "team_nhl_ari", + "alias_type": "city", + "alias_value": "Phoenix", + "valid_from": "1996-01-01", + "valid_until": "2013-12-31" + }, + { + "id": "alias_nhl_57", + "team_canonical_id": "team_nhl_ari", + "alias_type": "name", + "alias_value": "Winnipeg Jets", + "valid_from": "1979-01-01", + "valid_until": "1996-05-31" + }, + { + "id": "alias_nhl_58", + "team_canonical_id": "team_nhl_car", + "alias_type": "name", + "alias_value": "Hartford Whalers", + "valid_from": "1979-01-01", + "valid_until": "1997-05-31" + }, + { + "id": "alias_nhl_59", + "team_canonical_id": "team_nhl_car", + "alias_type": "abbreviation", + "alias_value": "HFD", + "valid_from": "1979-01-01", + "valid_until": "1997-05-31" + }, + { + "id": "alias_nhl_60", + "team_canonical_id": "team_nhl_car", + "alias_type": "city", + "alias_value": "Hartford", + "valid_from": "1979-01-01", + "valid_until": "1997-05-31" + }, + { + "id": "alias_nhl_61", + "team_canonical_id": "team_nhl_col", + "alias_type": "name", + "alias_value": "Quebec Nordiques", + "valid_from": "1979-01-01", + "valid_until": "1995-05-31" + }, + { + "id": "alias_nhl_62", + "team_canonical_id": "team_nhl_col", + "alias_type": "abbreviation", + "alias_value": "QUE", + "valid_from": "1979-01-01", + "valid_until": "1995-05-31" + }, + { + "id": "alias_nhl_63", + "team_canonical_id": "team_nhl_col", + "alias_type": "city", + "alias_value": "Quebec", + "valid_from": "1979-01-01", + "valid_until": "1995-05-31" + }, + { + "id": "alias_nhl_64", + "team_canonical_id": "team_nhl_dal", + "alias_type": "name", + "alias_value": "Minnesota North Stars", + "valid_from": "1967-01-01", + "valid_until": "1993-05-31" + }, + { + "id": "alias_nhl_65", + "team_canonical_id": "team_nhl_dal", + "alias_type": "abbreviation", + "alias_value": "MNS", + "valid_from": "1967-01-01", + "valid_until": "1993-05-31" + }, + { + "id": "alias_nhl_66", + "team_canonical_id": "team_nhl_dal", + "alias_type": "city", + "alias_value": "Minnesota", + "valid_from": "1967-01-01", + "valid_until": "1993-05-31" + }, + { + "id": "alias_nhl_67", + "team_canonical_id": "team_nhl_njd", + "alias_type": "name", + "alias_value": "Colorado Rockies", + "valid_from": "1976-01-01", + "valid_until": "1982-05-31" + }, + { + "id": "alias_nhl_68", + "team_canonical_id": "team_nhl_njd", + "alias_type": "abbreviation", + "alias_value": "CLR", + "valid_from": "1976-01-01", + "valid_until": "1982-05-31" + }, + { + "id": "alias_nhl_69", + "team_canonical_id": "team_nhl_njd", + "alias_type": "city", + "alias_value": "Colorado", + "valid_from": "1976-01-01", + "valid_until": "1982-05-31" + }, + { + "id": "alias_nhl_70", + "team_canonical_id": "team_nhl_njd", + "alias_type": "name", + "alias_value": "Kansas City Scouts", + "valid_from": "1974-01-01", + "valid_until": "1976-05-31" + }, + { + "id": "alias_nhl_71", + "team_canonical_id": "team_nhl_njd", + "alias_type": "abbreviation", + "alias_value": "KCS", + "valid_from": "1974-01-01", + "valid_until": "1976-05-31" + }, + { + "id": "alias_nhl_72", + "team_canonical_id": "team_nhl_njd", + "alias_type": "city", + "alias_value": "Kansas City", + "valid_from": "1974-01-01", + "valid_until": "1976-05-31" + }, + { + "id": "alias_nhl_73", + "team_canonical_id": "team_nhl_wpg", + "alias_type": "name", + "alias_value": "Atlanta Thrashers", + "valid_from": "1999-01-01", + "valid_until": "2011-05-31" + }, + { + "id": "alias_nhl_74", + "team_canonical_id": "team_nhl_wpg", + "alias_type": "abbreviation", + "alias_value": "ATL", + "valid_from": "1999-01-01", + "valid_until": "2011-05-31" + }, + { + "id": "alias_nhl_75", + "team_canonical_id": "team_nhl_wpg", + "alias_type": "city", + "alias_value": "Atlanta", + "valid_from": "1999-01-01", + "valid_until": "2011-05-31" + }, + { + "id": "alias_nhl_76", + "team_canonical_id": "team_nhl_fla", + "alias_type": "city", + "alias_value": "Miami", + "valid_from": "1993-01-01", + "valid_until": "1998-12-31" + } +] \ No newline at end of file diff --git a/Scripts/generate_canonical_data.py b/Scripts/generate_canonical_data.py new file mode 100644 index 0000000..b367666 --- /dev/null +++ b/Scripts/generate_canonical_data.py @@ -0,0 +1,405 @@ +#!/usr/bin/env python3 +""" +Generate Canonical Data for SportsTime App +========================================== +Generates team_aliases.json and league_structure.json from team mappings. + +Usage: + python generate_canonical_data.py + python generate_canonical_data.py --output ./data +""" + +import argparse +import json +from datetime import datetime +from pathlib import Path + +# ============================================================================= +# LEAGUE STRUCTURE +# ============================================================================= + +MLB_STRUCTURE = { + "leagues": [ + {"id": "mlb_al", "name": "American League", "abbreviation": "AL"}, + {"id": "mlb_nl", "name": "National League", "abbreviation": "NL"}, + ], + "divisions": [ + # American League + {"id": "mlb_al_east", "name": "AL East", "parent_id": "mlb_al", "teams": ["NYY", "BOS", "TOR", "BAL", "TBR"]}, + {"id": "mlb_al_central", "name": "AL Central", "parent_id": "mlb_al", "teams": ["CLE", "DET", "MIN", "CHW", "KCR"]}, + {"id": "mlb_al_west", "name": "AL West", "parent_id": "mlb_al", "teams": ["HOU", "SEA", "TEX", "LAA", "OAK"]}, + # National League + {"id": "mlb_nl_east", "name": "NL East", "parent_id": "mlb_nl", "teams": ["ATL", "PHI", "NYM", "MIA", "WSN"]}, + {"id": "mlb_nl_central", "name": "NL Central", "parent_id": "mlb_nl", "teams": ["MIL", "CHC", "STL", "PIT", "CIN"]}, + {"id": "mlb_nl_west", "name": "NL West", "parent_id": "mlb_nl", "teams": ["LAD", "ARI", "SDP", "SFG", "COL"]}, + ] +} + +NBA_STRUCTURE = { + "conferences": [ + {"id": "nba_eastern", "name": "Eastern Conference", "abbreviation": "East"}, + {"id": "nba_western", "name": "Western Conference", "abbreviation": "West"}, + ], + "divisions": [ + # Eastern Conference + {"id": "nba_atlantic", "name": "Atlantic", "parent_id": "nba_eastern", "teams": ["BOS", "BRK", "NYK", "PHI", "TOR"]}, + {"id": "nba_central", "name": "Central", "parent_id": "nba_eastern", "teams": ["CHI", "CLE", "DET", "IND", "MIL"]}, + {"id": "nba_southeast", "name": "Southeast", "parent_id": "nba_eastern", "teams": ["ATL", "CHO", "MIA", "ORL", "WAS"]}, + # Western Conference + {"id": "nba_northwest", "name": "Northwest", "parent_id": "nba_western", "teams": ["DEN", "MIN", "OKC", "POR", "UTA"]}, + {"id": "nba_pacific", "name": "Pacific", "parent_id": "nba_western", "teams": ["GSW", "LAC", "LAL", "PHO", "SAC"]}, + {"id": "nba_southwest", "name": "Southwest", "parent_id": "nba_western", "teams": ["DAL", "HOU", "MEM", "NOP", "SAS"]}, + ] +} + +NHL_STRUCTURE = { + "conferences": [ + {"id": "nhl_eastern", "name": "Eastern Conference", "abbreviation": "East"}, + {"id": "nhl_western", "name": "Western Conference", "abbreviation": "West"}, + ], + "divisions": [ + # Eastern Conference + {"id": "nhl_atlantic", "name": "Atlantic", "parent_id": "nhl_eastern", "teams": ["BOS", "BUF", "DET", "FLA", "MTL", "OTT", "TBL", "TOR"]}, + {"id": "nhl_metropolitan", "name": "Metropolitan", "parent_id": "nhl_eastern", "teams": ["CAR", "CBJ", "NJD", "NYI", "NYR", "PHI", "PIT", "WSH"]}, + # Western Conference + {"id": "nhl_central", "name": "Central", "parent_id": "nhl_western", "teams": ["ARI", "CHI", "COL", "DAL", "MIN", "NSH", "STL", "WPG"]}, + {"id": "nhl_pacific", "name": "Pacific", "parent_id": "nhl_western", "teams": ["ANA", "CGY", "EDM", "LAK", "SEA", "SJS", "VAN", "VGK"]}, + ] +} + + +# ============================================================================= +# TEAM ALIASES (Historical name changes, relocations, abbreviation changes) +# ============================================================================= + +# Format: {current_abbrev: [(alias_type, alias_value, valid_from, valid_until), ...]} + +MLB_ALIASES = { + # Washington Nationals (formerly Montreal Expos) + "WSN": [ + ("name", "Montreal Expos", "1969-01-01", "2004-12-31"), + ("abbreviation", "MON", "1969-01-01", "2004-12-31"), + ("city", "Montreal", "1969-01-01", "2004-12-31"), + ], + # Oakland Athletics (moving to Sacramento, formerly in Kansas City and Philadelphia) + "OAK": [ + ("name", "Kansas City Athletics", "1955-01-01", "1967-12-31"), + ("abbreviation", "KCA", "1955-01-01", "1967-12-31"), + ("city", "Kansas City", "1955-01-01", "1967-12-31"), + ("name", "Philadelphia Athletics", "1901-01-01", "1954-12-31"), + ("abbreviation", "PHA", "1901-01-01", "1954-12-31"), + ("city", "Philadelphia", "1901-01-01", "1954-12-31"), + ], + # Cleveland Guardians (formerly Indians) + "CLE": [ + ("name", "Cleveland Indians", "1915-01-01", "2021-12-31"), + ], + # Tampa Bay Rays (formerly Devil Rays) + "TBR": [ + ("name", "Tampa Bay Devil Rays", "1998-01-01", "2007-12-31"), + ], + # Miami Marlins (formerly Florida Marlins) + "MIA": [ + ("name", "Florida Marlins", "1993-01-01", "2011-12-31"), + ("city", "Florida", "1993-01-01", "2011-12-31"), + ], + # Los Angeles Angels (various names) + "LAA": [ + ("name", "Anaheim Angels", "1997-01-01", "2004-12-31"), + ("name", "Los Angeles Angels of Anaheim", "2005-01-01", "2015-12-31"), + ("name", "California Angels", "1965-01-01", "1996-12-31"), + ], + # Texas Rangers (formerly Washington Senators II) + "TEX": [ + ("name", "Washington Senators", "1961-01-01", "1971-12-31"), + ("abbreviation", "WS2", "1961-01-01", "1971-12-31"), + ("city", "Washington", "1961-01-01", "1971-12-31"), + ], + # Milwaukee Brewers (briefly Seattle Pilots) + "MIL": [ + ("name", "Seattle Pilots", "1969-01-01", "1969-12-31"), + ("abbreviation", "SEP", "1969-01-01", "1969-12-31"), + ("city", "Seattle", "1969-01-01", "1969-12-31"), + ], + # Houston Astros (formerly Colt .45s) + "HOU": [ + ("name", "Houston Colt .45s", "1962-01-01", "1964-12-31"), + ], +} + +NBA_ALIASES = { + # Brooklyn Nets (formerly New Jersey Nets, New York Nets) + "BRK": [ + ("name", "New Jersey Nets", "1977-01-01", "2012-04-30"), + ("abbreviation", "NJN", "1977-01-01", "2012-04-30"), + ("city", "New Jersey", "1977-01-01", "2012-04-30"), + ("name", "New York Nets", "1968-01-01", "1977-12-31"), + ], + # Oklahoma City Thunder (formerly Seattle SuperSonics) + "OKC": [ + ("name", "Seattle SuperSonics", "1967-01-01", "2008-07-01"), + ("abbreviation", "SEA", "1967-01-01", "2008-07-01"), + ("city", "Seattle", "1967-01-01", "2008-07-01"), + ], + # Memphis Grizzlies (formerly Vancouver Grizzlies) + "MEM": [ + ("name", "Vancouver Grizzlies", "1995-01-01", "2001-05-31"), + ("abbreviation", "VAN", "1995-01-01", "2001-05-31"), + ("city", "Vancouver", "1995-01-01", "2001-05-31"), + ], + # New Orleans Pelicans (formerly Hornets, formerly Charlotte Hornets original) + "NOP": [ + ("name", "New Orleans Hornets", "2002-01-01", "2013-04-30"), + ("abbreviation", "NOH", "2002-01-01", "2013-04-30"), + ("name", "New Orleans/Oklahoma City Hornets", "2005-01-01", "2007-12-31"), + ], + # Charlotte Hornets (current, formerly Bobcats) + "CHO": [ + ("name", "Charlotte Bobcats", "2004-01-01", "2014-04-30"), + ("abbreviation", "CHA", "2004-01-01", "2014-04-30"), + ], + # Washington Wizards (formerly Bullets) + "WAS": [ + ("name", "Washington Bullets", "1974-01-01", "1997-05-31"), + ("name", "Capital Bullets", "1973-01-01", "1973-12-31"), + ("name", "Baltimore Bullets", "1963-01-01", "1972-12-31"), + ], + # Los Angeles Clippers (formerly San Diego, Buffalo) + "LAC": [ + ("name", "San Diego Clippers", "1978-01-01", "1984-05-31"), + ("abbreviation", "SDC", "1978-01-01", "1984-05-31"), + ("city", "San Diego", "1978-01-01", "1984-05-31"), + ("name", "Buffalo Braves", "1970-01-01", "1978-05-31"), + ("abbreviation", "BUF", "1970-01-01", "1978-05-31"), + ("city", "Buffalo", "1970-01-01", "1978-05-31"), + ], + # Sacramento Kings (formerly Kansas City Kings, etc.) + "SAC": [ + ("name", "Kansas City Kings", "1975-01-01", "1985-05-31"), + ("abbreviation", "KCK", "1975-01-01", "1985-05-31"), + ("city", "Kansas City", "1975-01-01", "1985-05-31"), + ], + # Utah Jazz (formerly New Orleans Jazz) + "UTA": [ + ("name", "New Orleans Jazz", "1974-01-01", "1979-05-31"), + ("city", "New Orleans", "1974-01-01", "1979-05-31"), + ], +} + +NHL_ALIASES = { + # Arizona/Utah Hockey Club (formerly Phoenix Coyotes, originally Winnipeg Jets) + "ARI": [ + ("name", "Arizona Coyotes", "2014-01-01", "2024-04-30"), + ("name", "Phoenix Coyotes", "1996-01-01", "2013-12-31"), + ("abbreviation", "PHX", "1996-01-01", "2013-12-31"), + ("city", "Phoenix", "1996-01-01", "2013-12-31"), + ("name", "Winnipeg Jets", "1979-01-01", "1996-05-31"), # Original Jets + ], + # Carolina Hurricanes (formerly Hartford Whalers) + "CAR": [ + ("name", "Hartford Whalers", "1979-01-01", "1997-05-31"), + ("abbreviation", "HFD", "1979-01-01", "1997-05-31"), + ("city", "Hartford", "1979-01-01", "1997-05-31"), + ], + # Colorado Avalanche (formerly Quebec Nordiques) + "COL": [ + ("name", "Quebec Nordiques", "1979-01-01", "1995-05-31"), + ("abbreviation", "QUE", "1979-01-01", "1995-05-31"), + ("city", "Quebec", "1979-01-01", "1995-05-31"), + ], + # Dallas Stars (formerly Minnesota North Stars) + "DAL": [ + ("name", "Minnesota North Stars", "1967-01-01", "1993-05-31"), + ("abbreviation", "MNS", "1967-01-01", "1993-05-31"), + ("city", "Minnesota", "1967-01-01", "1993-05-31"), + ], + # New Jersey Devils (formerly Kansas City Scouts, Colorado Rockies) + "NJD": [ + ("name", "Colorado Rockies", "1976-01-01", "1982-05-31"), + ("abbreviation", "CLR", "1976-01-01", "1982-05-31"), + ("city", "Colorado", "1976-01-01", "1982-05-31"), + ("name", "Kansas City Scouts", "1974-01-01", "1976-05-31"), + ("abbreviation", "KCS", "1974-01-01", "1976-05-31"), + ("city", "Kansas City", "1974-01-01", "1976-05-31"), + ], + # Winnipeg Jets (current, formerly Atlanta Thrashers) + "WPG": [ + ("name", "Atlanta Thrashers", "1999-01-01", "2011-05-31"), + ("abbreviation", "ATL", "1999-01-01", "2011-05-31"), + ("city", "Atlanta", "1999-01-01", "2011-05-31"), + ], + # Florida Panthers (originally in Miami) + "FLA": [ + ("city", "Miami", "1993-01-01", "1998-12-31"), + ], + # Vegas Golden Knights (no aliases, expansion team) + # Seattle Kraken (no aliases, expansion team) +} + + +def generate_league_structure() -> list[dict]: + """Generate league_structure.json data.""" + structures = [] + order = 0 + + # MLB + structures.append({ + "id": "mlb_league", + "sport": "MLB", + "type": "league", + "name": "Major League Baseball", + "abbreviation": "MLB", + "parent_id": None, + "display_order": order, + }) + order += 1 + + for league in MLB_STRUCTURE["leagues"]: + structures.append({ + "id": league["id"], + "sport": "MLB", + "type": "conference", # AL/NL are like conferences + "name": league["name"], + "abbreviation": league["abbreviation"], + "parent_id": "mlb_league", + "display_order": order, + }) + order += 1 + + for div in MLB_STRUCTURE["divisions"]: + structures.append({ + "id": div["id"], + "sport": "MLB", + "type": "division", + "name": div["name"], + "abbreviation": None, + "parent_id": div["parent_id"], + "display_order": order, + }) + order += 1 + + # NBA + structures.append({ + "id": "nba_league", + "sport": "NBA", + "type": "league", + "name": "National Basketball Association", + "abbreviation": "NBA", + "parent_id": None, + "display_order": order, + }) + order += 1 + + for conf in NBA_STRUCTURE["conferences"]: + structures.append({ + "id": conf["id"], + "sport": "NBA", + "type": "conference", + "name": conf["name"], + "abbreviation": conf["abbreviation"], + "parent_id": "nba_league", + "display_order": order, + }) + order += 1 + + for div in NBA_STRUCTURE["divisions"]: + structures.append({ + "id": div["id"], + "sport": "NBA", + "type": "division", + "name": div["name"], + "abbreviation": None, + "parent_id": div["parent_id"], + "display_order": order, + }) + order += 1 + + # NHL + structures.append({ + "id": "nhl_league", + "sport": "NHL", + "type": "league", + "name": "National Hockey League", + "abbreviation": "NHL", + "parent_id": None, + "display_order": order, + }) + order += 1 + + for conf in NHL_STRUCTURE["conferences"]: + structures.append({ + "id": conf["id"], + "sport": "NHL", + "type": "conference", + "name": conf["name"], + "abbreviation": conf["abbreviation"], + "parent_id": "nhl_league", + "display_order": order, + }) + order += 1 + + for div in NHL_STRUCTURE["divisions"]: + structures.append({ + "id": div["id"], + "sport": "NHL", + "type": "division", + "name": div["name"], + "abbreviation": None, + "parent_id": div["parent_id"], + "display_order": order, + }) + order += 1 + + return structures + + +def generate_team_aliases() -> list[dict]: + """Generate team_aliases.json data.""" + aliases = [] + alias_id = 1 + + for sport, sport_aliases in [("MLB", MLB_ALIASES), ("NBA", NBA_ALIASES), ("NHL", NHL_ALIASES)]: + for current_abbrev, alias_list in sport_aliases.items(): + team_canonical_id = f"team_{sport.lower()}_{current_abbrev.lower()}" + + for alias_type, alias_value, valid_from, valid_until in alias_list: + aliases.append({ + "id": f"alias_{sport.lower()}_{alias_id}", + "team_canonical_id": team_canonical_id, + "alias_type": alias_type, + "alias_value": alias_value, + "valid_from": valid_from, + "valid_until": valid_until, + }) + alias_id += 1 + + return aliases + + +def main(): + parser = argparse.ArgumentParser(description='Generate canonical data JSON files') + parser.add_argument('--output', type=str, default='./data', help='Output directory') + args = parser.parse_args() + + output_dir = Path(args.output) + output_dir.mkdir(parents=True, exist_ok=True) + + # Generate league structure + print("Generating league_structure.json...") + league_structure = generate_league_structure() + with open(output_dir / 'league_structure.json', 'w') as f: + json.dump(league_structure, f, indent=2) + print(f" Created {len(league_structure)} structure entries") + + # Generate team aliases + print("Generating team_aliases.json...") + team_aliases = generate_team_aliases() + with open(output_dir / 'team_aliases.json', 'w') as f: + json.dump(team_aliases, f, indent=2) + print(f" Created {len(team_aliases)} alias entries") + + print(f"\nFiles written to {output_dir}") + + +if __name__ == '__main__': + main() diff --git a/SportsTime/Core/Models/CloudKit/CKModels.swift b/SportsTime/Core/Models/CloudKit/CKModels.swift index 83920fe..e99a2b8 100644 --- a/SportsTime/Core/Models/CloudKit/CKModels.swift +++ b/SportsTime/Core/Models/CloudKit/CKModels.swift @@ -15,6 +15,8 @@ enum CKRecordType { static let stadium = "Stadium" static let game = "Game" static let sport = "Sport" + static let leagueStructure = "LeagueStructure" + static let teamAlias = "TeamAlias" } // MARK: - CKTeam @@ -100,6 +102,7 @@ struct CKStadium { static let capacityKey = "capacity" static let yearOpenedKey = "yearOpened" static let imageURLKey = "imageURL" + static let sportKey = "sport" let record: CKRecord @@ -117,6 +120,7 @@ struct CKStadium { record[CKStadium.capacityKey] = stadium.capacity record[CKStadium.yearOpenedKey] = stadium.yearOpened record[CKStadium.imageURLKey] = stadium.imageURL?.absoluteString + record[CKStadium.sportKey] = stadium.sport.rawValue self.record = record } @@ -133,6 +137,8 @@ struct CKStadium { 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 sportRaw = record[CKStadium.sportKey] as? String ?? "MLB" + let sport = Sport(rawValue: sportRaw) ?? .mlb return Stadium( id: id, @@ -142,6 +148,7 @@ struct CKStadium { latitude: location?.coordinate.latitude ?? 0, longitude: location?.coordinate.longitude ?? 0, capacity: capacity, + sport: sport, yearOpened: record[CKStadium.yearOpenedKey] as? Int, imageURL: imageURL ) @@ -203,3 +210,123 @@ struct CKGame { ) } } + +// MARK: - CKLeagueStructure + +struct CKLeagueStructure { + static let idKey = "structureId" + static let sportKey = "sport" + static let typeKey = "type" + static let nameKey = "name" + static let abbreviationKey = "abbreviation" + static let parentIdKey = "parentId" + static let displayOrderKey = "displayOrder" + static let schemaVersionKey = "schemaVersion" + static let lastModifiedKey = "lastModified" + + let record: CKRecord + + init(record: CKRecord) { + self.record = record + } + + init(model: LeagueStructureModel) { + let record = CKRecord(recordType: CKRecordType.leagueStructure, recordID: CKRecord.ID(recordName: model.id)) + record[CKLeagueStructure.idKey] = model.id + record[CKLeagueStructure.sportKey] = model.sport + record[CKLeagueStructure.typeKey] = model.structureTypeRaw + record[CKLeagueStructure.nameKey] = model.name + record[CKLeagueStructure.abbreviationKey] = model.abbreviation + record[CKLeagueStructure.parentIdKey] = model.parentId + record[CKLeagueStructure.displayOrderKey] = model.displayOrder + record[CKLeagueStructure.schemaVersionKey] = model.schemaVersion + record[CKLeagueStructure.lastModifiedKey] = model.lastModified + self.record = record + } + + /// Convert to SwiftData model for local storage + func toModel() -> LeagueStructureModel? { + guard let id = record[CKLeagueStructure.idKey] as? String, + let sport = record[CKLeagueStructure.sportKey] as? String, + let typeRaw = record[CKLeagueStructure.typeKey] as? String, + let structureType = LeagueStructureType(rawValue: typeRaw), + let name = record[CKLeagueStructure.nameKey] as? String + else { return nil } + + let abbreviation = record[CKLeagueStructure.abbreviationKey] as? String + let parentId = record[CKLeagueStructure.parentIdKey] as? String + let displayOrder = record[CKLeagueStructure.displayOrderKey] as? Int ?? 0 + let schemaVersion = record[CKLeagueStructure.schemaVersionKey] as? Int ?? SchemaVersion.current + let lastModified = record[CKLeagueStructure.lastModifiedKey] as? Date ?? record.modificationDate ?? Date() + + return LeagueStructureModel( + id: id, + sport: sport, + structureType: structureType, + name: name, + abbreviation: abbreviation, + parentId: parentId, + displayOrder: displayOrder, + schemaVersion: schemaVersion, + lastModified: lastModified + ) + } +} + +// MARK: - CKTeamAlias + +struct CKTeamAlias { + static let idKey = "aliasId" + static let teamCanonicalIdKey = "teamCanonicalId" + static let aliasTypeKey = "aliasType" + static let aliasValueKey = "aliasValue" + static let validFromKey = "validFrom" + static let validUntilKey = "validUntil" + static let schemaVersionKey = "schemaVersion" + static let lastModifiedKey = "lastModified" + + let record: CKRecord + + init(record: CKRecord) { + self.record = record + } + + init(model: TeamAlias) { + let record = CKRecord(recordType: CKRecordType.teamAlias, recordID: CKRecord.ID(recordName: model.id)) + record[CKTeamAlias.idKey] = model.id + record[CKTeamAlias.teamCanonicalIdKey] = model.teamCanonicalId + record[CKTeamAlias.aliasTypeKey] = model.aliasTypeRaw + record[CKTeamAlias.aliasValueKey] = model.aliasValue + record[CKTeamAlias.validFromKey] = model.validFrom + record[CKTeamAlias.validUntilKey] = model.validUntil + record[CKTeamAlias.schemaVersionKey] = model.schemaVersion + record[CKTeamAlias.lastModifiedKey] = model.lastModified + self.record = record + } + + /// Convert to SwiftData model for local storage + func toModel() -> TeamAlias? { + guard let id = record[CKTeamAlias.idKey] as? String, + let teamCanonicalId = record[CKTeamAlias.teamCanonicalIdKey] as? String, + let aliasTypeRaw = record[CKTeamAlias.aliasTypeKey] as? String, + let aliasType = TeamAliasType(rawValue: aliasTypeRaw), + let aliasValue = record[CKTeamAlias.aliasValueKey] as? String + else { return nil } + + let validFrom = record[CKTeamAlias.validFromKey] as? Date + let validUntil = record[CKTeamAlias.validUntilKey] as? Date + let schemaVersion = record[CKTeamAlias.schemaVersionKey] as? Int ?? SchemaVersion.current + let lastModified = record[CKTeamAlias.lastModifiedKey] as? Date ?? record.modificationDate ?? Date() + + return TeamAlias( + id: id, + teamCanonicalId: teamCanonicalId, + aliasType: aliasType, + aliasValue: aliasValue, + validFrom: validFrom, + validUntil: validUntil, + schemaVersion: schemaVersion, + lastModified: lastModified + ) + } +} diff --git a/SportsTime/Core/Models/Domain/AchievementDefinitions.swift b/SportsTime/Core/Models/Domain/AchievementDefinitions.swift new file mode 100644 index 0000000..12fe833 --- /dev/null +++ b/SportsTime/Core/Models/Domain/AchievementDefinitions.swift @@ -0,0 +1,647 @@ +// +// AchievementDefinitions.swift +// SportsTime +// +// Registry of all achievement types and their requirements. +// + +import Foundation +import SwiftUI + +// MARK: - Achievement Category + +enum AchievementCategory: String, Codable, CaseIterable { + case count // Milestone counts (1, 5, 10, etc.) + case division // Complete a division + case conference // Complete a conference + case league // Complete entire league + case journey // Special journey-based achievements + case special // Special one-off achievements + + var displayName: String { + switch self { + case .count: return "Milestones" + case .division: return "Divisions" + case .conference: return "Conferences" + case .league: return "Leagues" + case .journey: return "Journeys" + case .special: return "Special" + } + } +} + +// MARK: - Achievement Definition + +struct AchievementDefinition: Identifiable, Hashable { + let id: String + let name: String + let description: String + let category: AchievementCategory + let sport: Sport? // nil for cross-sport achievements + let iconName: String + let iconColor: Color + let requirement: AchievementRequirement + let sortOrder: Int + + // Optional division/conference reference + let divisionId: String? + let conferenceId: String? + + init( + id: String, + name: String, + description: String, + category: AchievementCategory, + sport: Sport? = nil, + iconName: String, + iconColor: Color, + requirement: AchievementRequirement, + sortOrder: Int = 0, + divisionId: String? = nil, + conferenceId: String? = nil + ) { + self.id = id + self.name = name + self.description = description + self.category = category + self.sport = sport + self.iconName = iconName + self.iconColor = iconColor + self.requirement = requirement + self.sortOrder = sortOrder + self.divisionId = divisionId + self.conferenceId = conferenceId + } + + func hash(into hasher: inout Hasher) { + hasher.combine(id) + } + + static func == (lhs: AchievementDefinition, rhs: AchievementDefinition) -> Bool { + lhs.id == rhs.id + } +} + +// MARK: - Achievement Requirement + +enum AchievementRequirement: Hashable { + case visitCount(Int) // Visit N unique stadiums + case visitCountForSport(Int, Sport) // Visit N stadiums for a specific sport + case completeDivision(String) // Complete all stadiums in a division + case completeConference(String) // Complete all stadiums in a conference + case completeLeague(Sport) // Complete all stadiums in a league + case visitsInDays(Int, days: Int) // Visit N stadiums within N days + case multipleLeagues(Int) // Visit stadiums from N different leagues + case firstVisit // First stadium visit ever + case specificStadium(String) // Visit a specific stadium +} + +// MARK: - Achievement Registry + +enum AchievementRegistry { + + // MARK: - All Definitions + + /// All achievement definitions sorted by category and sort order + static let all: [AchievementDefinition] = { + var definitions: [AchievementDefinition] = [] + definitions.append(contentsOf: countAchievements) + definitions.append(contentsOf: mlbDivisionAchievements) + definitions.append(contentsOf: mlbConferenceAchievements) + definitions.append(contentsOf: nbaDivisionAchievements) + definitions.append(contentsOf: nbaConferenceAchievements) + definitions.append(contentsOf: nhlDivisionAchievements) + definitions.append(contentsOf: nhlConferenceAchievements) + definitions.append(contentsOf: leagueAchievements) + definitions.append(contentsOf: journeyAchievements) + definitions.append(contentsOf: specialAchievements) + return definitions.sorted { $0.sortOrder < $1.sortOrder } + }() + + // MARK: - Count Achievements + + static let countAchievements: [AchievementDefinition] = [ + AchievementDefinition( + id: "first_visit", + name: "First Pitch", + description: "Visit your first stadium", + category: .count, + iconName: "1.circle.fill", + iconColor: .green, + requirement: .firstVisit, + sortOrder: 100 + ), + AchievementDefinition( + id: "count_5", + name: "Getting Started", + description: "Visit 5 different stadiums", + category: .count, + iconName: "5.circle.fill", + iconColor: .blue, + requirement: .visitCount(5), + sortOrder: 101 + ), + AchievementDefinition( + id: "count_10", + name: "Double Digits", + description: "Visit 10 different stadiums", + category: .count, + iconName: "10.circle.fill", + iconColor: .orange, + requirement: .visitCount(10), + sortOrder: 102 + ), + AchievementDefinition( + id: "count_20", + name: "Veteran Fan", + description: "Visit 20 different stadiums", + category: .count, + iconName: "20.circle.fill", + iconColor: .purple, + requirement: .visitCount(20), + sortOrder: 103 + ), + AchievementDefinition( + id: "count_30", + name: "Stadium Enthusiast", + description: "Visit 30 different stadiums", + category: .count, + iconName: "30.circle.fill", + iconColor: .red, + requirement: .visitCount(30), + sortOrder: 104 + ), + AchievementDefinition( + id: "count_50", + name: "Road Warrior", + description: "Visit 50 different stadiums", + category: .count, + iconName: "50.circle.fill", + iconColor: .yellow, + requirement: .visitCount(50), + sortOrder: 105 + ), + AchievementDefinition( + id: "count_75", + name: "Stadium Expert", + description: "Visit 75 different stadiums", + category: .count, + iconName: "75.circle.fill", + iconColor: .mint, + requirement: .visitCount(75), + sortOrder: 106 + ), + AchievementDefinition( + id: "count_all", + name: "Stadium Master", + description: "Visit all 92 MLB, NBA, and NHL stadiums", + category: .count, + iconName: "star.circle.fill", + iconColor: .yellow, + requirement: .visitCount(92), + sortOrder: 107 + ) + ] + + // MARK: - MLB Division Achievements + + static let mlbDivisionAchievements: [AchievementDefinition] = [ + AchievementDefinition( + id: "mlb_al_east_complete", + name: "AL East Champion", + description: "Visit all AL East stadiums", + category: .division, + sport: .mlb, + iconName: "baseball.fill", + iconColor: .red, + requirement: .completeDivision("mlb_al_east"), + sortOrder: 200, + divisionId: "mlb_al_east" + ), + AchievementDefinition( + id: "mlb_al_central_complete", + name: "AL Central Champion", + description: "Visit all AL Central stadiums", + category: .division, + sport: .mlb, + iconName: "baseball.fill", + iconColor: .red, + requirement: .completeDivision("mlb_al_central"), + sortOrder: 201, + divisionId: "mlb_al_central" + ), + AchievementDefinition( + id: "mlb_al_west_complete", + name: "AL West Champion", + description: "Visit all AL West stadiums", + category: .division, + sport: .mlb, + iconName: "baseball.fill", + iconColor: .red, + requirement: .completeDivision("mlb_al_west"), + sortOrder: 202, + divisionId: "mlb_al_west" + ), + AchievementDefinition( + id: "mlb_nl_east_complete", + name: "NL East Champion", + description: "Visit all NL East stadiums", + category: .division, + sport: .mlb, + iconName: "baseball.fill", + iconColor: .red, + requirement: .completeDivision("mlb_nl_east"), + sortOrder: 203, + divisionId: "mlb_nl_east" + ), + AchievementDefinition( + id: "mlb_nl_central_complete", + name: "NL Central Champion", + description: "Visit all NL Central stadiums", + category: .division, + sport: .mlb, + iconName: "baseball.fill", + iconColor: .red, + requirement: .completeDivision("mlb_nl_central"), + sortOrder: 204, + divisionId: "mlb_nl_central" + ), + AchievementDefinition( + id: "mlb_nl_west_complete", + name: "NL West Champion", + description: "Visit all NL West stadiums", + category: .division, + sport: .mlb, + iconName: "baseball.fill", + iconColor: .red, + requirement: .completeDivision("mlb_nl_west"), + sortOrder: 205, + divisionId: "mlb_nl_west" + ) + ] + + // MARK: - MLB Conference Achievements + + static let mlbConferenceAchievements: [AchievementDefinition] = [ + AchievementDefinition( + id: "mlb_al_complete", + name: "American League Complete", + description: "Visit all American League stadiums", + category: .conference, + sport: .mlb, + iconName: "baseball.circle.fill", + iconColor: .red, + requirement: .completeConference("mlb_al"), + sortOrder: 300, + conferenceId: "mlb_al" + ), + AchievementDefinition( + id: "mlb_nl_complete", + name: "National League Complete", + description: "Visit all National League stadiums", + category: .conference, + sport: .mlb, + iconName: "baseball.circle.fill", + iconColor: .red, + requirement: .completeConference("mlb_nl"), + sortOrder: 301, + conferenceId: "mlb_nl" + ) + ] + + // MARK: - NBA Division Achievements + + static let nbaDivisionAchievements: [AchievementDefinition] = [ + AchievementDefinition( + id: "nba_atlantic_complete", + name: "Atlantic Division Champion", + description: "Visit all Atlantic Division arenas", + category: .division, + sport: .nba, + iconName: "basketball.fill", + iconColor: .orange, + requirement: .completeDivision("nba_atlantic"), + sortOrder: 210, + divisionId: "nba_atlantic" + ), + AchievementDefinition( + id: "nba_central_complete", + name: "Central Division Champion", + description: "Visit all Central Division arenas", + category: .division, + sport: .nba, + iconName: "basketball.fill", + iconColor: .orange, + requirement: .completeDivision("nba_central"), + sortOrder: 211, + divisionId: "nba_central" + ), + AchievementDefinition( + id: "nba_southeast_complete", + name: "Southeast Division Champion", + description: "Visit all Southeast Division arenas", + category: .division, + sport: .nba, + iconName: "basketball.fill", + iconColor: .orange, + requirement: .completeDivision("nba_southeast"), + sortOrder: 212, + divisionId: "nba_southeast" + ), + AchievementDefinition( + id: "nba_northwest_complete", + name: "Northwest Division Champion", + description: "Visit all Northwest Division arenas", + category: .division, + sport: .nba, + iconName: "basketball.fill", + iconColor: .orange, + requirement: .completeDivision("nba_northwest"), + sortOrder: 213, + divisionId: "nba_northwest" + ), + AchievementDefinition( + id: "nba_pacific_complete", + name: "Pacific Division Champion", + description: "Visit all Pacific Division arenas", + category: .division, + sport: .nba, + iconName: "basketball.fill", + iconColor: .orange, + requirement: .completeDivision("nba_pacific"), + sortOrder: 214, + divisionId: "nba_pacific" + ), + AchievementDefinition( + id: "nba_southwest_complete", + name: "Southwest Division Champion", + description: "Visit all Southwest Division arenas", + category: .division, + sport: .nba, + iconName: "basketball.fill", + iconColor: .orange, + requirement: .completeDivision("nba_southwest"), + sortOrder: 215, + divisionId: "nba_southwest" + ) + ] + + // MARK: - NBA Conference Achievements + + static let nbaConferenceAchievements: [AchievementDefinition] = [ + AchievementDefinition( + id: "nba_eastern_complete", + name: "Eastern Conference Complete", + description: "Visit all Eastern Conference arenas", + category: .conference, + sport: .nba, + iconName: "basketball.circle.fill", + iconColor: .orange, + requirement: .completeConference("nba_eastern"), + sortOrder: 310, + conferenceId: "nba_eastern" + ), + AchievementDefinition( + id: "nba_western_complete", + name: "Western Conference Complete", + description: "Visit all Western Conference arenas", + category: .conference, + sport: .nba, + iconName: "basketball.circle.fill", + iconColor: .orange, + requirement: .completeConference("nba_western"), + sortOrder: 311, + conferenceId: "nba_western" + ) + ] + + // MARK: - NHL Division Achievements + + static let nhlDivisionAchievements: [AchievementDefinition] = [ + AchievementDefinition( + id: "nhl_atlantic_complete", + name: "NHL Atlantic Champion", + description: "Visit all Atlantic Division arenas", + category: .division, + sport: .nhl, + iconName: "hockey.puck.fill", + iconColor: .blue, + requirement: .completeDivision("nhl_atlantic"), + sortOrder: 220, + divisionId: "nhl_atlantic" + ), + AchievementDefinition( + id: "nhl_metropolitan_complete", + name: "Metropolitan Champion", + description: "Visit all Metropolitan Division arenas", + category: .division, + sport: .nhl, + iconName: "hockey.puck.fill", + iconColor: .blue, + requirement: .completeDivision("nhl_metropolitan"), + sortOrder: 221, + divisionId: "nhl_metropolitan" + ), + AchievementDefinition( + id: "nhl_central_complete", + name: "NHL Central Champion", + description: "Visit all Central Division arenas", + category: .division, + sport: .nhl, + iconName: "hockey.puck.fill", + iconColor: .blue, + requirement: .completeDivision("nhl_central"), + sortOrder: 222, + divisionId: "nhl_central" + ), + AchievementDefinition( + id: "nhl_pacific_complete", + name: "NHL Pacific Champion", + description: "Visit all Pacific Division arenas", + category: .division, + sport: .nhl, + iconName: "hockey.puck.fill", + iconColor: .blue, + requirement: .completeDivision("nhl_pacific"), + sortOrder: 223, + divisionId: "nhl_pacific" + ) + ] + + // MARK: - NHL Conference Achievements + + static let nhlConferenceAchievements: [AchievementDefinition] = [ + AchievementDefinition( + id: "nhl_eastern_complete", + name: "NHL Eastern Conference Complete", + description: "Visit all Eastern Conference arenas", + category: .conference, + sport: .nhl, + iconName: "hockey.puck.circle.fill", + iconColor: .blue, + requirement: .completeConference("nhl_eastern"), + sortOrder: 320, + conferenceId: "nhl_eastern" + ), + AchievementDefinition( + id: "nhl_western_complete", + name: "NHL Western Conference Complete", + description: "Visit all Western Conference arenas", + category: .conference, + sport: .nhl, + iconName: "hockey.puck.circle.fill", + iconColor: .blue, + requirement: .completeConference("nhl_western"), + sortOrder: 321, + conferenceId: "nhl_western" + ) + ] + + // MARK: - League Achievements + + static let leagueAchievements: [AchievementDefinition] = [ + AchievementDefinition( + id: "mlb_complete", + name: "Diamond Collector", + description: "Visit all 30 MLB stadiums", + category: .league, + sport: .mlb, + iconName: "diamond.fill", + iconColor: .red, + requirement: .completeLeague(.mlb), + sortOrder: 400 + ), + AchievementDefinition( + id: "nba_complete", + name: "Court Master", + description: "Visit all 30 NBA arenas", + category: .league, + sport: .nba, + iconName: "trophy.fill", + iconColor: .orange, + requirement: .completeLeague(.nba), + sortOrder: 401 + ), + AchievementDefinition( + id: "nhl_complete", + name: "Ice Warrior", + description: "Visit all 32 NHL arenas", + category: .league, + sport: .nhl, + iconName: "crown.fill", + iconColor: .blue, + requirement: .completeLeague(.nhl), + sortOrder: 402 + ) + ] + + // MARK: - Journey Achievements + + static let journeyAchievements: [AchievementDefinition] = [ + AchievementDefinition( + id: "journey_weekend_warrior", + name: "Weekend Warrior", + description: "Visit 3 stadiums in 3 days", + category: .journey, + iconName: "figure.run", + iconColor: .green, + requirement: .visitsInDays(3, days: 3), + sortOrder: 500 + ), + AchievementDefinition( + id: "journey_road_trip", + name: "Road Trip Champion", + description: "Visit 5 stadiums in 7 days", + category: .journey, + iconName: "car.fill", + iconColor: .cyan, + requirement: .visitsInDays(5, days: 7), + sortOrder: 501 + ), + AchievementDefinition( + id: "journey_marathon", + name: "Stadium Marathon", + description: "Visit 7 stadiums in 10 days", + category: .journey, + iconName: "flame.fill", + iconColor: .orange, + requirement: .visitsInDays(7, days: 10), + sortOrder: 502 + ), + AchievementDefinition( + id: "journey_triple_threat", + name: "Triple Threat", + description: "Visit stadiums from all 3 leagues (MLB, NBA, NHL)", + category: .journey, + iconName: "star.fill", + iconColor: .yellow, + requirement: .multipleLeagues(3), + sortOrder: 503 + ) + ] + + // MARK: - Special Achievements + + static let specialAchievements: [AchievementDefinition] = [ + AchievementDefinition( + id: "special_fenway", + name: "Green Monster", + description: "Visit Fenway Park", + category: .special, + sport: .mlb, + iconName: "building.columns.fill", + iconColor: .green, + requirement: .specificStadium("stadium_mlb_bos"), + sortOrder: 600 + ), + AchievementDefinition( + id: "special_wrigley", + name: "Ivy League", + description: "Visit Wrigley Field", + category: .special, + sport: .mlb, + iconName: "leaf.fill", + iconColor: .green, + requirement: .specificStadium("stadium_mlb_chc"), + sortOrder: 601 + ), + AchievementDefinition( + id: "special_msg", + name: "World's Most Famous Arena", + description: "Visit Madison Square Garden", + category: .special, + sport: .nba, + iconName: "sparkles", + iconColor: .orange, + requirement: .specificStadium("stadium_nba_nyk"), + sortOrder: 602 + ) + ] + + // MARK: - Lookup Methods + + /// Get achievement by ID + static func achievement(byId id: String) -> AchievementDefinition? { + all.first { $0.id == id } + } + + /// Get achievements by category + static func achievements(forCategory category: AchievementCategory) -> [AchievementDefinition] { + all.filter { $0.category == category } + } + + /// Get achievements for a sport + static func achievements(forSport sport: Sport) -> [AchievementDefinition] { + all.filter { $0.sport == sport || $0.sport == nil } + } + + /// Get division achievements for a sport + static func divisionAchievements(forSport sport: Sport) -> [AchievementDefinition] { + all.filter { $0.sport == sport && $0.category == .division } + } + + /// Get conference achievements for a sport + static func conferenceAchievements(forSport sport: Sport) -> [AchievementDefinition] { + all.filter { $0.sport == sport && $0.category == .conference } + } +} diff --git a/SportsTime/Core/Models/Domain/Division.swift b/SportsTime/Core/Models/Domain/Division.swift new file mode 100644 index 0000000..2c89bb3 --- /dev/null +++ b/SportsTime/Core/Models/Domain/Division.swift @@ -0,0 +1,119 @@ +// +// Division.swift +// SportsTime +// +// Domain model for league structure: divisions and conferences. +// + +import Foundation + +// MARK: - Division + +struct Division: Identifiable, Codable, Hashable { + let id: String // e.g., "mlb_nl_west" + let name: String // e.g., "NL West" + let conference: String // e.g., "National League" + let conferenceId: String // e.g., "mlb_nl" + let sport: Sport + var teamCanonicalIds: [String] // Canonical team IDs in this division + + var teamCount: Int { teamCanonicalIds.count } +} + +// MARK: - Conference + +struct Conference: Identifiable, Codable, Hashable { + let id: String // e.g., "mlb_nl" + let name: String // e.g., "National League" + let abbreviation: String? // e.g., "NL" + let sport: Sport + let divisionIds: [String] // Division IDs in this conference +} + +// MARK: - League Structure Provider + +/// Provides access to league structure data (divisions, conferences). +/// Reads from SwiftData LeagueStructureModel and CanonicalTeam. +enum LeagueStructure { + + // MARK: - Static Division Definitions + + /// All MLB divisions + static let mlbDivisions: [Division] = [ + Division(id: "mlb_al_east", name: "AL East", conference: "American League", conferenceId: "mlb_al", sport: .mlb, teamCanonicalIds: []), + Division(id: "mlb_al_central", name: "AL Central", conference: "American League", conferenceId: "mlb_al", sport: .mlb, teamCanonicalIds: []), + Division(id: "mlb_al_west", name: "AL West", conference: "American League", conferenceId: "mlb_al", sport: .mlb, teamCanonicalIds: []), + Division(id: "mlb_nl_east", name: "NL East", conference: "National League", conferenceId: "mlb_nl", sport: .mlb, teamCanonicalIds: []), + Division(id: "mlb_nl_central", name: "NL Central", conference: "National League", conferenceId: "mlb_nl", sport: .mlb, teamCanonicalIds: []), + Division(id: "mlb_nl_west", name: "NL West", conference: "National League", conferenceId: "mlb_nl", sport: .mlb, teamCanonicalIds: []) + ] + + /// All NBA divisions + static let nbaDivisions: [Division] = [ + Division(id: "nba_atlantic", name: "Atlantic", conference: "Eastern Conference", conferenceId: "nba_eastern", sport: .nba, teamCanonicalIds: []), + Division(id: "nba_central", name: "Central", conference: "Eastern Conference", conferenceId: "nba_eastern", sport: .nba, teamCanonicalIds: []), + Division(id: "nba_southeast", name: "Southeast", conference: "Eastern Conference", conferenceId: "nba_eastern", sport: .nba, teamCanonicalIds: []), + Division(id: "nba_northwest", name: "Northwest", conference: "Western Conference", conferenceId: "nba_western", sport: .nba, teamCanonicalIds: []), + Division(id: "nba_pacific", name: "Pacific", conference: "Western Conference", conferenceId: "nba_western", sport: .nba, teamCanonicalIds: []), + Division(id: "nba_southwest", name: "Southwest", conference: "Western Conference", conferenceId: "nba_western", sport: .nba, teamCanonicalIds: []) + ] + + /// All NHL divisions + static let nhlDivisions: [Division] = [ + Division(id: "nhl_atlantic", name: "Atlantic", conference: "Eastern Conference", conferenceId: "nhl_eastern", sport: .nhl, teamCanonicalIds: []), + Division(id: "nhl_metropolitan", name: "Metropolitan", conference: "Eastern Conference", conferenceId: "nhl_eastern", sport: .nhl, teamCanonicalIds: []), + Division(id: "nhl_central", name: "Central", conference: "Western Conference", conferenceId: "nhl_western", sport: .nhl, teamCanonicalIds: []), + Division(id: "nhl_pacific", name: "Pacific", conference: "Western Conference", conferenceId: "nhl_western", sport: .nhl, teamCanonicalIds: []) + ] + + /// All conferences + static let conferences: [Conference] = [ + // MLB + Conference(id: "mlb_al", name: "American League", abbreviation: "AL", sport: .mlb, divisionIds: ["mlb_al_east", "mlb_al_central", "mlb_al_west"]), + Conference(id: "mlb_nl", name: "National League", abbreviation: "NL", sport: .mlb, divisionIds: ["mlb_nl_east", "mlb_nl_central", "mlb_nl_west"]), + // NBA + Conference(id: "nba_eastern", name: "Eastern Conference", abbreviation: "East", sport: .nba, divisionIds: ["nba_atlantic", "nba_central", "nba_southeast"]), + Conference(id: "nba_western", name: "Western Conference", abbreviation: "West", sport: .nba, divisionIds: ["nba_northwest", "nba_pacific", "nba_southwest"]), + // NHL + Conference(id: "nhl_eastern", name: "Eastern Conference", abbreviation: "East", sport: .nhl, divisionIds: ["nhl_atlantic", "nhl_metropolitan"]), + Conference(id: "nhl_western", name: "Western Conference", abbreviation: "West", sport: .nhl, divisionIds: ["nhl_central", "nhl_pacific"]) + ] + + // MARK: - Lookup Methods + + /// Get all divisions for a sport + static func divisions(for sport: Sport) -> [Division] { + switch sport { + case .mlb: return mlbDivisions + case .nba: return nbaDivisions + case .nhl: return nhlDivisions + default: return [] + } + } + + /// Get all conferences for a sport + static func conferences(for sport: Sport) -> [Conference] { + conferences.filter { $0.sport == sport } + } + + /// Find division by ID + static func division(byId id: String) -> Division? { + let allDivisions = mlbDivisions + nbaDivisions + nhlDivisions + return allDivisions.first { $0.id == id } + } + + /// Find conference by ID + static func conference(byId id: String) -> Conference? { + conferences.first { $0.id == id } + } + + /// Get total stadium count for a sport + static func stadiumCount(for sport: Sport) -> Int { + switch sport { + case .mlb: return 30 + case .nba: return 30 + case .nhl: return 32 + default: return 0 + } + } +} diff --git a/SportsTime/Core/Models/Domain/Progress.swift b/SportsTime/Core/Models/Domain/Progress.swift new file mode 100644 index 0000000..ffc1b9b --- /dev/null +++ b/SportsTime/Core/Models/Domain/Progress.swift @@ -0,0 +1,232 @@ +// +// Progress.swift +// SportsTime +// +// Domain models for tracking stadium visit progress and achievements. +// + +import Foundation +import SwiftUI + +// MARK: - League Progress + +/// Progress tracking for a single sport/league +struct LeagueProgress: Identifiable { + let sport: Sport + let totalStadiums: Int + let visitedStadiums: Int + let stadiumsVisited: [Stadium] + let stadiumsRemaining: [Stadium] + + var id: String { sport.rawValue } + + var completionPercentage: Double { + guard totalStadiums > 0 else { return 0 } + return Double(visitedStadiums) / Double(totalStadiums) * 100 + } + + var isComplete: Bool { + totalStadiums > 0 && visitedStadiums >= totalStadiums + } + + var progressFraction: Double { + guard totalStadiums > 0 else { return 0 } + return Double(visitedStadiums) / Double(totalStadiums) + } + + var progressDescription: String { + "\(visitedStadiums)/\(totalStadiums)" + } +} + +// MARK: - Division Progress + +/// Progress tracking for a single division +struct DivisionProgress: Identifiable { + let division: Division + let totalStadiums: Int + let visitedStadiums: Int + let stadiumsVisited: [Stadium] + let stadiumsRemaining: [Stadium] + + var id: String { division.id } + + var completionPercentage: Double { + guard totalStadiums > 0 else { return 0 } + return Double(visitedStadiums) / Double(totalStadiums) * 100 + } + + var isComplete: Bool { + totalStadiums > 0 && visitedStadiums >= totalStadiums + } + + var progressFraction: Double { + guard totalStadiums > 0 else { return 0 } + return Double(visitedStadiums) / Double(totalStadiums) + } +} + +// MARK: - Conference Progress + +/// Progress tracking for a conference +struct ConferenceProgress: Identifiable { + let conference: Conference + let totalStadiums: Int + let visitedStadiums: Int + let divisionProgress: [DivisionProgress] + + var id: String { conference.id } + + var completionPercentage: Double { + guard totalStadiums > 0 else { return 0 } + return Double(visitedStadiums) / Double(totalStadiums) * 100 + } + + var isComplete: Bool { + totalStadiums > 0 && visitedStadiums >= totalStadiums + } +} + +// MARK: - Overall Progress + +/// Combined progress across all sports +struct OverallProgress { + let leagueProgress: [LeagueProgress] + let totalVisits: Int + let uniqueStadiumsVisited: Int + let totalStadiumsAcrossLeagues: Int + let achievementsEarned: Int + let totalAchievements: Int + + var overallPercentage: Double { + guard totalStadiumsAcrossLeagues > 0 else { return 0 } + return Double(uniqueStadiumsVisited) / Double(totalStadiumsAcrossLeagues) * 100 + } + + /// Progress by sport + func progress(for sport: Sport) -> LeagueProgress? { + leagueProgress.first { $0.sport == sport } + } +} + +// MARK: - Visit Summary + +/// Summary of a stadium visit for display +struct VisitSummary: Identifiable { + let id: UUID + let stadium: Stadium + let visitDate: Date + let visitType: VisitType + let sport: Sport + let matchup: String? + let score: String? + let photoCount: Int + let notes: String? + + var dateDescription: String { + let formatter = DateFormatter() + formatter.dateStyle = .medium + return formatter.string(from: visitDate) + } + + var shortDateDescription: String { + let formatter = DateFormatter() + formatter.dateFormat = "MMM d, yyyy" + return formatter.string(from: visitDate) + } +} + +// MARK: - Stadium Visit Status + +/// Status of a stadium (visited or not, with visit info if applicable) +enum StadiumVisitStatus { + case visited(visits: [VisitSummary]) + case notVisited + + var isVisited: Bool { + if case .visited = self { + return true + } + return false + } + + var visitCount: Int { + if case .visited(let visits) = self { + return visits.count + } + return 0 + } + + var latestVisit: VisitSummary? { + if case .visited(let visits) = self { + return visits.max(by: { $0.visitDate < $1.visitDate }) + } + return nil + } + + var firstVisit: VisitSummary? { + if case .visited(let visits) = self { + return visits.min(by: { $0.visitDate < $1.visitDate }) + } + return nil + } +} + +// MARK: - Progress Card Data + +/// Data for generating shareable progress cards +struct ProgressCardData { + let sport: Sport + let progress: LeagueProgress + let username: String? + let includeMap: Bool + let showDetailedStats: Bool + + var title: String { + "\(sport.displayName) Stadium Quest" + } + + var subtitle: String { + "\(progress.visitedStadiums) of \(progress.totalStadiums) Stadiums" + } + + var percentageText: String { + String(format: "%.0f%%", progress.completionPercentage) + } +} + +// MARK: - Progress Card Options + +struct ProgressCardOptions { + var includeUsername: Bool = true + var username: String? + var includeMapSnapshot: Bool = true + var includeStats: Bool = true + var cardStyle: CardStyle = .dark + + enum CardStyle { + case dark + case light + + var backgroundColor: Color { + switch self { + case .dark: return Color(hex: "1A1A2E") + case .light: return Color.white + } + } + + var textColor: Color { + switch self { + case .dark: return .white + case .light: return Color(hex: "1A1A2E") + } + } + + var secondaryTextColor: Color { + switch self { + case .dark: return Color(hex: "B8B8D1") + case .light: return Color(hex: "666666") + } + } + } +} diff --git a/SportsTime/Core/Models/Domain/Stadium.swift b/SportsTime/Core/Models/Domain/Stadium.swift index 075dedf..10882b3 100644 --- a/SportsTime/Core/Models/Domain/Stadium.swift +++ b/SportsTime/Core/Models/Domain/Stadium.swift @@ -14,6 +14,7 @@ struct Stadium: Identifiable, Codable, Hashable { let latitude: Double let longitude: Double let capacity: Int + let sport: Sport let yearOpened: Int? let imageURL: URL? @@ -25,6 +26,7 @@ struct Stadium: Identifiable, Codable, Hashable { latitude: Double, longitude: Double, capacity: Int, + sport: Sport, yearOpened: Int? = nil, imageURL: URL? = nil ) { @@ -35,6 +37,7 @@ struct Stadium: Identifiable, Codable, Hashable { self.latitude = latitude self.longitude = longitude self.capacity = capacity + self.sport = sport self.yearOpened = yearOpened self.imageURL = imageURL } diff --git a/SportsTime/Core/Models/Domain/Trip.swift b/SportsTime/Core/Models/Domain/Trip.swift index 91ba2e4..e0a865f 100644 --- a/SportsTime/Core/Models/Domain/Trip.swift +++ b/SportsTime/Core/Models/Domain/Trip.swift @@ -58,7 +58,15 @@ struct Trip: Identifiable, Codable, Hashable { return totalDrivingHours / Double(tripDuration) } - var cities: [String] { stops.map { $0.city } } + var cities: [String] { + // Deduplicate while preserving order + var seen: Set = [] + return stops.compactMap { stop in + guard !seen.contains(stop.city) else { return nil } + seen.insert(stop.city) + return stop.city + } + } var uniqueSports: Set { preferences.sports } var startDate: Date { stops.first?.arrivalDate ?? preferences.startDate } var endDate: Date { stops.last?.departureDate ?? preferences.endDate } diff --git a/SportsTime/Core/Models/Local/CanonicalModels.swift b/SportsTime/Core/Models/Local/CanonicalModels.swift new file mode 100644 index 0000000..805e1b9 --- /dev/null +++ b/SportsTime/Core/Models/Local/CanonicalModels.swift @@ -0,0 +1,492 @@ +// +// CanonicalModels.swift +// SportsTime +// +// SwiftData models for canonical data: stadiums, teams, games, and league structure. +// These are the runtime source of truth, populated from bundled JSON and synced via CloudKit. +// + +import Foundation +import SwiftData +import CryptoKit + +// MARK: - Schema Version + +/// Schema version constants for canonical data models. +/// Marked nonisolated to allow access from any isolation domain. +nonisolated enum SchemaVersion { + static let current: Int = 1 + static let minimumSupported: Int = 1 +} + +// MARK: - Data Source + +enum DataSource: String, Codable { + case bundled // Shipped with app bundle + case cloudKit // Synced from CloudKit + case userCorrection // User-provided correction +} + +// MARK: - Team Alias Type + +enum TeamAliasType: String, Codable { + case abbreviation // Old abbreviation (e.g., "NJN" -> "BRK") + case name // Old team name (e.g., "New Jersey Nets") + case city // Old city (e.g., "New Jersey") +} + +// MARK: - League Structure Type + +enum LeagueStructureType: String, Codable { + case conference + case division + case league +} + +// MARK: - Sync State + +@Model +final class SyncState { + @Attribute(.unique) var id: String = "singleton" + + // Bootstrap tracking + var bootstrapCompleted: Bool = false + var bundledSchemaVersion: Int = 0 + var lastBootstrap: Date? + + // CloudKit sync tracking + var lastSuccessfulSync: Date? + var lastSyncAttempt: Date? + var lastSyncError: String? + var syncInProgress: Bool = false + var syncEnabled: Bool = true + var syncPausedReason: String? + var consecutiveFailures: Int = 0 + + // Change tokens for delta sync + var stadiumChangeToken: Data? + var teamChangeToken: Data? + var gameChangeToken: Data? + var leagueChangeToken: Data? + + init() {} + + static func current(in context: ModelContext) -> SyncState { + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.id == "singleton" } + ) + if let existing = try? context.fetch(descriptor).first { + return existing + } + let new = SyncState() + context.insert(new) + return new + } +} + +// MARK: - Canonical Stadium + +@Model +final class CanonicalStadium { + // Identity + @Attribute(.unique) var canonicalId: String + var uuid: UUID + + // Versioning + var schemaVersion: Int + var lastModified: Date + var sourceRaw: String + + // Deprecation (soft delete) + var deprecatedAt: Date? + var deprecationReason: String? + var replacedByCanonicalId: String? + + // Core data + var name: String + var city: String + var state: String + var latitude: Double + var longitude: Double + var capacity: Int + var yearOpened: Int? + var imageURL: String? + var sport: String + + // User-correctable fields (preserved during sync) + var userNickname: String? + var userNotes: String? + var isFavorite: Bool = false + + // Relationships + @Relationship(deleteRule: .cascade, inverse: \StadiumAlias.stadium) + var aliases: [StadiumAlias]? + + init( + canonicalId: String, + uuid: UUID? = nil, + schemaVersion: Int = SchemaVersion.current, + lastModified: Date = Date(), + source: DataSource = .bundled, + name: String, + city: String, + state: String, + latitude: Double, + longitude: Double, + capacity: Int, + yearOpened: Int? = nil, + imageURL: String? = nil, + sport: String + ) { + self.canonicalId = canonicalId + self.uuid = uuid ?? Self.deterministicUUID(from: canonicalId) + self.schemaVersion = schemaVersion + self.lastModified = lastModified + self.sourceRaw = source.rawValue + self.name = name + self.city = city + self.state = state + self.latitude = latitude + self.longitude = longitude + self.capacity = capacity + self.yearOpened = yearOpened + self.imageURL = imageURL + self.sport = sport + } + + var source: DataSource { + get { DataSource(rawValue: sourceRaw) ?? .bundled } + set { sourceRaw = newValue.rawValue } + } + + var isActive: Bool { deprecatedAt == nil } + + func toDomain() -> Stadium { + Stadium( + id: uuid, + name: name, + city: city, + state: state, + latitude: latitude, + longitude: longitude, + capacity: capacity, + sport: Sport(rawValue: sport) ?? .mlb, + yearOpened: yearOpened, + imageURL: imageURL.flatMap { URL(string: $0) } + ) + } + + static func deterministicUUID(from string: String) -> UUID { + let data = Data(string.utf8) + let hash = SHA256.hash(data: data) + let hashBytes = Array(hash) + var bytes = Array(hashBytes.prefix(16)) + // Set version 4 and variant bits + bytes[6] = (bytes[6] & 0x0F) | 0x40 + bytes[8] = (bytes[8] & 0x3F) | 0x80 + return UUID(uuid: ( + bytes[0], bytes[1], bytes[2], bytes[3], + bytes[4], bytes[5], bytes[6], bytes[7], + bytes[8], bytes[9], bytes[10], bytes[11], + bytes[12], bytes[13], bytes[14], bytes[15] + )) + } +} + +// MARK: - Stadium Alias + +@Model +final class StadiumAlias { + @Attribute(.unique) var aliasName: String + var stadiumCanonicalId: String + var validFrom: Date? + var validUntil: Date? + var schemaVersion: Int + var lastModified: Date + + var stadium: CanonicalStadium? + + init( + aliasName: String, + stadiumCanonicalId: String, + validFrom: Date? = nil, + validUntil: Date? = nil, + schemaVersion: Int = SchemaVersion.current, + lastModified: Date = Date() + ) { + self.aliasName = aliasName.lowercased() + self.stadiumCanonicalId = stadiumCanonicalId + self.validFrom = validFrom + self.validUntil = validUntil + self.schemaVersion = schemaVersion + self.lastModified = lastModified + } +} + +// MARK: - Canonical Team + +@Model +final class CanonicalTeam { + @Attribute(.unique) var canonicalId: String + var uuid: UUID + + var schemaVersion: Int + var lastModified: Date + var sourceRaw: String + + var deprecatedAt: Date? + var deprecationReason: String? + var relocatedToCanonicalId: String? + + var name: String + var abbreviation: String + var sport: String + var city: String + var stadiumCanonicalId: String + var logoURL: String? + var primaryColor: String? + var secondaryColor: String? + var conferenceId: String? + var divisionId: String? + + // User-correctable + var userNickname: String? + var isFavorite: Bool = false + + @Relationship(deleteRule: .cascade, inverse: \TeamAlias.team) + var aliases: [TeamAlias]? + + init( + canonicalId: String, + uuid: UUID? = nil, + schemaVersion: Int = SchemaVersion.current, + lastModified: Date = Date(), + source: DataSource = .bundled, + name: String, + abbreviation: String, + sport: String, + city: String, + stadiumCanonicalId: String, + logoURL: String? = nil, + primaryColor: String? = nil, + secondaryColor: String? = nil, + conferenceId: String? = nil, + divisionId: String? = nil + ) { + self.canonicalId = canonicalId + self.uuid = uuid ?? CanonicalStadium.deterministicUUID(from: canonicalId) + self.schemaVersion = schemaVersion + self.lastModified = lastModified + self.sourceRaw = source.rawValue + self.name = name + self.abbreviation = abbreviation + self.sport = sport + self.city = city + self.stadiumCanonicalId = stadiumCanonicalId + self.logoURL = logoURL + self.primaryColor = primaryColor + self.secondaryColor = secondaryColor + self.conferenceId = conferenceId + self.divisionId = divisionId + } + + var source: DataSource { + get { DataSource(rawValue: sourceRaw) ?? .bundled } + set { sourceRaw = newValue.rawValue } + } + + var isActive: Bool { deprecatedAt == nil } + + var sportEnum: Sport? { Sport(rawValue: sport) } + + func toDomain(stadiumUUID: UUID) -> Team { + Team( + id: uuid, + name: name, + abbreviation: abbreviation, + sport: sportEnum ?? .mlb, + city: city, + stadiumId: stadiumUUID, + logoURL: logoURL.flatMap { URL(string: $0) }, + primaryColor: primaryColor, + secondaryColor: secondaryColor + ) + } +} + +// MARK: - Team Alias + +@Model +final class TeamAlias { + @Attribute(.unique) var id: String + var teamCanonicalId: String + var aliasTypeRaw: String + var aliasValue: String + var validFrom: Date? + var validUntil: Date? + var schemaVersion: Int + var lastModified: Date + + var team: CanonicalTeam? + + init( + id: String, + teamCanonicalId: String, + aliasType: TeamAliasType, + aliasValue: String, + validFrom: Date? = nil, + validUntil: Date? = nil, + schemaVersion: Int = SchemaVersion.current, + lastModified: Date = Date() + ) { + self.id = id + self.teamCanonicalId = teamCanonicalId + self.aliasTypeRaw = aliasType.rawValue + self.aliasValue = aliasValue + self.validFrom = validFrom + self.validUntil = validUntil + self.schemaVersion = schemaVersion + self.lastModified = lastModified + } + + var aliasType: TeamAliasType { + get { TeamAliasType(rawValue: aliasTypeRaw) ?? .name } + set { aliasTypeRaw = newValue.rawValue } + } +} + +// MARK: - League Structure + +@Model +final class LeagueStructureModel { + @Attribute(.unique) var id: String + var sport: String + var structureTypeRaw: String + var name: String + var abbreviation: String? + var parentId: String? + var displayOrder: Int + var schemaVersion: Int + var lastModified: Date + + init( + id: String, + sport: String, + structureType: LeagueStructureType, + name: String, + abbreviation: String? = nil, + parentId: String? = nil, + displayOrder: Int = 0, + schemaVersion: Int = SchemaVersion.current, + lastModified: Date = Date() + ) { + self.id = id + self.sport = sport + self.structureTypeRaw = structureType.rawValue + self.name = name + self.abbreviation = abbreviation + self.parentId = parentId + self.displayOrder = displayOrder + self.schemaVersion = schemaVersion + self.lastModified = lastModified + } + + var structureType: LeagueStructureType { + get { LeagueStructureType(rawValue: structureTypeRaw) ?? .division } + set { structureTypeRaw = newValue.rawValue } + } + + var sportEnum: Sport? { Sport(rawValue: sport) } +} + +// MARK: - Canonical Game + +@Model +final class CanonicalGame { + @Attribute(.unique) var canonicalId: String + var uuid: UUID + + var schemaVersion: Int + var lastModified: Date + var sourceRaw: String + + var deprecatedAt: Date? + var deprecationReason: String? + var rescheduledToCanonicalId: String? + + var homeTeamCanonicalId: String + var awayTeamCanonicalId: String + var stadiumCanonicalId: String + var dateTime: Date + var sport: String + var season: String + var isPlayoff: Bool + var broadcastInfo: String? + + // User-correctable + var userAttending: Bool = false + var userNotes: String? + + init( + canonicalId: String, + uuid: UUID? = nil, + schemaVersion: Int = SchemaVersion.current, + lastModified: Date = Date(), + source: DataSource = .bundled, + homeTeamCanonicalId: String, + awayTeamCanonicalId: String, + stadiumCanonicalId: String, + dateTime: Date, + sport: String, + season: String, + isPlayoff: Bool = false, + broadcastInfo: String? = nil + ) { + self.canonicalId = canonicalId + self.uuid = uuid ?? CanonicalStadium.deterministicUUID(from: canonicalId) + self.schemaVersion = schemaVersion + self.lastModified = lastModified + self.sourceRaw = source.rawValue + self.homeTeamCanonicalId = homeTeamCanonicalId + self.awayTeamCanonicalId = awayTeamCanonicalId + self.stadiumCanonicalId = stadiumCanonicalId + self.dateTime = dateTime + self.sport = sport + self.season = season + self.isPlayoff = isPlayoff + self.broadcastInfo = broadcastInfo + } + + var source: DataSource { + get { DataSource(rawValue: sourceRaw) ?? .bundled } + set { sourceRaw = newValue.rawValue } + } + + var isActive: Bool { deprecatedAt == nil } + + var sportEnum: Sport? { Sport(rawValue: sport) } + + func toDomain(homeTeamUUID: UUID, awayTeamUUID: UUID, stadiumUUID: UUID) -> Game { + Game( + id: uuid, + homeTeamId: homeTeamUUID, + awayTeamId: awayTeamUUID, + stadiumId: stadiumUUID, + dateTime: dateTime, + sport: sportEnum ?? .mlb, + season: season, + isPlayoff: isPlayoff, + broadcastInfo: broadcastInfo + ) + } +} + +// MARK: - Bundled Data Timestamps + +/// Timestamps for bundled data files. +/// Marked nonisolated to allow access from any isolation domain. +nonisolated enum BundledDataTimestamp { + static let stadiums = ISO8601DateFormatter().date(from: "2026-01-08T00:00:00Z") ?? Date() + static let games = ISO8601DateFormatter().date(from: "2026-01-08T00:00:00Z") ?? Date() + static let leagueStructure = ISO8601DateFormatter().date(from: "2026-01-08T00:00:00Z") ?? Date() +} diff --git a/SportsTime/Core/Models/Local/StadiumProgress.swift b/SportsTime/Core/Models/Local/StadiumProgress.swift new file mode 100644 index 0000000..514cc91 --- /dev/null +++ b/SportsTime/Core/Models/Local/StadiumProgress.swift @@ -0,0 +1,364 @@ +// +// StadiumProgress.swift +// SportsTime +// +// SwiftData models for tracking stadium visits, achievements, and photo metadata. +// + +import Foundation +import SwiftData + +// MARK: - Visit Type + +enum VisitType: String, Codable, CaseIterable { + case game // Attended a game + case tour // Stadium tour without game + case other // Other visit (tailgating, event, etc.) + + var displayName: String { + switch self { + case .game: return "Game" + case .tour: return "Tour" + case .other: return "Other" + } + } +} + +// MARK: - Data Source Type + +enum VisitDataSource: String, Codable { + case automatic // All data from photo + API + case partialManual // Photo metadata + manual game selection + case fullyManual // User entered everything + case userCorrected // Was automatic, user edited +} + +// MARK: - Score Source Type + +enum ScoreSource: String, Codable { + case app // From app's schedule data + case api // From free sports API + case scraped // From reference site scraping + case user // User-provided +} + +// MARK: - Visit Source Type + +enum VisitSource: String, Codable { + case trip // Created from a planned trip + case manual // Manually entered + case photoImport // Imported from photo library +} + +// MARK: - Upload Status + +enum UploadStatus: String, Codable { + case pending + case uploaded + case failed +} + +// MARK: - Stadium Visit + +@Model +final class StadiumVisit { + @Attribute(.unique) var id: UUID + + // Stadium identity (stable across renames) + var canonicalStadiumId: String // Links to CanonicalStadium.canonicalId + var stadiumUUID: UUID // Runtime UUID for display lookups + var stadiumNameAtVisit: String // Frozen at visit time + + // Visit details + var visitDate: Date + var sport: String // Sport.rawValue + var visitTypeRaw: String // VisitType.rawValue + + // Game info (optional - nil for tours/other visits) + var gameId: UUID? + var homeTeamId: UUID? + var awayTeamId: UUID? + var homeTeamName: String? // For display when team lookup fails + var awayTeamName: String? + var finalScore: String? // "5-3" format + var manualGameDescription: String? // User's description if game not found + + // Resolution tracking + var scoreSourceRaw: String? // ScoreSource.rawValue + var dataSourceRaw: String // VisitDataSource.rawValue + var scoreResolutionPending: Bool // true if background retry needed + + // User data + var seatLocation: String? + var notes: String? + + // Photos + @Relationship(deleteRule: .cascade, inverse: \VisitPhotoMetadata.visit) + var photoMetadata: [VisitPhotoMetadata]? + + // Photo import metadata (preserved for debugging/re-matching) + var photoLatitude: Double? + var photoLongitude: Double? + var photoCaptureDate: Date? + + // Audit + var createdAt: Date + var sourceRaw: String // VisitSource.rawValue + + // MARK: - Initialization + + init( + id: UUID = UUID(), + canonicalStadiumId: String, + stadiumUUID: UUID, + stadiumNameAtVisit: String, + visitDate: Date, + sport: Sport, + visitType: VisitType = .game, + gameId: UUID? = nil, + homeTeamId: UUID? = nil, + awayTeamId: UUID? = nil, + homeTeamName: String? = nil, + awayTeamName: String? = nil, + finalScore: String? = nil, + manualGameDescription: String? = nil, + scoreSource: ScoreSource? = nil, + dataSource: VisitDataSource = .fullyManual, + scoreResolutionPending: Bool = false, + seatLocation: String? = nil, + notes: String? = nil, + photoLatitude: Double? = nil, + photoLongitude: Double? = nil, + photoCaptureDate: Date? = nil, + source: VisitSource = .manual + ) { + self.id = id + self.canonicalStadiumId = canonicalStadiumId + self.stadiumUUID = stadiumUUID + self.stadiumNameAtVisit = stadiumNameAtVisit + self.visitDate = visitDate + self.sport = sport.rawValue + self.visitTypeRaw = visitType.rawValue + self.gameId = gameId + self.homeTeamId = homeTeamId + self.awayTeamId = awayTeamId + self.homeTeamName = homeTeamName + self.awayTeamName = awayTeamName + self.finalScore = finalScore + self.manualGameDescription = manualGameDescription + self.scoreSourceRaw = scoreSource?.rawValue + self.dataSourceRaw = dataSource.rawValue + self.scoreResolutionPending = scoreResolutionPending + self.seatLocation = seatLocation + self.notes = notes + self.photoLatitude = photoLatitude + self.photoLongitude = photoLongitude + self.photoCaptureDate = photoCaptureDate + self.createdAt = Date() + self.sourceRaw = source.rawValue + } + + // MARK: - Computed Properties + + var visitType: VisitType { + get { VisitType(rawValue: visitTypeRaw) ?? .game } + set { visitTypeRaw = newValue.rawValue } + } + + var scoreSource: ScoreSource? { + get { scoreSourceRaw.flatMap { ScoreSource(rawValue: $0) } } + set { scoreSourceRaw = newValue?.rawValue } + } + + var dataSource: VisitDataSource { + get { VisitDataSource(rawValue: dataSourceRaw) ?? .fullyManual } + set { dataSourceRaw = newValue.rawValue } + } + + var source: VisitSource { + get { VisitSource(rawValue: sourceRaw) ?? .manual } + set { sourceRaw = newValue.rawValue } + } + + var sportEnum: Sport? { + Sport(rawValue: sport) + } + + /// Display string for the game matchup + var matchupDescription: String? { + if let home = homeTeamName, let away = awayTeamName { + return "\(away) @ \(home)" + } + return manualGameDescription + } + + /// Display string including score if available + var matchupWithScore: String? { + guard let matchup = matchupDescription else { return nil } + if let score = finalScore { + return "\(matchup) (\(score))" + } + return matchup + } +} + +// MARK: - Visit Photo Metadata + +@Model +final class VisitPhotoMetadata { + @Attribute(.unique) var id: UUID + var visitId: UUID + var cloudKitAssetId: String? // Set after successful upload + var thumbnailData: Data? // 200x200 JPEG for fast loading + var caption: String? + var orderIndex: Int + var uploadStatusRaw: String // UploadStatus.rawValue + var createdAt: Date + + var visit: StadiumVisit? + + init( + id: UUID = UUID(), + visitId: UUID, + cloudKitAssetId: String? = nil, + thumbnailData: Data? = nil, + caption: String? = nil, + orderIndex: Int = 0, + uploadStatus: UploadStatus = .pending + ) { + self.id = id + self.visitId = visitId + self.cloudKitAssetId = cloudKitAssetId + self.thumbnailData = thumbnailData + self.caption = caption + self.orderIndex = orderIndex + self.uploadStatusRaw = uploadStatus.rawValue + self.createdAt = Date() + } + + var uploadStatus: UploadStatus { + get { UploadStatus(rawValue: uploadStatusRaw) ?? .pending } + set { uploadStatusRaw = newValue.rawValue } + } +} + +// MARK: - Achievement + +@Model +final class Achievement { + @Attribute(.unique) var id: UUID + var achievementTypeId: String // e.g., "mlb_all_30", "nl_west_complete" + var sport: String? // Sport.rawValue, nil for cross-sport achievements + var earnedAt: Date + var revokedAt: Date? // Non-nil if visits deleted + var visitIdsSnapshot: Data // JSON-encoded [UUID] that earned this + + init( + id: UUID = UUID(), + achievementTypeId: String, + sport: Sport? = nil, + earnedAt: Date = Date(), + visitIds: [UUID] + ) { + self.id = id + self.achievementTypeId = achievementTypeId + self.sport = sport?.rawValue + self.earnedAt = earnedAt + self.revokedAt = nil + self.visitIdsSnapshot = (try? JSONEncoder().encode(visitIds)) ?? Data() + } + + var sportEnum: Sport? { + sport.flatMap { Sport(rawValue: $0) } + } + + var visitIds: [UUID] { + (try? JSONDecoder().decode([UUID].self, from: visitIdsSnapshot)) ?? [] + } + + var isEarned: Bool { + revokedAt == nil + } + + func revoke() { + revokedAt = Date() + } + + func restore() { + revokedAt = nil + } +} + +// MARK: - Cached Game Score + +/// Caches resolved game scores to avoid repeated API calls. +/// Historical scores never change, so they can be cached indefinitely. +@Model +final class CachedGameScore { + @Attribute(.unique) var cacheKey: String // "MLB_2010-06-15_SFG_LAD" + var sport: String + var gameDate: Date + var homeTeamAbbrev: String + var awayTeamAbbrev: String + var homeTeamName: String + var awayTeamName: String + var homeScore: Int? + var awayScore: Int? + var sourceRaw: String // ScoreSource.rawValue + var fetchedAt: Date + var expiresAt: Date? // nil = never expires (historical data) + + init( + cacheKey: String, + sport: Sport, + gameDate: Date, + homeTeamAbbrev: String, + awayTeamAbbrev: String, + homeTeamName: String, + awayTeamName: String, + homeScore: Int?, + awayScore: Int?, + source: ScoreSource, + expiresAt: Date? = nil + ) { + self.cacheKey = cacheKey + self.sport = sport.rawValue + self.gameDate = gameDate + self.homeTeamAbbrev = homeTeamAbbrev + self.awayTeamAbbrev = awayTeamAbbrev + self.homeTeamName = homeTeamName + self.awayTeamName = awayTeamName + self.homeScore = homeScore + self.awayScore = awayScore + self.sourceRaw = source.rawValue + self.fetchedAt = Date() + self.expiresAt = expiresAt + } + + var scoreSource: ScoreSource { + get { ScoreSource(rawValue: sourceRaw) ?? .api } + set { sourceRaw = newValue.rawValue } + } + + var sportEnum: Sport? { + Sport(rawValue: sport) + } + + var scoreString: String? { + guard let home = homeScore, let away = awayScore else { return nil } + return "\(away)-\(home)" + } + + var isExpired: Bool { + guard let expiresAt = expiresAt else { return false } + return Date() > expiresAt + } + + /// Generate cache key for a game query + static func generateKey(sport: Sport, date: Date, homeAbbrev: String, awayAbbrev: String) -> String { + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "yyyy-MM-dd" + let dateString = dateFormatter.string(from: date) + return "\(sport.rawValue)_\(dateString)_\(homeAbbrev)_\(awayAbbrev)" + } +} diff --git a/SportsTime/Core/Services/AchievementEngine.swift b/SportsTime/Core/Services/AchievementEngine.swift new file mode 100644 index 0000000..605ae12 --- /dev/null +++ b/SportsTime/Core/Services/AchievementEngine.swift @@ -0,0 +1,444 @@ +// +// AchievementEngine.swift +// SportsTime +// +// Computes achievements based on stadium visits. +// Recalculates and revokes achievements when visits are deleted. +// + +import Foundation +import SwiftData + +// MARK: - Achievement Delta + +struct AchievementDelta: Sendable { + let newlyEarned: [AchievementDefinition] + let revoked: [AchievementDefinition] + let stillEarned: [AchievementDefinition] + + var hasChanges: Bool { + !newlyEarned.isEmpty || !revoked.isEmpty + } +} + +// MARK: - Achievement Engine + +@MainActor +final class AchievementEngine { + + // MARK: - Properties + + private let modelContext: ModelContext + private let dataProvider: AppDataProvider + + // MARK: - Initialization + + init(modelContext: ModelContext, dataProvider: AppDataProvider = AppDataProvider.shared) { + self.modelContext = modelContext + self.dataProvider = dataProvider + } + + // MARK: - Public API + + /// Full recalculation (call after visit deleted or on app update) + func recalculateAllAchievements() async throws -> AchievementDelta { + // Get all visits + let visits = try fetchAllVisits() + let visitedStadiumIds = Set(visits.map { $0.canonicalStadiumId }) + + // Get currently earned achievements + let currentAchievements = try fetchEarnedAchievements() + let currentAchievementIds = Set(currentAchievements.map { $0.achievementTypeId }) + + // Calculate which achievements should be earned + var shouldBeEarned: Set = [] + var newlyEarnedDefinitions: [AchievementDefinition] = [] + var revokedDefinitions: [AchievementDefinition] = [] + var stillEarnedDefinitions: [AchievementDefinition] = [] + + for definition in AchievementRegistry.all { + let isEarned = checkRequirement(definition.requirement, visits: visits, visitedStadiumIds: visitedStadiumIds) + + if isEarned { + shouldBeEarned.insert(definition.id) + + if currentAchievementIds.contains(definition.id) { + stillEarnedDefinitions.append(definition) + } else { + newlyEarnedDefinitions.append(definition) + } + } else if currentAchievementIds.contains(definition.id) { + revokedDefinitions.append(definition) + } + } + + // Apply changes + // Grant new achievements + for definition in newlyEarnedDefinitions { + let visitIds = getContributingVisitIds(for: definition.requirement, visits: visits) + let achievement = Achievement( + achievementTypeId: definition.id, + sport: definition.sport, + visitIds: visitIds + ) + modelContext.insert(achievement) + } + + // Revoke achievements + for definition in revokedDefinitions { + if let achievement = currentAchievements.first(where: { $0.achievementTypeId == definition.id }) { + achievement.revoke() + } + } + + // Restore previously revoked achievements that are now earned again + for definition in stillEarnedDefinitions { + if let achievement = currentAchievements.first(where: { + $0.achievementTypeId == definition.id && $0.revokedAt != nil + }) { + achievement.restore() + } + } + + try modelContext.save() + + return AchievementDelta( + newlyEarned: newlyEarnedDefinitions, + revoked: revokedDefinitions, + stillEarned: stillEarnedDefinitions + ) + } + + /// Quick check after new visit (incremental) + func checkAchievementsForNewVisit(_ visit: StadiumVisit) async throws -> [AchievementDefinition] { + let visits = try fetchAllVisits() + let visitedStadiumIds = Set(visits.map { $0.canonicalStadiumId }) + + let currentAchievements = try fetchEarnedAchievements() + let currentAchievementIds = Set(currentAchievements.map { $0.achievementTypeId }) + + var newlyEarned: [AchievementDefinition] = [] + + for definition in AchievementRegistry.all { + // Skip already earned + guard !currentAchievementIds.contains(definition.id) else { continue } + + let isEarned = checkRequirement(definition.requirement, visits: visits, visitedStadiumIds: visitedStadiumIds) + + if isEarned { + newlyEarned.append(definition) + + let visitIds = getContributingVisitIds(for: definition.requirement, visits: visits) + let achievement = Achievement( + achievementTypeId: definition.id, + sport: definition.sport, + visitIds: visitIds + ) + modelContext.insert(achievement) + } + } + + try modelContext.save() + + return newlyEarned + } + + /// Get all earned achievements + func getEarnedAchievements() throws -> [AchievementDefinition] { + let achievements = try fetchEarnedAchievements() + return achievements.compactMap { AchievementRegistry.achievement(byId: $0.achievementTypeId) } + } + + /// Get progress toward all achievements + func getProgress() async throws -> [AchievementProgress] { + let visits = try fetchAllVisits() + let visitedStadiumIds = Set(visits.map { $0.canonicalStadiumId }) + let earnedAchievements = try fetchEarnedAchievements() + let earnedIds = Set(earnedAchievements.map { $0.achievementTypeId }) + + var progress: [AchievementProgress] = [] + + for definition in AchievementRegistry.all { + let (current, total) = calculateProgress( + for: definition.requirement, + visits: visits, + visitedStadiumIds: visitedStadiumIds + ) + + let isEarned = earnedIds.contains(definition.id) + let earnedAt = earnedAchievements.first(where: { $0.achievementTypeId == definition.id })?.earnedAt + + progress.append(AchievementProgress( + definition: definition, + currentProgress: current, + totalRequired: total, + isEarned: isEarned, + earnedAt: earnedAt + )) + } + + return progress + } + + // MARK: - Requirement Checking + + private func checkRequirement( + _ requirement: AchievementRequirement, + visits: [StadiumVisit], + visitedStadiumIds: Set + ) -> Bool { + switch requirement { + case .firstVisit: + return !visits.isEmpty + + case .visitCount(let count): + return visitedStadiumIds.count >= count + + case .visitCountForSport(let count, let sport): + let sportVisits = visits.filter { $0.sport == sport.rawValue } + let sportStadiums = Set(sportVisits.map { $0.canonicalStadiumId }) + return sportStadiums.count >= count + + case .completeDivision(let divisionId): + return checkDivisionComplete(divisionId, visitedStadiumIds: visitedStadiumIds) + + case .completeConference(let conferenceId): + return checkConferenceComplete(conferenceId, visitedStadiumIds: visitedStadiumIds) + + case .completeLeague(let sport): + return checkLeagueComplete(sport, visitedStadiumIds: visitedStadiumIds) + + case .visitsInDays(let visitCount, let days): + return checkVisitsInDays(visits: visits, requiredVisits: visitCount, withinDays: days) + + case .multipleLeagues(let leagueCount): + return checkMultipleLeagues(visits: visits, requiredLeagues: leagueCount) + + case .specificStadium(let stadiumId): + return visitedStadiumIds.contains(stadiumId) + } + } + + private func checkDivisionComplete(_ divisionId: String, visitedStadiumIds: Set) -> Bool { + guard let division = LeagueStructure.division(byId: divisionId) else { return false } + + // Get stadium IDs for teams in this division + let stadiumIds = getStadiumIdsForDivision(divisionId) + guard !stadiumIds.isEmpty else { return false } + + return stadiumIds.allSatisfy { visitedStadiumIds.contains($0) } + } + + private func checkConferenceComplete(_ conferenceId: String, visitedStadiumIds: Set) -> Bool { + guard let conference = LeagueStructure.conference(byId: conferenceId) else { return false } + + // Get stadium IDs for all teams in this conference + let stadiumIds = getStadiumIdsForConference(conferenceId) + guard !stadiumIds.isEmpty else { return false } + + return stadiumIds.allSatisfy { visitedStadiumIds.contains($0) } + } + + private func checkLeagueComplete(_ sport: Sport, visitedStadiumIds: Set) -> Bool { + let stadiumIds = getStadiumIdsForLeague(sport) + guard !stadiumIds.isEmpty else { return false } + + return stadiumIds.allSatisfy { visitedStadiumIds.contains($0) } + } + + private func checkVisitsInDays(visits: [StadiumVisit], requiredVisits: Int, withinDays: Int) -> Bool { + guard visits.count >= requiredVisits else { return false } + + // Sort visits by date + let sortedVisits = visits.sorted { $0.visitDate < $1.visitDate } + + // Sliding window + for i in 0...(sortedVisits.count - requiredVisits) { + let windowStart = sortedVisits[i].visitDate + let windowEnd = sortedVisits[i + requiredVisits - 1].visitDate + + let daysDiff = Calendar.current.dateComponents([.day], from: windowStart, to: windowEnd).day ?? Int.max + if daysDiff < withinDays { + // Check unique stadiums in window + let windowVisits = Array(sortedVisits[i..<(i + requiredVisits)]) + let uniqueStadiums = Set(windowVisits.map { $0.canonicalStadiumId }) + if uniqueStadiums.count >= requiredVisits { + return true + } + } + } + + return false + } + + private func checkMultipleLeagues(visits: [StadiumVisit], requiredLeagues: Int) -> Bool { + let leagues = Set(visits.compactMap { Sport(rawValue: $0.sport) }) + return leagues.count >= requiredLeagues + } + + // MARK: - Progress Calculation + + private func calculateProgress( + for requirement: AchievementRequirement, + visits: [StadiumVisit], + visitedStadiumIds: Set + ) -> (current: Int, total: Int) { + switch requirement { + case .firstVisit: + return (visits.isEmpty ? 0 : 1, 1) + + case .visitCount(let count): + return (visitedStadiumIds.count, count) + + case .visitCountForSport(let count, let sport): + let sportVisits = visits.filter { $0.sport == sport.rawValue } + let sportStadiums = Set(sportVisits.map { $0.canonicalStadiumId }) + return (sportStadiums.count, count) + + case .completeDivision(let divisionId): + let stadiumIds = getStadiumIdsForDivision(divisionId) + let visited = stadiumIds.filter { visitedStadiumIds.contains($0) }.count + return (visited, stadiumIds.count) + + case .completeConference(let conferenceId): + let stadiumIds = getStadiumIdsForConference(conferenceId) + let visited = stadiumIds.filter { visitedStadiumIds.contains($0) }.count + return (visited, stadiumIds.count) + + case .completeLeague(let sport): + let stadiumIds = getStadiumIdsForLeague(sport) + let visited = stadiumIds.filter { visitedStadiumIds.contains($0) }.count + return (visited, stadiumIds.count) + + case .visitsInDays(let visitCount, _): + // For journey achievements, show total unique stadiums vs required + return (min(visitedStadiumIds.count, visitCount), visitCount) + + case .multipleLeagues(let leagueCount): + let leagues = Set(visits.compactMap { Sport(rawValue: $0.sport) }) + return (leagues.count, leagueCount) + + case .specificStadium(let stadiumId): + return (visitedStadiumIds.contains(stadiumId) ? 1 : 0, 1) + } + } + + // MARK: - Contributing Visits + + private func getContributingVisitIds(for requirement: AchievementRequirement, visits: [StadiumVisit]) -> [UUID] { + switch requirement { + case .firstVisit: + return visits.first.map { [$0.id] } ?? [] + + case .visitCount, .visitCountForSport, .multipleLeagues: + // All visits contribute + return visits.map { $0.id } + + case .completeDivision(let divisionId): + let stadiumIds = Set(getStadiumIdsForDivision(divisionId)) + return visits.filter { stadiumIds.contains($0.canonicalStadiumId) }.map { $0.id } + + case .completeConference(let conferenceId): + let stadiumIds = Set(getStadiumIdsForConference(conferenceId)) + return visits.filter { stadiumIds.contains($0.canonicalStadiumId) }.map { $0.id } + + case .completeLeague(let sport): + let stadiumIds = Set(getStadiumIdsForLeague(sport)) + return visits.filter { stadiumIds.contains($0.canonicalStadiumId) }.map { $0.id } + + case .visitsInDays(let requiredVisits, let days): + // Find the qualifying window of visits + let sortedVisits = visits.sorted { $0.visitDate < $1.visitDate } + for i in 0...(sortedVisits.count - requiredVisits) { + let windowStart = sortedVisits[i].visitDate + let windowEnd = sortedVisits[i + requiredVisits - 1].visitDate + let daysDiff = Calendar.current.dateComponents([.day], from: windowStart, to: windowEnd).day ?? Int.max + if daysDiff < days { + return Array(sortedVisits[i..<(i + requiredVisits)]).map { $0.id } + } + } + return [] + + case .specificStadium(let stadiumId): + return visits.filter { $0.canonicalStadiumId == stadiumId }.map { $0.id } + } + } + + // MARK: - Stadium Lookups + + private func getStadiumIdsForDivision(_ divisionId: String) -> [String] { + // Get teams in division, then their stadiums + let teams = dataProvider.teams.filter { team in + // Match division by checking team's division assignment + // This would normally come from CanonicalTeam.divisionId + // For now, return based on division structure + return false // Will be populated when division data is linked + } + + // For now, return hardcoded counts based on typical division sizes + // This should be replaced with actual team-to-stadium mapping + return [] + } + + private func getStadiumIdsForConference(_ conferenceId: String) -> [String] { + guard let conference = LeagueStructure.conference(byId: conferenceId) else { return [] } + + var stadiumIds: [String] = [] + for divisionId in conference.divisionIds { + stadiumIds.append(contentsOf: getStadiumIdsForDivision(divisionId)) + } + return stadiumIds + } + + private func getStadiumIdsForLeague(_ sport: Sport) -> [String] { + // Get all stadiums for this sport + return dataProvider.stadiums + .filter { stadium in + // Check if stadium hosts teams of this sport + dataProvider.teams.contains { team in + team.stadiumId == stadium.id && team.sport == sport + } + } + .map { "stadium_\(sport.rawValue.lowercased())_\($0.id.uuidString)" } + } + + // MARK: - Data Fetching + + private func fetchAllVisits() throws -> [StadiumVisit] { + let descriptor = FetchDescriptor( + sortBy: [SortDescriptor(\.visitDate, order: .forward)] + ) + return try modelContext.fetch(descriptor) + } + + private func fetchEarnedAchievements() throws -> [Achievement] { + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.revokedAt == nil } + ) + return try modelContext.fetch(descriptor) + } +} + +// MARK: - Achievement Progress + +struct AchievementProgress: Identifiable { + let definition: AchievementDefinition + let currentProgress: Int + let totalRequired: Int + let isEarned: Bool + let earnedAt: Date? + + var id: String { definition.id } + + var progressPercentage: Double { + guard totalRequired > 0 else { return 0 } + return Double(currentProgress) / Double(totalRequired) + } + + var progressText: String { + if isEarned { + return "Completed" + } + return "\(currentProgress)/\(totalRequired)" + } +} diff --git a/SportsTime/Core/Services/BootstrapService.swift b/SportsTime/Core/Services/BootstrapService.swift new file mode 100644 index 0000000..b0226c0 --- /dev/null +++ b/SportsTime/Core/Services/BootstrapService.swift @@ -0,0 +1,512 @@ +// +// BootstrapService.swift +// SportsTime +// +// Bootstraps canonical data from bundled JSON files into SwiftData. +// Runs once on first launch, then relies on CloudKit for updates. +// + +import Foundation +import SwiftData +import CryptoKit + +actor BootstrapService { + + // MARK: - Errors + + enum BootstrapError: Error, LocalizedError { + case bundledResourceNotFound(String) + case jsonDecodingFailed(String, Error) + case saveFailed(Error) + + var errorDescription: String? { + switch self { + case .bundledResourceNotFound(let resource): + return "Bundled resource not found: \(resource)" + case .jsonDecodingFailed(let resource, let error): + return "Failed to decode \(resource): \(error.localizedDescription)" + case .saveFailed(let error): + return "Failed to save bootstrap data: \(error.localizedDescription)" + } + } + } + + // MARK: - JSON Models (match bundled JSON structure) + + private struct JSONStadium: Codable { + let id: String + let name: String + let city: String + let state: String + let latitude: Double + let longitude: Double + let capacity: Int + let sport: String + let team_abbrevs: [String] + let source: String + let year_opened: Int? + } + + private struct JSONGame: Codable { + let id: String + let sport: String + let season: String + let date: String + let time: String? + let home_team: String + let away_team: String + let home_team_abbrev: String + let away_team_abbrev: String + let venue: String + let source: String + let is_playoff: Bool + let broadcast: String? + } + + private struct JSONLeagueStructure: Codable { + let id: String + let sport: String + let type: String // "conference", "division", "league" + let name: String + let abbreviation: String? + let parent_id: String? + let display_order: Int + } + + private struct JSONTeamAlias: Codable { + let id: String + let team_canonical_id: String + let alias_type: String // "abbreviation", "name", "city" + let alias_value: String + let valid_from: String? + let valid_until: String? + } + + // MARK: - Public Methods + + /// Bootstrap canonical data from bundled JSON if not already done. + /// This is the main entry point called at app launch. + @MainActor + func bootstrapIfNeeded(context: ModelContext) async throws { + let syncState = SyncState.current(in: context) + + // Skip if already bootstrapped + guard !syncState.bootstrapCompleted else { + return + } + + // Bootstrap in dependency order + try await bootstrapStadiums(context: context) + try await bootstrapLeagueStructure(context: context) + try await bootstrapTeamsAndGames(context: context) + try await bootstrapTeamAliases(context: context) + + // Mark bootstrap complete + syncState.bootstrapCompleted = true + syncState.bundledSchemaVersion = SchemaVersion.current + syncState.lastBootstrap = Date() + + do { + try context.save() + } catch { + throw BootstrapError.saveFailed(error) + } + } + + // MARK: - Bootstrap Steps + + @MainActor + private func bootstrapStadiums(context: ModelContext) async throws { + guard let url = Bundle.main.url(forResource: "stadiums", withExtension: "json") else { + throw BootstrapError.bundledResourceNotFound("stadiums.json") + } + + let data: Data + let stadiums: [JSONStadium] + + do { + data = try Data(contentsOf: url) + stadiums = try JSONDecoder().decode([JSONStadium].self, from: data) + } catch { + throw BootstrapError.jsonDecodingFailed("stadiums.json", error) + } + + // Convert and insert + for jsonStadium in stadiums { + let canonical = CanonicalStadium( + canonicalId: jsonStadium.id, + schemaVersion: SchemaVersion.current, + lastModified: BundledDataTimestamp.stadiums, + source: .bundled, + name: jsonStadium.name, + city: jsonStadium.city, + state: jsonStadium.state.isEmpty ? stateFromCity(jsonStadium.city) : jsonStadium.state, + latitude: jsonStadium.latitude, + longitude: jsonStadium.longitude, + capacity: jsonStadium.capacity, + yearOpened: jsonStadium.year_opened, + sport: jsonStadium.sport + ) + context.insert(canonical) + + // Create stadium alias for the current name (lowercase for matching) + let alias = StadiumAlias( + aliasName: jsonStadium.name, + stadiumCanonicalId: jsonStadium.id, + schemaVersion: SchemaVersion.current, + lastModified: BundledDataTimestamp.stadiums + ) + alias.stadium = canonical + context.insert(alias) + } + } + + @MainActor + private func bootstrapLeagueStructure(context: ModelContext) async throws { + // Load league structure if file exists + guard let url = Bundle.main.url(forResource: "league_structure", withExtension: "json") else { + // League structure is optional for MVP - create basic structure from known sports + createDefaultLeagueStructure(context: context) + return + } + + let data: Data + let structures: [JSONLeagueStructure] + + do { + data = try Data(contentsOf: url) + structures = try JSONDecoder().decode([JSONLeagueStructure].self, from: data) + } catch { + throw BootstrapError.jsonDecodingFailed("league_structure.json", error) + } + + for structure in structures { + let structureType: LeagueStructureType + switch structure.type.lowercased() { + case "conference": structureType = .conference + case "division": structureType = .division + case "league": structureType = .league + default: structureType = .division + } + + let model = LeagueStructureModel( + id: structure.id, + sport: structure.sport, + structureType: structureType, + name: structure.name, + abbreviation: structure.abbreviation, + parentId: structure.parent_id, + displayOrder: structure.display_order, + schemaVersion: SchemaVersion.current, + lastModified: BundledDataTimestamp.leagueStructure + ) + context.insert(model) + } + } + + @MainActor + private func bootstrapTeamsAndGames(context: ModelContext) async throws { + guard let url = Bundle.main.url(forResource: "games", withExtension: "json") else { + throw BootstrapError.bundledResourceNotFound("games.json") + } + + let data: Data + let games: [JSONGame] + + do { + data = try Data(contentsOf: url) + games = try JSONDecoder().decode([JSONGame].self, from: data) + } catch { + throw BootstrapError.jsonDecodingFailed("games.json", error) + } + + // Build stadium lookup by venue name for game → stadium matching + let stadiumDescriptor = FetchDescriptor() + let canonicalStadiums = (try? context.fetch(stadiumDescriptor)) ?? [] + var stadiumsByVenue: [String: CanonicalStadium] = [:] + for stadium in canonicalStadiums { + stadiumsByVenue[stadium.name.lowercased()] = stadium + } + + // Extract unique teams from games and create CanonicalTeam entries + var teamsCreated: [String: CanonicalTeam] = [:] + var seenGameIds = Set() + + for jsonGame in games { + let sport = jsonGame.sport.uppercased() + + // Process home team + let homeTeamCanonicalId = "team_\(sport.lowercased())_\(jsonGame.home_team_abbrev.lowercased())" + if teamsCreated[homeTeamCanonicalId] == nil { + let stadiumCanonicalId = findStadiumCanonicalId( + venue: jsonGame.venue, + sport: sport, + stadiumsByVenue: stadiumsByVenue + ) + + let team = CanonicalTeam( + canonicalId: homeTeamCanonicalId, + schemaVersion: SchemaVersion.current, + lastModified: BundledDataTimestamp.games, + source: .bundled, + name: extractTeamName(from: jsonGame.home_team), + abbreviation: jsonGame.home_team_abbrev, + sport: sport, + city: extractCity(from: jsonGame.home_team), + stadiumCanonicalId: stadiumCanonicalId + ) + context.insert(team) + teamsCreated[homeTeamCanonicalId] = team + } + + // Process away team + let awayTeamCanonicalId = "team_\(sport.lowercased())_\(jsonGame.away_team_abbrev.lowercased())" + if teamsCreated[awayTeamCanonicalId] == nil { + // Away teams might not have a known stadium yet + let team = CanonicalTeam( + canonicalId: awayTeamCanonicalId, + schemaVersion: SchemaVersion.current, + lastModified: BundledDataTimestamp.games, + source: .bundled, + name: extractTeamName(from: jsonGame.away_team), + abbreviation: jsonGame.away_team_abbrev, + sport: sport, + city: extractCity(from: jsonGame.away_team), + stadiumCanonicalId: "unknown" // Will be filled in when they're home team + ) + context.insert(team) + teamsCreated[awayTeamCanonicalId] = team + } + + // Deduplicate games by ID + guard !seenGameIds.contains(jsonGame.id) else { continue } + seenGameIds.insert(jsonGame.id) + + // Create game + guard let dateTime = parseDateTime(date: jsonGame.date, time: jsonGame.time ?? "7:00p") else { + continue + } + + let stadiumCanonicalId = findStadiumCanonicalId( + venue: jsonGame.venue, + sport: sport, + stadiumsByVenue: stadiumsByVenue + ) + + let game = CanonicalGame( + canonicalId: jsonGame.id, + schemaVersion: SchemaVersion.current, + lastModified: BundledDataTimestamp.games, + source: .bundled, + homeTeamCanonicalId: homeTeamCanonicalId, + awayTeamCanonicalId: awayTeamCanonicalId, + stadiumCanonicalId: stadiumCanonicalId, + dateTime: dateTime, + sport: sport, + season: jsonGame.season, + isPlayoff: jsonGame.is_playoff, + broadcastInfo: jsonGame.broadcast + ) + context.insert(game) + } + } + + @MainActor + private func bootstrapTeamAliases(context: ModelContext) async throws { + // Team aliases are optional - load if file exists + guard let url = Bundle.main.url(forResource: "team_aliases", withExtension: "json") else { + return + } + + let data: Data + let aliases: [JSONTeamAlias] + + do { + data = try Data(contentsOf: url) + aliases = try JSONDecoder().decode([JSONTeamAlias].self, from: data) + } catch { + throw BootstrapError.jsonDecodingFailed("team_aliases.json", error) + } + + let dateFormatter = ISO8601DateFormatter() + + for jsonAlias in aliases { + let aliasType: TeamAliasType + switch jsonAlias.alias_type.lowercased() { + case "abbreviation": aliasType = .abbreviation + case "name": aliasType = .name + case "city": aliasType = .city + default: aliasType = .name + } + + let alias = TeamAlias( + id: jsonAlias.id, + teamCanonicalId: jsonAlias.team_canonical_id, + aliasType: aliasType, + aliasValue: jsonAlias.alias_value, + validFrom: jsonAlias.valid_from.flatMap { dateFormatter.date(from: $0) }, + validUntil: jsonAlias.valid_until.flatMap { dateFormatter.date(from: $0) }, + schemaVersion: SchemaVersion.current, + lastModified: BundledDataTimestamp.games + ) + context.insert(alias) + } + } + + // MARK: - Helpers + + @MainActor + private func createDefaultLeagueStructure(context: ModelContext) { + // Create minimal league structure for supported sports + let timestamp = BundledDataTimestamp.leagueStructure + + // MLB + context.insert(LeagueStructureModel( + id: "mlb_league", + sport: "MLB", + structureType: .league, + name: "Major League Baseball", + abbreviation: "MLB", + displayOrder: 0, + schemaVersion: SchemaVersion.current, + lastModified: timestamp + )) + + // NBA + context.insert(LeagueStructureModel( + id: "nba_league", + sport: "NBA", + structureType: .league, + name: "National Basketball Association", + abbreviation: "NBA", + displayOrder: 0, + schemaVersion: SchemaVersion.current, + lastModified: timestamp + )) + + // NHL + context.insert(LeagueStructureModel( + id: "nhl_league", + sport: "NHL", + structureType: .league, + name: "National Hockey League", + abbreviation: "NHL", + displayOrder: 0, + schemaVersion: SchemaVersion.current, + lastModified: timestamp + )) + } + + // Venue name aliases for stadiums that changed names + private static let venueAliases: [String: String] = [ + "daikin park": "minute maid park", + "rate field": "guaranteed rate field", + "george m. steinbrenner field": "tropicana field", + "loandepot park": "loandepot park", + ] + + nonisolated private func findStadiumCanonicalId( + venue: String, + sport: String, + stadiumsByVenue: [String: CanonicalStadium] + ) -> String { + var venueLower = venue.lowercased() + + // Check for known aliases + if let aliasedName = Self.venueAliases[venueLower] { + venueLower = aliasedName + } + + // Try exact match + if let stadium = stadiumsByVenue[venueLower] { + return stadium.canonicalId + } + + // Try partial match + for (name, stadium) in stadiumsByVenue { + if name.contains(venueLower) || venueLower.contains(name) { + return stadium.canonicalId + } + } + + // Generate deterministic ID for unknown venues + return "venue_unknown_\(venue.lowercased().replacingOccurrences(of: " ", with: "_"))" + } + + nonisolated private func parseDateTime(date: String, time: String) -> Date? { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + + // Parse date + formatter.dateFormat = "yyyy-MM-dd" + guard let dateOnly = formatter.date(from: date) else { return nil } + + // Parse time (e.g., "7:30p", "10:00p", "1:05p") + var hour = 12 + var minute = 0 + + let cleanTime = time.lowercased().replacingOccurrences(of: " ", with: "") + let isPM = cleanTime.contains("p") + let timeWithoutAMPM = cleanTime.replacingOccurrences(of: "p", with: "").replacingOccurrences(of: "a", with: "") + + let components = timeWithoutAMPM.split(separator: ":") + if !components.isEmpty, let h = Int(components[0]) { + hour = h + if isPM && hour != 12 { + hour += 12 + } else if !isPM && hour == 12 { + hour = 0 + } + } + if components.count > 1, let m = Int(components[1]) { + minute = m + } + + return Calendar.current.date(bySettingHour: hour, minute: minute, second: 0, of: dateOnly) + } + + nonisolated private func extractTeamName(from fullName: String) -> String { + // "Boston Celtics" -> "Celtics" + let parts = fullName.split(separator: " ") + if parts.count > 1 { + return parts.dropFirst().joined(separator: " ") + } + return fullName + } + + nonisolated private func extractCity(from fullName: String) -> String { + // "Boston Celtics" -> "Boston" + // "New York Knicks" -> "New York" + let knownCities = [ + "New York", "Los Angeles", "San Francisco", "San Diego", "San Antonio", + "New Orleans", "Oklahoma City", "Salt Lake City", "Kansas City", + "St. Louis", "St Louis" + ] + + for city in knownCities { + if fullName.hasPrefix(city) { + return city + } + } + + // Default: first word + return String(fullName.split(separator: " ").first ?? Substring(fullName)) + } + + nonisolated private func stateFromCity(_ city: String) -> String { + let cityToState: [String: String] = [ + "Atlanta": "GA", "Boston": "MA", "Brooklyn": "NY", "Charlotte": "NC", + "Chicago": "IL", "Cleveland": "OH", "Dallas": "TX", "Denver": "CO", + "Detroit": "MI", "Houston": "TX", "Indianapolis": "IN", "Los Angeles": "CA", + "Memphis": "TN", "Miami": "FL", "Milwaukee": "WI", "Minneapolis": "MN", + "New Orleans": "LA", "New York": "NY", "Oklahoma City": "OK", "Orlando": "FL", + "Philadelphia": "PA", "Phoenix": "AZ", "Portland": "OR", "Sacramento": "CA", + "San Antonio": "TX", "San Francisco": "CA", "Seattle": "WA", "Toronto": "ON", + "Washington": "DC", "Las Vegas": "NV", "Tampa": "FL", "Pittsburgh": "PA", + "Baltimore": "MD", "Cincinnati": "OH", "St. Louis": "MO", "Kansas City": "MO", + "Arlington": "TX", "Anaheim": "CA", "Oakland": "CA", "San Diego": "CA", + "Tampa Bay": "FL", "St Petersburg": "FL", "Salt Lake City": "UT" + ] + return cityToState[city] ?? "" + } +} diff --git a/SportsTime/Core/Services/CanonicalDataProvider.swift b/SportsTime/Core/Services/CanonicalDataProvider.swift new file mode 100644 index 0000000..35327bd --- /dev/null +++ b/SportsTime/Core/Services/CanonicalDataProvider.swift @@ -0,0 +1,234 @@ +// +// CanonicalDataProvider.swift +// SportsTime +// +// DataProvider implementation that reads from SwiftData canonical models. +// This is the primary data source after bootstrap completes. +// + +import Foundation +import SwiftData + +actor CanonicalDataProvider: DataProvider { + + // MARK: - Properties + + private let modelContainer: ModelContainer + + // Caches for converted domain objects (rebuilt on first access) + private var cachedTeams: [Team]? + private var cachedStadiums: [Stadium]? + private var teamsByCanonicalId: [String: Team] = [:] + private var stadiumsByCanonicalId: [String: Stadium] = [:] + private var teamUUIDByCanonicalId: [String: UUID] = [:] + private var stadiumUUIDByCanonicalId: [String: UUID] = [:] + + // MARK: - Initialization + + init(modelContainer: ModelContainer) { + self.modelContainer = modelContainer + } + + // MARK: - DataProvider Protocol + + func fetchTeams(for sport: Sport) async throws -> [Team] { + try await loadCachesIfNeeded() + return cachedTeams?.filter { $0.sport == sport } ?? [] + } + + func fetchAllTeams() async throws -> [Team] { + try await loadCachesIfNeeded() + return cachedTeams ?? [] + } + + func fetchStadiums() async throws -> [Stadium] { + try await loadCachesIfNeeded() + return cachedStadiums ?? [] + } + + func fetchGames(sports: Set, startDate: Date, endDate: Date) async throws -> [Game] { + try await loadCachesIfNeeded() + + let context = ModelContext(modelContainer) + + // Fetch canonical games within date range + let sportStrings = sports.map { $0.rawValue } + let descriptor = FetchDescriptor( + predicate: #Predicate { game in + sportStrings.contains(game.sport) && + game.dateTime >= startDate && + game.dateTime <= endDate && + game.deprecatedAt == nil + }, + sortBy: [SortDescriptor(\.dateTime)] + ) + + let canonicalGames = try context.fetch(descriptor) + + // Convert to domain models + return canonicalGames.compactMap { canonical -> Game? in + guard let homeTeamUUID = teamUUIDByCanonicalId[canonical.homeTeamCanonicalId], + let awayTeamUUID = teamUUIDByCanonicalId[canonical.awayTeamCanonicalId], + let stadiumUUID = stadiumUUIDByCanonicalId[canonical.stadiumCanonicalId] else { + return nil + } + + return Game( + id: canonical.uuid, + homeTeamId: homeTeamUUID, + awayTeamId: awayTeamUUID, + stadiumId: stadiumUUID, + dateTime: canonical.dateTime, + sport: canonical.sportEnum ?? .mlb, + season: canonical.season, + isPlayoff: canonical.isPlayoff, + broadcastInfo: canonical.broadcastInfo + ) + } + } + + func fetchGame(by id: UUID) async throws -> Game? { + try await loadCachesIfNeeded() + + let context = ModelContext(modelContainer) + + // Search by UUID + let descriptor = FetchDescriptor( + predicate: #Predicate { game in + game.uuid == id && game.deprecatedAt == nil + } + ) + + guard let canonical = try context.fetch(descriptor).first else { + return nil + } + + guard let homeTeamUUID = teamUUIDByCanonicalId[canonical.homeTeamCanonicalId], + let awayTeamUUID = teamUUIDByCanonicalId[canonical.awayTeamCanonicalId], + let stadiumUUID = stadiumUUIDByCanonicalId[canonical.stadiumCanonicalId] else { + return nil + } + + return Game( + id: canonical.uuid, + homeTeamId: homeTeamUUID, + awayTeamId: awayTeamUUID, + stadiumId: stadiumUUID, + dateTime: canonical.dateTime, + sport: canonical.sportEnum ?? .mlb, + season: canonical.season, + isPlayoff: canonical.isPlayoff, + broadcastInfo: canonical.broadcastInfo + ) + } + + func fetchRichGames(sports: Set, startDate: Date, endDate: Date) async throws -> [RichGame] { + try await loadCachesIfNeeded() + + let games = try await fetchGames(sports: sports, startDate: startDate, endDate: endDate) + let teamsById = Dictionary(uniqueKeysWithValues: (cachedTeams ?? []).map { ($0.id, $0) }) + let stadiumsById = Dictionary(uniqueKeysWithValues: (cachedStadiums ?? []).map { ($0.id, $0) }) + + return games.compactMap { game in + guard let homeTeam = teamsById[game.homeTeamId], + let awayTeam = teamsById[game.awayTeamId], + let stadium = stadiumsById[game.stadiumId] else { + return nil + } + return RichGame(game: game, homeTeam: homeTeam, awayTeam: awayTeam, stadium: stadium) + } + } + + // MARK: - Additional Queries + + /// Fetch stadium by canonical ID (useful for visit tracking) + func fetchStadium(byCanonicalId canonicalId: String) async throws -> Stadium? { + try await loadCachesIfNeeded() + return stadiumsByCanonicalId[canonicalId] + } + + /// Fetch team by canonical ID + func fetchTeam(byCanonicalId canonicalId: String) async throws -> Team? { + try await loadCachesIfNeeded() + return teamsByCanonicalId[canonicalId] + } + + /// Find stadium by name (matches aliases) + func findStadium(byName name: String) async throws -> Stadium? { + let context = ModelContext(modelContainer) + + // Precompute lowercased name outside the predicate + let lowercasedName = name.lowercased() + + // First try exact alias match + let aliasDescriptor = FetchDescriptor( + predicate: #Predicate { alias in + alias.aliasName == lowercasedName + } + ) + + if let alias = try context.fetch(aliasDescriptor).first, + let stadiumCanonicalId = Optional(alias.stadiumCanonicalId) { + return try await fetchStadium(byCanonicalId: stadiumCanonicalId) + } + + return nil + } + + /// Invalidate caches (call after sync completes) + func invalidateCaches() { + cachedTeams = nil + cachedStadiums = nil + teamsByCanonicalId.removeAll() + stadiumsByCanonicalId.removeAll() + teamUUIDByCanonicalId.removeAll() + stadiumUUIDByCanonicalId.removeAll() + } + + // MARK: - Private Helpers + + private func loadCachesIfNeeded() async throws { + guard cachedTeams == nil else { return } + + let context = ModelContext(modelContainer) + + // Load stadiums + let stadiumDescriptor = FetchDescriptor( + predicate: #Predicate { stadium in + stadium.deprecatedAt == nil + } + ) + let canonicalStadiums = try context.fetch(stadiumDescriptor) + + cachedStadiums = canonicalStadiums.map { canonical in + let stadium = canonical.toDomain() + stadiumsByCanonicalId[canonical.canonicalId] = stadium + stadiumUUIDByCanonicalId[canonical.canonicalId] = stadium.id + return stadium + } + + // Load teams + let teamDescriptor = FetchDescriptor( + predicate: #Predicate { team in + team.deprecatedAt == nil + } + ) + let canonicalTeams = try context.fetch(teamDescriptor) + + cachedTeams = canonicalTeams.compactMap { canonical -> Team? in + guard let stadiumUUID = stadiumUUIDByCanonicalId[canonical.stadiumCanonicalId] else { + // Generate a placeholder UUID for teams without known stadiums + let placeholderUUID = CanonicalStadium.deterministicUUID(from: canonical.stadiumCanonicalId) + let team = canonical.toDomain(stadiumUUID: placeholderUUID) + teamsByCanonicalId[canonical.canonicalId] = team + teamUUIDByCanonicalId[canonical.canonicalId] = team.id + return team + } + + let team = canonical.toDomain(stadiumUUID: stadiumUUID) + teamsByCanonicalId[canonical.canonicalId] = team + teamUUIDByCanonicalId[canonical.canonicalId] = team.id + return team + } + } +} diff --git a/SportsTime/Core/Services/CanonicalSyncService.swift b/SportsTime/Core/Services/CanonicalSyncService.swift new file mode 100644 index 0000000..f40350f --- /dev/null +++ b/SportsTime/Core/Services/CanonicalSyncService.swift @@ -0,0 +1,634 @@ +// +// CanonicalSyncService.swift +// SportsTime +// +// Orchestrates syncing canonical data from CloudKit into SwiftData. +// Uses date-based delta sync for public database efficiency. +// + +import Foundation +import SwiftData +import CloudKit + +actor CanonicalSyncService { + + // MARK: - Errors + + enum SyncError: Error, LocalizedError { + case cloudKitUnavailable + case syncAlreadyInProgress + case saveFailed(Error) + case schemaVersionTooNew(Int) + + var errorDescription: String? { + switch self { + case .cloudKitUnavailable: + return "CloudKit is not available. Check your internet connection and iCloud settings." + case .syncAlreadyInProgress: + return "A sync operation is already in progress." + case .saveFailed(let error): + return "Failed to save synced data: \(error.localizedDescription)" + case .schemaVersionTooNew(let version): + return "Data requires app version supporting schema \(version). Please update the app." + } + } + } + + // MARK: - Sync Result + + struct SyncResult { + let stadiumsUpdated: Int + let teamsUpdated: Int + let gamesUpdated: Int + let leagueStructuresUpdated: Int + let teamAliasesUpdated: Int + let skippedIncompatible: Int + let skippedOlder: Int + let duration: TimeInterval + + var totalUpdated: Int { + stadiumsUpdated + teamsUpdated + gamesUpdated + leagueStructuresUpdated + teamAliasesUpdated + } + + var isEmpty: Bool { totalUpdated == 0 } + } + + // MARK: - Properties + + private let cloudKitService: CloudKitService + + // MARK: - Initialization + + init(cloudKitService: CloudKitService = .shared) { + self.cloudKitService = cloudKitService + } + + // MARK: - Public Sync Methods + + /// Perform a full sync of all canonical data types. + /// This is the main entry point for background sync. + @MainActor + func syncAll(context: ModelContext) async throws -> SyncResult { + let startTime = Date() + let syncState = SyncState.current(in: context) + + // Prevent concurrent syncs + guard !syncState.syncInProgress else { + throw SyncError.syncAlreadyInProgress + } + + // Check if sync is enabled + guard syncState.syncEnabled else { + return SyncResult( + stadiumsUpdated: 0, teamsUpdated: 0, gamesUpdated: 0, + leagueStructuresUpdated: 0, teamAliasesUpdated: 0, + skippedIncompatible: 0, skippedOlder: 0, + duration: 0 + ) + } + + // Check CloudKit availability + guard await cloudKitService.isAvailable() else { + throw SyncError.cloudKitUnavailable + } + + // Mark sync in progress + syncState.syncInProgress = true + syncState.lastSyncAttempt = Date() + + var totalStadiums = 0 + var totalTeams = 0 + var totalGames = 0 + var totalLeagueStructures = 0 + var totalTeamAliases = 0 + var totalSkippedIncompatible = 0 + var totalSkippedOlder = 0 + + do { + // Sync in dependency order + let (stadiums, skipIncompat1, skipOlder1) = try await syncStadiums( + context: context, + since: syncState.lastSuccessfulSync + ) + totalStadiums = stadiums + totalSkippedIncompatible += skipIncompat1 + totalSkippedOlder += skipOlder1 + + let (leagueStructures, skipIncompat2, skipOlder2) = try await syncLeagueStructure( + context: context, + since: syncState.lastSuccessfulSync + ) + totalLeagueStructures = leagueStructures + totalSkippedIncompatible += skipIncompat2 + totalSkippedOlder += skipOlder2 + + let (teams, skipIncompat3, skipOlder3) = try await syncTeams( + context: context, + since: syncState.lastSuccessfulSync + ) + totalTeams = teams + totalSkippedIncompatible += skipIncompat3 + totalSkippedOlder += skipOlder3 + + let (teamAliases, skipIncompat4, skipOlder4) = try await syncTeamAliases( + context: context, + since: syncState.lastSuccessfulSync + ) + totalTeamAliases = teamAliases + totalSkippedIncompatible += skipIncompat4 + totalSkippedOlder += skipOlder4 + + let (games, skipIncompat5, skipOlder5) = try await syncGames( + context: context, + since: syncState.lastSuccessfulSync + ) + totalGames = games + totalSkippedIncompatible += skipIncompat5 + totalSkippedOlder += skipOlder5 + + // Mark sync successful + syncState.syncInProgress = false + syncState.lastSuccessfulSync = Date() + syncState.lastSyncError = nil + syncState.consecutiveFailures = 0 + + try context.save() + + } catch { + // Mark sync failed + syncState.syncInProgress = false + syncState.lastSyncError = error.localizedDescription + syncState.consecutiveFailures += 1 + + // Pause sync after too many failures + if syncState.consecutiveFailures >= 5 { + syncState.syncEnabled = false + syncState.syncPausedReason = "Too many consecutive failures. Sync paused." + } + + try? context.save() + throw error + } + + return SyncResult( + stadiumsUpdated: totalStadiums, + teamsUpdated: totalTeams, + gamesUpdated: totalGames, + leagueStructuresUpdated: totalLeagueStructures, + teamAliasesUpdated: totalTeamAliases, + skippedIncompatible: totalSkippedIncompatible, + skippedOlder: totalSkippedOlder, + duration: Date().timeIntervalSince(startTime) + ) + } + + /// Re-enable sync after it was paused due to failures. + @MainActor + func resumeSync(context: ModelContext) { + let syncState = SyncState.current(in: context) + syncState.syncEnabled = true + syncState.syncPausedReason = nil + syncState.consecutiveFailures = 0 + try? context.save() + } + + // MARK: - Individual Sync Methods + + @MainActor + private func syncStadiums( + context: ModelContext, + since lastSync: Date? + ) async throws -> (updated: Int, skippedIncompatible: Int, skippedOlder: Int) { + let remoteStadiums = try await cloudKitService.fetchStadiums() + + var updated = 0 + var skippedIncompatible = 0 + var skippedOlder = 0 + + for remoteStadium in remoteStadiums { + // For now, fetch full list and merge - CloudKit public DB doesn't have delta sync + // In future, could add lastModified filtering on CloudKit query + + let canonicalId = "stadium_\(remoteStadium.sport.rawValue.lowercased())_\(remoteStadium.id.uuidString.prefix(8))" + + let result = try mergeStadium( + remoteStadium, + canonicalId: canonicalId, + context: context + ) + + switch result { + case .applied: updated += 1 + case .skippedIncompatible: skippedIncompatible += 1 + case .skippedOlder: skippedOlder += 1 + } + } + + return (updated, skippedIncompatible, skippedOlder) + } + + @MainActor + private func syncTeams( + context: ModelContext, + since lastSync: Date? + ) async throws -> (updated: Int, skippedIncompatible: Int, skippedOlder: Int) { + // Fetch teams for all sports + var allTeams: [Team] = [] + for sport in Sport.allCases { + let teams = try await cloudKitService.fetchTeams(for: sport) + allTeams.append(contentsOf: teams) + } + + var updated = 0 + var skippedIncompatible = 0 + var skippedOlder = 0 + + for remoteTeam in allTeams { + let canonicalId = "team_\(remoteTeam.sport.rawValue.lowercased())_\(remoteTeam.abbreviation.lowercased())" + + let result = try mergeTeam( + remoteTeam, + canonicalId: canonicalId, + context: context + ) + + switch result { + case .applied: updated += 1 + case .skippedIncompatible: skippedIncompatible += 1 + case .skippedOlder: skippedOlder += 1 + } + } + + return (updated, skippedIncompatible, skippedOlder) + } + + @MainActor + private func syncGames( + context: ModelContext, + since lastSync: Date? + ) async throws -> (updated: Int, skippedIncompatible: Int, skippedOlder: Int) { + // Fetch games for the next 6 months from all sports + let startDate = lastSync ?? Date() + let endDate = Calendar.current.date(byAdding: .month, value: 6, to: Date()) ?? Date() + + let remoteGames = try await cloudKitService.fetchGames( + sports: Set(Sport.allCases), + startDate: startDate, + endDate: endDate + ) + + var updated = 0 + var skippedIncompatible = 0 + var skippedOlder = 0 + + for remoteGame in remoteGames { + let result = try mergeGame( + remoteGame, + canonicalId: remoteGame.id.uuidString, + context: context + ) + + switch result { + case .applied: updated += 1 + case .skippedIncompatible: skippedIncompatible += 1 + case .skippedOlder: skippedOlder += 1 + } + } + + return (updated, skippedIncompatible, skippedOlder) + } + + @MainActor + private func syncLeagueStructure( + context: ModelContext, + since lastSync: Date? + ) async throws -> (updated: Int, skippedIncompatible: Int, skippedOlder: Int) { + let remoteStructures = try await cloudKitService.fetchLeagueStructureChanges(since: lastSync) + + var updated = 0 + var skippedIncompatible = 0 + var skippedOlder = 0 + + for remoteStructure in remoteStructures { + let result = try mergeLeagueStructure(remoteStructure, context: context) + + switch result { + case .applied: updated += 1 + case .skippedIncompatible: skippedIncompatible += 1 + case .skippedOlder: skippedOlder += 1 + } + } + + return (updated, skippedIncompatible, skippedOlder) + } + + @MainActor + private func syncTeamAliases( + context: ModelContext, + since lastSync: Date? + ) async throws -> (updated: Int, skippedIncompatible: Int, skippedOlder: Int) { + let remoteAliases = try await cloudKitService.fetchTeamAliasChanges(since: lastSync) + + var updated = 0 + var skippedIncompatible = 0 + var skippedOlder = 0 + + for remoteAlias in remoteAliases { + let result = try mergeTeamAlias(remoteAlias, context: context) + + switch result { + case .applied: updated += 1 + case .skippedIncompatible: skippedIncompatible += 1 + case .skippedOlder: skippedOlder += 1 + } + } + + return (updated, skippedIncompatible, skippedOlder) + } + + // MARK: - Merge Logic + + private enum MergeResult { + case applied + case skippedIncompatible + case skippedOlder + } + + @MainActor + private func mergeStadium( + _ remote: Stadium, + canonicalId: String, + context: ModelContext + ) throws -> MergeResult { + // Look up existing + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.canonicalId == canonicalId } + ) + let existing = try context.fetch(descriptor).first + + if let existing = existing { + // Preserve user fields + let savedNickname = existing.userNickname + let savedNotes = existing.userNotes + let savedFavorite = existing.isFavorite + + // Update system fields + existing.name = remote.name + existing.city = remote.city + existing.state = remote.state + existing.latitude = remote.latitude + existing.longitude = remote.longitude + existing.capacity = remote.capacity + existing.yearOpened = remote.yearOpened + existing.imageURL = remote.imageURL?.absoluteString + existing.sport = remote.sport.rawValue + existing.source = .cloudKit + existing.lastModified = Date() + + // Restore user fields + existing.userNickname = savedNickname + existing.userNotes = savedNotes + existing.isFavorite = savedFavorite + + return .applied + } else { + // Insert new + let canonical = CanonicalStadium( + canonicalId: canonicalId, + uuid: remote.id, + schemaVersion: SchemaVersion.current, + lastModified: Date(), + source: .cloudKit, + name: remote.name, + city: remote.city, + state: remote.state, + latitude: remote.latitude, + longitude: remote.longitude, + capacity: remote.capacity, + yearOpened: remote.yearOpened, + imageURL: remote.imageURL?.absoluteString, + sport: remote.sport.rawValue + ) + context.insert(canonical) + return .applied + } + } + + @MainActor + private func mergeTeam( + _ remote: Team, + canonicalId: String, + context: ModelContext + ) throws -> MergeResult { + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.canonicalId == canonicalId } + ) + let existing = try context.fetch(descriptor).first + + // Find stadium canonical ID + let remoteStadiumId = remote.stadiumId + let stadiumDescriptor = FetchDescriptor( + predicate: #Predicate { $0.uuid == remoteStadiumId } + ) + let stadium = try context.fetch(stadiumDescriptor).first + let stadiumCanonicalId = stadium?.canonicalId ?? "unknown" + + if let existing = existing { + // Preserve user fields + let savedNickname = existing.userNickname + let savedFavorite = existing.isFavorite + + // Update system fields + existing.name = remote.name + existing.abbreviation = remote.abbreviation + existing.sport = remote.sport.rawValue + existing.city = remote.city + existing.stadiumCanonicalId = stadiumCanonicalId + existing.logoURL = remote.logoURL?.absoluteString + existing.primaryColor = remote.primaryColor + existing.secondaryColor = remote.secondaryColor + existing.source = .cloudKit + existing.lastModified = Date() + + // Restore user fields + existing.userNickname = savedNickname + existing.isFavorite = savedFavorite + + return .applied + } else { + let canonical = CanonicalTeam( + canonicalId: canonicalId, + uuid: remote.id, + schemaVersion: SchemaVersion.current, + lastModified: Date(), + source: .cloudKit, + name: remote.name, + abbreviation: remote.abbreviation, + sport: remote.sport.rawValue, + city: remote.city, + stadiumCanonicalId: stadiumCanonicalId, + logoURL: remote.logoURL?.absoluteString, + primaryColor: remote.primaryColor, + secondaryColor: remote.secondaryColor + ) + context.insert(canonical) + return .applied + } + } + + @MainActor + private func mergeGame( + _ remote: Game, + canonicalId: String, + context: ModelContext + ) throws -> MergeResult { + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.canonicalId == canonicalId } + ) + let existing = try context.fetch(descriptor).first + + // Look up canonical IDs for teams and stadium + let remoteHomeTeamId = remote.homeTeamId + let remoteAwayTeamId = remote.awayTeamId + let remoteStadiumId = remote.stadiumId + + let homeTeamDescriptor = FetchDescriptor( + predicate: #Predicate { $0.uuid == remoteHomeTeamId } + ) + let awayTeamDescriptor = FetchDescriptor( + predicate: #Predicate { $0.uuid == remoteAwayTeamId } + ) + let stadiumDescriptor = FetchDescriptor( + predicate: #Predicate { $0.uuid == remoteStadiumId } + ) + + let homeTeam = try context.fetch(homeTeamDescriptor).first + let awayTeam = try context.fetch(awayTeamDescriptor).first + let stadium = try context.fetch(stadiumDescriptor).first + + let homeTeamCanonicalId = homeTeam?.canonicalId ?? "unknown" + let awayTeamCanonicalId = awayTeam?.canonicalId ?? "unknown" + let stadiumCanonicalId = stadium?.canonicalId ?? "unknown" + + if let existing = existing { + // Preserve user fields + let savedAttending = existing.userAttending + let savedNotes = existing.userNotes + + // Update system fields + existing.homeTeamCanonicalId = homeTeamCanonicalId + existing.awayTeamCanonicalId = awayTeamCanonicalId + existing.stadiumCanonicalId = stadiumCanonicalId + existing.dateTime = remote.dateTime + existing.sport = remote.sport.rawValue + existing.season = remote.season + existing.isPlayoff = remote.isPlayoff + existing.broadcastInfo = remote.broadcastInfo + existing.source = .cloudKit + existing.lastModified = Date() + + // Restore user fields + existing.userAttending = savedAttending + existing.userNotes = savedNotes + + return .applied + } else { + let canonical = CanonicalGame( + canonicalId: canonicalId, + uuid: remote.id, + schemaVersion: SchemaVersion.current, + lastModified: Date(), + source: .cloudKit, + homeTeamCanonicalId: homeTeamCanonicalId, + awayTeamCanonicalId: awayTeamCanonicalId, + stadiumCanonicalId: stadiumCanonicalId, + dateTime: remote.dateTime, + sport: remote.sport.rawValue, + season: remote.season, + isPlayoff: remote.isPlayoff, + broadcastInfo: remote.broadcastInfo + ) + context.insert(canonical) + return .applied + } + } + + @MainActor + private func mergeLeagueStructure( + _ remote: LeagueStructureModel, + context: ModelContext + ) throws -> MergeResult { + // Schema version check + guard remote.schemaVersion <= SchemaVersion.current else { + return .skippedIncompatible + } + + let remoteId = remote.id + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.id == remoteId } + ) + let existing = try context.fetch(descriptor).first + + if let existing = existing { + // lastModified check + guard remote.lastModified > existing.lastModified else { + return .skippedOlder + } + + // Update all fields (no user fields on LeagueStructure) + existing.sport = remote.sport + existing.structureTypeRaw = remote.structureTypeRaw + existing.name = remote.name + existing.abbreviation = remote.abbreviation + existing.parentId = remote.parentId + existing.displayOrder = remote.displayOrder + existing.schemaVersion = remote.schemaVersion + existing.lastModified = remote.lastModified + + return .applied + } else { + // Insert new + context.insert(remote) + return .applied + } + } + + @MainActor + private func mergeTeamAlias( + _ remote: TeamAlias, + context: ModelContext + ) throws -> MergeResult { + // Schema version check + guard remote.schemaVersion <= SchemaVersion.current else { + return .skippedIncompatible + } + + let remoteId = remote.id + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.id == remoteId } + ) + let existing = try context.fetch(descriptor).first + + if let existing = existing { + // lastModified check + guard remote.lastModified > existing.lastModified else { + return .skippedOlder + } + + // Update all fields (no user fields on TeamAlias) + existing.teamCanonicalId = remote.teamCanonicalId + existing.aliasTypeRaw = remote.aliasTypeRaw + existing.aliasValue = remote.aliasValue + existing.validFrom = remote.validFrom + existing.validUntil = remote.validUntil + existing.schemaVersion = remote.schemaVersion + existing.lastModified = remote.lastModified + + return .applied + } else { + // Insert new + context.insert(remote) + return .applied + } + } +} diff --git a/SportsTime/Core/Services/CloudKitService.swift b/SportsTime/Core/Services/CloudKitService.swift index f3fb09b..f140fae 100644 --- a/SportsTime/Core/Services/CloudKitService.swift +++ b/SportsTime/Core/Services/CloudKitService.swift @@ -189,6 +189,87 @@ actor CloudKitService { return ckGame.game(homeTeamId: homeId, awayTeamId: awayId, stadiumId: stadiumId) } + // MARK: - League Structure & Team Aliases + + func fetchLeagueStructure(for sport: Sport? = nil) async throws -> [LeagueStructureModel] { + let predicate: NSPredicate + if let sport = sport { + predicate = NSPredicate(format: "sport == %@", sport.rawValue) + } else { + predicate = NSPredicate(value: true) + } + + let query = CKQuery(recordType: CKRecordType.leagueStructure, predicate: predicate) + query.sortDescriptors = [NSSortDescriptor(key: CKLeagueStructure.displayOrderKey, ascending: true)] + + let (results, _) = try await publicDatabase.records(matching: query) + + return results.compactMap { result in + guard case .success(let record) = result.1 else { return nil } + return CKLeagueStructure(record: record).toModel() + } + } + + func fetchTeamAliases(for teamCanonicalId: String? = nil) async throws -> [TeamAlias] { + let predicate: NSPredicate + if let teamId = teamCanonicalId { + predicate = NSPredicate(format: "teamCanonicalId == %@", teamId) + } else { + predicate = NSPredicate(value: true) + } + + let query = CKQuery(recordType: CKRecordType.teamAlias, predicate: predicate) + + let (results, _) = try await publicDatabase.records(matching: query) + + return results.compactMap { result in + guard case .success(let record) = result.1 else { return nil } + return CKTeamAlias(record: record).toModel() + } + } + + // MARK: - Delta Sync (Date-Based for Public Database) + + /// Fetch league structure records modified after the given date + func fetchLeagueStructureChanges(since lastSync: Date?) async throws -> [LeagueStructureModel] { + let predicate: NSPredicate + if let lastSync = lastSync { + predicate = NSPredicate(format: "lastModified > %@", lastSync as NSDate) + } else { + predicate = NSPredicate(value: true) + } + + let query = CKQuery(recordType: CKRecordType.leagueStructure, predicate: predicate) + query.sortDescriptors = [NSSortDescriptor(key: CKLeagueStructure.lastModifiedKey, ascending: true)] + + let (results, _) = try await publicDatabase.records(matching: query) + + return results.compactMap { result in + guard case .success(let record) = result.1 else { return nil } + return CKLeagueStructure(record: record).toModel() + } + } + + /// Fetch team alias records modified after the given date + func fetchTeamAliasChanges(since lastSync: Date?) async throws -> [TeamAlias] { + let predicate: NSPredicate + if let lastSync = lastSync { + predicate = NSPredicate(format: "lastModified > %@", lastSync as NSDate) + } else { + predicate = NSPredicate(value: true) + } + + let query = CKQuery(recordType: CKRecordType.teamAlias, predicate: predicate) + query.sortDescriptors = [NSSortDescriptor(key: CKTeamAlias.lastModifiedKey, ascending: true)] + + let (results, _) = try await publicDatabase.records(matching: query) + + return results.compactMap { result in + guard case .success(let record) = result.1 else { return nil } + return CKTeamAlias(record: record).toModel() + } + } + // MARK: - Sync Status func checkAccountStatus() async -> CKAccountStatus { @@ -199,7 +280,7 @@ actor CloudKitService { } } - // MARK: - Subscription (for schedule updates) + // MARK: - Subscriptions func subscribeToScheduleUpdates() async throws { let subscription = CKQuerySubscription( @@ -215,4 +296,41 @@ actor CloudKitService { try await publicDatabase.save(subscription) } + + func subscribeToLeagueStructureUpdates() async throws { + let subscription = CKQuerySubscription( + recordType: CKRecordType.leagueStructure, + predicate: NSPredicate(value: true), + subscriptionID: "league-structure-updates", + options: [.firesOnRecordCreation, .firesOnRecordUpdate] + ) + + let notification = CKSubscription.NotificationInfo() + notification.shouldSendContentAvailable = true + subscription.notificationInfo = notification + + try await publicDatabase.save(subscription) + } + + func subscribeToTeamAliasUpdates() async throws { + let subscription = CKQuerySubscription( + recordType: CKRecordType.teamAlias, + predicate: NSPredicate(value: true), + subscriptionID: "team-alias-updates", + options: [.firesOnRecordCreation, .firesOnRecordUpdate] + ) + + let notification = CKSubscription.NotificationInfo() + notification.shouldSendContentAvailable = true + subscription.notificationInfo = notification + + try await publicDatabase.save(subscription) + } + + /// Subscribe to all canonical data updates + func subscribeToAllUpdates() async throws { + try await subscribeToScheduleUpdates() + try await subscribeToLeagueStructureUpdates() + try await subscribeToTeamAliasUpdates() + } } diff --git a/SportsTime/Core/Services/FreeScoreAPI.swift b/SportsTime/Core/Services/FreeScoreAPI.swift new file mode 100644 index 0000000..37b5d09 --- /dev/null +++ b/SportsTime/Core/Services/FreeScoreAPI.swift @@ -0,0 +1,298 @@ +// +// FreeScoreAPI.swift +// SportsTime +// +// Multi-provider score resolution facade using FREE data sources only. +// + +import Foundation + +// MARK: - Provider Reliability + +enum ProviderReliability: String, Sendable { + case official // MLB Stats, NHL Stats - stable, documented + case unofficial // ESPN API - works but may break + case scraped // Sports-Reference - HTML parsing, fragile +} + +// MARK: - Historical Game Query + +struct HistoricalGameQuery: Sendable { + let sport: Sport + let date: Date + let homeTeamAbbrev: String? + let awayTeamAbbrev: String? + let stadiumCanonicalId: String? + + init( + sport: Sport, + date: Date, + homeTeamAbbrev: String? = nil, + awayTeamAbbrev: String? = nil, + stadiumCanonicalId: String? = nil + ) { + self.sport = sport + self.date = date + self.homeTeamAbbrev = homeTeamAbbrev + self.awayTeamAbbrev = awayTeamAbbrev + self.stadiumCanonicalId = stadiumCanonicalId + } + + /// Normalized date string for matching + var normalizedDateString: String { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd" + formatter.timeZone = TimeZone(identifier: "America/New_York") + return formatter.string(from: date) + } +} + +// MARK: - Historical Game Result + +struct HistoricalGameResult: Sendable { + let sport: Sport + let gameDate: Date + let homeTeamAbbrev: String + let awayTeamAbbrev: String + let homeTeamName: String + let awayTeamName: String + let homeScore: Int? + let awayScore: Int? + let source: ScoreSource + let providerName: String + + var scoreString: String? { + guard let home = homeScore, let away = awayScore else { return nil } + return "\(away)-\(home)" + } + + var hasScore: Bool { + homeScore != nil && awayScore != nil + } +} + +// MARK: - Score Resolution Result + +enum ScoreResolutionResult: Sendable { + case resolved(HistoricalGameResult) + case pending // Background retry queued + case requiresUserInput(reason: String) // All tiers failed + case notFound(reason: String) // No game matched query + + var isResolved: Bool { + if case .resolved = self { return true } + return false + } + + var result: HistoricalGameResult? { + if case .resolved(let result) = self { return result } + return nil + } +} + +// MARK: - Score API Provider Protocol + +protocol ScoreAPIProvider: Sendable { + var name: String { get } + var supportedSports: Set { get } + var reliability: ProviderReliability { get } + var rateLimitKey: String { get } + + func fetchGame(query: HistoricalGameQuery) async throws -> HistoricalGameResult? +} + +// MARK: - Provider Errors + +enum ScoreProviderError: Error, LocalizedError, Sendable { + case networkError(underlying: String) + case rateLimited + case parseError(message: String) + case gameNotFound + case unsupportedSport(Sport) + case providerUnavailable(reason: String) + + var errorDescription: String? { + switch self { + case .networkError(let underlying): + return "Network error: \(underlying)" + case .rateLimited: + return "Rate limited by provider" + case .parseError(let message): + return "Failed to parse response: \(message)" + case .gameNotFound: + return "Game not found" + case .unsupportedSport(let sport): + return "\(sport.rawValue) not supported by this provider" + case .providerUnavailable(let reason): + return "Provider unavailable: \(reason)" + } + } +} + +// MARK: - Free Score API Orchestrator + +@MainActor +final class FreeScoreAPI { + + // MARK: - Properties + + static let shared = FreeScoreAPI() + + private var providers: [ScoreAPIProvider] = [] + private var disabledProviders: [String: Date] = [:] // provider → disabled until + private var failureCounts: [String: Int] = [:] + + // Failure thresholds + private let officialFailureThreshold = Int.max // Never auto-disable + private let unofficialFailureThreshold = 3 + private let scrapedFailureThreshold = 2 + private let disableDuration: TimeInterval = 24 * 60 * 60 // 24 hours + private let failureWindowDuration: TimeInterval = 60 * 60 // 1 hour + + private let rateLimiter = RateLimiter.shared + + // MARK: - Initialization + + private init() { + // Register providers in priority order + registerDefaultProviders() + } + + private func registerDefaultProviders() { + // Official APIs first (most reliable) + providers.append(MLBStatsProvider()) + providers.append(NHLStatsProvider()) + providers.append(NBAStatsProvider()) + + // Note: ESPN provider could be added here as unofficial fallback + // Note: Sports-Reference scraper could be added as last resort + } + + // MARK: - Public API + + /// Register a custom provider + func registerProvider(_ provider: ScoreAPIProvider) { + providers.append(provider) + } + + /// Resolve score for a game query + /// Tries each provider in order: official > unofficial > scraped + func resolveScore(query: HistoricalGameQuery) async -> ScoreResolutionResult { + // Filter providers that support this sport + let eligibleProviders = providers.filter { + $0.supportedSports.contains(query.sport) && !isDisabled($0) + } + + guard !eligibleProviders.isEmpty else { + return .requiresUserInput(reason: "No providers available for \(query.sport.rawValue)") + } + + // Sort by reliability (official first) + let sortedProviders = eligibleProviders.sorted { p1, p2 in + reliabilityOrder(p1.reliability) < reliabilityOrder(p2.reliability) + } + + // Try each provider in order + for provider in sortedProviders { + do { + // Wait for rate limit + await rateLimiter.waitIfNeeded(for: provider.rateLimitKey) + + // Attempt fetch + if let result = try await provider.fetchGame(query: query) { + // Success - reset failure count + resetFailureCount(for: provider) + return .resolved(result) + } + } catch { + // Record failure + recordFailure(for: provider, error: error) + + // Continue to next provider if this one failed + continue + } + } + + // All providers failed or returned nil + return .notFound(reason: "Game not found in any provider for \(query.sport.rawValue) on \(query.normalizedDateString)") + } + + /// Check if a provider is available + func isProviderAvailable(_ providerName: String) -> Bool { + guard let provider = providers.first(where: { $0.name == providerName }) else { + return false + } + return !isDisabled(provider) + } + + /// Get list of available providers for a sport + func availableProviders(for sport: Sport) -> [String] { + providers + .filter { $0.supportedSports.contains(sport) && !isDisabled($0) } + .map { $0.name } + } + + /// Manually re-enable a disabled provider + func enableProvider(_ providerName: String) { + disabledProviders.removeValue(forKey: providerName) + failureCounts.removeValue(forKey: providerName) + } + + /// Manually disable a provider + func disableProvider(_ providerName: String, until date: Date) { + disabledProviders[providerName] = date + } + + // MARK: - Provider Management + + private func isDisabled(_ provider: ScoreAPIProvider) -> Bool { + guard let disabledUntil = disabledProviders[provider.name] else { + return false + } + + // Check if disable period has expired + if Date() > disabledUntil { + disabledProviders.removeValue(forKey: provider.name) + return false + } + + return true + } + + private func recordFailure(for provider: ScoreAPIProvider, error: Error) { + let count = (failureCounts[provider.name] ?? 0) + 1 + failureCounts[provider.name] = count + + // Check if should auto-disable + let threshold = failureThreshold(for: provider.reliability) + + if count >= threshold { + let disableUntil = Date().addingTimeInterval(disableDuration) + disabledProviders[provider.name] = disableUntil + failureCounts.removeValue(forKey: provider.name) + } + } + + private func resetFailureCount(for provider: ScoreAPIProvider) { + failureCounts.removeValue(forKey: provider.name) + } + + private func failureThreshold(for reliability: ProviderReliability) -> Int { + switch reliability { + case .official: + return officialFailureThreshold + case .unofficial: + return unofficialFailureThreshold + case .scraped: + return scrapedFailureThreshold + } + } + + private func reliabilityOrder(_ reliability: ProviderReliability) -> Int { + switch reliability { + case .official: return 0 + case .unofficial: return 1 + case .scraped: return 2 + } + } +} diff --git a/SportsTime/Core/Services/GameMatcher.swift b/SportsTime/Core/Services/GameMatcher.swift new file mode 100644 index 0000000..bb8cac2 --- /dev/null +++ b/SportsTime/Core/Services/GameMatcher.swift @@ -0,0 +1,324 @@ +// +// GameMatcher.swift +// SportsTime +// +// Deterministic game matching from photo metadata. +// + +import Foundation +import CoreLocation + +// MARK: - No Match Reason + +enum NoMatchReason: Sendable { + case noStadiumNearby + case noGamesOnDate + case metadataMissing(MetadataMissingReason) + + enum MetadataMissingReason: Sendable { + case noLocation + case noDate + case noBoth + } + + var description: String { + switch self { + case .noStadiumNearby: + return "No stadium found nearby" + case .noGamesOnDate: + return "No games found on this date" + case .metadataMissing(let reason): + switch reason { + case .noLocation: + return "Photo has no location data" + case .noDate: + return "Photo has no date information" + case .noBoth: + return "Photo has no location or date data" + } + } + } +} + +// MARK: - Game Match Result + +struct GameMatchCandidate: Identifiable, Sendable { + let id: UUID + let game: Game + let stadium: Stadium + let homeTeam: Team + let awayTeam: Team + let confidence: PhotoMatchConfidence + + init(game: Game, stadium: Stadium, homeTeam: Team, awayTeam: Team, confidence: PhotoMatchConfidence) { + self.id = game.id + self.game = game + self.stadium = stadium + self.homeTeam = homeTeam + self.awayTeam = awayTeam + self.confidence = confidence + } + + var matchupDescription: String { + "\(awayTeam.abbreviation) @ \(homeTeam.abbreviation)" + } + + var fullMatchupDescription: String { + "\(awayTeam.fullName) at \(homeTeam.fullName)" + } + + var gameDateTime: String { + let formatter = DateFormatter() + formatter.dateStyle = .medium + formatter.timeStyle = .short + return formatter.string(from: game.dateTime) + } +} + +enum GameMatchResult: Sendable { + case singleMatch(GameMatchCandidate) // Auto-select + case multipleMatches([GameMatchCandidate]) // User selects (doubleheader, nearby stadiums) + case noMatches(NoMatchReason) // Manual entry required + + var hasMatch: Bool { + switch self { + case .singleMatch, .multipleMatches: + return true + case .noMatches: + return false + } + } +} + +// MARK: - Photo Import Result + +struct PhotoImportCandidate: Identifiable, Sendable { + let id: UUID + let metadata: PhotoMetadata + let matchResult: GameMatchResult + let stadiumMatches: [StadiumMatch] + + init(metadata: PhotoMetadata, matchResult: GameMatchResult, stadiumMatches: [StadiumMatch]) { + self.id = UUID() + self.metadata = metadata + self.matchResult = matchResult + self.stadiumMatches = stadiumMatches + } + + /// Best stadium match if available + var bestStadiumMatch: StadiumMatch? { + stadiumMatches.first + } + + /// Whether this can be auto-processed without user input + var canAutoProcess: Bool { + if case .singleMatch(let candidate) = matchResult { + return candidate.confidence.combined == .autoSelect + } + return false + } +} + +// MARK: - Game Matcher + +@MainActor +final class GameMatcher { + static let shared = GameMatcher() + + private let dataProvider = AppDataProvider.shared + private let proximityMatcher = StadiumProximityMatcher.shared + + private init() {} + + // MARK: - Primary Matching + + /// Match photo metadata to a game + /// Uses deterministic rules - never guesses + func matchGame( + metadata: PhotoMetadata, + sport: Sport? = nil + ) async -> GameMatchResult { + // 1. Check for required metadata + guard metadata.hasValidLocation else { + let reason: NoMatchReason.MetadataMissingReason = metadata.hasValidDate ? .noLocation : .noBoth + return .noMatches(.metadataMissing(reason)) + } + + guard metadata.hasValidDate, let photoDate = metadata.captureDate else { + return .noMatches(.metadataMissing(.noDate)) + } + + guard let coordinates = metadata.coordinates else { + return .noMatches(.metadataMissing(.noLocation)) + } + + // 2. Find nearby stadiums + let stadiumMatches = proximityMatcher.findNearbyStadiums( + coordinates: coordinates, + sport: sport + ) + + guard !stadiumMatches.isEmpty else { + return .noMatches(.noStadiumNearby) + } + + // 3. Find games at those stadiums on/around that date + var candidates: [GameMatchCandidate] = [] + + for stadiumMatch in stadiumMatches { + let games = await findGames( + at: stadiumMatch.stadium, + around: photoDate, + sport: sport + ) + + for game in games { + // Look up teams + guard let homeTeam = dataProvider.teams.first(where: { $0.id == game.homeTeamId }), + let awayTeam = dataProvider.teams.first(where: { $0.id == game.awayTeamId }) else { + continue + } + + // Calculate confidence + let confidence = proximityMatcher.calculateMatchConfidence( + stadiumMatch: stadiumMatch, + photoDate: photoDate, + gameDate: game.dateTime + ) + + // Only include if temporal confidence is acceptable + if confidence.temporal != .outOfRange { + candidates.append(GameMatchCandidate( + game: game, + stadium: stadiumMatch.stadium, + homeTeam: homeTeam, + awayTeam: awayTeam, + confidence: confidence + )) + } + } + } + + // 4. Return based on matches found + if candidates.isEmpty { + return .noMatches(.noGamesOnDate) + } else if candidates.count == 1 { + return .singleMatch(candidates[0]) + } else { + // Sort by confidence (best first) + let sorted = candidates.sorted { c1, c2 in + c1.confidence.combined > c2.confidence.combined + } + return .multipleMatches(sorted) + } + } + + // MARK: - Full Import Processing + + /// Process a photo for import, returning full match context + func processPhotoForImport( + metadata: PhotoMetadata, + sport: Sport? = nil + ) async -> PhotoImportCandidate { + // Get stadium matches regardless of game matching + var stadiumMatches: [StadiumMatch] = [] + if let coordinates = metadata.coordinates { + stadiumMatches = proximityMatcher.findNearbyStadiums( + coordinates: coordinates, + sport: sport + ) + } + + let matchResult = await matchGame(metadata: metadata, sport: sport) + + return PhotoImportCandidate( + metadata: metadata, + matchResult: matchResult, + stadiumMatches: stadiumMatches + ) + } + + /// Process multiple photos for import + func processPhotosForImport( + _ metadataList: [PhotoMetadata], + sport: Sport? = nil + ) async -> [PhotoImportCandidate] { + var results: [PhotoImportCandidate] = [] + + for metadata in metadataList { + let candidate = await processPhotoForImport(metadata: metadata, sport: sport) + results.append(candidate) + } + + return results + } + + // MARK: - Private Helpers + + /// Find games at a stadium around a given date (±1 day for timezone/tailgating) + private func findGames( + at stadium: Stadium, + around date: Date, + sport: Sport? + ) async -> [Game] { + let calendar = Calendar.current + + // Search window: ±1 day + guard let startDate = calendar.date(byAdding: .day, value: -1, to: date), + let endDate = calendar.date(byAdding: .day, value: 2, to: date) else { + return [] + } + + // Determine which sports to query + let sports: Set = sport != nil ? [sport!] : Set(Sport.allCases) + + do { + let allGames = try await dataProvider.fetchGames(sports: sports, startDate: startDate, endDate: endDate) + + // Filter by stadium + let games = allGames.filter { $0.stadiumId == stadium.id } + + return games + } catch { + return [] + } + } +} + +// MARK: - Batch Processing Helpers + +extension GameMatcher { + /// Separate photos into categories for UI + struct CategorizedImports: Sendable { + let autoProcessable: [PhotoImportCandidate] + let needsConfirmation: [PhotoImportCandidate] + let needsManualEntry: [PhotoImportCandidate] + } + + nonisolated func categorizeImports(_ candidates: [PhotoImportCandidate]) -> CategorizedImports { + var auto: [PhotoImportCandidate] = [] + var confirm: [PhotoImportCandidate] = [] + var manual: [PhotoImportCandidate] = [] + + for candidate in candidates { + switch candidate.matchResult { + case .singleMatch(let match): + if match.confidence.combined == .autoSelect { + auto.append(candidate) + } else { + confirm.append(candidate) + } + case .multipleMatches: + confirm.append(candidate) + case .noMatches: + manual.append(candidate) + } + } + + return CategorizedImports( + autoProcessable: auto, + needsConfirmation: confirm, + needsManualEntry: manual + ) + } +} diff --git a/SportsTime/Core/Services/PhotoMetadataExtractor.swift b/SportsTime/Core/Services/PhotoMetadataExtractor.swift new file mode 100644 index 0000000..55bf9cc --- /dev/null +++ b/SportsTime/Core/Services/PhotoMetadataExtractor.swift @@ -0,0 +1,200 @@ +// +// PhotoMetadataExtractor.swift +// SportsTime +// +// Service for extracting EXIF metadata (GPS, date) from photos. +// + +import Foundation +import Photos +import CoreLocation +import ImageIO +import UIKit + +// MARK: - Photo Metadata + +struct PhotoMetadata: Sendable { + let captureDate: Date? + let coordinates: CLLocationCoordinate2D? + let hasValidLocation: Bool + let hasValidDate: Bool + + nonisolated init(captureDate: Date?, coordinates: CLLocationCoordinate2D?) { + self.captureDate = captureDate + self.coordinates = coordinates + self.hasValidLocation = coordinates != nil + self.hasValidDate = captureDate != nil + } + + nonisolated static var empty: PhotoMetadata { + PhotoMetadata(captureDate: nil, coordinates: nil) + } +} + +// MARK: - Photo Metadata Extractor + +actor PhotoMetadataExtractor { + static let shared = PhotoMetadataExtractor() + + private init() {} + + // MARK: - PHAsset Extraction (Preferred) + + /// Extract metadata from PHAsset (preferred method) + /// Uses PHAsset's location and creationDate properties + func extractMetadata(from asset: PHAsset) async -> PhotoMetadata { + // PHAsset provides location and date directly + let coordinates: CLLocationCoordinate2D? + if let location = asset.location { + coordinates = location.coordinate + } else { + coordinates = nil + } + + return PhotoMetadata( + captureDate: asset.creationDate, + coordinates: coordinates + ) + } + + /// Extract metadata from multiple PHAssets + func extractMetadata(from assets: [PHAsset]) async -> [PhotoMetadata] { + var results: [PhotoMetadata] = [] + for asset in assets { + let metadata = await extractMetadata(from: asset) + results.append(metadata) + } + return results + } + + // MARK: - Image Data Extraction (Fallback) + + /// Extract metadata from raw image data using ImageIO + /// Useful when PHAsset is not available + func extractMetadata(from imageData: Data) -> PhotoMetadata { + guard let source = CGImageSourceCreateWithData(imageData as CFData, nil), + let properties = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any] else { + return .empty + } + + let captureDate = extractDate(from: properties) + let coordinates = extractCoordinates(from: properties) + + return PhotoMetadata( + captureDate: captureDate, + coordinates: coordinates + ) + } + + // MARK: - Private Helpers + + private func extractDate(from properties: [CFString: Any]) -> Date? { + // Try EXIF DateTimeOriginal first + if let exif = properties[kCGImagePropertyExifDictionary] as? [CFString: Any], + let dateString = exif[kCGImagePropertyExifDateTimeOriginal] as? String { + return parseExifDate(dateString) + } + + // Try TIFF DateTime + if let tiff = properties[kCGImagePropertyTIFFDictionary] as? [CFString: Any], + let dateString = tiff[kCGImagePropertyTIFFDateTime] as? String { + return parseExifDate(dateString) + } + + return nil + } + + private func extractCoordinates(from properties: [CFString: Any]) -> CLLocationCoordinate2D? { + guard let gps = properties[kCGImagePropertyGPSDictionary] as? [CFString: Any], + let latitude = gps[kCGImagePropertyGPSLatitude] as? Double, + let longitude = gps[kCGImagePropertyGPSLongitude] as? Double else { + return nil + } + + // Handle N/S and E/W references + var lat = latitude + var lon = longitude + + if let latRef = gps[kCGImagePropertyGPSLatitudeRef] as? String, latRef == "S" { + lat = -lat + } + + if let lonRef = gps[kCGImagePropertyGPSLongitudeRef] as? String, lonRef == "W" { + lon = -lon + } + + // Validate coordinates + guard lat >= -90 && lat <= 90 && lon >= -180 && lon <= 180 else { + return nil + } + + return CLLocationCoordinate2D(latitude: lat, longitude: lon) + } + + private func parseExifDate(_ dateString: String) -> Date? { + // EXIF date format: "2024:06:15 14:30:00" + let formatter = DateFormatter() + formatter.dateFormat = "yyyy:MM:dd HH:mm:ss" + formatter.locale = Locale(identifier: "en_US_POSIX") + return formatter.date(from: dateString) + } +} + +// MARK: - Photo Library Access + +extension PhotoMetadataExtractor { + /// Request photo library access + @MainActor + func requestPhotoLibraryAccess() async -> PHAuthorizationStatus { + let status = PHPhotoLibrary.authorizationStatus(for: .readWrite) + + switch status { + case .notDetermined: + return await PHPhotoLibrary.requestAuthorization(for: .readWrite) + default: + return status + } + } + + /// Check if photo library access is available + var hasPhotoLibraryAccess: Bool { + let status = PHPhotoLibrary.authorizationStatus(for: .readWrite) + return status == .authorized || status == .limited + } +} + +// MARK: - Asset Image Loading + +extension PhotoMetadataExtractor { + /// Load thumbnail image from PHAsset + func loadThumbnail(from asset: PHAsset, targetSize: CGSize = CGSize(width: 200, height: 200)) async -> UIImage? { + await withCheckedContinuation { continuation in + let options = PHImageRequestOptions() + options.deliveryMode = .fastFormat + options.resizeMode = .fast + options.isSynchronous = false + + PHImageManager.default().requestImage( + for: asset, + targetSize: targetSize, + contentMode: .aspectFill, + options: options + ) { image, _ in + continuation.resume(returning: image) + } + } + } + + /// Load full-size image data from PHAsset + func loadImageData(from asset: PHAsset) async -> Data? { + await withCheckedContinuation { continuation in + let options = PHImageRequestOptions() + options.deliveryMode = .highQualityFormat + options.isSynchronous = false + + PHImageManager.default().requestImageDataAndOrientation(for: asset, options: options) { data, _, _, _ in + continuation.resume(returning: data) + } + } + } +} diff --git a/SportsTime/Core/Services/RateLimiter.swift b/SportsTime/Core/Services/RateLimiter.swift new file mode 100644 index 0000000..d38819c --- /dev/null +++ b/SportsTime/Core/Services/RateLimiter.swift @@ -0,0 +1,208 @@ +// +// RateLimiter.swift +// SportsTime +// +// Rate limiting for API providers to respect their rate limits. +// + +import Foundation + +// MARK: - Rate Limiter + +/// Per-provider rate limiting to avoid hitting API limits +actor RateLimiter { + + // MARK: - Types + + struct ProviderConfig { + let name: String + let minInterval: TimeInterval // Minimum time between requests + let burstLimit: Int // Max requests in burst window + let burstWindow: TimeInterval // Window for burst counting + } + + // MARK: - Properties + + private var lastRequestTimes: [String: Date] = [:] + private var requestCounts: [String: [Date]] = [:] + private var configs: [String: ProviderConfig] = [:] + + // MARK: - Default Configurations + + /// Default provider rate limit configurations + private static let defaultConfigs: [ProviderConfig] = [ + ProviderConfig(name: "mlb_stats", minInterval: 0.1, burstLimit: 30, burstWindow: 60), // 10 req/sec + ProviderConfig(name: "nhl_stats", minInterval: 0.2, burstLimit: 20, burstWindow: 60), // 5 req/sec + ProviderConfig(name: "nba_stats", minInterval: 0.5, burstLimit: 10, burstWindow: 60), // 2 req/sec + ProviderConfig(name: "espn", minInterval: 1.0, burstLimit: 30, burstWindow: 60), // 1 req/sec + ProviderConfig(name: "sports_reference", minInterval: 3.0, burstLimit: 10, burstWindow: 60) // 1 req/3 sec + ] + + // MARK: - Singleton + + static let shared = RateLimiter() + + private init() { + // Load default configs + for config in Self.defaultConfigs { + configs[config.name] = config + } + } + + // MARK: - Configuration + + /// Configure rate limiting for a provider + func configureProvider(_ config: ProviderConfig) { + configs[config.name] = config + } + + // MARK: - Rate Limiting + + /// Wait if needed to respect rate limits for a provider + /// Returns immediately if rate limit allows, otherwise sleeps until allowed + func waitIfNeeded(for provider: String) async { + let config = configs[provider] ?? ProviderConfig( + name: provider, + minInterval: 1.0, + burstLimit: 60, + burstWindow: 60 + ) + + await enforceMinInterval(for: provider, interval: config.minInterval) + await enforceBurstLimit(for: provider, limit: config.burstLimit, window: config.burstWindow) + + recordRequest(for: provider) + } + + /// Check if a request can be made without waiting + func canMakeRequest(for provider: String) -> Bool { + let config = configs[provider] ?? ProviderConfig( + name: provider, + minInterval: 1.0, + burstLimit: 60, + burstWindow: 60 + ) + + // Check min interval + if let lastRequest = lastRequestTimes[provider] { + let elapsed = Date().timeIntervalSince(lastRequest) + if elapsed < config.minInterval { + return false + } + } + + // Check burst limit + let now = Date() + let windowStart = now.addingTimeInterval(-config.burstWindow) + + if let requests = requestCounts[provider] { + let recentRequests = requests.filter { $0 > windowStart } + if recentRequests.count >= config.burstLimit { + return false + } + } + + return true + } + + /// Get estimated wait time until next request is allowed + func estimatedWaitTime(for provider: String) -> TimeInterval { + let config = configs[provider] ?? ProviderConfig( + name: provider, + minInterval: 1.0, + burstLimit: 60, + burstWindow: 60 + ) + + var maxWait: TimeInterval = 0 + + // Check min interval wait + if let lastRequest = lastRequestTimes[provider] { + let elapsed = Date().timeIntervalSince(lastRequest) + if elapsed < config.minInterval { + maxWait = max(maxWait, config.minInterval - elapsed) + } + } + + // Check burst limit wait + let now = Date() + let windowStart = now.addingTimeInterval(-config.burstWindow) + + if let requests = requestCounts[provider] { + let recentRequests = requests.filter { $0 > windowStart }.sorted() + if recentRequests.count >= config.burstLimit { + // Need to wait until oldest request falls out of window + if let oldestInWindow = recentRequests.first { + let waitUntil = oldestInWindow.addingTimeInterval(config.burstWindow) + let wait = waitUntil.timeIntervalSince(now) + maxWait = max(maxWait, wait) + } + } + } + + return maxWait + } + + /// Reset rate limit tracking for a provider + func reset(for provider: String) { + lastRequestTimes.removeValue(forKey: provider) + requestCounts.removeValue(forKey: provider) + } + + /// Reset all rate limit tracking + func resetAll() { + lastRequestTimes.removeAll() + requestCounts.removeAll() + } + + // MARK: - Private Helpers + + private func enforceMinInterval(for provider: String, interval: TimeInterval) async { + if let lastRequest = lastRequestTimes[provider] { + let elapsed = Date().timeIntervalSince(lastRequest) + if elapsed < interval { + let waitTime = interval - elapsed + try? await Task.sleep(nanoseconds: UInt64(waitTime * 1_000_000_000)) + } + } + } + + private func enforceBurstLimit(for provider: String, limit: Int, window: TimeInterval) async { + let now = Date() + let windowStart = now.addingTimeInterval(-window) + + // Clean up old requests + if var requests = requestCounts[provider] { + requests = requests.filter { $0 > windowStart } + requestCounts[provider] = requests + + // Check if at limit + if requests.count >= limit { + // Wait until oldest request falls out of window + if let oldestInWindow = requests.sorted().first { + let waitUntil = oldestInWindow.addingTimeInterval(window) + let waitTime = waitUntil.timeIntervalSince(now) + if waitTime > 0 { + try? await Task.sleep(nanoseconds: UInt64(waitTime * 1_000_000_000)) + } + } + } + } + } + + private func recordRequest(for provider: String) { + let now = Date() + lastRequestTimes[provider] = now + + if requestCounts[provider] == nil { + requestCounts[provider] = [] + } + requestCounts[provider]?.append(now) + + // Clean up old requests periodically + if let requests = requestCounts[provider], requests.count > 1000 { + let oneHourAgo = now.addingTimeInterval(-3600) + requestCounts[provider] = requests.filter { $0 > oneHourAgo } + } + } +} diff --git a/SportsTime/Core/Services/ScoreAPIProviders/MLBStatsProvider.swift b/SportsTime/Core/Services/ScoreAPIProviders/MLBStatsProvider.swift new file mode 100644 index 0000000..2e9b8a8 --- /dev/null +++ b/SportsTime/Core/Services/ScoreAPIProviders/MLBStatsProvider.swift @@ -0,0 +1,173 @@ +// +// MLBStatsProvider.swift +// SportsTime +// +// MLB Stats API provider - official, documented, stable. +// API: https://statsapi.mlb.com +// + +import Foundation + +// MARK: - MLB Stats Provider + +struct MLBStatsProvider: ScoreAPIProvider { + + // MARK: - Protocol Requirements + + let name = "MLB Stats API" + let supportedSports: Set = [.mlb] + let reliability: ProviderReliability = .official + let rateLimitKey = "mlb_stats" + + // MARK: - API Configuration + + private let baseURL = "https://statsapi.mlb.com/api/v1" + + // MARK: - Team ID Mapping + + /// Maps team abbreviations to MLB Stats API team IDs + private static let teamIdMapping: [String: Int] = [ + "ARI": 109, "ATL": 144, "BAL": 110, "BOS": 111, + "CHC": 112, "CWS": 145, "CIN": 113, "CLE": 114, + "COL": 115, "DET": 116, "HOU": 117, "KC": 118, + "LAA": 108, "LAD": 119, "MIA": 146, "MIL": 158, + "MIN": 142, "NYM": 121, "NYY": 147, "OAK": 133, + "PHI": 143, "PIT": 134, "SD": 135, "SF": 137, + "SEA": 136, "STL": 138, "TB": 139, "TEX": 140, + "TOR": 141, "WSH": 120 + ] + + // Reverse mapping for API response + private static let idToAbbrevMapping: [Int: String] = { + Dictionary(uniqueKeysWithValues: teamIdMapping.map { ($1, $0) }) + }() + + // MARK: - Fetch Game + + func fetchGame(query: HistoricalGameQuery) async throws -> HistoricalGameResult? { + guard query.sport == .mlb else { + throw ScoreProviderError.unsupportedSport(query.sport) + } + + // Build schedule URL for the date + let dateString = query.normalizedDateString + let urlString = "\(baseURL)/schedule?sportId=1&date=\(dateString)&hydrate=team,linescore" + + guard let url = URL(string: urlString) else { + throw ScoreProviderError.networkError(underlying: "Invalid URL") + } + + // Fetch data + let (data, response) = try await URLSession.shared.data(from: url) + + // Check HTTP response + guard let httpResponse = response as? HTTPURLResponse else { + throw ScoreProviderError.networkError(underlying: "Invalid response type") + } + + guard httpResponse.statusCode == 200 else { + if httpResponse.statusCode == 429 { + throw ScoreProviderError.rateLimited + } + throw ScoreProviderError.networkError(underlying: "HTTP \(httpResponse.statusCode)") + } + + // Parse response + return try parseScheduleResponse(data: data, query: query) + } + + // MARK: - Response Parsing + + private func parseScheduleResponse(data: Data, query: HistoricalGameQuery) throws -> HistoricalGameResult? { + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let dates = json["dates"] as? [[String: Any]] else { + throw ScoreProviderError.parseError(message: "Invalid JSON structure") + } + + // Find games on the requested date + for dateEntry in dates { + guard let games = dateEntry["games"] as? [[String: Any]] else { continue } + + for game in games { + // Extract team info + guard let teams = game["teams"] as? [String: Any], + let homeTeamData = teams["home"] as? [String: Any], + let awayTeamData = teams["away"] as? [String: Any], + let homeTeam = homeTeamData["team"] as? [String: Any], + let awayTeam = awayTeamData["team"] as? [String: Any], + let homeTeamId = homeTeam["id"] as? Int, + let awayTeamId = awayTeam["id"] as? Int else { + continue + } + + // Get team abbreviations + guard let homeAbbrev = Self.idToAbbrevMapping[homeTeamId], + let awayAbbrev = Self.idToAbbrevMapping[awayTeamId] else { + continue + } + + // Check if this matches the query + if let queryHome = query.homeTeamAbbrev, queryHome.uppercased() != homeAbbrev { + continue + } + if let queryAway = query.awayTeamAbbrev, queryAway.uppercased() != awayAbbrev { + continue + } + + // Extract team names + let homeTeamName = homeTeam["name"] as? String ?? homeAbbrev + let awayTeamName = awayTeam["name"] as? String ?? awayAbbrev + + // Extract scores from linescore if available + var homeScore: Int? + var awayScore: Int? + + if let linescore = game["linescore"] as? [String: Any], + let lineTeams = linescore["teams"] as? [String: Any] { + if let homeLineData = lineTeams["home"] as? [String: Any] { + homeScore = homeLineData["runs"] as? Int + } + if let awayLineData = lineTeams["away"] as? [String: Any] { + awayScore = awayLineData["runs"] as? Int + } + } + + // Alternative: get scores from team data + if homeScore == nil, let score = homeTeamData["score"] as? Int { + homeScore = score + } + if awayScore == nil, let score = awayTeamData["score"] as? Int { + awayScore = score + } + + // Extract game date + let gameDate: Date + if let gameDateString = game["gameDate"] as? String { + let formatter = ISO8601DateFormatter() + gameDate = formatter.date(from: gameDateString) ?? query.date + } else { + gameDate = query.date + } + + return HistoricalGameResult( + sport: .mlb, + gameDate: gameDate, + homeTeamAbbrev: homeAbbrev, + awayTeamAbbrev: awayAbbrev, + homeTeamName: homeTeamName, + awayTeamName: awayTeamName, + homeScore: homeScore, + awayScore: awayScore, + source: .api, + providerName: name + ) + } + } + + return nil + } +} + +// MARK: - Sendable Conformance + +extension MLBStatsProvider: Sendable {} diff --git a/SportsTime/Core/Services/ScoreAPIProviders/NBAStatsProvider.swift b/SportsTime/Core/Services/ScoreAPIProviders/NBAStatsProvider.swift new file mode 100644 index 0000000..3413780 --- /dev/null +++ b/SportsTime/Core/Services/ScoreAPIProviders/NBAStatsProvider.swift @@ -0,0 +1,215 @@ +// +// NBAStatsProvider.swift +// SportsTime +// +// NBA Stats API provider - unofficial but functional. +// API: https://stats.nba.com +// Note: Requires specific headers, may break without notice. +// + +import Foundation + +// MARK: - NBA Stats Provider + +struct NBAStatsProvider: ScoreAPIProvider { + + // MARK: - Protocol Requirements + + let name = "NBA Stats API" + let supportedSports: Set = [.nba] + let reliability: ProviderReliability = .unofficial + let rateLimitKey = "nba_stats" + + // MARK: - API Configuration + + private let baseURL = "https://stats.nba.com/stats" + + // Required headers to avoid 403 errors + private let requiredHeaders: [String: String] = [ + "Host": "stats.nba.com", + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36", + "Accept": "application/json, text/plain, */*", + "Accept-Language": "en-US,en;q=0.5", + "Referer": "https://www.nba.com/", + "x-nba-stats-origin": "stats", + "x-nba-stats-token": "true", + "Connection": "keep-alive" + ] + + // MARK: - Team ID Mapping + + /// Maps team abbreviations to NBA Stats API team IDs + private static let teamIdMapping: [String: Int] = [ + "ATL": 1610612737, "BOS": 1610612738, "BKN": 1610612751, "CHA": 1610612766, + "CHI": 1610612741, "CLE": 1610612739, "DAL": 1610612742, "DEN": 1610612743, + "DET": 1610612765, "GSW": 1610612744, "HOU": 1610612745, "IND": 1610612754, + "LAC": 1610612746, "LAL": 1610612747, "MEM": 1610612763, "MIA": 1610612748, + "MIL": 1610612749, "MIN": 1610612750, "NOP": 1610612740, "NYK": 1610612752, + "OKC": 1610612760, "ORL": 1610612753, "PHI": 1610612755, "PHX": 1610612756, + "POR": 1610612757, "SAC": 1610612758, "SAS": 1610612759, "TOR": 1610612761, + "UTA": 1610612762, "WAS": 1610612764 + ] + + // Reverse mapping + private static let idToAbbrevMapping: [Int: String] = { + Dictionary(uniqueKeysWithValues: teamIdMapping.map { ($1, $0) }) + }() + + // Team names + private static let teamNames: [String: String] = [ + "ATL": "Atlanta Hawks", "BOS": "Boston Celtics", "BKN": "Brooklyn Nets", + "CHA": "Charlotte Hornets", "CHI": "Chicago Bulls", "CLE": "Cleveland Cavaliers", + "DAL": "Dallas Mavericks", "DEN": "Denver Nuggets", "DET": "Detroit Pistons", + "GSW": "Golden State Warriors", "HOU": "Houston Rockets", "IND": "Indiana Pacers", + "LAC": "Los Angeles Clippers", "LAL": "Los Angeles Lakers", "MEM": "Memphis Grizzlies", + "MIA": "Miami Heat", "MIL": "Milwaukee Bucks", "MIN": "Minnesota Timberwolves", + "NOP": "New Orleans Pelicans", "NYK": "New York Knicks", "OKC": "Oklahoma City Thunder", + "ORL": "Orlando Magic", "PHI": "Philadelphia 76ers", "PHX": "Phoenix Suns", + "POR": "Portland Trail Blazers", "SAC": "Sacramento Kings", "SAS": "San Antonio Spurs", + "TOR": "Toronto Raptors", "UTA": "Utah Jazz", "WAS": "Washington Wizards" + ] + + // MARK: - Fetch Game + + func fetchGame(query: HistoricalGameQuery) async throws -> HistoricalGameResult? { + guard query.sport == .nba else { + throw ScoreProviderError.unsupportedSport(query.sport) + } + + // Build scoreboard URL for the date + let dateString = query.normalizedDateString.replacingOccurrences(of: "-", with: "") + let urlString = "\(baseURL)/scoreboardv2?GameDate=\(dateString)&LeagueID=00&DayOffset=0" + + guard let url = URL(string: urlString) else { + throw ScoreProviderError.networkError(underlying: "Invalid URL") + } + + // Create request with required headers + var request = URLRequest(url: url) + request.httpMethod = "GET" + for (key, value) in requiredHeaders { + request.setValue(value, forHTTPHeaderField: key) + } + + // Fetch data + let (data, response) = try await URLSession.shared.data(for: request) + + // Check HTTP response + guard let httpResponse = response as? HTTPURLResponse else { + throw ScoreProviderError.networkError(underlying: "Invalid response type") + } + + guard httpResponse.statusCode == 200 else { + if httpResponse.statusCode == 429 { + throw ScoreProviderError.rateLimited + } + if httpResponse.statusCode == 403 { + throw ScoreProviderError.providerUnavailable(reason: "Access denied - headers may need update") + } + throw ScoreProviderError.networkError(underlying: "HTTP \(httpResponse.statusCode)") + } + + // Parse response + return try parseScoreboardResponse(data: data, query: query) + } + + // MARK: - Response Parsing + + private func parseScoreboardResponse(data: Data, query: HistoricalGameQuery) throws -> HistoricalGameResult? { + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let resultSets = json["resultSets"] as? [[String: Any]] else { + throw ScoreProviderError.parseError(message: "Invalid JSON structure") + } + + // Find the GameHeader result set + guard let gameHeaderSet = resultSets.first(where: { ($0["name"] as? String) == "GameHeader" }), + let headers = gameHeaderSet["headers"] as? [String], + let rowSet = gameHeaderSet["rowSet"] as? [[Any]] else { + return nil + } + + // Get column indices + let homeTeamIdIdx = headers.firstIndex(of: "HOME_TEAM_ID") + let visitorTeamIdIdx = headers.firstIndex(of: "VISITOR_TEAM_ID") + let gameStatusIdx = headers.firstIndex(of: "GAME_STATUS_TEXT") + + // Find the LineScore result set for scores + let lineScoreSet = resultSets.first(where: { ($0["name"] as? String) == "LineScore" }) + let lineScoreHeaders = lineScoreSet?["headers"] as? [String] + let lineScoreRows = lineScoreSet?["rowSet"] as? [[Any]] + let teamIdScoreIdx = lineScoreHeaders?.firstIndex(of: "TEAM_ID") + let ptsIdx = lineScoreHeaders?.firstIndex(of: "PTS") + + // Process each game + for row in rowSet { + guard let homeTeamIdIdx = homeTeamIdIdx, + let visitorTeamIdIdx = visitorTeamIdIdx, + homeTeamIdIdx < row.count, + visitorTeamIdIdx < row.count, + let homeTeamId = row[homeTeamIdIdx] as? Int, + let awayTeamId = row[visitorTeamIdIdx] as? Int else { + continue + } + + // Get team abbreviations + guard let homeAbbrev = Self.idToAbbrevMapping[homeTeamId], + let awayAbbrev = Self.idToAbbrevMapping[awayTeamId] else { + continue + } + + // Check if this matches the query + if let queryHome = query.homeTeamAbbrev, queryHome.uppercased() != homeAbbrev { + continue + } + if let queryAway = query.awayTeamAbbrev, queryAway.uppercased() != awayAbbrev { + continue + } + + // Get team names + let homeTeamName = Self.teamNames[homeAbbrev] ?? homeAbbrev + let awayTeamName = Self.teamNames[awayAbbrev] ?? awayAbbrev + + // Get scores from LineScore + var homeScore: Int? + var awayScore: Int? + + if let lineScoreRows = lineScoreRows, + let teamIdScoreIdx = teamIdScoreIdx, + let ptsIdx = ptsIdx { + + for scoreRow in lineScoreRows { + guard teamIdScoreIdx < scoreRow.count, + ptsIdx < scoreRow.count, + let teamId = scoreRow[teamIdScoreIdx] as? Int else { + continue + } + + if teamId == homeTeamId { + homeScore = scoreRow[ptsIdx] as? Int + } else if teamId == awayTeamId { + awayScore = scoreRow[ptsIdx] as? Int + } + } + } + + return HistoricalGameResult( + sport: .nba, + gameDate: query.date, + homeTeamAbbrev: homeAbbrev, + awayTeamAbbrev: awayAbbrev, + homeTeamName: homeTeamName, + awayTeamName: awayTeamName, + homeScore: homeScore, + awayScore: awayScore, + source: .api, + providerName: name + ) + } + + return nil + } +} + +// MARK: - Sendable Conformance + +extension NBAStatsProvider: Sendable {} diff --git a/SportsTime/Core/Services/ScoreAPIProviders/NHLStatsProvider.swift b/SportsTime/Core/Services/ScoreAPIProviders/NHLStatsProvider.swift new file mode 100644 index 0000000..d4e4d41 --- /dev/null +++ b/SportsTime/Core/Services/ScoreAPIProviders/NHLStatsProvider.swift @@ -0,0 +1,172 @@ +// +// NHLStatsProvider.swift +// SportsTime +// +// NHL Stats API provider - official, documented, stable. +// API: https://api-web.nhle.com +// + +import Foundation + +// MARK: - NHL Stats Provider + +struct NHLStatsProvider: ScoreAPIProvider { + + // MARK: - Protocol Requirements + + let name = "NHL Stats API" + let supportedSports: Set = [.nhl] + let reliability: ProviderReliability = .official + let rateLimitKey = "nhl_stats" + + // MARK: - API Configuration + + private let baseURL = "https://api-web.nhle.com/v1" + + // MARK: - Team Abbreviation Mapping + + /// Maps common team abbreviations to NHL API team codes + private static let teamAbbrevMapping: [String: String] = [ + "ANA": "ANA", "ARI": "ARI", "BOS": "BOS", "BUF": "BUF", + "CGY": "CGY", "CAR": "CAR", "CHI": "CHI", "COL": "COL", + "CBJ": "CBJ", "DAL": "DAL", "DET": "DET", "EDM": "EDM", + "FLA": "FLA", "LA": "LAK", "LAK": "LAK", "MIN": "MIN", + "MTL": "MTL", "NSH": "NSH", "NJ": "NJD", "NJD": "NJD", + "NYI": "NYI", "NYR": "NYR", "OTT": "OTT", "PHI": "PHI", + "PIT": "PIT", "SJ": "SJS", "SJS": "SJS", "SEA": "SEA", + "STL": "STL", "TB": "TBL", "TBL": "TBL", "TOR": "TOR", + "UTA": "UTA", "VAN": "VAN", "VGK": "VGK", "WSH": "WSH", + "WPG": "WPG" + ] + + // Team names for display + private static let teamNames: [String: String] = [ + "ANA": "Anaheim Ducks", "ARI": "Arizona Coyotes", "BOS": "Boston Bruins", + "BUF": "Buffalo Sabres", "CGY": "Calgary Flames", "CAR": "Carolina Hurricanes", + "CHI": "Chicago Blackhawks", "COL": "Colorado Avalanche", + "CBJ": "Columbus Blue Jackets", "DAL": "Dallas Stars", "DET": "Detroit Red Wings", + "EDM": "Edmonton Oilers", "FLA": "Florida Panthers", "LAK": "Los Angeles Kings", + "MIN": "Minnesota Wild", "MTL": "Montreal Canadiens", "NSH": "Nashville Predators", + "NJD": "New Jersey Devils", "NYI": "New York Islanders", "NYR": "New York Rangers", + "OTT": "Ottawa Senators", "PHI": "Philadelphia Flyers", "PIT": "Pittsburgh Penguins", + "SJS": "San Jose Sharks", "SEA": "Seattle Kraken", "STL": "St. Louis Blues", + "TBL": "Tampa Bay Lightning", "TOR": "Toronto Maple Leafs", "UTA": "Utah Hockey Club", + "VAN": "Vancouver Canucks", "VGK": "Vegas Golden Knights", + "WSH": "Washington Capitals", "WPG": "Winnipeg Jets" + ] + + // MARK: - Fetch Game + + func fetchGame(query: HistoricalGameQuery) async throws -> HistoricalGameResult? { + guard query.sport == .nhl else { + throw ScoreProviderError.unsupportedSport(query.sport) + } + + // Build schedule URL for the date + let dateString = query.normalizedDateString + let urlString = "\(baseURL)/schedule/\(dateString)" + + guard let url = URL(string: urlString) else { + throw ScoreProviderError.networkError(underlying: "Invalid URL") + } + + // Fetch data + let (data, response) = try await URLSession.shared.data(from: url) + + // Check HTTP response + guard let httpResponse = response as? HTTPURLResponse else { + throw ScoreProviderError.networkError(underlying: "Invalid response type") + } + + guard httpResponse.statusCode == 200 else { + if httpResponse.statusCode == 429 { + throw ScoreProviderError.rateLimited + } + throw ScoreProviderError.networkError(underlying: "HTTP \(httpResponse.statusCode)") + } + + // Parse response + return try parseScheduleResponse(data: data, query: query) + } + + // MARK: - Response Parsing + + private func parseScheduleResponse(data: Data, query: HistoricalGameQuery) throws -> HistoricalGameResult? { + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let gameWeek = json["gameWeek"] as? [[String: Any]] else { + throw ScoreProviderError.parseError(message: "Invalid JSON structure") + } + + // Find games on the requested date + for dayEntry in gameWeek { + guard let games = dayEntry["games"] as? [[String: Any]] else { continue } + + for game in games { + // Extract team info + guard let homeTeam = game["homeTeam"] as? [String: Any], + let awayTeam = game["awayTeam"] as? [String: Any], + let homeAbbrevRaw = homeTeam["abbrev"] as? String, + let awayAbbrevRaw = awayTeam["abbrev"] as? String else { + continue + } + + // Normalize abbreviations + let homeAbbrev = Self.teamAbbrevMapping[homeAbbrevRaw.uppercased()] ?? homeAbbrevRaw.uppercased() + let awayAbbrev = Self.teamAbbrevMapping[awayAbbrevRaw.uppercased()] ?? awayAbbrevRaw.uppercased() + + // Check if this matches the query + if let queryHome = query.homeTeamAbbrev { + let normalizedQueryHome = Self.teamAbbrevMapping[queryHome.uppercased()] ?? queryHome.uppercased() + if normalizedQueryHome != homeAbbrev { + continue + } + } + if let queryAway = query.awayTeamAbbrev { + let normalizedQueryAway = Self.teamAbbrevMapping[queryAway.uppercased()] ?? queryAway.uppercased() + if normalizedQueryAway != awayAbbrev { + continue + } + } + + // Extract team names + let homeTeamName = homeTeam["placeName"] as? [String: Any] + let homeTeamNameDefault = (homeTeamName?["default"] as? String) ?? Self.teamNames[homeAbbrev] ?? homeAbbrev + + let awayTeamName = awayTeam["placeName"] as? [String: Any] + let awayTeamNameDefault = (awayTeamName?["default"] as? String) ?? Self.teamNames[awayAbbrev] ?? awayAbbrev + + // Extract scores + let homeScore = homeTeam["score"] as? Int + let awayScore = awayTeam["score"] as? Int + + // Extract game date + let gameDate: Date + if let startTime = game["startTimeUTC"] as? String { + let formatter = ISO8601DateFormatter() + gameDate = formatter.date(from: startTime) ?? query.date + } else { + gameDate = query.date + } + + return HistoricalGameResult( + sport: .nhl, + gameDate: gameDate, + homeTeamAbbrev: homeAbbrev, + awayTeamAbbrev: awayAbbrev, + homeTeamName: homeTeamNameDefault, + awayTeamName: awayTeamNameDefault, + homeScore: homeScore, + awayScore: awayScore, + source: .api, + providerName: name + ) + } + } + + return nil + } +} + +// MARK: - Sendable Conformance + +extension NHLStatsProvider: Sendable {} diff --git a/SportsTime/Core/Services/ScoreResolutionCache.swift b/SportsTime/Core/Services/ScoreResolutionCache.swift new file mode 100644 index 0000000..8048cf8 --- /dev/null +++ b/SportsTime/Core/Services/ScoreResolutionCache.swift @@ -0,0 +1,312 @@ +// +// ScoreResolutionCache.swift +// SportsTime +// +// Manages caching of resolved game scores using SwiftData. +// Historical scores never change, so they can be cached indefinitely. +// + +import Foundation +import SwiftData + +// MARK: - Score Resolution Cache + +@MainActor +final class ScoreResolutionCache { + + // MARK: - Properties + + private let modelContext: ModelContext + + // Cache configuration + private static let recentGameCacheDuration: TimeInterval = 24 * 60 * 60 // 24 hours + private static let failedLookupCacheDuration: TimeInterval = 7 * 24 * 60 * 60 // 7 days + private static let historicalAgeThreshold: TimeInterval = 30 * 24 * 60 * 60 // 30 days + + // MARK: - Initialization + + init(modelContext: ModelContext) { + self.modelContext = modelContext + } + + // MARK: - Cache Operations + + /// Get cached score for a game query + func getCached(query: HistoricalGameQuery) -> CachedGameScore? { + guard let homeAbbrev = query.homeTeamAbbrev, + let awayAbbrev = query.awayTeamAbbrev else { + return nil + } + + let cacheKey = CachedGameScore.generateKey( + sport: query.sport, + date: query.date, + homeAbbrev: homeAbbrev, + awayAbbrev: awayAbbrev + ) + + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.cacheKey == cacheKey } + ) + + do { + let results = try modelContext.fetch(descriptor) + if let cached = results.first { + // Check if expired + if cached.isExpired { + // Delete expired entry + modelContext.delete(cached) + try? modelContext.save() + return nil + } + return cached + } + } catch { + // Fetch failed, return nil + } + + return nil + } + + /// Convert cached score to HistoricalGameResult + func getCachedResult(query: HistoricalGameQuery) -> HistoricalGameResult? { + guard let cached = getCached(query: query) else { + return nil + } + + return HistoricalGameResult( + sport: cached.sportEnum ?? query.sport, + gameDate: cached.gameDate, + homeTeamAbbrev: cached.homeTeamAbbrev, + awayTeamAbbrev: cached.awayTeamAbbrev, + homeTeamName: cached.homeTeamName, + awayTeamName: cached.awayTeamName, + homeScore: cached.homeScore, + awayScore: cached.awayScore, + source: cached.scoreSource, + providerName: "cache" + ) + } + + /// Cache a resolved game result + func cache(result: HistoricalGameResult, query: HistoricalGameQuery) { + let cacheKey = CachedGameScore.generateKey( + sport: result.sport, + date: result.gameDate, + homeAbbrev: result.homeTeamAbbrev, + awayAbbrev: result.awayTeamAbbrev + ) + + // Check if already cached + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.cacheKey == cacheKey } + ) + + do { + let existing = try modelContext.fetch(descriptor) + if let existingEntry = existing.first { + // Update existing entry + existingEntry.homeScore = result.homeScore + existingEntry.awayScore = result.awayScore + existingEntry.sourceRaw = result.source.rawValue + existingEntry.fetchedAt = Date() + existingEntry.expiresAt = calculateExpiration(for: result.gameDate) + } else { + // Create new entry + let cached = CachedGameScore( + cacheKey: cacheKey, + sport: result.sport, + gameDate: result.gameDate, + homeTeamAbbrev: result.homeTeamAbbrev, + awayTeamAbbrev: result.awayTeamAbbrev, + homeTeamName: result.homeTeamName, + awayTeamName: result.awayTeamName, + homeScore: result.homeScore, + awayScore: result.awayScore, + source: result.source, + expiresAt: calculateExpiration(for: result.gameDate) + ) + modelContext.insert(cached) + } + + try modelContext.save() + } catch { + // Cache save failed, continue without caching + } + } + + /// Cache a failed lookup to avoid repeated failures + func cacheFailedLookup(query: HistoricalGameQuery) { + guard let homeAbbrev = query.homeTeamAbbrev, + let awayAbbrev = query.awayTeamAbbrev else { + return + } + + let cacheKey = CachedGameScore.generateKey( + sport: query.sport, + date: query.date, + homeAbbrev: homeAbbrev, + awayAbbrev: awayAbbrev + ) + + // Check if already cached + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.cacheKey == cacheKey } + ) + + do { + let existing = try modelContext.fetch(descriptor) + if existing.isEmpty { + // Create failed lookup entry (no scores) + let cached = CachedGameScore( + cacheKey: cacheKey, + sport: query.sport, + gameDate: query.date, + homeTeamAbbrev: homeAbbrev, + awayTeamAbbrev: awayAbbrev, + homeTeamName: homeAbbrev, + awayTeamName: awayAbbrev, + homeScore: nil, + awayScore: nil, + source: .api, + expiresAt: Date().addingTimeInterval(Self.failedLookupCacheDuration) + ) + modelContext.insert(cached) + try modelContext.save() + } + } catch { + // Ignore cache failures + } + } + + /// Remove a cached entry + func invalidate(query: HistoricalGameQuery) { + guard let homeAbbrev = query.homeTeamAbbrev, + let awayAbbrev = query.awayTeamAbbrev else { + return + } + + let cacheKey = CachedGameScore.generateKey( + sport: query.sport, + date: query.date, + homeAbbrev: homeAbbrev, + awayAbbrev: awayAbbrev + ) + + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.cacheKey == cacheKey } + ) + + do { + let results = try modelContext.fetch(descriptor) + for entry in results { + modelContext.delete(entry) + } + try modelContext.save() + } catch { + // Ignore deletion failures + } + } + + /// Clean up expired cache entries + func cleanupExpired() { + let now = Date() + + // Can't use date comparison directly in predicate with non-nil check + // Fetch all and filter + let descriptor = FetchDescriptor() + + do { + let allCached = try modelContext.fetch(descriptor) + var deletedCount = 0 + + for entry in allCached { + if let expiresAt = entry.expiresAt, expiresAt < now { + modelContext.delete(entry) + deletedCount += 1 + } + } + + if deletedCount > 0 { + try modelContext.save() + } + } catch { + // Cleanup failed, will try again later + } + } + + /// Get cache statistics + func getCacheStats() -> CacheStats { + let descriptor = FetchDescriptor() + + do { + let all = try modelContext.fetch(descriptor) + let now = Date() + + var withScores = 0 + var withoutScores = 0 + var expired = 0 + var bySport: [Sport: Int] = [:] + + for entry in all { + // Count by sport + if let sport = entry.sportEnum { + bySport[sport, default: 0] += 1 + } + + // Count with/without scores + if entry.homeScore != nil && entry.awayScore != nil { + withScores += 1 + } else { + withoutScores += 1 + } + + // Count expired + if let expiresAt = entry.expiresAt, expiresAt < now { + expired += 1 + } + } + + return CacheStats( + totalEntries: all.count, + entriesWithScores: withScores, + entriesWithoutScores: withoutScores, + expiredEntries: expired, + entriesBySport: bySport + ) + } catch { + return CacheStats( + totalEntries: 0, + entriesWithScores: 0, + entriesWithoutScores: 0, + expiredEntries: 0, + entriesBySport: [:] + ) + } + } + + // MARK: - Private Helpers + + private func calculateExpiration(for gameDate: Date) -> Date? { + let now = Date() + let gameAge = now.timeIntervalSince(gameDate) + + if gameAge > Self.historicalAgeThreshold { + // Historical games never expire + return nil + } else { + // Recent games expire after 24 hours + return now.addingTimeInterval(Self.recentGameCacheDuration) + } + } +} + +// MARK: - Cache Statistics + +struct CacheStats { + let totalEntries: Int + let entriesWithScores: Int + let entriesWithoutScores: Int + let expiredEntries: Int + let entriesBySport: [Sport: Int] +} diff --git a/SportsTime/Core/Services/StadiumIdentityService.swift b/SportsTime/Core/Services/StadiumIdentityService.swift new file mode 100644 index 0000000..53f628b --- /dev/null +++ b/SportsTime/Core/Services/StadiumIdentityService.swift @@ -0,0 +1,273 @@ +// +// StadiumIdentityService.swift +// SportsTime +// +// Service for resolving stadium identities across renames and aliases. +// Wraps CanonicalStadium lookups from SwiftData. +// + +import Foundation +import SwiftData + +// MARK: - Stadium Identity Service + +/// Resolves stadium identities to canonical IDs, handling renames and aliases. +/// Example: "SBC Park", "AT&T Park", and "Oracle Park" all resolve to the same canonical ID. +actor StadiumIdentityService { + + // MARK: - Singleton + + static let shared = StadiumIdentityService() + + // MARK: - Properties + + private var modelContainer: ModelContainer? + + // Cache for performance + private var uuidToCanonicalId: [UUID: String] = [:] + private var canonicalIdToUUID: [String: UUID] = [:] + private var nameToCanonicalId: [String: String] = [:] + + // MARK: - Initialization + + private init() {} + + // MARK: - Configuration + + /// Configure the service with a model container + func configure(with container: ModelContainer) { + self.modelContainer = container + invalidateCache() + } + + // MARK: - Public Methods + + /// Get the canonical ID for a stadium UUID + /// Returns the same canonicalId for stadiums that are the same physical location + func canonicalId(for stadiumUUID: UUID) async throws -> String? { + // Check cache first + if let cached = uuidToCanonicalId[stadiumUUID] { + return cached + } + + guard let container = modelContainer else { + return nil + } + + let context = ModelContext(container) + + let descriptor = FetchDescriptor( + predicate: #Predicate { stadium in + stadium.uuid == stadiumUUID + } + ) + + guard let stadium = try context.fetch(descriptor).first else { + return nil + } + + // Cache the result + uuidToCanonicalId[stadiumUUID] = stadium.canonicalId + canonicalIdToUUID[stadium.canonicalId] = stadium.uuid + + return stadium.canonicalId + } + + /// Get the canonical ID for a stadium name (searches aliases too) + func canonicalId(forName name: String) async throws -> String? { + let lowercasedName = name.lowercased() + + // Check cache first + if let cached = nameToCanonicalId[lowercasedName] { + return cached + } + + guard let container = modelContainer else { + return nil + } + + let context = ModelContext(container) + + // First check stadium aliases + let aliasDescriptor = FetchDescriptor( + predicate: #Predicate { alias in + alias.aliasName == lowercasedName + } + ) + + if let alias = try context.fetch(aliasDescriptor).first { + nameToCanonicalId[lowercasedName] = alias.stadiumCanonicalId + return alias.stadiumCanonicalId + } + + // Fall back to direct stadium name match + let stadiumDescriptor = FetchDescriptor() + let stadiums = try context.fetch(stadiumDescriptor) + + // Case-insensitive match on stadium name + if let stadium = stadiums.first(where: { $0.name.lowercased() == lowercasedName }) { + nameToCanonicalId[lowercasedName] = stadium.canonicalId + return stadium.canonicalId + } + + return nil + } + + /// Check if two stadium UUIDs represent the same physical stadium + func isSameStadium(_ id1: UUID, _ id2: UUID) async throws -> Bool { + guard let canonicalId1 = try await canonicalId(for: id1), + let canonicalId2 = try await canonicalId(for: id2) else { + // If we can't resolve, fall back to direct comparison + return id1 == id2 + } + return canonicalId1 == canonicalId2 + } + + /// Get the current UUID for a canonical stadium ID + func currentUUID(forCanonicalId canonicalId: String) async throws -> UUID? { + // Check cache first + if let cached = canonicalIdToUUID[canonicalId] { + return cached + } + + guard let container = modelContainer else { + return nil + } + + let context = ModelContext(container) + + let descriptor = FetchDescriptor( + predicate: #Predicate { stadium in + stadium.canonicalId == canonicalId && stadium.deprecatedAt == nil + } + ) + + guard let stadium = try context.fetch(descriptor).first else { + return nil + } + + // Cache the result + canonicalIdToUUID[canonicalId] = stadium.uuid + uuidToCanonicalId[stadium.uuid] = stadium.canonicalId + + return stadium.uuid + } + + /// Get the current name for a canonical stadium ID + func currentName(forCanonicalId canonicalId: String) async throws -> String? { + guard let container = modelContainer else { + return nil + } + + let context = ModelContext(container) + + let descriptor = FetchDescriptor( + predicate: #Predicate { stadium in + stadium.canonicalId == canonicalId && stadium.deprecatedAt == nil + } + ) + + guard let stadium = try context.fetch(descriptor).first else { + return nil + } + + return stadium.name + } + + /// Get all historical names for a stadium + func allNames(forCanonicalId canonicalId: String) async throws -> [String] { + guard let container = modelContainer else { + return [] + } + + let context = ModelContext(container) + + // Get aliases + let aliasDescriptor = FetchDescriptor( + predicate: #Predicate { alias in + alias.stadiumCanonicalId == canonicalId + } + ) + + let aliases = try context.fetch(aliasDescriptor) + var names = aliases.map { $0.aliasName } + + // Add current name + if let currentName = try await currentName(forCanonicalId: canonicalId) { + if !names.contains(currentName.lowercased()) { + names.append(currentName) + } + } + + return names + } + + /// Find stadium by approximate location (for photo import) + func findStadium(near latitude: Double, longitude: Double, radiusMeters: Double = 5000) async throws -> CanonicalStadium? { + guard let container = modelContainer else { + return nil + } + + let context = ModelContext(container) + + // Fetch all active stadiums and filter by distance + let descriptor = FetchDescriptor( + predicate: #Predicate { stadium in + stadium.deprecatedAt == nil + } + ) + + let stadiums = try context.fetch(descriptor) + + // Calculate approximate degree ranges for the radius + // At equator: 1 degree ≈ 111km, so radiusMeters / 111000 gives degrees + let degreeDelta = radiusMeters / 111000.0 + + let nearbyStadiums = stadiums.filter { stadium in + abs(stadium.latitude - latitude) <= degreeDelta && + abs(stadium.longitude - longitude) <= degreeDelta * 1.5 // Account for longitude compression at higher latitudes + } + + // If multiple stadiums nearby, find the closest + guard !nearbyStadiums.isEmpty else { + return nil + } + + if nearbyStadiums.count == 1 { + return nearbyStadiums.first + } + + // Calculate actual distances for the nearby stadiums + return nearbyStadiums.min { s1, s2 in + let d1 = haversineDistance(lat1: latitude, lon1: longitude, lat2: s1.latitude, lon2: s1.longitude) + let d2 = haversineDistance(lat1: latitude, lon1: longitude, lat2: s2.latitude, lon2: s2.longitude) + return d1 < d2 + } + } + + // MARK: - Cache Management + + /// Invalidate all caches (call after sync) + func invalidateCache() { + uuidToCanonicalId.removeAll() + canonicalIdToUUID.removeAll() + nameToCanonicalId.removeAll() + } + + // MARK: - Private Helpers + + /// Calculate distance between two coordinates using Haversine formula + nonisolated private func haversineDistance(lat1: Double, lon1: Double, lat2: Double, lon2: Double) -> Double { + let R = 6371000.0 // Earth's radius in meters + let phi1 = lat1 * .pi / 180 + let phi2 = lat2 * .pi / 180 + let deltaPhi = (lat2 - lat1) * .pi / 180 + let deltaLambda = (lon2 - lon1) * .pi / 180 + + let a = sin(deltaPhi / 2) * sin(deltaPhi / 2) + + cos(phi1) * cos(phi2) * sin(deltaLambda / 2) * sin(deltaLambda / 2) + let c = 2 * atan2(sqrt(a), sqrt(1 - a)) + + return R * c + } +} diff --git a/SportsTime/Core/Services/StadiumProximityMatcher.swift b/SportsTime/Core/Services/StadiumProximityMatcher.swift new file mode 100644 index 0000000..3125961 --- /dev/null +++ b/SportsTime/Core/Services/StadiumProximityMatcher.swift @@ -0,0 +1,348 @@ +// +// StadiumProximityMatcher.swift +// SportsTime +// +// Service for matching GPS coordinates to nearby stadiums. +// + +import Foundation +import CoreLocation + +// MARK: - Match Confidence + +enum MatchConfidence: Sendable { + case high // < 500m from stadium center + case medium // 500m - 2km + case low // 2km - 5km + case none // > 5km or no coordinates + + nonisolated var description: String { + switch self { + case .high: return "High (within 500m)" + case .medium: return "Medium (500m - 2km)" + case .low: return "Low (2km - 5km)" + case .none: return "No match" + } + } + + /// Should auto-select this match without user confirmation? + nonisolated var shouldAutoSelect: Bool { + switch self { + case .high: return true + default: return false + } + } +} + +// Explicit nonisolated Equatable and Comparable conformance +extension MatchConfidence: Equatable { + nonisolated static func == (lhs: MatchConfidence, rhs: MatchConfidence) -> Bool { + switch (lhs, rhs) { + case (.high, .high), (.medium, .medium), (.low, .low), (.none, .none): + return true + default: + return false + } + } +} + +extension MatchConfidence: Comparable { + nonisolated static func < (lhs: MatchConfidence, rhs: MatchConfidence) -> Bool { + let order: [MatchConfidence] = [.none, .low, .medium, .high] + guard let lhsIndex = order.firstIndex(of: lhs), + let rhsIndex = order.firstIndex(of: rhs) else { + return false + } + return lhsIndex < rhsIndex + } +} + +// MARK: - Stadium Match + +struct StadiumMatch: Identifiable, Sendable { + let id: UUID + let stadium: Stadium + let distance: CLLocationDistance + let confidence: MatchConfidence + + init(stadium: Stadium, distance: CLLocationDistance) { + self.id = stadium.id + self.stadium = stadium + self.distance = distance + self.confidence = Self.calculateConfidence(for: distance) + } + + var formattedDistance: String { + if distance < 1000 { + return String(format: "%.0fm away", distance) + } else { + return String(format: "%.1f km away", distance / 1000) + } + } + + private static func calculateConfidence(for distance: CLLocationDistance) -> MatchConfidence { + switch distance { + case 0..<500: + return .high + case 500..<2000: + return .medium + case 2000..<5000: + return .low + default: + return .none + } + } +} + +// MARK: - Temporal Confidence + +enum TemporalConfidence: Sendable { + case exactDay // Same local date as game + case adjacentDay // ±1 day (tailgating, next morning) + case outOfRange // >1 day difference + + nonisolated var description: String { + switch self { + case .exactDay: return "Same day" + case .adjacentDay: return "Adjacent day (±1)" + case .outOfRange: return "Out of range" + } + } +} + +extension TemporalConfidence: Equatable { + nonisolated static func == (lhs: TemporalConfidence, rhs: TemporalConfidence) -> Bool { + switch (lhs, rhs) { + case (.exactDay, .exactDay), (.adjacentDay, .adjacentDay), (.outOfRange, .outOfRange): + return true + default: + return false + } + } +} + +extension TemporalConfidence: Comparable { + nonisolated static func < (lhs: TemporalConfidence, rhs: TemporalConfidence) -> Bool { + let order: [TemporalConfidence] = [.outOfRange, .adjacentDay, .exactDay] + guard let lhsIndex = order.firstIndex(of: lhs), + let rhsIndex = order.firstIndex(of: rhs) else { + return false + } + return lhsIndex < rhsIndex + } +} + +// MARK: - Combined Confidence + +enum CombinedConfidence: Sendable { + case autoSelect // High spatial + exactDay → auto-select + case userConfirm // Medium spatial OR adjacentDay → user confirms + case manualOnly // Low spatial OR outOfRange → manual entry + + nonisolated var description: String { + switch self { + case .autoSelect: return "Auto-select" + case .userConfirm: return "Needs confirmation" + case .manualOnly: return "Manual entry required" + } + } + + nonisolated static func combine(spatial: MatchConfidence, temporal: TemporalConfidence) -> CombinedConfidence { + // Low spatial or out of range → manual only + switch spatial { + case .low, .none: + return .manualOnly + default: + break + } + + switch temporal { + case .outOfRange: + return .manualOnly + default: + break + } + + // High spatial + exact day → auto-select + switch (spatial, temporal) { + case (.high, .exactDay): + return .autoSelect + default: + break + } + + // Everything else needs user confirmation + return .userConfirm + } +} + +extension CombinedConfidence: Equatable { + nonisolated static func == (lhs: CombinedConfidence, rhs: CombinedConfidence) -> Bool { + switch (lhs, rhs) { + case (.autoSelect, .autoSelect), (.userConfirm, .userConfirm), (.manualOnly, .manualOnly): + return true + default: + return false + } + } +} + +extension CombinedConfidence: Comparable { + nonisolated static func < (lhs: CombinedConfidence, rhs: CombinedConfidence) -> Bool { + let order: [CombinedConfidence] = [.manualOnly, .userConfirm, .autoSelect] + guard let lhsIndex = order.firstIndex(of: lhs), + let rhsIndex = order.firstIndex(of: rhs) else { + return false + } + return lhsIndex < rhsIndex + } +} + +// MARK: - Photo Match Confidence + +struct PhotoMatchConfidence: Sendable { + let spatial: MatchConfidence + let temporal: TemporalConfidence + let combined: CombinedConfidence + + nonisolated init(spatial: MatchConfidence, temporal: TemporalConfidence) { + self.spatial = spatial + self.temporal = temporal + self.combined = CombinedConfidence.combine(spatial: spatial, temporal: temporal) + } +} + +// MARK: - Stadium Proximity Matcher + +@MainActor +final class StadiumProximityMatcher { + static let shared = StadiumProximityMatcher() + + // Configuration constants + static let highConfidenceRadius: CLLocationDistance = 500 // 500m + static let mediumConfidenceRadius: CLLocationDistance = 2000 // 2km + static let searchRadius: CLLocationDistance = 5000 // 5km default + static let dateToleranceDays: Int = 1 // ±1 day for timezone/tailgating + + private let dataProvider = AppDataProvider.shared + + private init() {} + + // MARK: - Stadium Matching + + /// Find stadiums within radius of coordinates + func findNearbyStadiums( + coordinates: CLLocationCoordinate2D, + radius: CLLocationDistance = StadiumProximityMatcher.searchRadius, + sport: Sport? = nil + ) -> [StadiumMatch] { + let photoLocation = CLLocation(latitude: coordinates.latitude, longitude: coordinates.longitude) + + var stadiums = dataProvider.stadiums + + // Filter by sport if specified + if let sport = sport { + let sportTeams = dataProvider.teams.filter { $0.sport == sport } + let stadiumIds = Set(sportTeams.map { $0.stadiumId }) + stadiums = stadiums.filter { stadiumIds.contains($0.id) } + } + + // Calculate distances and filter by radius + var matches: [StadiumMatch] = [] + + for stadium in stadiums { + let stadiumLocation = CLLocation(latitude: stadium.latitude, longitude: stadium.longitude) + let distance = photoLocation.distance(from: stadiumLocation) + + if distance <= radius { + matches.append(StadiumMatch(stadium: stadium, distance: distance)) + } + } + + // Sort by distance (closest first) + return matches.sorted { $0.distance < $1.distance } + } + + /// Find best matching stadium (single result) + func findBestMatch( + coordinates: CLLocationCoordinate2D, + sport: Sport? = nil + ) -> StadiumMatch? { + let matches = findNearbyStadiums(coordinates: coordinates, sport: sport) + return matches.first + } + + /// Check if coordinates are near any stadium + func isNearStadium( + coordinates: CLLocationCoordinate2D, + radius: CLLocationDistance = StadiumProximityMatcher.searchRadius + ) -> Bool { + let matches = findNearbyStadiums(coordinates: coordinates, radius: radius) + return !matches.isEmpty + } + + // MARK: - Temporal Matching + + /// Calculate temporal confidence between photo date and game date + nonisolated func calculateTemporalConfidence(photoDate: Date, gameDate: Date) -> TemporalConfidence { + let calendar = Calendar.current + + // Normalize to day boundaries + let photoDay = calendar.startOfDay(for: photoDate) + let gameDay = calendar.startOfDay(for: gameDate) + + let daysDifference = abs(calendar.dateComponents([.day], from: photoDay, to: gameDay).day ?? Int.max) + + switch daysDifference { + case 0: + return .exactDay + case 1: + return .adjacentDay + default: + return .outOfRange + } + } + + /// Calculate combined confidence for a photo-stadium-game match + nonisolated func calculateMatchConfidence( + stadiumMatch: StadiumMatch, + photoDate: Date?, + gameDate: Date? + ) -> PhotoMatchConfidence { + let spatial = stadiumMatch.confidence + + let temporal: TemporalConfidence + if let photoDate = photoDate, let gameDate = gameDate { + temporal = calculateTemporalConfidence(photoDate: photoDate, gameDate: gameDate) + } else { + // Missing date information + temporal = .outOfRange + } + + return PhotoMatchConfidence(spatial: spatial, temporal: temporal) + } +} + +// MARK: - Batch Processing + +extension StadiumProximityMatcher { + /// Find matches for multiple photos + func findMatchesForPhotos( + _ metadata: [PhotoMetadata], + sport: Sport? = nil + ) -> [(metadata: PhotoMetadata, matches: [StadiumMatch])] { + var results: [(metadata: PhotoMetadata, matches: [StadiumMatch])] = [] + + for photo in metadata { + if let coordinates = photo.coordinates { + let matches = findNearbyStadiums(coordinates: coordinates, sport: sport) + results.append((metadata: photo, matches: matches)) + } else { + // No coordinates - empty matches + results.append((metadata: photo, matches: [])) + } + } + + return results + } +} diff --git a/SportsTime/Core/Services/StubDataProvider.swift b/SportsTime/Core/Services/StubDataProvider.swift index 4d428bc..658928c 100644 --- a/SportsTime/Core/Services/StubDataProvider.swift +++ b/SportsTime/Core/Services/StubDataProvider.swift @@ -194,6 +194,7 @@ actor StubDataProvider: DataProvider { latitude: json.latitude, longitude: json.longitude, capacity: json.capacity, + sport: parseSport(json.sport), yearOpened: json.year_opened ) } diff --git a/SportsTime/Core/Services/SuggestedTripsGenerator.swift b/SportsTime/Core/Services/SuggestedTripsGenerator.swift index 907a650..784d0a4 100644 --- a/SportsTime/Core/Services/SuggestedTripsGenerator.swift +++ b/SportsTime/Core/Services/SuggestedTripsGenerator.swift @@ -265,13 +265,17 @@ final class SuggestedTripsGenerator { // Build richGames dictionary let richGames = buildRichGames(from: selectedGames, teams: teams, stadiums: stadiums) + // Compute sports from games actually in the trip (not all selectedGames) + let gameIdsInTrip = Set(trip.stops.flatMap { $0.games }) + let actualSports = Set(gameIdsInTrip.compactMap { richGames[$0]?.game.sport }) + return SuggestedTrip( id: UUID(), region: region, - isSingleSport: singleSport, + isSingleSport: actualSports.count == 1, trip: trip, richGames: richGames, - sports: sports + sports: actualSports.isEmpty ? sports : actualSports ) case .failure: @@ -339,6 +343,10 @@ final class SuggestedTripsGenerator { guard selectedGames.count >= 4 else { return nil } + // Ensure enough unique cities for a true cross-country trip + let uniqueCities = Set(selectedGames.compactMap { stadiums[$0.stadiumId]?.city }) + guard uniqueCities.count >= 3 else { return nil } + // Calculate trip dates guard let firstGame = selectedGames.first, let lastGame = selectedGames.last else { return nil } @@ -377,13 +385,29 @@ final class SuggestedTripsGenerator { // Build richGames dictionary let richGames = buildRichGames(from: selectedGames, teams: teams, stadiums: stadiums) + // Validate the final trip meets cross-country requirements: + // - At least 4 stops (cities) + // - At least 2 different regions + guard trip.stops.count >= 4 else { return nil } + + let stopsWithRegions = trip.stops.compactMap { stop -> Region? in + guard let stadium = stadiums.values.first(where: { $0.city == stop.city }) else { return nil } + return stadium.region + } + let uniqueRegions = Set(stopsWithRegions) + guard uniqueRegions.count >= 2 else { return nil } + + // Compute sports from games actually in the trip (not all selectedGames) + let gameIdsInTrip = Set(trip.stops.flatMap { $0.games }) + let actualSports = Set(gameIdsInTrip.compactMap { richGames[$0]?.game.sport }) + return SuggestedTrip( id: UUID(), region: .crossCountry, - isSingleSport: sports.count == 1, + isSingleSport: actualSports.count == 1, trip: trip, richGames: richGames, - sports: sports + sports: actualSports.isEmpty ? sports : actualSports ) case .failure: diff --git a/SportsTime/Core/Services/VisitPhotoService.swift b/SportsTime/Core/Services/VisitPhotoService.swift new file mode 100644 index 0000000..5d61e0a --- /dev/null +++ b/SportsTime/Core/Services/VisitPhotoService.swift @@ -0,0 +1,410 @@ +// +// VisitPhotoService.swift +// SportsTime +// +// Manages visit photos with CloudKit sync for backup. +// Thumbnails stored locally in SwiftData for fast loading. +// Full images stored in CloudKit private database. +// + +import Foundation +import CloudKit +import SwiftData +import UIKit + +// MARK: - Photo Service Errors + +enum PhotoServiceError: Error, LocalizedError { + case notSignedIn + case uploadFailed(String) + case downloadFailed(String) + case thumbnailGenerationFailed + case invalidImage + case assetNotFound + case quotaExceeded + + var errorDescription: String? { + switch self { + case .notSignedIn: + return "Please sign in to iCloud to sync photos" + case .uploadFailed(let message): + return "Upload failed: \(message)" + case .downloadFailed(let message): + return "Download failed: \(message)" + case .thumbnailGenerationFailed: + return "Could not generate thumbnail" + case .invalidImage: + return "Invalid image data" + case .assetNotFound: + return "Photo not found in cloud storage" + case .quotaExceeded: + return "iCloud storage quota exceeded" + } + } +} + +// MARK: - Visit Photo Service + +@MainActor +final class VisitPhotoService { + + // MARK: - Properties + + private let modelContext: ModelContext + private let container: CKContainer + private let privateDatabase: CKDatabase + + // Configuration + private static let thumbnailSize = CGSize(width: 200, height: 200) + private static let compressionQuality: CGFloat = 0.7 + private static let recordType = "VisitPhoto" + + // MARK: - Initialization + + init(modelContext: ModelContext) { + self.modelContext = modelContext + self.container = CKContainer(identifier: "iCloud.com.sportstime.app") + self.privateDatabase = container.privateCloudDatabase + } + + // MARK: - Public API + + /// Add a photo to a visit + /// - Parameters: + /// - visit: The visit to add the photo to + /// - image: The UIImage to add + /// - caption: Optional caption for the photo + /// - Returns: The created photo metadata + func addPhoto(to visit: StadiumVisit, image: UIImage, caption: String? = nil) async throws -> VisitPhotoMetadata { + // Generate thumbnail + guard let thumbnail = generateThumbnail(from: image) else { + throw PhotoServiceError.thumbnailGenerationFailed + } + + guard let thumbnailData = thumbnail.jpegData(compressionQuality: Self.compressionQuality) else { + throw PhotoServiceError.thumbnailGenerationFailed + } + + // Get current photo count for order index + let orderIndex = visit.photoMetadata?.count ?? 0 + + // Create metadata record + let metadata = VisitPhotoMetadata( + visitId: visit.id, + cloudKitAssetId: nil, + thumbnailData: thumbnailData, + caption: caption, + orderIndex: orderIndex, + uploadStatus: .pending + ) + + // Add to visit + if visit.photoMetadata == nil { + visit.photoMetadata = [] + } + visit.photoMetadata?.append(metadata) + + modelContext.insert(metadata) + try modelContext.save() + + // Queue background upload + Task.detached { [weak self] in + await self?.uploadPhoto(metadata: metadata, image: image) + } + + return metadata + } + + /// Fetch full-resolution image for a photo + /// - Parameter metadata: The photo metadata + /// - Returns: The full-resolution UIImage + func fetchFullImage(for metadata: VisitPhotoMetadata) async throws -> UIImage { + guard let assetId = metadata.cloudKitAssetId else { + throw PhotoServiceError.assetNotFound + } + + let recordID = CKRecord.ID(recordName: assetId) + + do { + let record = try await privateDatabase.record(for: recordID) + + guard let asset = record["imageAsset"] as? CKAsset, + let fileURL = asset.fileURL, + let data = try? Data(contentsOf: fileURL), + let image = UIImage(data: data) else { + throw PhotoServiceError.downloadFailed("Could not read image data") + } + + return image + } catch let error as CKError { + throw mapCloudKitError(error) + } + } + + /// Delete a photo from visit and CloudKit + /// - Parameter metadata: The photo metadata to delete + func deletePhoto(_ metadata: VisitPhotoMetadata) async throws { + // Delete from CloudKit if uploaded + if let assetId = metadata.cloudKitAssetId { + let recordID = CKRecord.ID(recordName: assetId) + do { + try await privateDatabase.deleteRecord(withID: recordID) + } catch { + // Continue with local deletion even if CloudKit fails + } + } + + // Delete from SwiftData + modelContext.delete(metadata) + try modelContext.save() + } + + /// Retry uploading failed photos + func retryFailedUploads() async { + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.uploadStatusRaw == "failed" || $0.uploadStatusRaw == "pending" } + ) + + do { + let pendingPhotos = try modelContext.fetch(descriptor) + + for metadata in pendingPhotos { + // We can't upload without the original image + // Mark as failed permanently if no thumbnail + if metadata.thumbnailData == nil { + metadata.uploadStatus = .failed + } + } + + try modelContext.save() + } catch { + // Silently fail - will retry on next launch + } + } + + /// Get upload status summary + func getUploadStatus() -> (pending: Int, uploaded: Int, failed: Int) { + let descriptor = FetchDescriptor() + + do { + let all = try modelContext.fetch(descriptor) + + let pending = all.filter { $0.uploadStatus == .pending }.count + let uploaded = all.filter { $0.uploadStatus == .uploaded }.count + let failed = all.filter { $0.uploadStatus == .failed }.count + + return (pending, uploaded, failed) + } catch { + return (0, 0, 0) + } + } + + /// Check if CloudKit is available for photo sync + func isCloudKitAvailable() async -> Bool { + do { + let status = try await container.accountStatus() + return status == .available + } catch { + return false + } + } + + // MARK: - Private Methods + + private func uploadPhoto(metadata: VisitPhotoMetadata, image: UIImage) async { + guard let imageData = image.jpegData(compressionQuality: Self.compressionQuality) else { + await MainActor.run { + metadata.uploadStatus = .failed + try? modelContext.save() + } + return + } + + // Check CloudKit availability + do { + let status = try await container.accountStatus() + guard status == .available else { + await MainActor.run { + metadata.uploadStatus = .failed + try? modelContext.save() + } + return + } + } catch { + await MainActor.run { + metadata.uploadStatus = .failed + try? modelContext.save() + } + return + } + + // Create CloudKit record + let recordID = CKRecord.ID(recordName: metadata.id.uuidString) + let record = CKRecord(recordType: Self.recordType, recordID: recordID) + + // Write image to temporary file for CKAsset + let tempURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString) + .appendingPathExtension("jpg") + + do { + try imageData.write(to: tempURL) + + let asset = CKAsset(fileURL: tempURL) + record["imageAsset"] = asset + record["visitId"] = metadata.visitId.uuidString + record["caption"] = metadata.caption + record["orderIndex"] = metadata.orderIndex as CKRecordValue + + // Upload to CloudKit + let savedRecord = try await privateDatabase.save(record) + + // Clean up temp file + try? FileManager.default.removeItem(at: tempURL) + + // Update metadata + await MainActor.run { + metadata.cloudKitAssetId = savedRecord.recordID.recordName + metadata.uploadStatus = .uploaded + try? modelContext.save() + } + + } catch let error as CKError { + // Clean up temp file + try? FileManager.default.removeItem(at: tempURL) + + await MainActor.run { + metadata.uploadStatus = .failed + try? modelContext.save() + } + } catch { + // Clean up temp file + try? FileManager.default.removeItem(at: tempURL) + + await MainActor.run { + metadata.uploadStatus = .failed + try? modelContext.save() + } + } + } + + private func generateThumbnail(from image: UIImage) -> UIImage? { + let size = Self.thumbnailSize + let aspectRatio = image.size.width / image.size.height + + let targetSize: CGSize + if aspectRatio > 1 { + // Landscape + targetSize = CGSize(width: size.width, height: size.width / aspectRatio) + } else { + // Portrait or square + targetSize = CGSize(width: size.height * aspectRatio, height: size.height) + } + + let renderer = UIGraphicsImageRenderer(size: targetSize) + return renderer.image { context in + image.draw(in: CGRect(origin: .zero, size: targetSize)) + } + } + + private func mapCloudKitError(_ error: CKError) -> PhotoServiceError { + switch error.code { + case .notAuthenticated: + return .notSignedIn + case .quotaExceeded: + return .quotaExceeded + case .unknownItem: + return .assetNotFound + default: + return .downloadFailed(error.localizedDescription) + } + } +} + +// MARK: - Photo Gallery View Model + +@Observable +@MainActor +final class PhotoGalleryViewModel { + var photos: [VisitPhotoMetadata] = [] + var selectedPhoto: VisitPhotoMetadata? + var fullResolutionImage: UIImage? + var isLoadingFullImage = false + var error: PhotoServiceError? + + private let photoService: VisitPhotoService + private let visit: StadiumVisit + + init(visit: StadiumVisit, modelContext: ModelContext) { + self.visit = visit + self.photoService = VisitPhotoService(modelContext: modelContext) + loadPhotos() + } + + func loadPhotos() { + photos = (visit.photoMetadata ?? []).sorted { $0.orderIndex < $1.orderIndex } + } + + func addPhoto(_ image: UIImage, caption: String? = nil) async { + do { + let metadata = try await photoService.addPhoto(to: visit, image: image, caption: caption) + photos.append(metadata) + photos.sort { $0.orderIndex < $1.orderIndex } + } catch let error as PhotoServiceError { + self.error = error + } catch { + self.error = .uploadFailed(error.localizedDescription) + } + } + + func selectPhoto(_ metadata: VisitPhotoMetadata) { + selectedPhoto = metadata + loadFullResolution(for: metadata) + } + + func loadFullResolution(for metadata: VisitPhotoMetadata) { + guard metadata.cloudKitAssetId != nil else { + // Photo not uploaded yet, use thumbnail + if let data = metadata.thumbnailData { + fullResolutionImage = UIImage(data: data) + } + return + } + + isLoadingFullImage = true + Task { + do { + let image = try await photoService.fetchFullImage(for: metadata) + fullResolutionImage = image + } catch let error as PhotoServiceError { + self.error = error + // Fall back to thumbnail + if let data = metadata.thumbnailData { + fullResolutionImage = UIImage(data: data) + } + } catch { + self.error = .downloadFailed(error.localizedDescription) + } + isLoadingFullImage = false + } + } + + func deletePhoto(_ metadata: VisitPhotoMetadata) async { + do { + try await photoService.deletePhoto(metadata) + photos.removeAll { $0.id == metadata.id } + if selectedPhoto?.id == metadata.id { + selectedPhoto = nil + fullResolutionImage = nil + } + } catch let error as PhotoServiceError { + self.error = error + } catch { + self.error = .uploadFailed(error.localizedDescription) + } + } + + func clearError() { + error = nil + } +} diff --git a/SportsTime/Core/Theme/AnimatedComponents.swift b/SportsTime/Core/Theme/AnimatedComponents.swift index c03d8ec..8163c59 100644 --- a/SportsTime/Core/Theme/AnimatedComponents.swift +++ b/SportsTime/Core/Theme/AnimatedComponents.swift @@ -103,6 +103,75 @@ struct AnimatedRouteGraphic: View { } } +// MARK: - Themed Spinner + +/// A custom animated spinner matching the app's visual style +struct ThemedSpinner: View { + var size: CGFloat = 40 + var lineWidth: CGFloat = 4 + + @State private var rotation: Double = 0 + @State private var trimEnd: CGFloat = 0.6 + + var body: some View { + ZStack { + // Background track + Circle() + .stroke(Theme.warmOrange.opacity(0.15), lineWidth: lineWidth) + + // Animated arc + Circle() + .trim(from: 0, to: trimEnd) + .stroke( + AngularGradient( + gradient: Gradient(colors: [Theme.warmOrange, Theme.routeGold, Theme.warmOrange.opacity(0.3)]), + center: .center, + startAngle: .degrees(0), + endAngle: .degrees(360) + ), + style: StrokeStyle(lineWidth: lineWidth, lineCap: .round) + ) + .rotationEffect(.degrees(rotation)) + + // Center glow dot + Circle() + .fill(Theme.warmOrange.opacity(0.2)) + .frame(width: size * 0.3, height: size * 0.3) + .blur(radius: 4) + } + .frame(width: size, height: size) + .onAppear { + withAnimation(.linear(duration: 1).repeatForever(autoreverses: false)) { + rotation = 360 + } + withAnimation(.easeInOut(duration: 0.8).repeatForever(autoreverses: true)) { + trimEnd = 0.8 + } + } + } +} + +/// Compact themed spinner for inline use +struct ThemedSpinnerCompact: View { + var size: CGFloat = 20 + var color: Color = Theme.warmOrange + + @State private var rotation: Double = 0 + + var body: some View { + Circle() + .trim(from: 0, to: 0.7) + .stroke(color, style: StrokeStyle(lineWidth: size > 16 ? 2.5 : 2, lineCap: .round)) + .frame(width: size, height: size) + .rotationEffect(.degrees(rotation)) + .onAppear { + withAnimation(.linear(duration: 0.8).repeatForever(autoreverses: false)) { + rotation = 360 + } + } + } +} + // MARK: - Pulsing Dot struct PulsingDot: View { @@ -188,10 +257,8 @@ struct PlanningProgressView: View { var body: some View { VStack(spacing: 24) { - // Simple spinner - ProgressView() - .scaleEffect(1.5) - .tint(Theme.warmOrange) + // Themed spinner + ThemedSpinner(size: 56, lineWidth: 5) // Current step text Text(steps[currentStep]) @@ -284,8 +351,96 @@ struct EmptyStateView: View { } } +// MARK: - Loading Overlay + +/// A modal loading overlay with progress indication +/// Reusable pattern from PDF export overlay +struct LoadingOverlay: View { + let message: String + var detail: String? + var progress: Double? + var icon: String = "hourglass" + + @Environment(\.colorScheme) private var colorScheme + + var body: some View { + ZStack { + // Background dimmer + Color.black.opacity(0.6) + .ignoresSafeArea() + + // Progress card + VStack(spacing: Theme.Spacing.lg) { + // Progress ring or spinner + ZStack { + Circle() + .stroke(Theme.cardBackgroundElevated(colorScheme), lineWidth: 8) + .frame(width: 80, height: 80) + + if let progress = progress { + Circle() + .trim(from: 0, to: progress) + .stroke(Theme.warmOrange, style: StrokeStyle(lineWidth: 8, lineCap: .round)) + .frame(width: 80, height: 80) + .rotationEffect(.degrees(-90)) + .animation(.easeInOut(duration: 0.3), value: progress) + } else { + ThemedSpinner(size: 48, lineWidth: 5) + } + + Image(systemName: icon) + .font(.system(size: 24)) + .foregroundStyle(Theme.warmOrange) + .opacity(progress != nil ? 1 : 0) + } + + VStack(spacing: Theme.Spacing.xs) { + Text(message) + .font(.system(size: Theme.FontSize.cardTitle, weight: .semibold)) + .foregroundStyle(Theme.textPrimary(colorScheme)) + + if let detail = detail { + Text(detail) + .font(.system(size: Theme.FontSize.caption)) + .foregroundStyle(Theme.textSecondary(colorScheme)) + .multilineTextAlignment(.center) + } + + if let progress = progress { + Text("\(Int(progress * 100))%") + .font(.system(size: Theme.FontSize.micro, weight: .medium, design: .monospaced)) + .foregroundStyle(Theme.textMuted(colorScheme)) + } + } + } + .padding(Theme.Spacing.xl) + .background(Theme.cardBackground(colorScheme)) + .clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.large)) + .shadow(color: .black.opacity(0.3), radius: 20, y: 10) + } + .transition(.opacity) + } +} + // MARK: - Preview +#Preview("Themed Spinners") { + VStack(spacing: 40) { + ThemedSpinner(size: 60, lineWidth: 5) + + ThemedSpinner(size: 40) + + ThemedSpinnerCompact() + + HStack(spacing: 20) { + ThemedSpinnerCompact(size: 16) + Text("Loading...") + } + } + .padding(40) + .themedBackground() +} + #Preview("Animated Components") { VStack(spacing: 40) { AnimatedRouteGraphic() @@ -306,3 +461,25 @@ struct EmptyStateView: View { .padding() .themedBackground() } + +#Preview("Loading Overlay") { + ZStack { + Color.gray + LoadingOverlay( + message: "Planning Your Trip", + detail: "Finding the best route..." + ) + } +} + +#Preview("Loading Overlay with Progress") { + ZStack { + Color.gray + LoadingOverlay( + message: "Creating PDF", + detail: "Processing images...", + progress: 0.65, + icon: "doc.fill" + ) + } +} diff --git a/SportsTime/Export/Services/ProgressCardGenerator.swift b/SportsTime/Export/Services/ProgressCardGenerator.swift new file mode 100644 index 0000000..2f8aee5 --- /dev/null +++ b/SportsTime/Export/Services/ProgressCardGenerator.swift @@ -0,0 +1,598 @@ +// +// ProgressCardGenerator.swift +// SportsTime +// +// Generates shareable progress cards for social media. +// Cards include progress ring, stats, optional username, and app branding. +// + +import SwiftUI +import UIKit +import MapKit + +// MARK: - Progress Card Generator + +@MainActor +final class ProgressCardGenerator { + + // Card dimensions (Instagram story size) + private static let cardSize = CGSize(width: 1080, height: 1920) + private static let mapSnapshotSize = CGSize(width: 1000, height: 500) + + // MARK: - Generate Card + + /// Generate a shareable progress card image + /// - Parameters: + /// - progress: The league progress data + /// - options: Card generation options + /// - Returns: The generated UIImage + func generateCard( + progress: LeagueProgress, + options: ProgressCardOptions = ProgressCardOptions() + ) async throws -> UIImage { + // Generate map snapshot if needed + var mapSnapshot: UIImage? + if options.includeMapSnapshot { + mapSnapshot = await generateMapSnapshot( + visited: progress.stadiumsVisited, + remaining: progress.stadiumsRemaining + ) + } + + // Render SwiftUI view to image + let cardView = ProgressCardView( + progress: progress, + options: options, + mapSnapshot: mapSnapshot + ) + + let renderer = ImageRenderer(content: cardView) + renderer.scale = 3.0 // High resolution + + guard let image = renderer.uiImage else { + throw CardGeneratorError.renderingFailed + } + + return image + } + + /// Generate a map snapshot showing visited/unvisited stadiums + /// - Parameters: + /// - visited: Stadiums that have been visited + /// - remaining: Stadiums not yet visited + /// - Returns: The map snapshot image + func generateMapSnapshot( + visited: [Stadium], + remaining: [Stadium] + ) async -> UIImage? { + let allStadiums = visited + remaining + guard !allStadiums.isEmpty else { return nil } + + // Calculate region to show all stadiums + let coordinates = allStadiums.map { + CLLocationCoordinate2D(latitude: $0.latitude, longitude: $0.longitude) + } + + let minLat = coordinates.map(\.latitude).min() ?? 0 + let maxLat = coordinates.map(\.latitude).max() ?? 0 + let minLon = coordinates.map(\.longitude).min() ?? 0 + let maxLon = coordinates.map(\.longitude).max() ?? 0 + + let center = CLLocationCoordinate2D( + latitude: (minLat + maxLat) / 2, + longitude: (minLon + maxLon) / 2 + ) + + let span = MKCoordinateSpan( + latitudeDelta: (maxLat - minLat) * 1.3, + longitudeDelta: (maxLon - minLon) * 1.3 + ) + + let region = MKCoordinateRegion(center: center, span: span) + + // Create snapshot options + let options = MKMapSnapshotter.Options() + options.region = region + options.size = Self.mapSnapshotSize + options.mapType = .mutedStandard + + let snapshotter = MKMapSnapshotter(options: options) + + do { + let snapshot = try await snapshotter.start() + + // Draw annotations on snapshot + let image = UIGraphicsImageRenderer(size: Self.mapSnapshotSize).image { context in + snapshot.image.draw(at: .zero) + + // Draw stadium markers + for stadium in remaining { + let point = snapshot.point(for: CLLocationCoordinate2D( + latitude: stadium.latitude, + longitude: stadium.longitude + )) + drawMarker(at: point, color: .gray, context: context.cgContext) + } + + for stadium in visited { + let point = snapshot.point(for: CLLocationCoordinate2D( + latitude: stadium.latitude, + longitude: stadium.longitude + )) + drawMarker(at: point, color: UIColor(Theme.warmOrange), context: context.cgContext) + } + } + + return image + } catch { + return nil + } + } + + private func drawMarker(at point: CGPoint, color: UIColor, context: CGContext) { + let markerSize: CGFloat = 16 + + context.setFillColor(color.cgColor) + context.fillEllipse(in: CGRect( + x: point.x - markerSize / 2, + y: point.y - markerSize / 2, + width: markerSize, + height: markerSize + )) + + // White border + context.setStrokeColor(UIColor.white.cgColor) + context.setLineWidth(2) + context.strokeEllipse(in: CGRect( + x: point.x - markerSize / 2, + y: point.y - markerSize / 2, + width: markerSize, + height: markerSize + )) + } +} + +// MARK: - Card Generator Errors + +enum CardGeneratorError: Error, LocalizedError { + case renderingFailed + case mapSnapshotFailed + + var errorDescription: String? { + switch self { + case .renderingFailed: + return "Failed to render progress card" + case .mapSnapshotFailed: + return "Failed to generate map snapshot" + } + } +} + +// MARK: - Progress Card View + +struct ProgressCardView: View { + let progress: LeagueProgress + let options: ProgressCardOptions + let mapSnapshot: UIImage? + + var body: some View { + ZStack { + // Background gradient + LinearGradient( + colors: options.cardStyle == .dark + ? [Color(hex: "1A1A2E"), Color(hex: "16213E")] + : [Color.white, Color(hex: "F5F5F5")], + startPoint: .top, + endPoint: .bottom + ) + + VStack(spacing: 40) { + // App logo and title + headerSection + + Spacer() + + // Progress ring + progressRingSection + + // Stats row + if options.includeStats { + statsSection + } + + // Map snapshot + if options.includeMapSnapshot, let snapshot = mapSnapshot { + mapSection(image: snapshot) + } + + Spacer() + + // Username if included + if options.includeUsername, let username = options.username, !username.isEmpty { + usernameSection(username) + } + + // App branding footer + footerSection + } + .padding(60) + } + .frame(width: 1080, height: 1920) + } + + // MARK: - Header + + private var headerSection: some View { + VStack(spacing: 16) { + // Sport icon + ZStack { + Circle() + .fill(progress.sport.themeColor.opacity(0.2)) + .frame(width: 80, height: 80) + + Image(systemName: progress.sport.iconName) + .font(.system(size: 40)) + .foregroundStyle(progress.sport.themeColor) + } + + Text("\(progress.sport.displayName) Stadium Quest") + .font(.system(size: 48, weight: .bold, design: .rounded)) + .foregroundStyle(options.cardStyle.textColor) + } + } + + // MARK: - Progress Ring + + private var progressRingSection: some View { + ZStack { + // Background ring + Circle() + .stroke(Theme.warmOrange.opacity(0.2), lineWidth: 24) + .frame(width: 320, height: 320) + + // Progress ring + Circle() + .trim(from: 0, to: progress.completionPercentage / 100) + .stroke( + Theme.warmOrange, + style: StrokeStyle(lineWidth: 24, lineCap: .round) + ) + .frame(width: 320, height: 320) + .rotationEffect(.degrees(-90)) + + // Center content + VStack(spacing: 8) { + Text("\(progress.visitedStadiums)") + .font(.system(size: 96, weight: .bold, design: .rounded)) + .foregroundStyle(options.cardStyle.textColor) + + Text("of \(progress.totalStadiums)") + .font(.system(size: 32, weight: .medium)) + .foregroundStyle(options.cardStyle.secondaryTextColor) + + Text("Stadiums Visited") + .font(.system(size: 24)) + .foregroundStyle(options.cardStyle.secondaryTextColor) + } + } + } + + // MARK: - Stats + + private var statsSection: some View { + HStack(spacing: 60) { + statItem(value: "\(progress.visitedStadiums)", label: "Visited") + statItem(value: "\(progress.totalStadiums - progress.visitedStadiums)", label: "Remaining") + statItem(value: String(format: "%.0f%%", progress.completionPercentage), label: "Complete") + } + .padding(.vertical, 30) + .padding(.horizontal, 40) + .background( + RoundedRectangle(cornerRadius: 20) + .fill(options.cardStyle == .dark + ? Color.white.opacity(0.05) + : Color.black.opacity(0.05)) + ) + } + + private func statItem(value: String, label: String) -> some View { + VStack(spacing: 8) { + Text(value) + .font(.system(size: 36, weight: .bold, design: .rounded)) + .foregroundStyle(Theme.warmOrange) + + Text(label) + .font(.system(size: 20)) + .foregroundStyle(options.cardStyle.secondaryTextColor) + } + } + + // MARK: - Map + + private func mapSection(image: UIImage) -> some View { + Image(uiImage: image) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(maxWidth: 960) + .clipShape(RoundedRectangle(cornerRadius: 20)) + .overlay { + RoundedRectangle(cornerRadius: 20) + .stroke(Theme.warmOrange.opacity(0.3), lineWidth: 2) + } + } + + // MARK: - Username + + private func usernameSection(_ username: String) -> some View { + HStack(spacing: 12) { + Image(systemName: "person.circle.fill") + .font(.system(size: 24)) + Text(username) + .font(.system(size: 28, weight: .medium)) + } + .foregroundStyle(options.cardStyle.secondaryTextColor) + } + + // MARK: - Footer + + private var footerSection: some View { + VStack(spacing: 12) { + HStack(spacing: 8) { + Image(systemName: "sportscourt.fill") + .font(.system(size: 20)) + Text("SportsTime") + .font(.system(size: 24, weight: .semibold)) + } + .foregroundStyle(Theme.warmOrange) + + Text("Track your stadium adventures") + .font(.system(size: 18)) + .foregroundStyle(options.cardStyle.secondaryTextColor) + } + } +} + +// MARK: - Progress Share View + +struct ProgressShareView: View { + let progress: LeagueProgress + + @Environment(\.colorScheme) private var colorScheme + @Environment(\.dismiss) private var dismiss + + @State private var generatedImage: UIImage? + @State private var isGenerating = false + @State private var showShareSheet = false + @State private var error: String? + + @State private var includeUsername = true + @State private var username = "" + @State private var includeMap = true + @State private var cardStyle: ProgressCardOptions.CardStyle = .dark + + var body: some View { + NavigationStack { + ScrollView { + VStack(spacing: Theme.Spacing.lg) { + // Preview card + previewCard + .padding(.horizontal) + + // Options + optionsSection + + // Generate button + generateButton + .padding(.horizontal) + } + .padding(.vertical) + } + .navigationTitle("Share Progress") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + } + } + .sheet(isPresented: $showShareSheet) { + if let image = generatedImage { + ShareSheet(items: [image]) + } + } + .alert("Error", isPresented: .constant(error != nil)) { + Button("OK") { error = nil } + } message: { + Text(error ?? "") + } + } + } + + private var previewCard: some View { + VStack(spacing: Theme.Spacing.md) { + Text("Preview") + .font(.system(size: Theme.FontSize.caption, weight: .medium)) + .foregroundStyle(Theme.textMuted(colorScheme)) + + // Mini preview + ZStack { + RoundedRectangle(cornerRadius: Theme.CornerRadius.medium) + .fill(cardStyle == .dark + ? Color(hex: "1A1A2E") + : Color.white) + .aspectRatio(9/16, contentMode: .fit) + .frame(maxHeight: 300) + + VStack(spacing: 12) { + // Sport badge + HStack(spacing: 4) { + Image(systemName: progress.sport.iconName) + Text(progress.sport.displayName) + } + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(progress.sport.themeColor) + + // Progress ring + ZStack { + Circle() + .stroke(Theme.warmOrange.opacity(0.2), lineWidth: 4) + .frame(width: 60, height: 60) + + Circle() + .trim(from: 0, to: progress.completionPercentage / 100) + .stroke(Theme.warmOrange, style: StrokeStyle(lineWidth: 4, lineCap: .round)) + .frame(width: 60, height: 60) + .rotationEffect(.degrees(-90)) + + VStack(spacing: 0) { + Text("\(progress.visitedStadiums)") + .font(.system(size: 18, weight: .bold)) + Text("/\(progress.totalStadiums)") + .font(.system(size: 10)) + } + .foregroundStyle(cardStyle == .dark ? .white : .black) + } + + if includeMap { + RoundedRectangle(cornerRadius: 4) + .fill(Color.gray.opacity(0.2)) + .frame(height: 40) + .overlay { + Image(systemName: "map") + .foregroundStyle(Color.gray) + } + } + + if includeUsername && !username.isEmpty { + Text("@\(username)") + .font(.system(size: 10)) + .foregroundStyle(cardStyle == .dark ? Color.gray : Color.gray) + } + + // Branding + HStack(spacing: 4) { + Image(systemName: "sportscourt.fill") + Text("SportsTime") + } + .font(.system(size: 10, weight: .medium)) + .foregroundStyle(Theme.warmOrange) + } + .padding() + } + } + } + + private var optionsSection: some View { + VStack(spacing: Theme.Spacing.md) { + // Style selector + VStack(alignment: .leading, spacing: Theme.Spacing.xs) { + Text("Style") + .font(.system(size: Theme.FontSize.caption, weight: .medium)) + .foregroundStyle(Theme.textMuted(colorScheme)) + + HStack(spacing: Theme.Spacing.sm) { + styleButton(style: .dark, label: "Dark") + styleButton(style: .light, label: "Light") + } + } + .padding(.horizontal) + + // Username toggle + Toggle(isOn: $includeUsername) { + Text("Include Username") + .font(.system(size: Theme.FontSize.body)) + } + .padding(.horizontal) + + if includeUsername { + TextField("Username", text: $username) + .textFieldStyle(.roundedBorder) + .padding(.horizontal) + } + + // Map toggle + Toggle(isOn: $includeMap) { + Text("Include Map") + .font(.system(size: Theme.FontSize.body)) + } + .padding(.horizontal) + } + .padding(.vertical) + .background(Theme.cardBackground(colorScheme)) + } + + private func styleButton(style: ProgressCardOptions.CardStyle, label: String) -> some View { + Button { + withAnimation { cardStyle = style } + } label: { + Text(label) + .font(.system(size: Theme.FontSize.caption, weight: .medium)) + .foregroundStyle(cardStyle == style ? .white : Theme.textPrimary(colorScheme)) + .padding(.horizontal, Theme.Spacing.md) + .padding(.vertical, Theme.Spacing.sm) + .background(cardStyle == style ? Theme.warmOrange : Theme.cardBackgroundElevated(colorScheme)) + .clipShape(Capsule()) + } + .buttonStyle(.plain) + } + + private var generateButton: some View { + Button { + generateCard() + } label: { + HStack { + if isGenerating { + ThemedSpinnerCompact(size: 18, color: .white) + } else { + Image(systemName: "square.and.arrow.up") + } + Text(isGenerating ? "Generating..." : "Generate & Share") + } + .frame(maxWidth: .infinity) + .padding(Theme.Spacing.md) + .background(Theme.warmOrange) + .foregroundStyle(.white) + .clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)) + } + .disabled(isGenerating) + } + + private func generateCard() { + isGenerating = true + + Task { + let options = ProgressCardOptions( + includeUsername: includeUsername, + username: username, + includeMapSnapshot: includeMap, + includeStats: true, + cardStyle: cardStyle + ) + + let generator = ProgressCardGenerator() + + do { + generatedImage = try await generator.generateCard( + progress: progress, + options: options + ) + showShareSheet = true + } catch { + self.error = error.localizedDescription + } + + isGenerating = false + } + } +} + +// MARK: - Preview + +#Preview { + ProgressShareView(progress: LeagueProgress( + sport: .mlb, + totalStadiums: 30, + visitedStadiums: 12, + stadiumsVisited: [], + stadiumsRemaining: [] + )) +} diff --git a/SportsTime/Features/Home/Views/HomeView.swift b/SportsTime/Features/Home/Views/HomeView.swift index 0011556..8eda66c 100644 --- a/SportsTime/Features/Home/Views/HomeView.swift +++ b/SportsTime/Features/Home/Views/HomeView.swift @@ -16,6 +16,7 @@ struct HomeView: View { @State private var selectedTab = 0 @State private var suggestedTripsGenerator = SuggestedTripsGenerator() @State private var selectedSuggestedTrip: SuggestedTrip? + @State private var tripCreationViewModel = TripCreationViewModel() var body: some View { TabView(selection: $selectedTab) { @@ -83,6 +84,15 @@ struct HomeView: View { } .tag(2) + // Progress Tab + NavigationStack { + ProgressTabView() + } + .tabItem { + Label("Progress", systemImage: "chart.bar.fill") + } + .tag(3) + // Settings Tab NavigationStack { SettingsView() @@ -90,11 +100,11 @@ struct HomeView: View { .tabItem { Label("Settings", systemImage: "gear") } - .tag(3) + .tag(4) } .tint(Theme.warmOrange) .sheet(isPresented: $showNewTrip) { - TripCreationView(initialSport: selectedSport) + TripCreationView(viewModel: tripCreationViewModel, initialSport: selectedSport) } .onChange(of: showNewTrip) { _, isShowing in if !isShowing { @@ -110,6 +120,7 @@ struct HomeView: View { NavigationStack { TripDetailView(trip: suggestedTrip.trip, games: suggestedTrip.richGames) } + .interactiveDismissDisabled() } } diff --git a/SportsTime/Features/Progress/ViewModels/PhotoImportViewModel.swift b/SportsTime/Features/Progress/ViewModels/PhotoImportViewModel.swift new file mode 100644 index 0000000..17451da --- /dev/null +++ b/SportsTime/Features/Progress/ViewModels/PhotoImportViewModel.swift @@ -0,0 +1,173 @@ +// +// PhotoImportViewModel.swift +// SportsTime +// +// ViewModel for photo import flow - orchestrates extraction, matching, and import. +// + +import Foundation +import SwiftUI +import PhotosUI +import SwiftData +import Photos + +@MainActor @Observable +final class PhotoImportViewModel { + // State + var showingPicker = false + var isProcessing = false + var processedCount = 0 + var totalCount = 0 + + // Results + var processedPhotos: [PhotoImportCandidate] = [] + var confirmedImports: Set = [] + var selectedMatches: [UUID: GameMatchCandidate] = [:] + + // Services + private let metadataExtractor = PhotoMetadataExtractor.shared + private let gameMatcher = GameMatcher.shared + + // MARK: - Computed + + var categorized: GameMatcher.CategorizedImports { + gameMatcher.categorizeImports(processedPhotos) + } + + var hasConfirmedImports: Bool { + !confirmedImports.isEmpty + } + + var confirmedCount: Int { + confirmedImports.count + } + + // MARK: - Photo Processing + + func processSelectedPhotos(_ items: [PhotosPickerItem]) async { + guard !items.isEmpty else { return } + + isProcessing = true + totalCount = items.count + processedCount = 0 + processedPhotos = [] + confirmedImports = [] + selectedMatches = [:] + + // Load PHAssets from PhotosPickerItems + var assets: [PHAsset] = [] + + for item in items { + if let assetId = item.itemIdentifier { + let fetchResult = PHAsset.fetchAssets(withLocalIdentifiers: [assetId], options: nil) + if let asset = fetchResult.firstObject { + assets.append(asset) + } + } + processedCount += 1 + } + + // Extract metadata from all assets + let metadataList = await metadataExtractor.extractMetadata(from: assets) + + // Process each photo through game matcher + processedCount = 0 + for metadata in metadataList { + let candidate = await gameMatcher.processPhotoForImport(metadata: metadata) + processedPhotos.append(candidate) + + // Auto-confirm high-confidence matches + if candidate.canAutoProcess { + confirmedImports.insert(candidate.id) + } + + processedCount += 1 + } + + isProcessing = false + } + + // MARK: - User Actions + + func toggleConfirmation(for candidateId: UUID) { + if confirmedImports.contains(candidateId) { + confirmedImports.remove(candidateId) + } else { + confirmedImports.insert(candidateId) + } + } + + func selectMatch(_ match: GameMatchCandidate, for candidateId: UUID) { + selectedMatches[candidateId] = match + confirmedImports.insert(candidateId) + } + + func confirmAll() { + for candidate in processedPhotos { + if case .singleMatch = candidate.matchResult { + confirmedImports.insert(candidate.id) + } else if case .multipleMatches = candidate.matchResult, + selectedMatches[candidate.id] != nil { + confirmedImports.insert(candidate.id) + } + } + } + + // MARK: - Import Creation + + func createVisits(modelContext: ModelContext) async { + for candidate in processedPhotos { + guard confirmedImports.contains(candidate.id) else { continue } + + // Get the match to use + let matchToUse: GameMatchCandidate? + + switch candidate.matchResult { + case .singleMatch(let match): + matchToUse = match + case .multipleMatches: + matchToUse = selectedMatches[candidate.id] + case .noMatches: + matchToUse = nil + } + + guard let match = matchToUse else { continue } + + // Create the visit + let visit = StadiumVisit( + canonicalStadiumId: match.stadium.id.uuidString, + stadiumUUID: match.stadium.id, + stadiumNameAtVisit: match.stadium.name, + visitDate: match.game.dateTime, + sport: match.game.sport, + visitType: .game, + homeTeamName: match.homeTeam.fullName, + awayTeamName: match.awayTeam.fullName, + finalScore: nil, + scoreSource: nil, + dataSource: .automatic, + seatLocation: nil, + notes: nil, + photoLatitude: candidate.metadata.coordinates?.latitude, + photoLongitude: candidate.metadata.coordinates?.longitude, + photoCaptureDate: candidate.metadata.captureDate, + source: .photoImport + ) + + modelContext.insert(visit) + } + + try? modelContext.save() + } + + // MARK: - Reset + + func reset() { + processedPhotos = [] + confirmedImports = [] + selectedMatches = [:] + isProcessing = false + processedCount = 0 + totalCount = 0 + } +} diff --git a/SportsTime/Features/Progress/ViewModels/ProgressViewModel.swift b/SportsTime/Features/Progress/ViewModels/ProgressViewModel.swift new file mode 100644 index 0000000..5a84d69 --- /dev/null +++ b/SportsTime/Features/Progress/ViewModels/ProgressViewModel.swift @@ -0,0 +1,204 @@ +// +// ProgressViewModel.swift +// SportsTime +// +// ViewModel for stadium progress tracking and visualization. +// + +import Foundation +import SwiftUI +import SwiftData + +@MainActor +@Observable +final class ProgressViewModel { + + // MARK: - State + + var selectedSport: Sport = .mlb + var isLoading = false + var error: Error? + var errorMessage: String? + + // MARK: - Data + + private(set) var visits: [StadiumVisit] = [] + private(set) var stadiums: [Stadium] = [] + private(set) var teams: [Team] = [] + + // MARK: - Dependencies + + private var modelContainer: ModelContainer? + private let dataProvider = AppDataProvider.shared + + // MARK: - Computed Properties + + /// Overall progress for the selected sport + var leagueProgress: LeagueProgress { + // Filter stadiums by sport directly (same as sportStadiums) + let sportStadiums = stadiums.filter { $0.sport == selectedSport } + + let visitedStadiumIds = Set( + visits + .filter { $0.sportEnum == selectedSport } + .compactMap { visit -> UUID? in + // Match visit's canonical stadium ID to a stadium + stadiums.first { stadium in + stadium.id == visit.stadiumUUID + }?.id + } + ) + + let visited = sportStadiums.filter { visitedStadiumIds.contains($0.id) } + let remaining = sportStadiums.filter { !visitedStadiumIds.contains($0.id) } + + return LeagueProgress( + sport: selectedSport, + totalStadiums: sportStadiums.count, + visitedStadiums: visited.count, + stadiumsVisited: visited, + stadiumsRemaining: remaining + ) + } + + /// Stadium visit status indexed by stadium ID + var stadiumVisitStatus: [UUID: StadiumVisitStatus] { + var statusMap: [UUID: StadiumVisitStatus] = [:] + + // Group visits by stadium + let visitsByStadium = Dictionary(grouping: visits.filter { $0.sportEnum == selectedSport }) { $0.stadiumUUID } + + for stadium in stadiums { + if let stadiumVisits = visitsByStadium[stadium.id], !stadiumVisits.isEmpty { + let summaries = stadiumVisits.map { visit in + VisitSummary( + id: visit.id, + stadium: stadium, + visitDate: visit.visitDate, + visitType: visit.visitType, + sport: selectedSport, + matchup: visit.matchupDescription, + score: visit.finalScore, + photoCount: visit.photoMetadata?.count ?? 0, + notes: visit.notes + ) + } + statusMap[stadium.id] = .visited(visits: summaries) + } else { + statusMap[stadium.id] = .notVisited + } + } + + return statusMap + } + + /// Stadiums for the selected sport + var sportStadiums: [Stadium] { + stadiums.filter { $0.sport == selectedSport } + } + + /// Visited stadiums for the selected sport + var visitedStadiums: [Stadium] { + leagueProgress.stadiumsVisited + } + + /// Unvisited stadiums for the selected sport + var unvisitedStadiums: [Stadium] { + leagueProgress.stadiumsRemaining + } + + /// Recent visits sorted by date + var recentVisits: [VisitSummary] { + visits + .sorted { $0.visitDate > $1.visitDate } + .prefix(10) + .compactMap { visit -> VisitSummary? in + guard let stadium = stadiums.first(where: { $0.id == visit.stadiumUUID }), + let sport = visit.sportEnum else { + return nil + } + return VisitSummary( + id: visit.id, + stadium: stadium, + visitDate: visit.visitDate, + visitType: visit.visitType, + sport: sport, + matchup: visit.matchupDescription, + score: visit.finalScore, + photoCount: visit.photoMetadata?.count ?? 0, + notes: visit.notes + ) + } + } + + // MARK: - Configuration + + func configure(with container: ModelContainer) { + self.modelContainer = container + } + + // MARK: - Actions + + func loadData() async { + isLoading = true + error = nil + errorMessage = nil + + do { + // Load stadiums and teams from data provider + if dataProvider.stadiums.isEmpty { + await dataProvider.loadInitialData() + } + stadiums = dataProvider.stadiums + teams = dataProvider.teams + + // Load visits from SwiftData + if let container = modelContainer { + let context = ModelContext(container) + let descriptor = FetchDescriptor( + sortBy: [SortDescriptor(\.visitDate, order: .reverse)] + ) + visits = try context.fetch(descriptor) + } + } catch { + self.error = error + self.errorMessage = error.localizedDescription + } + + isLoading = false + } + + func selectSport(_ sport: Sport) { + selectedSport = sport + } + + func clearError() { + error = nil + errorMessage = nil + } + + // MARK: - Visit Management + + func deleteVisit(_ visit: StadiumVisit) async throws { + guard let container = modelContainer else { return } + + let context = ModelContext(container) + context.delete(visit) + try context.save() + + // Reload data + await loadData() + } + + // MARK: - Progress Card Generation + + func progressCardData(includeUsername: Bool = false) -> ProgressCardData { + ProgressCardData( + sport: selectedSport, + progress: leagueProgress, + username: nil, + includeMap: true, + showDetailedStats: false + ) + } +} diff --git a/SportsTime/Features/Progress/Views/AchievementsListView.swift b/SportsTime/Features/Progress/Views/AchievementsListView.swift new file mode 100644 index 0000000..95cc541 --- /dev/null +++ b/SportsTime/Features/Progress/Views/AchievementsListView.swift @@ -0,0 +1,526 @@ +// +// AchievementsListView.swift +// SportsTime +// +// Displays achievements gallery with earned, in-progress, and locked badges. +// + +import SwiftUI +import SwiftData + +struct AchievementsListView: View { + @Environment(\.modelContext) private var modelContext + @Environment(\.colorScheme) private var colorScheme + + @State private var achievements: [AchievementProgress] = [] + @State private var isLoading = true + @State private var selectedCategory: AchievementCategory? + @State private var selectedAchievement: AchievementProgress? + + var body: some View { + ScrollView { + VStack(spacing: Theme.Spacing.lg) { + // Summary header + achievementSummary + .staggeredAnimation(index: 0) + + // Category filter + categoryFilter + .staggeredAnimation(index: 1) + + // Achievements grid + achievementsGrid + .staggeredAnimation(index: 2) + } + .padding(Theme.Spacing.md) + } + .themedBackground() + .navigationTitle("Achievements") + .task { + await loadAchievements() + } + .sheet(item: $selectedAchievement) { achievement in + AchievementDetailSheet(achievement: achievement) + .presentationDetents([.medium]) + } + } + + // MARK: - Achievement Summary + + private var achievementSummary: some View { + let earned = achievements.filter { $0.isEarned }.count + let total = achievements.count + + return HStack(spacing: Theme.Spacing.lg) { + // Trophy icon + ZStack { + Circle() + .fill(Theme.warmOrange.opacity(0.15)) + .frame(width: 70, height: 70) + + Image(systemName: "trophy.fill") + .font(.system(size: 32)) + .foregroundStyle(Theme.warmOrange) + } + + VStack(alignment: .leading, spacing: Theme.Spacing.xs) { + Text("\(earned) / \(total)") + .font(.system(size: Theme.FontSize.heroTitle, weight: .bold, design: .rounded)) + .foregroundStyle(Theme.textPrimary(colorScheme)) + + Text("Achievements Earned") + .font(.system(size: Theme.FontSize.body)) + .foregroundStyle(Theme.textSecondary(colorScheme)) + + if earned == total && total > 0 { + HStack(spacing: 4) { + Image(systemName: "star.fill") + Text("All achievements unlocked!") + } + .font(.system(size: Theme.FontSize.caption, weight: .semibold)) + .foregroundStyle(Theme.warmOrange) + } + } + + Spacer() + } + .padding(Theme.Spacing.lg) + .background(Theme.cardBackground(colorScheme)) + .clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.large)) + .overlay { + RoundedRectangle(cornerRadius: Theme.CornerRadius.large) + .stroke(Theme.surfaceGlow(colorScheme), lineWidth: 1) + } + .shadow(color: Theme.cardShadow(colorScheme), radius: 10, y: 5) + } + + // MARK: - Category Filter + + private var categoryFilter: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: Theme.Spacing.sm) { + CategoryFilterButton( + title: "All", + icon: "square.grid.2x2", + isSelected: selectedCategory == nil + ) { + withAnimation(Theme.Animation.spring) { + selectedCategory = nil + } + } + + ForEach(AchievementCategory.allCases, id: \.self) { category in + CategoryFilterButton( + title: category.displayName, + icon: category.iconName, + isSelected: selectedCategory == category + ) { + withAnimation(Theme.Animation.spring) { + selectedCategory = category + } + } + } + } + } + } + + // MARK: - Achievements Grid + + private var achievementsGrid: some View { + let filtered = filteredAchievements + + return LazyVGrid( + columns: [GridItem(.flexible()), GridItem(.flexible())], + spacing: Theme.Spacing.md + ) { + ForEach(filtered) { achievement in + AchievementCard(achievement: achievement) + .onTapGesture { + selectedAchievement = achievement + } + } + } + } + + private var filteredAchievements: [AchievementProgress] { + guard let category = selectedCategory else { + return achievements.sorted { first, second in + // Earned first, then by progress + if first.isEarned != second.isEarned { + return first.isEarned + } + return first.progressPercentage > second.progressPercentage + } + } + return achievements.filter { $0.definition.category == category } + .sorted { first, second in + if first.isEarned != second.isEarned { + return first.isEarned + } + return first.progressPercentage > second.progressPercentage + } + } + + // MARK: - Data Loading + + private func loadAchievements() async { + isLoading = true + do { + let engine = AchievementEngine(modelContext: modelContext) + achievements = try await engine.getProgress() + } catch { + // Handle error silently, show empty state + achievements = [] + } + isLoading = false + } +} + +// MARK: - Category Filter Button + +struct CategoryFilterButton: View { + let title: String + let icon: String + let isSelected: Bool + let action: () -> Void + + @Environment(\.colorScheme) private var colorScheme + + var body: some View { + Button(action: action) { + HStack(spacing: Theme.Spacing.xs) { + Image(systemName: icon) + .font(.system(size: 14)) + Text(title) + .font(.system(size: Theme.FontSize.caption, weight: .medium)) + } + .padding(.horizontal, Theme.Spacing.md) + .padding(.vertical, Theme.Spacing.sm) + .background(isSelected ? Theme.warmOrange : Theme.cardBackground(colorScheme)) + .foregroundStyle(isSelected ? .white : Theme.textPrimary(colorScheme)) + .clipShape(Capsule()) + .overlay { + Capsule() + .stroke(isSelected ? Color.clear : Theme.surfaceGlow(colorScheme), lineWidth: 1) + } + } + .buttonStyle(.plain) + } +} + +// MARK: - Achievement Card + +struct AchievementCard: View { + let achievement: AchievementProgress + + @Environment(\.colorScheme) private var colorScheme + + var body: some View { + VStack(spacing: Theme.Spacing.sm) { + // Badge icon + ZStack { + Circle() + .fill(badgeBackgroundColor) + .frame(width: 60, height: 60) + + Image(systemName: achievement.definition.iconName) + .font(.system(size: 28)) + .foregroundStyle(badgeIconColor) + + if !achievement.isEarned { + Circle() + .fill(.black.opacity(0.3)) + .frame(width: 60, height: 60) + + Image(systemName: "lock.fill") + .font(.system(size: 14)) + .foregroundStyle(.white) + } + } + + // Title + Text(achievement.definition.name) + .font(.system(size: Theme.FontSize.caption, weight: .semibold)) + .foregroundStyle(achievement.isEarned ? Theme.textPrimary(colorScheme) : Theme.textMuted(colorScheme)) + .multilineTextAlignment(.center) + .lineLimit(2) + .fixedSize(horizontal: false, vertical: true) + + // Progress or earned date + if achievement.isEarned { + if let earnedAt = achievement.earnedAt { + Text(earnedAt.formatted(date: .abbreviated, time: .omitted)) + .font(.system(size: Theme.FontSize.micro)) + .foregroundStyle(Theme.warmOrange) + } + } else { + // Progress bar + VStack(spacing: 4) { + ProgressView(value: achievement.progressPercentage) + .progressViewStyle(AchievementProgressStyle()) + + Text(achievement.progressText) + .font(.system(size: Theme.FontSize.micro)) + .foregroundStyle(Theme.textMuted(colorScheme)) + } + } + } + .padding(Theme.Spacing.md) + .frame(maxWidth: .infinity, minHeight: 170) + .background(Theme.cardBackground(colorScheme)) + .clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)) + .overlay { + RoundedRectangle(cornerRadius: Theme.CornerRadius.medium) + .stroke(achievement.isEarned ? Theme.warmOrange.opacity(0.5) : Theme.surfaceGlow(colorScheme), lineWidth: achievement.isEarned ? 2 : 1) + } + .shadow(color: Theme.cardShadow(colorScheme), radius: 5, y: 2) + .opacity(achievement.isEarned ? 1.0 : 0.7) + } + + private var badgeBackgroundColor: Color { + if achievement.isEarned { + return categoryColor.opacity(0.2) + } + return Theme.cardBackgroundElevated(colorScheme) + } + + private var badgeIconColor: Color { + if achievement.isEarned { + return categoryColor + } + return Theme.textMuted(colorScheme) + } + + private var categoryColor: Color { + switch achievement.definition.category { + case .count: + return Theme.warmOrange + case .division: + return Theme.routeGold + case .conference: + return Theme.routeAmber + case .league: + return Color(hex: "FFD700") // Gold + case .journey: + return Color(hex: "9B59B6") // Purple + case .special: + return Color(hex: "E74C3C") // Red + } + } +} + +// MARK: - Achievement Progress Style + +struct AchievementProgressStyle: ProgressViewStyle { + @Environment(\.colorScheme) private var colorScheme + + func makeBody(configuration: Configuration) -> some View { + GeometryReader { geometry in + ZStack(alignment: .leading) { + RoundedRectangle(cornerRadius: 2) + .fill(Theme.cardBackgroundElevated(colorScheme)) + .frame(height: 4) + + RoundedRectangle(cornerRadius: 2) + .fill(Theme.warmOrange) + .frame(width: geometry.size.width * CGFloat(configuration.fractionCompleted ?? 0), height: 4) + } + } + .frame(height: 4) + } +} + +// MARK: - Achievement Detail Sheet + +struct AchievementDetailSheet: View { + let achievement: AchievementProgress + + @Environment(\.colorScheme) private var colorScheme + @Environment(\.dismiss) private var dismiss + + var body: some View { + NavigationStack { + VStack(spacing: Theme.Spacing.xl) { + // Large badge + ZStack { + Circle() + .fill(badgeBackgroundColor) + .frame(width: 120, height: 120) + + if achievement.isEarned { + Circle() + .stroke(Theme.warmOrange, lineWidth: 4) + .frame(width: 130, height: 130) + } + + Image(systemName: achievement.definition.iconName) + .font(.system(size: 56)) + .foregroundStyle(badgeIconColor) + + if !achievement.isEarned { + Circle() + .fill(.black.opacity(0.3)) + .frame(width: 120, height: 120) + + Image(systemName: "lock.fill") + .font(.system(size: 24)) + .foregroundStyle(.white) + } + } + + // Title and description + VStack(spacing: Theme.Spacing.sm) { + Text(achievement.definition.name) + .font(.system(size: Theme.FontSize.sectionTitle, weight: .bold, design: .rounded)) + .foregroundStyle(Theme.textPrimary(colorScheme)) + + Text(achievement.definition.description) + .font(.system(size: Theme.FontSize.body)) + .foregroundStyle(Theme.textSecondary(colorScheme)) + .multilineTextAlignment(.center) + + // Category badge + HStack(spacing: 4) { + Image(systemName: achievement.definition.category.iconName) + Text(achievement.definition.category.displayName) + } + .font(.system(size: Theme.FontSize.caption)) + .foregroundStyle(categoryColor) + .padding(.horizontal, Theme.Spacing.sm) + .padding(.vertical, Theme.Spacing.xs) + .background(categoryColor.opacity(0.15)) + .clipShape(Capsule()) + } + + // Status section + if achievement.isEarned { + if let earnedAt = achievement.earnedAt { + VStack(spacing: 4) { + Image(systemName: "checkmark.seal.fill") + .font(.system(size: 24)) + .foregroundStyle(.green) + + Text("Earned on \(earnedAt.formatted(date: .long, time: .omitted))") + .font(.system(size: Theme.FontSize.body, weight: .medium)) + .foregroundStyle(Theme.textPrimary(colorScheme)) + } + } + } else { + // Progress section + VStack(spacing: Theme.Spacing.sm) { + Text("Progress") + .font(.system(size: Theme.FontSize.caption, weight: .medium)) + .foregroundStyle(Theme.textMuted(colorScheme)) + + ProgressView(value: achievement.progressPercentage) + .progressViewStyle(LargeProgressStyle()) + .frame(width: 200) + + Text("\(achievement.currentProgress) / \(achievement.totalRequired)") + .font(.system(size: Theme.FontSize.cardTitle, weight: .bold)) + .foregroundStyle(Theme.textPrimary(colorScheme)) + } + } + + // Sport badge if applicable + if let sport = achievement.definition.sport { + HStack(spacing: Theme.Spacing.xs) { + Image(systemName: sport.iconName) + Text(sport.displayName) + } + .font(.system(size: Theme.FontSize.caption, weight: .medium)) + .foregroundStyle(sport.themeColor) + .padding(.horizontal, Theme.Spacing.md) + .padding(.vertical, Theme.Spacing.sm) + .background(sport.themeColor.opacity(0.15)) + .clipShape(Capsule()) + } + + Spacer() + } + .padding(Theme.Spacing.lg) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Done") { dismiss() } + } + } + } + } + + private var badgeBackgroundColor: Color { + if achievement.isEarned { + return categoryColor.opacity(0.2) + } + return Theme.cardBackgroundElevated(colorScheme) + } + + private var badgeIconColor: Color { + if achievement.isEarned { + return categoryColor + } + return Theme.textMuted(colorScheme) + } + + private var categoryColor: Color { + switch achievement.definition.category { + case .count: + return Theme.warmOrange + case .division: + return Theme.routeGold + case .conference: + return Theme.routeAmber + case .league: + return Color(hex: "FFD700") + case .journey: + return Color(hex: "9B59B6") + case .special: + return Color(hex: "E74C3C") + } + } +} + +// MARK: - Large Progress Style + +struct LargeProgressStyle: ProgressViewStyle { + @Environment(\.colorScheme) private var colorScheme + + func makeBody(configuration: Configuration) -> some View { + GeometryReader { geometry in + ZStack(alignment: .leading) { + RoundedRectangle(cornerRadius: 4) + .fill(Theme.cardBackgroundElevated(colorScheme)) + .frame(height: 8) + + RoundedRectangle(cornerRadius: 4) + .fill(Theme.warmOrange) + .frame(width: geometry.size.width * CGFloat(configuration.fractionCompleted ?? 0), height: 8) + } + } + .frame(height: 8) + } +} + +// MARK: - Category Extensions + +extension AchievementCategory { + var iconName: String { + switch self { + case .count: return "number.circle" + case .division: return "map" + case .conference: return "building.2" + case .league: return "crown" + case .journey: return "car.fill" + case .special: return "star.circle" + } + } +} + +// MARK: - Preview + +#Preview { + NavigationStack { + AchievementsListView() + } + .modelContainer(for: StadiumVisit.self, inMemory: true) +} diff --git a/SportsTime/Features/Progress/Views/GameMatchConfirmationView.swift b/SportsTime/Features/Progress/Views/GameMatchConfirmationView.swift new file mode 100644 index 0000000..1490711 --- /dev/null +++ b/SportsTime/Features/Progress/Views/GameMatchConfirmationView.swift @@ -0,0 +1,341 @@ +// +// GameMatchConfirmationView.swift +// SportsTime +// +// View for confirming/selecting the correct game match from photo import. +// + +import SwiftUI + +// MARK: - Game Match Confirmation View + +struct GameMatchConfirmationView: View { + let candidate: PhotoImportCandidate + let onConfirm: (GameMatchCandidate) -> Void + let onSkip: () -> Void + + @Environment(\.colorScheme) private var colorScheme + @Environment(\.dismiss) private var dismiss + @State private var selectedMatch: GameMatchCandidate? + + var body: some View { + NavigationStack { + ScrollView { + VStack(spacing: Theme.Spacing.lg) { + // Photo info header + photoInfoHeader + .staggeredAnimation(index: 0) + + // Stadium info + if let stadium = candidate.bestStadiumMatch { + stadiumCard(stadium) + .staggeredAnimation(index: 1) + } + + // Match options + matchOptionsSection + .staggeredAnimation(index: 2) + + // Action buttons + actionButtons + .staggeredAnimation(index: 3) + } + .padding(Theme.Spacing.md) + } + .themedBackground() + .navigationTitle("Confirm Game") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { + dismiss() + } + } + } + } + .onAppear { + // Pre-select if single match + if case .singleMatch(let match) = candidate.matchResult { + selectedMatch = match + } + } + } + + // MARK: - Photo Info Header + + private var photoInfoHeader: some View { + VStack(spacing: Theme.Spacing.md) { + // Icon + ZStack { + Circle() + .fill(Theme.warmOrange.opacity(0.15)) + .frame(width: 80, height: 80) + + Image(systemName: "photo.fill") + .font(.system(size: 36)) + .foregroundStyle(Theme.warmOrange) + } + + VStack(spacing: Theme.Spacing.xs) { + if let date = candidate.metadata.captureDate { + Label(formatDate(date), systemImage: "calendar") + .font(.system(size: Theme.FontSize.body, weight: .medium)) + .foregroundStyle(Theme.textPrimary(colorScheme)) + } + + if candidate.metadata.hasValidLocation { + Label("Location data available", systemImage: "location.fill") + .font(.system(size: Theme.FontSize.caption)) + .foregroundStyle(Theme.textSecondary(colorScheme)) + } else { + Label("No location data", systemImage: "location.slash") + .font(.system(size: Theme.FontSize.caption)) + .foregroundStyle(.red) + } + } + } + .frame(maxWidth: .infinity) + .padding(Theme.Spacing.lg) + .background(Theme.cardBackground(colorScheme)) + .clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.large)) + } + + // MARK: - Stadium Card + + private func stadiumCard(_ match: StadiumMatch) -> some View { + VStack(alignment: .leading, spacing: Theme.Spacing.md) { + HStack { + Image(systemName: "mappin.circle.fill") + .foregroundStyle(Theme.warmOrange) + Text("Nearest Stadium") + .font(.system(size: Theme.FontSize.body, weight: .semibold)) + .foregroundStyle(Theme.textPrimary(colorScheme)) + Spacer() + } + + HStack { + VStack(alignment: .leading, spacing: 4) { + Text(match.stadium.name) + .font(.system(size: Theme.FontSize.cardTitle, weight: .bold, design: .rounded)) + .foregroundStyle(Theme.textPrimary(colorScheme)) + + Text(match.stadium.fullAddress) + .font(.system(size: Theme.FontSize.caption)) + .foregroundStyle(Theme.textSecondary(colorScheme)) + } + + Spacer() + + // Distance badge + VStack(spacing: 2) { + Text(match.formattedDistance) + .font(.system(size: Theme.FontSize.caption, weight: .medium)) + .foregroundStyle(confidenceColor(match.confidence)) + + Text(match.confidence.description) + .font(.system(size: 10)) + .foregroundStyle(Theme.textMuted(colorScheme)) + } + } + } + .padding(Theme.Spacing.lg) + .background(Theme.cardBackground(colorScheme)) + .clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.large)) + .overlay { + RoundedRectangle(cornerRadius: Theme.CornerRadius.large) + .stroke(Theme.surfaceGlow(colorScheme), lineWidth: 1) + } + } + + // MARK: - Match Options Section + + private var matchOptionsSection: some View { + VStack(alignment: .leading, spacing: Theme.Spacing.md) { + HStack { + Image(systemName: "sportscourt.fill") + .foregroundStyle(Theme.warmOrange) + Text(matchOptionsTitle) + .font(.system(size: Theme.FontSize.body, weight: .semibold)) + .foregroundStyle(Theme.textPrimary(colorScheme)) + Spacer() + } + + matchOptionsContent + } + .padding(Theme.Spacing.lg) + .background(Theme.cardBackground(colorScheme)) + .clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.large)) + .overlay { + RoundedRectangle(cornerRadius: Theme.CornerRadius.large) + .stroke(Theme.surfaceGlow(colorScheme), lineWidth: 1) + } + } + + private var matchOptionsTitle: String { + switch candidate.matchResult { + case .singleMatch: + return "Matched Game" + case .multipleMatches(let matches): + return "Select Game (\(matches.count) options)" + case .noMatches: + return "No Games Found" + } + } + + @ViewBuilder + private var matchOptionsContent: some View { + switch candidate.matchResult { + case .singleMatch(let match): + gameMatchRow(match, isSelected: true) + + case .multipleMatches(let matches): + VStack(spacing: Theme.Spacing.sm) { + ForEach(matches) { match in + Button { + selectedMatch = match + } label: { + gameMatchRow(match, isSelected: selectedMatch?.id == match.id) + } + } + } + + case .noMatches(let reason): + HStack { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.red) + Text(reason.description) + .font(.system(size: Theme.FontSize.body)) + .foregroundStyle(Theme.textSecondary(colorScheme)) + } + .padding(Theme.Spacing.md) + } + } + + private func gameMatchRow(_ match: GameMatchCandidate, isSelected: Bool) -> some View { + HStack { + VStack(alignment: .leading, spacing: 4) { + HStack { + Text(match.matchupDescription) + .font(.system(size: Theme.FontSize.body, weight: .semibold)) + .foregroundStyle(Theme.textPrimary(colorScheme)) + + Image(systemName: match.game.sport.iconName) + .font(.caption) + .foregroundStyle(match.game.sport.themeColor) + } + + Text(match.gameDateTime) + .font(.system(size: Theme.FontSize.caption)) + .foregroundStyle(Theme.textSecondary(colorScheme)) + + // Confidence + HStack(spacing: 4) { + Circle() + .fill(combinedConfidenceColor(match.confidence.combined)) + .frame(width: 8, height: 8) + Text(match.confidence.combined.description) + .font(.system(size: 10)) + .foregroundStyle(Theme.textMuted(colorScheme)) + } + } + + Spacer() + + // Selection indicator + Image(systemName: isSelected ? "checkmark.circle.fill" : "circle") + .font(.title2) + .foregroundStyle(isSelected ? .green : Theme.textMuted(colorScheme)) + } + .padding(Theme.Spacing.md) + .background(isSelected ? Theme.cardBackgroundElevated(colorScheme) : Color.clear) + .clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.small)) + .overlay { + if isSelected { + RoundedRectangle(cornerRadius: Theme.CornerRadius.small) + .stroke(.green.opacity(0.5), lineWidth: 2) + } + } + } + + // MARK: - Action Buttons + + private var actionButtons: some View { + VStack(spacing: Theme.Spacing.sm) { + // Confirm button + Button { + if let match = selectedMatch { + onConfirm(match) + dismiss() + } + } label: { + HStack { + Image(systemName: "checkmark.circle.fill") + Text("Confirm & Import") + } + .font(.system(size: Theme.FontSize.body, weight: .semibold)) + .foregroundStyle(.white) + .frame(maxWidth: .infinity) + .padding(Theme.Spacing.md) + .background(selectedMatch != nil ? .green : Theme.textMuted(colorScheme)) + .clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)) + } + .disabled(selectedMatch == nil) + + // Skip button + Button { + onSkip() + dismiss() + } label: { + Text("Skip This Photo") + .font(.system(size: Theme.FontSize.body)) + .foregroundStyle(Theme.textSecondary(colorScheme)) + } + } + } + + // MARK: - Helpers + + private func formatDate(_ date: Date) -> String { + let formatter = DateFormatter() + formatter.dateStyle = .long + formatter.timeStyle = .short + return formatter.string(from: date) + } + + private func confidenceColor(_ confidence: MatchConfidence) -> Color { + switch confidence { + case .high: return .green + case .medium: return Theme.warmOrange + case .low: return .red + case .none: return Theme.textMuted(colorScheme) + } + } + + private func combinedConfidenceColor(_ confidence: CombinedConfidence) -> Color { + switch confidence { + case .autoSelect: return .green + case .userConfirm: return Theme.warmOrange + case .manualOnly: return .red + } + } +} + +// MARK: - Preview + +#Preview { + let metadata = PhotoMetadata( + captureDate: Date(), + coordinates: nil + ) + let candidate = PhotoImportCandidate( + metadata: metadata, + matchResult: .noMatches(.metadataMissing(.noLocation)), + stadiumMatches: [] + ) + + GameMatchConfirmationView( + candidate: candidate, + onConfirm: { _ in }, + onSkip: {} + ) +} diff --git a/SportsTime/Features/Progress/Views/PhotoImportView.swift b/SportsTime/Features/Progress/Views/PhotoImportView.swift new file mode 100644 index 0000000..549b617 --- /dev/null +++ b/SportsTime/Features/Progress/Views/PhotoImportView.swift @@ -0,0 +1,548 @@ +// +// PhotoImportView.swift +// SportsTime +// +// View for importing stadium visits from photos using GPS/date metadata. +// + +import SwiftUI +import SwiftData +import PhotosUI +import Photos + +// MARK: - Photo Import View + +struct PhotoImportView: View { + @Environment(\.modelContext) private var modelContext + @Environment(\.colorScheme) private var colorScheme + @Environment(\.dismiss) private var dismiss + + @State private var viewModel = PhotoImportViewModel() + @State private var selectedPhotos: [PhotosPickerItem] = [] + @State private var showingPermissionAlert = false + + var body: some View { + NavigationStack { + VStack(spacing: 0) { + if viewModel.isProcessing { + processingView + } else if viewModel.processedPhotos.isEmpty { + emptyStateView + } else { + resultsView + } + } + .themedBackground() + .navigationTitle("Import from Photos") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { + dismiss() + } + } + + if !viewModel.processedPhotos.isEmpty { + ToolbarItem(placement: .confirmationAction) { + Button("Import") { + importSelectedVisits() + } + .fontWeight(.semibold) + .disabled(!viewModel.hasConfirmedImports) + } + } + } + .photosPicker( + isPresented: $viewModel.showingPicker, + selection: $selectedPhotos, + maxSelectionCount: 20, + matching: .images, + photoLibrary: .shared() + ) + .onChange(of: selectedPhotos) { _, newValue in + Task { + await viewModel.processSelectedPhotos(newValue) + } + } + .alert("Photo Library Access", isPresented: $showingPermissionAlert) { + Button("Open Settings") { + if let url = URL(string: UIApplication.openSettingsURLString) { + UIApplication.shared.open(url) + } + } + Button("Cancel", role: .cancel) {} + } message: { + Text("SportsTime needs access to your photos to import stadium visits. Please enable access in Settings.") + } + } + } + + // MARK: - Empty State + + private var emptyStateView: some View { + VStack(spacing: Theme.Spacing.lg) { + Spacer() + + // Icon + ZStack { + Circle() + .fill(Theme.warmOrange.opacity(0.15)) + .frame(width: 120, height: 120) + + Image(systemName: "photo.on.rectangle.angled") + .font(.system(size: 50)) + .foregroundStyle(Theme.warmOrange) + } + + VStack(spacing: Theme.Spacing.sm) { + Text("Import from Photos") + .font(.system(size: Theme.FontSize.sectionTitle, weight: .bold, design: .rounded)) + .foregroundStyle(Theme.textPrimary(colorScheme)) + + Text("Select photos taken at stadiums to automatically log your visits. We'll use GPS and date data to match them to games.") + .font(.system(size: Theme.FontSize.body)) + .foregroundStyle(Theme.textSecondary(colorScheme)) + .multilineTextAlignment(.center) + .padding(.horizontal, Theme.Spacing.xl) + } + + // Select Photos Button + Button { + checkPermissionsAndShowPicker() + } label: { + HStack { + Image(systemName: "photo.stack") + Text("Select Photos") + } + .font(.system(size: Theme.FontSize.body, weight: .semibold)) + .foregroundStyle(.white) + .frame(maxWidth: .infinity) + .padding(Theme.Spacing.md) + .background(Theme.warmOrange) + .clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)) + } + .padding(.horizontal, Theme.Spacing.xl) + + // Info card + infoCard + + Spacer() + } + .padding(Theme.Spacing.md) + } + + private var infoCard: some View { + VStack(alignment: .leading, spacing: Theme.Spacing.sm) { + HStack { + Image(systemName: "info.circle.fill") + .foregroundStyle(Theme.warmOrange) + Text("How it works") + .font(.system(size: Theme.FontSize.body, weight: .semibold)) + } + + VStack(alignment: .leading, spacing: Theme.Spacing.xs) { + InfoRow(icon: "location.fill", text: "We read GPS location from your photos") + InfoRow(icon: "calendar", text: "We match the date to scheduled games") + InfoRow(icon: "checkmark.circle", text: "High confidence matches are auto-selected") + InfoRow(icon: "hand.tap", text: "You confirm or edit the rest") + } + } + .font(.system(size: Theme.FontSize.caption)) + .foregroundStyle(Theme.textSecondary(colorScheme)) + .padding(Theme.Spacing.md) + .background(Theme.cardBackground(colorScheme)) + .clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)) + .padding(.horizontal, Theme.Spacing.lg) + } + + // MARK: - Processing View + + private var processingView: some View { + VStack(spacing: Theme.Spacing.lg) { + Spacer() + + ThemedSpinner(size: 50, lineWidth: 4) + + Text("Processing photos...") + .font(.system(size: Theme.FontSize.body, weight: .medium)) + .foregroundStyle(Theme.textSecondary(colorScheme)) + + Text("\(viewModel.processedCount) of \(viewModel.totalCount) photos") + .font(.system(size: Theme.FontSize.caption)) + .foregroundStyle(Theme.textMuted(colorScheme)) + + Spacer() + } + } + + // MARK: - Results View + + private var resultsView: some View { + ScrollView { + VStack(spacing: Theme.Spacing.lg) { + // Summary header + summaryHeader + + // Categorized results + if !viewModel.categorized.autoProcessable.isEmpty { + resultSection( + title: "Auto-Matched", + subtitle: "High confidence matches", + icon: "checkmark.circle.fill", + color: .green, + candidates: viewModel.categorized.autoProcessable + ) + } + + if !viewModel.categorized.needsConfirmation.isEmpty { + resultSection( + title: "Needs Confirmation", + subtitle: "Please verify these matches", + icon: "questionmark.circle.fill", + color: Theme.warmOrange, + candidates: viewModel.categorized.needsConfirmation + ) + } + + if !viewModel.categorized.needsManualEntry.isEmpty { + resultSection( + title: "Manual Entry Required", + subtitle: "Could not auto-match these photos", + icon: "exclamationmark.triangle.fill", + color: .red, + candidates: viewModel.categorized.needsManualEntry + ) + } + + // Add more photos button + Button { + viewModel.showingPicker = true + } label: { + HStack { + Image(systemName: "plus.circle") + Text("Add More Photos") + } + .font(.system(size: Theme.FontSize.body)) + .foregroundStyle(Theme.warmOrange) + } + .padding(.top, Theme.Spacing.md) + } + .padding(Theme.Spacing.md) + } + } + + private var summaryHeader: some View { + HStack(spacing: Theme.Spacing.md) { + summaryBadge( + count: viewModel.categorized.autoProcessable.count, + label: "Auto", + color: .green + ) + + summaryBadge( + count: viewModel.categorized.needsConfirmation.count, + label: "Confirm", + color: Theme.warmOrange + ) + + summaryBadge( + count: viewModel.categorized.needsManualEntry.count, + label: "Manual", + color: .red + ) + } + .padding(Theme.Spacing.md) + .background(Theme.cardBackground(colorScheme)) + .clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)) + } + + private func summaryBadge(count: Int, label: String, color: Color) -> some View { + VStack(spacing: 4) { + Text("\(count)") + .font(.system(size: Theme.FontSize.sectionTitle, weight: .bold, design: .rounded)) + .foregroundStyle(color) + + Text(label) + .font(.system(size: Theme.FontSize.caption)) + .foregroundStyle(Theme.textSecondary(colorScheme)) + } + .frame(maxWidth: .infinity) + } + + private func resultSection( + title: String, + subtitle: String, + icon: String, + color: Color, + candidates: [PhotoImportCandidate] + ) -> some View { + VStack(alignment: .leading, spacing: Theme.Spacing.sm) { + // Section header + HStack { + Image(systemName: icon) + .foregroundStyle(color) + VStack(alignment: .leading, spacing: 2) { + Text(title) + .font(.system(size: Theme.FontSize.body, weight: .semibold)) + .foregroundStyle(Theme.textPrimary(colorScheme)) + Text(subtitle) + .font(.system(size: Theme.FontSize.caption)) + .foregroundStyle(Theme.textSecondary(colorScheme)) + } + Spacer() + } + + // Candidate cards + ForEach(candidates) { candidate in + PhotoImportCandidateCard( + candidate: candidate, + isConfirmed: viewModel.confirmedImports.contains(candidate.id), + onToggleConfirm: { + viewModel.toggleConfirmation(for: candidate.id) + }, + onSelectMatch: { match in + viewModel.selectMatch(match, for: candidate.id) + } + ) + } + } + .padding(Theme.Spacing.md) + .background(Theme.cardBackground(colorScheme)) + .clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.large)) + } + + // MARK: - Actions + + private func checkPermissionsAndShowPicker() { + Task { + let status = await PhotoMetadataExtractor.shared.requestPhotoLibraryAccess() + + await MainActor.run { + switch status { + case .authorized, .limited: + viewModel.showingPicker = true + case .denied, .restricted: + showingPermissionAlert = true + default: + break + } + } + } + } + + private func importSelectedVisits() { + Task { + await viewModel.createVisits(modelContext: modelContext) + dismiss() + } + } +} + +// MARK: - Photo Import Candidate Card + +struct PhotoImportCandidateCard: View { + let candidate: PhotoImportCandidate + let isConfirmed: Bool + let onToggleConfirm: () -> Void + let onSelectMatch: (GameMatchCandidate) -> Void + + @Environment(\.colorScheme) private var colorScheme + @State private var showingMatchPicker = false + + var body: some View { + VStack(alignment: .leading, spacing: Theme.Spacing.sm) { + // Photo date/location info + HStack { + if let date = candidate.metadata.captureDate { + Label(formatDate(date), systemImage: "calendar") + } + + if let stadium = candidate.bestStadiumMatch { + Label(stadium.stadium.name, systemImage: "mappin") + } + + Spacer() + + // Confirm toggle + Button { + onToggleConfirm() + } label: { + Image(systemName: isConfirmed ? "checkmark.circle.fill" : "circle") + .font(.title2) + .foregroundStyle(isConfirmed ? .green : Theme.textMuted(colorScheme)) + } + } + .font(.system(size: Theme.FontSize.caption)) + .foregroundStyle(Theme.textSecondary(colorScheme)) + + // Match result + matchResultView + } + .padding(Theme.Spacing.sm) + .background(Theme.cardBackgroundElevated(colorScheme)) + .clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.small)) + .sheet(isPresented: $showingMatchPicker) { + if case .multipleMatches(let matches) = candidate.matchResult { + GameMatchPickerSheet( + matches: matches, + onSelect: { match in + onSelectMatch(match) + showingMatchPicker = false + } + ) + } + } + } + + @ViewBuilder + private var matchResultView: some View { + switch candidate.matchResult { + case .singleMatch(let match): + matchRow(match) + + case .multipleMatches(let matches): + Button { + showingMatchPicker = true + } label: { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text("\(matches.count) possible games") + .font(.system(size: Theme.FontSize.body, weight: .medium)) + .foregroundStyle(Theme.textPrimary(colorScheme)) + Text("Tap to select the correct game") + .font(.system(size: Theme.FontSize.caption)) + .foregroundStyle(Theme.warmOrange) + } + Spacer() + Image(systemName: "chevron.right") + .foregroundStyle(Theme.textMuted(colorScheme)) + } + } + + case .noMatches(let reason): + HStack { + Image(systemName: "exclamationmark.triangle") + .foregroundStyle(.red) + Text(reason.description) + .font(.system(size: Theme.FontSize.caption)) + .foregroundStyle(Theme.textSecondary(colorScheme)) + } + } + } + + private func matchRow(_ match: GameMatchCandidate) -> some View { + VStack(alignment: .leading, spacing: 4) { + HStack { + Text(match.matchupDescription) + .font(.system(size: Theme.FontSize.body, weight: .semibold)) + .foregroundStyle(Theme.textPrimary(colorScheme)) + + Image(systemName: match.game.sport.iconName) + .foregroundStyle(match.game.sport.themeColor) + } + + Text("\(match.stadium.name) • \(match.gameDateTime)") + .font(.system(size: Theme.FontSize.caption)) + .foregroundStyle(Theme.textSecondary(colorScheme)) + + // Confidence badge + confidenceBadge(match.confidence.combined) + } + } + + private func confidenceBadge(_ confidence: CombinedConfidence) -> some View { + let (text, color): (String, Color) = { + switch confidence { + case .autoSelect: + return ("High confidence", .green) + case .userConfirm: + return ("Needs confirmation", Theme.warmOrange) + case .manualOnly: + return ("Low confidence", .red) + } + }() + + return Text(text) + .font(.system(size: 10, weight: .medium)) + .foregroundStyle(color) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(color.opacity(0.15)) + .clipShape(Capsule()) + } + + private func formatDate(_ date: Date) -> String { + let formatter = DateFormatter() + formatter.dateStyle = .medium + return formatter.string(from: date) + } +} + +// MARK: - Game Match Picker Sheet + +struct GameMatchPickerSheet: View { + let matches: [GameMatchCandidate] + let onSelect: (GameMatchCandidate) -> Void + + @Environment(\.colorScheme) private var colorScheme + @Environment(\.dismiss) private var dismiss + + var body: some View { + NavigationStack { + List(matches) { match in + Button { + onSelect(match) + } label: { + VStack(alignment: .leading, spacing: 4) { + HStack { + Text(match.fullMatchupDescription) + .font(.system(size: Theme.FontSize.body, weight: .medium)) + .foregroundStyle(Theme.textPrimary(colorScheme)) + + Spacer() + + Image(systemName: match.game.sport.iconName) + .foregroundStyle(match.game.sport.themeColor) + } + + Text("\(match.stadium.name) • \(match.gameDateTime)") + .font(.system(size: Theme.FontSize.caption)) + .foregroundStyle(Theme.textSecondary(colorScheme)) + } + .padding(.vertical, 4) + } + } + .navigationTitle("Select Game") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { + dismiss() + } + } + } + } + } +} + +// MARK: - Info Row + +private struct InfoRow: View { + let icon: String + let text: String + + var body: some View { + HStack(spacing: 8) { + Image(systemName: icon) + .frame(width: 16) + Text(text) + } + } +} + +// MARK: - Preview + +#Preview { + PhotoImportView() + .modelContainer(for: StadiumVisit.self, inMemory: true) +} diff --git a/SportsTime/Features/Progress/Views/ProgressMapView.swift b/SportsTime/Features/Progress/Views/ProgressMapView.swift new file mode 100644 index 0000000..8301511 --- /dev/null +++ b/SportsTime/Features/Progress/Views/ProgressMapView.swift @@ -0,0 +1,186 @@ +// +// ProgressMapView.swift +// SportsTime +// +// Interactive map showing stadium visit progress with custom annotations. +// + +import SwiftUI +import MapKit + +// MARK: - Progress Map View + +struct ProgressMapView: View { + let stadiums: [Stadium] + let visitStatus: [UUID: StadiumVisitStatus] + @Binding var selectedStadium: Stadium? + + @State private var mapRegion = MKCoordinateRegion( + center: CLLocationCoordinate2D(latitude: 39.8283, longitude: -98.5795), // US center + span: MKCoordinateSpan(latitudeDelta: 50, longitudeDelta: 50) + ) + + var body: some View { + Map(coordinateRegion: $mapRegion, annotationItems: stadiums) { stadium in + MapAnnotation(coordinate: CLLocationCoordinate2D( + latitude: stadium.latitude, + longitude: stadium.longitude + )) { + StadiumMapPin( + stadium: stadium, + isVisited: isVisited(stadium), + isSelected: selectedStadium?.id == stadium.id, + onTap: { + withAnimation(.spring(response: 0.3)) { + if selectedStadium?.id == stadium.id { + selectedStadium = nil + } else { + selectedStadium = stadium + } + } + } + ) + } + } + .mapStyle(.standard(elevation: .realistic)) + .clipShape(RoundedRectangle(cornerRadius: 16)) + } + + private func isVisited(_ stadium: Stadium) -> Bool { + if case .visited = visitStatus[stadium.id] { + return true + } + return false + } +} + +// MARK: - Stadium Map Pin + +struct StadiumMapPin: View { + let stadium: Stadium + let isVisited: Bool + let isSelected: Bool + let onTap: () -> Void + + @Environment(\.colorScheme) private var colorScheme + + var body: some View { + Button(action: onTap) { + VStack(spacing: 2) { + ZStack { + // Pin background + Circle() + .fill(pinColor) + .frame(width: pinSize, height: pinSize) + .shadow(color: .black.opacity(0.2), radius: 2, y: 1) + + // Icon + Image(systemName: isVisited ? "checkmark" : "sportscourt") + .font(.system(size: iconSize, weight: .bold)) + .foregroundStyle(.white) + } + + // Pin pointer + Triangle() + .fill(pinColor) + .frame(width: 10, height: 6) + .offset(y: -2) + + // Stadium name (when selected) + if isSelected { + Text(stadium.name) + .font(.caption2) + .fontWeight(.semibold) + .foregroundStyle(colorScheme == .dark ? .white : .primary) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background { + Capsule() + .fill(colorScheme == .dark ? Color(.systemGray5) : .white) + .shadow(color: .black.opacity(0.15), radius: 4, y: 2) + } + .fixedSize() + .transition(.scale.combined(with: .opacity)) + } + } + } + .buttonStyle(.plain) + .animation(.spring(response: 0.3), value: isSelected) + } + + private var pinColor: Color { + if isVisited { + return .green + } else { + return .orange + } + } + + private var pinSize: CGFloat { + isSelected ? 36 : 28 + } + + private var iconSize: CGFloat { + isSelected ? 16 : 12 + } +} + +// MARK: - Triangle Shape + +struct Triangle: Shape { + func path(in rect: CGRect) -> Path { + var path = Path() + path.move(to: CGPoint(x: rect.midX, y: rect.maxY)) + path.addLine(to: CGPoint(x: rect.minX, y: rect.minY)) + path.addLine(to: CGPoint(x: rect.maxX, y: rect.minY)) + path.closeSubpath() + return path + } +} + +// MARK: - Map Region Extension + +extension ProgressMapView { + /// Calculate region to fit all stadiums + static func region(for stadiums: [Stadium]) -> MKCoordinateRegion { + guard !stadiums.isEmpty else { + // Default to US center + return MKCoordinateRegion( + center: CLLocationCoordinate2D(latitude: 39.8283, longitude: -98.5795), + span: MKCoordinateSpan(latitudeDelta: 50, longitudeDelta: 50) + ) + } + + let latitudes = stadiums.map { $0.latitude } + let longitudes = stadiums.map { $0.longitude } + + let minLat = latitudes.min()! + let maxLat = latitudes.max()! + let minLon = longitudes.min()! + let maxLon = longitudes.max()! + + let center = CLLocationCoordinate2D( + latitude: (minLat + maxLat) / 2, + longitude: (minLon + maxLon) / 2 + ) + + let span = MKCoordinateSpan( + latitudeDelta: (maxLat - minLat) * 1.3 + 2, // Add padding + longitudeDelta: (maxLon - minLon) * 1.3 + 2 + ) + + return MKCoordinateRegion(center: center, span: span) + } +} + +// MARK: - Preview + +#Preview { + ProgressMapView( + stadiums: [], + visitStatus: [:], + selectedStadium: .constant(nil) + ) + .frame(height: 300) + .padding() +} diff --git a/SportsTime/Features/Progress/Views/ProgressTabView.swift b/SportsTime/Features/Progress/Views/ProgressTabView.swift new file mode 100644 index 0000000..5dd9d94 --- /dev/null +++ b/SportsTime/Features/Progress/Views/ProgressTabView.swift @@ -0,0 +1,685 @@ +// +// ProgressTabView.swift +// SportsTime +// +// Main view for stadium progress tracking with league selector and map. +// + +import SwiftUI +import SwiftData + +struct ProgressTabView: View { + @Environment(\.modelContext) private var modelContext + @Environment(\.colorScheme) private var colorScheme + + @State private var viewModel = ProgressViewModel() + @State private var showVisitSheet = false + @State private var showPhotoImport = false + @State private var showShareSheet = false + @State private var selectedStadium: Stadium? + @State private var selectedVisitId: UUID? + + @Query private var visits: [StadiumVisit] + + var body: some View { + ScrollView { + VStack(spacing: Theme.Spacing.lg) { + // League Selector + leagueSelector + .staggeredAnimation(index: 0) + + // Progress Summary Card + progressSummaryCard + .staggeredAnimation(index: 1) + + // Map View + ProgressMapView( + stadiums: viewModel.sportStadiums, + visitStatus: viewModel.stadiumVisitStatus, + selectedStadium: $selectedStadium + ) + .frame(height: 300) + .clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.large)) + .overlay { + RoundedRectangle(cornerRadius: Theme.CornerRadius.large) + .stroke(Theme.surfaceGlow(colorScheme), lineWidth: 1) + } + .staggeredAnimation(index: 2) + + // Stadium Lists + stadiumListsSection + .staggeredAnimation(index: 3) + + // Achievements Teaser + achievementsSection + .staggeredAnimation(index: 4) + + // Recent Visits + if !viewModel.recentVisits.isEmpty { + recentVisitsSection + .staggeredAnimation(index: 5) + } + } + .padding(Theme.Spacing.md) + } + .themedBackground() + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button { + showShareSheet = true + } label: { + Image(systemName: "square.and.arrow.up") + .foregroundStyle(Theme.warmOrange) + } + } + + ToolbarItem(placement: .primaryAction) { + Menu { + Button { + showVisitSheet = true + } label: { + Label("Manual Entry", systemImage: "pencil") + } + + Button { + showPhotoImport = true + } label: { + Label("Import from Photos", systemImage: "photo.on.rectangle.angled") + } + } label: { + Image(systemName: "plus.circle.fill") + .font(.title2) + .foregroundStyle(Theme.warmOrange) + } + } + } + .task { + viewModel.configure(with: modelContext.container) + await viewModel.loadData() + } + .sheet(isPresented: $showVisitSheet) { + StadiumVisitSheet(initialSport: viewModel.selectedSport) { _ in + Task { + await viewModel.loadData() + } + } + } + .sheet(isPresented: $showPhotoImport) { + PhotoImportView() + .onDisappear { + Task { + await viewModel.loadData() + } + } + } + .sheet(item: $selectedStadium) { stadium in + StadiumDetailSheet( + stadium: stadium, + visitStatus: viewModel.stadiumVisitStatus[stadium.id] ?? .notVisited, + sport: viewModel.selectedSport, + onVisitLogged: { + Task { + await viewModel.loadData() + } + } + ) + .presentationDetents([.medium]) + } + .sheet(isPresented: $showShareSheet) { + ProgressShareView(progress: viewModel.leagueProgress) + } + } + + // MARK: - League Selector + + private var leagueSelector: some View { + HStack(spacing: Theme.Spacing.sm) { + ForEach(Sport.supported) { sport in + LeagueSelectorButton( + sport: sport, + isSelected: viewModel.selectedSport == sport, + progress: progressForSport(sport) + ) { + withAnimation(Theme.Animation.spring) { + viewModel.selectSport(sport) + } + } + } + } + } + + private func progressForSport(_ sport: Sport) -> Double { + let visitedCount = viewModel.visits.filter { $0.sportEnum == sport }.count + let total = LeagueStructure.stadiumCount(for: sport) + guard total > 0 else { return 0 } + return Double(min(visitedCount, total)) / Double(total) + } + + // MARK: - Progress Summary Card + + private var progressSummaryCard: some View { + let progress = viewModel.leagueProgress + + return VStack(spacing: Theme.Spacing.lg) { + // Title and progress ring + HStack(alignment: .center, spacing: Theme.Spacing.lg) { + // Progress Ring + ZStack { + Circle() + .stroke(Theme.warmOrange.opacity(0.2), lineWidth: 8) + .frame(width: 80, height: 80) + + Circle() + .trim(from: 0, to: progress.progressFraction) + .stroke(Theme.warmOrange, style: StrokeStyle(lineWidth: 8, lineCap: .round)) + .frame(width: 80, height: 80) + .rotationEffect(.degrees(-90)) + .animation(.easeInOut(duration: 0.5), value: progress.progressFraction) + + VStack(spacing: 0) { + Text("\(progress.visitedStadiums)") + .font(.system(size: 24, weight: .bold, design: .rounded)) + .foregroundStyle(Theme.textPrimary(colorScheme)) + Text("/\(progress.totalStadiums)") + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(Theme.textMuted(colorScheme)) + } + } + + VStack(alignment: .leading, spacing: Theme.Spacing.xs) { + Text(viewModel.selectedSport.displayName) + .font(.system(size: Theme.FontSize.cardTitle, weight: .bold, design: .rounded)) + .foregroundStyle(Theme.textPrimary(colorScheme)) + + Text("Stadium Quest") + .font(.system(size: Theme.FontSize.caption)) + .foregroundStyle(Theme.textSecondary(colorScheme)) + + if progress.isComplete { + HStack(spacing: 4) { + Image(systemName: "checkmark.seal.fill") + Text("Complete!") + } + .font(.system(size: Theme.FontSize.caption, weight: .semibold)) + .foregroundStyle(Theme.warmOrange) + } else { + Text("\(progress.totalStadiums - progress.visitedStadiums) stadiums remaining") + .font(.system(size: Theme.FontSize.caption)) + .foregroundStyle(Theme.textMuted(colorScheme)) + } + } + + Spacer() + } + + // Stats row + HStack(spacing: Theme.Spacing.lg) { + ProgressStatPill( + icon: "mappin.circle.fill", + value: "\(progress.visitedStadiums)", + label: "Visited" + ) + + ProgressStatPill( + icon: "circle.dotted", + value: "\(progress.totalStadiums - progress.visitedStadiums)", + label: "Remaining" + ) + + ProgressStatPill( + icon: "percent", + value: String(format: "%.0f%%", progress.completionPercentage), + label: "Complete" + ) + } + } + .padding(Theme.Spacing.lg) + .background(Theme.cardBackground(colorScheme)) + .clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.large)) + .overlay { + RoundedRectangle(cornerRadius: Theme.CornerRadius.large) + .stroke(Theme.surfaceGlow(colorScheme), lineWidth: 1) + } + .shadow(color: Theme.cardShadow(colorScheme), radius: 10, y: 5) + } + + // MARK: - Stadium Lists Section + + private var stadiumListsSection: some View { + VStack(alignment: .leading, spacing: Theme.Spacing.md) { + // Visited Stadiums + if !viewModel.visitedStadiums.isEmpty { + VStack(alignment: .leading, spacing: Theme.Spacing.sm) { + HStack { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(.green) + Text("Visited (\(viewModel.visitedStadiums.count))") + .font(.system(size: Theme.FontSize.body, weight: .semibold)) + .foregroundStyle(Theme.textPrimary(colorScheme)) + } + + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: Theme.Spacing.sm) { + ForEach(viewModel.visitedStadiums) { stadium in + StadiumChip( + stadium: stadium, + isVisited: true + ) { + selectedStadium = stadium + } + } + } + } + } + } + + // Unvisited Stadiums + if !viewModel.unvisitedStadiums.isEmpty { + VStack(alignment: .leading, spacing: Theme.Spacing.sm) { + HStack { + Image(systemName: "circle.dotted") + .foregroundStyle(Theme.textMuted(colorScheme)) + Text("Not Yet Visited (\(viewModel.unvisitedStadiums.count))") + .font(.system(size: Theme.FontSize.body, weight: .semibold)) + .foregroundStyle(Theme.textPrimary(colorScheme)) + } + + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: Theme.Spacing.sm) { + ForEach(viewModel.unvisitedStadiums) { stadium in + StadiumChip( + stadium: stadium, + isVisited: false + ) { + selectedStadium = stadium + } + } + } + } + } + } + } + } + + // MARK: - Achievements Section + + private var achievementsSection: some View { + VStack(alignment: .leading, spacing: Theme.Spacing.sm) { + HStack { + Text("Achievements") + .font(.system(size: Theme.FontSize.sectionTitle, weight: .bold, design: .rounded)) + .foregroundStyle(Theme.textPrimary(colorScheme)) + + Spacer() + + NavigationLink { + AchievementsListView() + } label: { + HStack(spacing: 4) { + Text("View All") + Image(systemName: "chevron.right") + } + .font(.system(size: Theme.FontSize.caption, weight: .medium)) + .foregroundStyle(Theme.warmOrange) + } + } + + NavigationLink { + AchievementsListView() + } label: { + HStack(spacing: Theme.Spacing.md) { + // Trophy icon + ZStack { + Circle() + .fill(Theme.warmOrange.opacity(0.15)) + .frame(width: 50, height: 50) + + Image(systemName: "trophy.fill") + .font(.system(size: 24)) + .foregroundStyle(Theme.warmOrange) + } + + VStack(alignment: .leading, spacing: 4) { + Text("Track Your Progress") + .font(.system(size: Theme.FontSize.body, weight: .semibold)) + .foregroundStyle(Theme.textPrimary(colorScheme)) + + Text("Earn badges for stadium visits") + .font(.system(size: Theme.FontSize.caption)) + .foregroundStyle(Theme.textSecondary(colorScheme)) + } + + Spacer() + + Image(systemName: "chevron.right") + .foregroundStyle(Theme.textMuted(colorScheme)) + } + .padding(Theme.Spacing.md) + .background(Theme.cardBackground(colorScheme)) + .clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)) + .overlay { + RoundedRectangle(cornerRadius: Theme.CornerRadius.medium) + .stroke(Theme.warmOrange.opacity(0.3), lineWidth: 1) + } + } + .buttonStyle(.plain) + } + } + + // MARK: - Recent Visits Section + + private var recentVisitsSection: some View { + VStack(alignment: .leading, spacing: Theme.Spacing.sm) { + Text("Recent Visits") + .font(.system(size: Theme.FontSize.sectionTitle, weight: .bold, design: .rounded)) + .foregroundStyle(Theme.textPrimary(colorScheme)) + + ForEach(viewModel.recentVisits) { visitSummary in + if let stadiumVisit = visits.first(where: { $0.id == visitSummary.id }) { + NavigationLink { + VisitDetailView(visit: stadiumVisit, stadium: visitSummary.stadium) + } label: { + RecentVisitRow(visit: visitSummary) + } + .buttonStyle(.plain) + } else { + RecentVisitRow(visit: visitSummary) + } + } + } + } +} + +// MARK: - Supporting Views + +struct LeagueSelectorButton: View { + let sport: Sport + let isSelected: Bool + let progress: Double + let action: () -> Void + + @Environment(\.colorScheme) private var colorScheme + + var body: some View { + Button(action: action) { + VStack(spacing: Theme.Spacing.xs) { + ZStack { + // Background circle with progress + Circle() + .stroke(sport.themeColor.opacity(0.2), lineWidth: 3) + .frame(width: 50, height: 50) + + Circle() + .trim(from: 0, to: progress) + .stroke(sport.themeColor, style: StrokeStyle(lineWidth: 3, lineCap: .round)) + .frame(width: 50, height: 50) + .rotationEffect(.degrees(-90)) + + // Sport icon + Image(systemName: sport.iconName) + .font(.title2) + .foregroundStyle(isSelected ? sport.themeColor : Theme.textMuted(colorScheme)) + } + + Text(sport.rawValue) + .font(.system(size: Theme.FontSize.micro, weight: isSelected ? .bold : .medium)) + .foregroundStyle(isSelected ? Theme.textPrimary(colorScheme) : Theme.textMuted(colorScheme)) + } + .frame(maxWidth: .infinity) + .padding(.vertical, Theme.Spacing.sm) + .background(isSelected ? Theme.cardBackground(colorScheme) : Color.clear) + .clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)) + .overlay { + if isSelected { + RoundedRectangle(cornerRadius: Theme.CornerRadius.medium) + .stroke(sport.themeColor, lineWidth: 2) + } + } + } + .buttonStyle(.plain) + } +} + +struct ProgressStatPill: View { + let icon: String + let value: String + let label: String + + @Environment(\.colorScheme) private var colorScheme + + var body: some View { + VStack(spacing: 4) { + HStack(spacing: 4) { + Image(systemName: icon) + .font(.system(size: 12)) + Text(value) + .font(.system(size: Theme.FontSize.body, weight: .bold)) + } + .foregroundStyle(Theme.textPrimary(colorScheme)) + + Text(label) + .font(.system(size: Theme.FontSize.micro)) + .foregroundStyle(Theme.textMuted(colorScheme)) + } + .frame(maxWidth: .infinity) + } +} + +struct StadiumChip: View { + let stadium: Stadium + let isVisited: Bool + let action: () -> Void + + @Environment(\.colorScheme) private var colorScheme + + var body: some View { + Button(action: action) { + HStack(spacing: Theme.Spacing.xs) { + if isVisited { + Image(systemName: "checkmark.circle.fill") + .font(.caption) + .foregroundStyle(.green) + } + + VStack(alignment: .leading, spacing: 2) { + Text(stadium.name) + .font(.system(size: Theme.FontSize.caption, weight: .medium)) + .foregroundStyle(Theme.textPrimary(colorScheme)) + .lineLimit(1) + + Text(stadium.city) + .font(.system(size: Theme.FontSize.micro)) + .foregroundStyle(Theme.textMuted(colorScheme)) + } + } + .padding(.horizontal, Theme.Spacing.sm) + .padding(.vertical, Theme.Spacing.xs) + .background(Theme.cardBackground(colorScheme)) + .clipShape(Capsule()) + .overlay { + Capsule() + .stroke(isVisited ? Color.green.opacity(0.3) : Theme.surfaceGlow(colorScheme), lineWidth: 1) + } + } + .buttonStyle(.plain) + } +} + +struct RecentVisitRow: View { + let visit: VisitSummary + + @Environment(\.colorScheme) private var colorScheme + + var body: some View { + HStack(spacing: Theme.Spacing.md) { + // Sport icon + ZStack { + Circle() + .fill(visit.sport.themeColor.opacity(0.15)) + .frame(width: 40, height: 40) + + Image(systemName: visit.sport.iconName) + .foregroundStyle(visit.sport.themeColor) + } + + VStack(alignment: .leading, spacing: 4) { + Text(visit.stadium.name) + .font(.system(size: Theme.FontSize.body, weight: .semibold)) + .foregroundStyle(Theme.textPrimary(colorScheme)) + + HStack(spacing: Theme.Spacing.sm) { + Text(visit.shortDateDescription) + if let matchup = visit.matchup { + Text("•") + Text(matchup) + } + } + .font(.system(size: Theme.FontSize.caption)) + .foregroundStyle(Theme.textSecondary(colorScheme)) + } + + Spacer() + + if visit.photoCount > 0 { + HStack(spacing: 4) { + Image(systemName: "photo") + Text("\(visit.photoCount)") + } + .font(.system(size: Theme.FontSize.micro)) + .foregroundStyle(Theme.textMuted(colorScheme)) + } + + Image(systemName: "chevron.right") + .font(.caption) + .foregroundStyle(Theme.textMuted(colorScheme)) + } + .padding(Theme.Spacing.md) + .background(Theme.cardBackground(colorScheme)) + .clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)) + .overlay { + RoundedRectangle(cornerRadius: Theme.CornerRadius.medium) + .stroke(Theme.surfaceGlow(colorScheme), lineWidth: 1) + } + } +} + +struct StadiumDetailSheet: View { + let stadium: Stadium + let visitStatus: StadiumVisitStatus + let sport: Sport + var onVisitLogged: (() -> Void)? + + @Environment(\.modelContext) private var modelContext + @Environment(\.colorScheme) private var colorScheme + @Environment(\.dismiss) private var dismiss + @State private var showLogVisit = false + + var body: some View { + NavigationStack { + VStack(spacing: Theme.Spacing.lg) { + // Stadium header + VStack(spacing: Theme.Spacing.sm) { + ZStack { + Circle() + .fill(sport.themeColor.opacity(0.15)) + .frame(width: 80, height: 80) + + Image(systemName: visitStatus.isVisited ? "checkmark.seal.fill" : sport.iconName) + .font(.system(size: 36)) + .foregroundStyle(visitStatus.isVisited ? .green : sport.themeColor) + } + + Text(stadium.name) + .font(.system(size: Theme.FontSize.cardTitle, weight: .bold, design: .rounded)) + .foregroundStyle(Theme.textPrimary(colorScheme)) + .multilineTextAlignment(.center) + + Text(stadium.fullAddress) + .font(.system(size: Theme.FontSize.body)) + .foregroundStyle(Theme.textSecondary(colorScheme)) + + if visitStatus.isVisited { + HStack(spacing: 4) { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(.green) + Text("Visited \(visitStatus.visitCount) time\(visitStatus.visitCount == 1 ? "" : "s")") + } + .font(.system(size: Theme.FontSize.caption, weight: .medium)) + .foregroundStyle(.green) + } + } + + // Visit history if visited + if case .visited(let visits) = visitStatus { + VStack(alignment: .leading, spacing: Theme.Spacing.sm) { + Text("Visit History") + .font(.system(size: Theme.FontSize.body, weight: .semibold)) + .foregroundStyle(Theme.textPrimary(colorScheme)) + + ForEach(visits.sorted(by: { $0.visitDate > $1.visitDate })) { visit in + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(visit.shortDateDescription) + .font(.system(size: Theme.FontSize.caption, weight: .medium)) + if let matchup = visit.matchup { + Text(matchup) + .font(.system(size: Theme.FontSize.micro)) + .foregroundStyle(Theme.textSecondary(colorScheme)) + } + } + Spacer() + Text(visit.visitType.displayName) + .font(.system(size: Theme.FontSize.micro)) + .foregroundStyle(Theme.textMuted(colorScheme)) + } + .padding(Theme.Spacing.sm) + .background(Theme.cardBackgroundElevated(colorScheme)) + .clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.small)) + } + } + } + + Spacer() + + // Action button + Button { + showLogVisit = true + } label: { + HStack { + Image(systemName: visitStatus.isVisited ? "plus" : "checkmark.circle") + Text(visitStatus.isVisited ? "Log Another Visit" : "Log Visit") + } + .frame(maxWidth: .infinity) + .padding(Theme.Spacing.md) + .background(Theme.warmOrange) + .foregroundStyle(.white) + .clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)) + } + .pressableStyle() + } + .padding(Theme.Spacing.lg) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Done") { dismiss() } + } + } + .sheet(isPresented: $showLogVisit) { + StadiumVisitSheet( + initialStadium: stadium, + initialSport: sport + ) { _ in + onVisitLogged?() + dismiss() + } + } + } + } +} + +#Preview { + NavigationStack { + ProgressTabView() + } + .modelContainer(for: StadiumVisit.self, inMemory: true) +} diff --git a/SportsTime/Features/Progress/Views/StadiumVisitSheet.swift b/SportsTime/Features/Progress/Views/StadiumVisitSheet.swift new file mode 100644 index 0000000..41685b2 --- /dev/null +++ b/SportsTime/Features/Progress/Views/StadiumVisitSheet.swift @@ -0,0 +1,357 @@ +// +// StadiumVisitSheet.swift +// SportsTime +// +// Sheet for manually logging a stadium visit. +// + +import SwiftUI +import SwiftData + +struct StadiumVisitSheet: View { + @Environment(\.modelContext) private var modelContext + @Environment(\.colorScheme) private var colorScheme + @Environment(\.dismiss) private var dismiss + + // Optional pre-selected values + var initialStadium: Stadium? + var initialSport: Sport? + var onSave: ((StadiumVisit) -> Void)? + + // Form state + @State private var selectedSport: Sport + @State private var selectedStadium: Stadium? + @State private var visitDate: Date = Date() + @State private var visitType: VisitType = .game + @State private var homeTeamName: String = "" + @State private var awayTeamName: String = "" + @State private var homeScore: String = "" + @State private var awayScore: String = "" + @State private var seatLocation: String = "" + @State private var notes: String = "" + + // UI state + @State private var showStadiumPicker = false + @State private var isSaving = false + @State private var errorMessage: String? + + // Data + private let dataProvider = AppDataProvider.shared + + init( + initialStadium: Stadium? = nil, + initialSport: Sport? = nil, + onSave: ((StadiumVisit) -> Void)? = nil + ) { + self.initialStadium = initialStadium + self.initialSport = initialSport + self.onSave = onSave + _selectedSport = State(initialValue: initialSport ?? .mlb) + _selectedStadium = State(initialValue: initialStadium) + } + + var body: some View { + NavigationStack { + Form { + // Sport & Stadium Section + Section { + // Sport Picker + Picker("Sport", selection: $selectedSport) { + ForEach(Sport.supported) { sport in + HStack { + Image(systemName: sport.iconName) + Text(sport.displayName) + } + .tag(sport) + } + } + + // Stadium Selection + Button { + showStadiumPicker = true + } label: { + HStack { + Text("Stadium") + .foregroundStyle(Theme.textPrimary(colorScheme)) + Spacer() + if let stadium = selectedStadium { + Text(stadium.name) + .foregroundStyle(Theme.textSecondary(colorScheme)) + } else { + Text("Select Stadium") + .foregroundStyle(Theme.textMuted(colorScheme)) + } + Image(systemName: "chevron.right") + .font(.caption) + .foregroundStyle(Theme.textMuted(colorScheme)) + } + } + } header: { + Text("Location") + } + + // Visit Details Section + Section { + DatePicker("Date", selection: $visitDate, displayedComponents: .date) + + Picker("Visit Type", selection: $visitType) { + ForEach(VisitType.allCases, id: \.self) { type in + Text(type.displayName).tag(type) + } + } + } header: { + Text("Visit Details") + } + + // Game Info Section (only for game visits) + if visitType == .game { + Section { + HStack { + Text("Away Team") + Spacer() + TextField("Team Name", text: $awayTeamName) + .multilineTextAlignment(.trailing) + } + + HStack { + Text("Home Team") + Spacer() + TextField("Team Name", text: $homeTeamName) + .multilineTextAlignment(.trailing) + } + + HStack { + Text("Final Score") + Spacer() + TextField("Away", text: $awayScore) + .keyboardType(.numberPad) + .frame(width: 50) + .multilineTextAlignment(.center) + Text("-") + .foregroundStyle(Theme.textMuted(colorScheme)) + TextField("Home", text: $homeScore) + .keyboardType(.numberPad) + .frame(width: 50) + .multilineTextAlignment(.center) + } + } header: { + Text("Game Info") + } footer: { + Text("Leave blank if you don't remember the score") + } + } + + // Optional Details Section + Section { + HStack { + Text("Seat Location") + Spacer() + TextField("e.g., Section 120", text: $seatLocation) + .multilineTextAlignment(.trailing) + } + + VStack(alignment: .leading, spacing: Theme.Spacing.xs) { + Text("Notes") + TextEditor(text: $notes) + .frame(minHeight: 80) + .scrollContentBackground(.hidden) + .background(Theme.cardBackgroundElevated(colorScheme)) + .clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.small)) + } + } header: { + Text("Additional Info") + } + + // Error Message + if let error = errorMessage { + Section { + HStack { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.orange) + Text(error) + .foregroundStyle(.red) + } + } + } + } + .scrollDismissesKeyboard(.interactively) + .navigationTitle("Log Visit") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { + dismiss() + } + } + + ToolbarItem(placement: .confirmationAction) { + Button("Save") { + saveVisit() + } + .disabled(!canSave || isSaving) + .fontWeight(.semibold) + } + } + .sheet(isPresented: $showStadiumPicker) { + StadiumPickerSheet( + sport: selectedSport, + selectedStadium: $selectedStadium + ) + } + .onChange(of: selectedSport) { _, _ in + // Clear stadium selection when sport changes + if let stadium = selectedStadium { + // Check if stadium belongs to new sport + let sportTeams = dataProvider.teams.filter { $0.sport == selectedSport } + if !sportTeams.contains(where: { $0.stadiumId == stadium.id }) { + selectedStadium = nil + } + } + } + } + } + + // MARK: - Computed Properties + + private var canSave: Bool { + selectedStadium != nil + } + + private var finalScoreString: String? { + guard let away = Int(awayScore), let home = Int(homeScore) else { + return nil + } + return "\(away)-\(home)" + } + + // MARK: - Actions + + private func saveVisit() { + guard let stadium = selectedStadium else { + errorMessage = "Please select a stadium" + return + } + + isSaving = true + errorMessage = nil + + // Create the visit + let visit = StadiumVisit( + canonicalStadiumId: stadium.id.uuidString, // Simplified - in production use StadiumIdentityService + stadiumUUID: stadium.id, + stadiumNameAtVisit: stadium.name, + visitDate: visitDate, + sport: selectedSport, + visitType: visitType, + homeTeamName: homeTeamName.isEmpty ? nil : homeTeamName, + awayTeamName: awayTeamName.isEmpty ? nil : awayTeamName, + finalScore: finalScoreString, + scoreSource: finalScoreString != nil ? .user : nil, + dataSource: .fullyManual, + seatLocation: seatLocation.isEmpty ? nil : seatLocation, + notes: notes.isEmpty ? nil : notes, + source: .manual + ) + + // Save to SwiftData + modelContext.insert(visit) + + do { + try modelContext.save() + onSave?(visit) + dismiss() + } catch { + errorMessage = "Failed to save visit: \(error.localizedDescription)" + isSaving = false + } + } +} + +// MARK: - Stadium Picker Sheet + +struct StadiumPickerSheet: View { + let sport: Sport + @Binding var selectedStadium: Stadium? + + @Environment(\.colorScheme) private var colorScheme + @Environment(\.dismiss) private var dismiss + @State private var searchText = "" + + private let dataProvider = AppDataProvider.shared + + private var stadiums: [Stadium] { + let sportTeams = dataProvider.teams.filter { $0.sport == sport } + let stadiumIds = Set(sportTeams.map { $0.stadiumId }) + return dataProvider.stadiums.filter { stadiumIds.contains($0.id) } + } + + private var filteredStadiums: [Stadium] { + if searchText.isEmpty { + return stadiums.sorted { $0.name < $1.name } + } + return stadiums.filter { + $0.name.localizedCaseInsensitiveContains(searchText) || + $0.city.localizedCaseInsensitiveContains(searchText) + }.sorted { $0.name < $1.name } + } + + var body: some View { + NavigationStack { + Group { + if stadiums.isEmpty { + ContentUnavailableView( + "No Stadiums", + systemImage: "building.2", + description: Text("No stadiums found for \(sport.displayName)") + ) + } else if filteredStadiums.isEmpty { + ContentUnavailableView.search(text: searchText) + } else { + List(filteredStadiums) { stadium in + Button { + selectedStadium = stadium + dismiss() + } label: { + HStack { + VStack(alignment: .leading, spacing: 4) { + Text(stadium.name) + .font(.system(size: Theme.FontSize.body, weight: .medium)) + .foregroundStyle(Theme.textPrimary(colorScheme)) + + Text(stadium.fullAddress) + .font(.system(size: Theme.FontSize.caption)) + .foregroundStyle(Theme.textSecondary(colorScheme)) + } + + Spacer() + + if selectedStadium?.id == stadium.id { + Image(systemName: "checkmark") + .foregroundStyle(Theme.warmOrange) + } + } + } + } + .scrollDismissesKeyboard(.interactively) + } + } + .searchable(text: $searchText, prompt: "Search stadiums") + .navigationTitle("Select Stadium") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { + dismiss() + } + } + } + } + } +} + +// MARK: - Preview + +#Preview { + StadiumVisitSheet() + .modelContainer(for: StadiumVisit.self, inMemory: true) +} diff --git a/SportsTime/Features/Progress/Views/VisitDetailView.swift b/SportsTime/Features/Progress/Views/VisitDetailView.swift new file mode 100644 index 0000000..fbf2f0e --- /dev/null +++ b/SportsTime/Features/Progress/Views/VisitDetailView.swift @@ -0,0 +1,538 @@ +// +// VisitDetailView.swift +// SportsTime +// +// View for displaying and editing a stadium visit's details. +// + +import SwiftUI +import SwiftData + +struct VisitDetailView: View { + @Bindable var visit: StadiumVisit + let stadium: Stadium + + @Environment(\.modelContext) private var modelContext + @Environment(\.colorScheme) private var colorScheme + @Environment(\.dismiss) private var dismiss + + @State private var isEditing = false + @State private var showDeleteConfirmation = false + + // Edit state + @State private var editVisitDate: Date + @State private var editVisitType: VisitType + @State private var editHomeTeamName: String + @State private var editAwayTeamName: String + @State private var editHomeScore: String + @State private var editAwayScore: String + @State private var editSeatLocation: String + @State private var editNotes: String + + init(visit: StadiumVisit, stadium: Stadium) { + self.visit = visit + self.stadium = stadium + + // Initialize edit state from visit + _editVisitDate = State(initialValue: visit.visitDate) + _editVisitType = State(initialValue: visit.visitType) + _editHomeTeamName = State(initialValue: visit.homeTeamName ?? "") + _editAwayTeamName = State(initialValue: visit.awayTeamName ?? "") + + // Parse score if available + if let score = visit.finalScore { + let parts = score.split(separator: "-") + if parts.count == 2 { + _editAwayScore = State(initialValue: String(parts[0])) + _editHomeScore = State(initialValue: String(parts[1])) + } else { + _editAwayScore = State(initialValue: "") + _editHomeScore = State(initialValue: "") + } + } else { + _editAwayScore = State(initialValue: "") + _editHomeScore = State(initialValue: "") + } + + _editSeatLocation = State(initialValue: visit.seatLocation ?? "") + _editNotes = State(initialValue: visit.notes ?? "") + } + + var body: some View { + ScrollView { + VStack(spacing: Theme.Spacing.lg) { + // Header + visitHeader + .staggeredAnimation(index: 0) + + // Game info (if applicable) + if visit.visitType == .game { + gameInfoCard + .staggeredAnimation(index: 1) + } + + // Visit details + detailsCard + .staggeredAnimation(index: 2) + + // Notes + if !isEditing && (visit.notes?.isEmpty == false) { + notesCard + .staggeredAnimation(index: 3) + } + + // Edit form (when editing) + if isEditing { + editForm + .staggeredAnimation(index: 4) + } + + // Delete button + if !isEditing { + deleteButton + .staggeredAnimation(index: 5) + } + } + .padding(Theme.Spacing.md) + } + .themedBackground() + .navigationTitle(isEditing ? "Edit Visit" : "Visit Details") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .primaryAction) { + if isEditing { + Button("Save") { + saveChanges() + } + .fontWeight(.semibold) + } else { + Button("Edit") { + withAnimation { + isEditing = true + } + } + } + } + + if isEditing { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { + cancelEditing() + } + } + } + } + .confirmationDialog( + "Delete Visit", + isPresented: $showDeleteConfirmation, + titleVisibility: .visible + ) { + Button("Delete Visit", role: .destructive) { + deleteVisit() + } + Button("Cancel", role: .cancel) {} + } message: { + Text("Are you sure you want to delete this visit? This action cannot be undone.") + } + } + + // MARK: - Header + + private var visitHeader: some View { + VStack(spacing: Theme.Spacing.md) { + // Sport icon + ZStack { + Circle() + .fill(sportColor.opacity(0.15)) + .frame(width: 80, height: 80) + + Image(systemName: visit.sportEnum?.iconName ?? "sportscourt") + .font(.system(size: 36)) + .foregroundStyle(sportColor) + } + + VStack(spacing: Theme.Spacing.xs) { + Text(stadium.name) + .font(.system(size: Theme.FontSize.cardTitle, weight: .bold, design: .rounded)) + .foregroundStyle(Theme.textPrimary(colorScheme)) + .multilineTextAlignment(.center) + + Text(stadium.fullAddress) + .font(.system(size: Theme.FontSize.body)) + .foregroundStyle(Theme.textSecondary(colorScheme)) + + // Visit type badge + Text(visit.visitType.displayName) + .font(.system(size: Theme.FontSize.caption, weight: .medium)) + .foregroundStyle(.white) + .padding(.horizontal, Theme.Spacing.sm) + .padding(.vertical, 4) + .background(sportColor) + .clipShape(Capsule()) + } + } + .frame(maxWidth: .infinity) + .padding(Theme.Spacing.lg) + .background(Theme.cardBackground(colorScheme)) + .clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.large)) + .overlay { + RoundedRectangle(cornerRadius: Theme.CornerRadius.large) + .stroke(Theme.surfaceGlow(colorScheme), lineWidth: 1) + } + } + + // MARK: - Game Info Card + + private var gameInfoCard: some View { + VStack(alignment: .leading, spacing: Theme.Spacing.md) { + HStack { + Image(systemName: "sportscourt.fill") + .foregroundStyle(sportColor) + Text("Game Info") + .font(.system(size: Theme.FontSize.body, weight: .semibold)) + .foregroundStyle(Theme.textPrimary(colorScheme)) + } + + if let matchup = visit.matchupDescription { + HStack { + Text("Matchup") + .foregroundStyle(Theme.textSecondary(colorScheme)) + Spacer() + Text(matchup) + .foregroundStyle(Theme.textPrimary(colorScheme)) + .fontWeight(.medium) + } + } + + if let score = visit.finalScore { + HStack { + Text("Final Score") + .foregroundStyle(Theme.textSecondary(colorScheme)) + Spacer() + Text(score) + .foregroundStyle(Theme.textPrimary(colorScheme)) + .fontWeight(.bold) + } + } + } + .font(.system(size: Theme.FontSize.body)) + .padding(Theme.Spacing.lg) + .background(Theme.cardBackground(colorScheme)) + .clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.large)) + .overlay { + RoundedRectangle(cornerRadius: Theme.CornerRadius.large) + .stroke(Theme.surfaceGlow(colorScheme), lineWidth: 1) + } + } + + // MARK: - Details Card + + private var detailsCard: some View { + VStack(alignment: .leading, spacing: Theme.Spacing.md) { + HStack { + Image(systemName: "info.circle.fill") + .foregroundStyle(Theme.warmOrange) + Text("Details") + .font(.system(size: Theme.FontSize.body, weight: .semibold)) + .foregroundStyle(Theme.textPrimary(colorScheme)) + } + + // Date + HStack { + Text("Date") + .foregroundStyle(Theme.textSecondary(colorScheme)) + Spacer() + Text(formattedDate) + .foregroundStyle(Theme.textPrimary(colorScheme)) + } + + // Seat location + if let seat = visit.seatLocation, !seat.isEmpty { + HStack { + Text("Seat") + .foregroundStyle(Theme.textSecondary(colorScheme)) + Spacer() + Text(seat) + .foregroundStyle(Theme.textPrimary(colorScheme)) + } + } + + // Source + HStack { + Text("Source") + .foregroundStyle(Theme.textSecondary(colorScheme)) + Spacer() + Text(visit.source.displayName) + .foregroundStyle(Theme.textMuted(colorScheme)) + } + + // Created date + HStack { + Text("Logged") + .foregroundStyle(Theme.textSecondary(colorScheme)) + Spacer() + Text(formattedCreatedDate) + .foregroundStyle(Theme.textMuted(colorScheme)) + } + } + .font(.system(size: Theme.FontSize.body)) + .padding(Theme.Spacing.lg) + .background(Theme.cardBackground(colorScheme)) + .clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.large)) + .overlay { + RoundedRectangle(cornerRadius: Theme.CornerRadius.large) + .stroke(Theme.surfaceGlow(colorScheme), lineWidth: 1) + } + } + + // MARK: - Notes Card + + private var notesCard: some View { + VStack(alignment: .leading, spacing: Theme.Spacing.sm) { + HStack { + Image(systemName: "note.text") + .foregroundStyle(Theme.routeGold) + Text("Notes") + .font(.system(size: Theme.FontSize.body, weight: .semibold)) + .foregroundStyle(Theme.textPrimary(colorScheme)) + } + + Text(visit.notes ?? "") + .font(.system(size: Theme.FontSize.body)) + .foregroundStyle(Theme.textSecondary(colorScheme)) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(Theme.Spacing.lg) + .background(Theme.cardBackground(colorScheme)) + .clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.large)) + .overlay { + RoundedRectangle(cornerRadius: Theme.CornerRadius.large) + .stroke(Theme.surfaceGlow(colorScheme), lineWidth: 1) + } + } + + // MARK: - Edit Form + + private var editForm: some View { + VStack(alignment: .leading, spacing: Theme.Spacing.lg) { + // Date + VStack(alignment: .leading, spacing: Theme.Spacing.xs) { + Text("Date") + .font(.system(size: Theme.FontSize.caption, weight: .medium)) + .foregroundStyle(Theme.textSecondary(colorScheme)) + + DatePicker("", selection: $editVisitDate, displayedComponents: .date) + .labelsHidden() + } + + // Visit Type + VStack(alignment: .leading, spacing: Theme.Spacing.xs) { + Text("Visit Type") + .font(.system(size: Theme.FontSize.caption, weight: .medium)) + .foregroundStyle(Theme.textSecondary(colorScheme)) + + Picker("", selection: $editVisitType) { + ForEach(VisitType.allCases, id: \.self) { type in + Text(type.displayName).tag(type) + } + } + .pickerStyle(.segmented) + } + + // Game info (if game type) + if editVisitType == .game { + VStack(alignment: .leading, spacing: Theme.Spacing.sm) { + Text("Game Info") + .font(.system(size: Theme.FontSize.caption, weight: .medium)) + .foregroundStyle(Theme.textSecondary(colorScheme)) + + HStack { + TextField("Away Team", text: $editAwayTeamName) + .textFieldStyle(.roundedBorder) + Text("@") + .foregroundStyle(Theme.textMuted(colorScheme)) + TextField("Home Team", text: $editHomeTeamName) + .textFieldStyle(.roundedBorder) + } + + HStack { + TextField("Away Score", text: $editAwayScore) + .textFieldStyle(.roundedBorder) + .keyboardType(.numberPad) + .frame(width: 80) + Text("-") + .foregroundStyle(Theme.textMuted(colorScheme)) + TextField("Home Score", text: $editHomeScore) + .textFieldStyle(.roundedBorder) + .keyboardType(.numberPad) + .frame(width: 80) + Spacer() + } + } + } + + // Seat location + VStack(alignment: .leading, spacing: Theme.Spacing.xs) { + Text("Seat Location") + .font(.system(size: Theme.FontSize.caption, weight: .medium)) + .foregroundStyle(Theme.textSecondary(colorScheme)) + + TextField("e.g., Section 120, Row 5", text: $editSeatLocation) + .textFieldStyle(.roundedBorder) + } + + // Notes + VStack(alignment: .leading, spacing: Theme.Spacing.xs) { + Text("Notes") + .font(.system(size: Theme.FontSize.caption, weight: .medium)) + .foregroundStyle(Theme.textSecondary(colorScheme)) + + TextEditor(text: $editNotes) + .frame(minHeight: 100) + .scrollContentBackground(.hidden) + .background(Theme.cardBackgroundElevated(colorScheme)) + .clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.small)) + .overlay { + RoundedRectangle(cornerRadius: Theme.CornerRadius.small) + .stroke(Theme.surfaceGlow(colorScheme), lineWidth: 1) + } + } + } + .padding(Theme.Spacing.lg) + .background(Theme.cardBackground(colorScheme)) + .clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.large)) + .overlay { + RoundedRectangle(cornerRadius: Theme.CornerRadius.large) + .stroke(Theme.warmOrange.opacity(0.5), lineWidth: 2) + } + } + + // MARK: - Delete Button + + private var deleteButton: some View { + Button(role: .destructive) { + showDeleteConfirmation = true + } label: { + HStack { + Image(systemName: "trash") + Text("Delete Visit") + } + .frame(maxWidth: .infinity) + .padding(Theme.Spacing.md) + .background(Color.red.opacity(0.1)) + .foregroundStyle(.red) + .clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)) + } + } + + // MARK: - Computed Properties + + private var sportColor: Color { + visit.sportEnum?.themeColor ?? Theme.warmOrange + } + + private var formattedDate: String { + let formatter = DateFormatter() + formatter.dateStyle = .long + return formatter.string(from: visit.visitDate) + } + + private var formattedCreatedDate: String { + let formatter = DateFormatter() + formatter.dateStyle = .medium + formatter.timeStyle = .short + return formatter.string(from: visit.createdAt) + } + + // MARK: - Actions + + private func saveChanges() { + visit.visitDate = editVisitDate + visit.visitType = editVisitType + visit.homeTeamName = editHomeTeamName.isEmpty ? nil : editHomeTeamName + visit.awayTeamName = editAwayTeamName.isEmpty ? nil : editAwayTeamName + visit.seatLocation = editSeatLocation.isEmpty ? nil : editSeatLocation + visit.notes = editNotes.isEmpty ? nil : editNotes + + // Update score + if let away = Int(editAwayScore), let home = Int(editHomeScore) { + visit.finalScore = "\(away)-\(home)" + visit.scoreSource = .user + } else { + visit.finalScore = nil + visit.scoreSource = nil + } + + // Mark as user corrected if it wasn't fully manual + if visit.dataSource != .fullyManual { + visit.dataSource = .userCorrected + } + + try? modelContext.save() + + withAnimation { + isEditing = false + } + } + + private func cancelEditing() { + // Reset to original values + editVisitDate = visit.visitDate + editVisitType = visit.visitType + editHomeTeamName = visit.homeTeamName ?? "" + editAwayTeamName = visit.awayTeamName ?? "" + editSeatLocation = visit.seatLocation ?? "" + editNotes = visit.notes ?? "" + + if let score = visit.finalScore { + let parts = score.split(separator: "-") + if parts.count == 2 { + editAwayScore = String(parts[0]) + editHomeScore = String(parts[1]) + } + } else { + editAwayScore = "" + editHomeScore = "" + } + + withAnimation { + isEditing = false + } + } + + private func deleteVisit() { + modelContext.delete(visit) + try? modelContext.save() + dismiss() + } +} + +// MARK: - Visit Source Display Name + +extension VisitSource { + var displayName: String { + switch self { + case .trip: return "From Trip" + case .manual: return "Manual Entry" + case .photoImport: return "Photo Import" + } + } +} + +// MARK: - Preview + +#Preview { + let stadium = Stadium( + name: "Oracle Park", + city: "San Francisco", + state: "CA", + latitude: 37.7786, + longitude: -122.3893, + capacity: 41915, + sport: .mlb + ) + + NavigationStack { + Text("Preview placeholder") + } +} diff --git a/SportsTime/Features/Schedule/Views/ScheduleListView.swift b/SportsTime/Features/Schedule/Views/ScheduleListView.swift index 3eba2a2..d03e592 100644 --- a/SportsTime/Features/Schedule/Views/ScheduleListView.swift +++ b/SportsTime/Features/Schedule/Views/ScheduleListView.swift @@ -6,6 +6,7 @@ import SwiftUI struct ScheduleListView: View { + @Environment(\.colorScheme) private var colorScheme @State private var viewModel = ScheduleViewModel() @State private var showDatePicker = false @@ -97,9 +98,11 @@ struct ScheduleListView: View { Text(formatSectionDate(dateGroup.date)) .font(.headline) } + .listRowBackground(Theme.cardBackground(colorScheme)) } } .listStyle(.plain) + .scrollContentBackground(.hidden) .refreshable { await viewModel.loadGames() } @@ -128,8 +131,7 @@ struct ScheduleListView: View { private var loadingView: some View { VStack(spacing: 16) { - ProgressView() - .scaleEffect(1.5) + ThemedSpinner(size: 44) Text("Loading schedule...") .foregroundStyle(.secondary) } diff --git a/SportsTime/Features/Settings/Views/SettingsView.swift b/SportsTime/Features/Settings/Views/SettingsView.swift index 0b55685..15ad177 100644 --- a/SportsTime/Features/Settings/Views/SettingsView.swift +++ b/SportsTime/Features/Settings/Views/SettingsView.swift @@ -6,6 +6,7 @@ import SwiftUI struct SettingsView: View { + @Environment(\.colorScheme) private var colorScheme @State private var viewModel = SettingsViewModel() @State private var showResetConfirmation = false @@ -91,6 +92,7 @@ struct SettingsView: View { } footer: { Text("Choose a color scheme for the app.") } + .listRowBackground(Theme.cardBackground(colorScheme)) } // MARK: - Sports Section @@ -115,6 +117,7 @@ struct SettingsView: View { } footer: { Text("Selected sports will be shown by default in schedules and trip planning.") } + .listRowBackground(Theme.cardBackground(colorScheme)) } // MARK: - Travel Section @@ -159,6 +162,7 @@ struct SettingsView: View { } footer: { Text("Trips will be optimized to keep daily driving within this limit.") } + .listRowBackground(Theme.cardBackground(colorScheme)) } // MARK: - Data Section @@ -176,7 +180,7 @@ struct SettingsView: View { Spacer() if viewModel.isSyncing { - ProgressView() + ThemedSpinnerCompact(size: 18) } } } @@ -209,6 +213,7 @@ struct SettingsView: View { Text("Schedule data is synced from CloudKit.") #endif } + .listRowBackground(Theme.cardBackground(colorScheme)) } // MARK: - About Section @@ -236,6 +241,7 @@ struct SettingsView: View { } header: { Text("About") } + .listRowBackground(Theme.cardBackground(colorScheme)) } // MARK: - Reset Section @@ -248,6 +254,7 @@ struct SettingsView: View { Label("Reset to Defaults", systemImage: "arrow.counterclockwise") } } + .listRowBackground(Theme.cardBackground(colorScheme)) } // MARK: - Helpers diff --git a/SportsTime/Features/Trip/ViewModels/TripCreationViewModel.swift b/SportsTime/Features/Trip/ViewModels/TripCreationViewModel.swift index 3fec394..25043ca 100644 --- a/SportsTime/Features/Trip/ViewModels/TripCreationViewModel.swift +++ b/SportsTime/Features/Trip/ViewModels/TripCreationViewModel.swift @@ -348,6 +348,10 @@ final class TripCreationViewModel { } } + func deselectAllGames() { + mustSeeGameIds.removeAll() + } + func switchPlanningMode(_ mode: PlanningMode) { planningMode = mode // Clear mode-specific selections when switching diff --git a/SportsTime/Features/Trip/Views/TripCreationView.swift b/SportsTime/Features/Trip/Views/TripCreationView.swift index 19edd3f..ad96e5e 100644 --- a/SportsTime/Features/Trip/Views/TripCreationView.swift +++ b/SportsTime/Features/Trip/Views/TripCreationView.swift @@ -9,11 +9,11 @@ struct TripCreationView: View { @Environment(\.dismiss) private var dismiss @Environment(\.colorScheme) private var colorScheme + @Bindable var viewModel: TripCreationViewModel let initialSport: Sport? - @State private var viewModel = TripCreationViewModel() - - init(initialSport: Sport? = nil) { + init(viewModel: TripCreationViewModel, initialSport: Sport? = nil) { + self.viewModel = viewModel self.initialSport = initialSport } @State private var showGamePicker = false @@ -25,6 +25,16 @@ struct TripCreationView: View { @State private var completedTrip: Trip? @State private var tripOptions: [ItineraryOption] = [] + // Location search state + @State private var startLocationSuggestions: [LocationSearchResult] = [] + @State private var endLocationSuggestions: [LocationSearchResult] = [] + @State private var startSearchTask: Task? + @State private var endSearchTask: Task? + @State private var isSearchingStart = false + @State private var isSearchingEnd = false + + private let locationService = LocationService.shared + enum CityInputType { case mustStop case preferred @@ -214,35 +224,192 @@ struct TripCreationView: View { private var locationSection: some View { ThemedSection(title: "Locations") { - ThemedTextField( - label: "Start Location", - placeholder: "Where are you starting from?", - text: $viewModel.startLocationText, - icon: "location.circle.fill" - ) + // Start Location with suggestions + VStack(alignment: .leading, spacing: 0) { + ThemedTextField( + label: "Start Location", + placeholder: "Where are you starting from?", + text: $viewModel.startLocationText, + icon: "location.circle.fill" + ) + .onChange(of: viewModel.startLocationText) { _, newValue in + searchLocation(query: newValue, isStart: true) + } - ThemedTextField( - label: "End Location", - placeholder: "Where do you want to end up?", - text: $viewModel.endLocationText, - icon: "mappin.circle.fill" - ) + // Suggestions for start location + if !startLocationSuggestions.isEmpty { + locationSuggestionsList( + suggestions: startLocationSuggestions, + isLoading: isSearchingStart + ) { result in + viewModel.startLocationText = result.name + viewModel.startLocation = result.toLocationInput() + startLocationSuggestions = [] + } + } else if isSearchingStart { + HStack { + ThemedSpinnerCompact(size: 14) + Text("Searching...") + .font(.system(size: Theme.FontSize.caption)) + .foregroundStyle(Theme.textMuted(colorScheme)) + } + .padding(.top, Theme.Spacing.xs) + } + } + + // End Location with suggestions + VStack(alignment: .leading, spacing: 0) { + ThemedTextField( + label: "End Location", + placeholder: "Where do you want to end up?", + text: $viewModel.endLocationText, + icon: "mappin.circle.fill" + ) + .onChange(of: viewModel.endLocationText) { _, newValue in + searchLocation(query: newValue, isStart: false) + } + + // Suggestions for end location + if !endLocationSuggestions.isEmpty { + locationSuggestionsList( + suggestions: endLocationSuggestions, + isLoading: isSearchingEnd + ) { result in + viewModel.endLocationText = result.name + viewModel.endLocation = result.toLocationInput() + endLocationSuggestions = [] + } + } else if isSearchingEnd { + HStack { + ThemedSpinnerCompact(size: 14) + Text("Searching...") + .font(.system(size: Theme.FontSize.caption)) + .foregroundStyle(Theme.textMuted(colorScheme)) + } + .padding(.top, Theme.Spacing.xs) + } + } } } + private func searchLocation(query: String, isStart: Bool) { + // Cancel previous search + if isStart { + startSearchTask?.cancel() + } else { + endSearchTask?.cancel() + } + + guard query.count >= 2 else { + if isStart { + startLocationSuggestions = [] + isSearchingStart = false + } else { + endLocationSuggestions = [] + isSearchingEnd = false + } + return + } + + let task = Task { + // Debounce + try? await Task.sleep(for: .milliseconds(300)) + guard !Task.isCancelled else { return } + + if isStart { + isSearchingStart = true + } else { + isSearchingEnd = true + } + + do { + let results = try await locationService.searchLocations(query) + guard !Task.isCancelled else { return } + + if isStart { + startLocationSuggestions = Array(results.prefix(5)) + isSearchingStart = false + } else { + endLocationSuggestions = Array(results.prefix(5)) + isSearchingEnd = false + } + } catch { + if isStart { + startLocationSuggestions = [] + isSearchingStart = false + } else { + endLocationSuggestions = [] + isSearchingEnd = false + } + } + } + + if isStart { + startSearchTask = task + } else { + endSearchTask = task + } + } + + @ViewBuilder + private func locationSuggestionsList( + suggestions: [LocationSearchResult], + isLoading: Bool, + onSelect: @escaping (LocationSearchResult) -> Void + ) -> some View { + VStack(alignment: .leading, spacing: 0) { + ForEach(suggestions) { result in + Button { + onSelect(result) + } label: { + HStack(spacing: Theme.Spacing.sm) { + Image(systemName: "mappin.circle.fill") + .foregroundStyle(Theme.warmOrange) + .font(.system(size: 14)) + + VStack(alignment: .leading, spacing: 2) { + Text(result.name) + .font(.system(size: Theme.FontSize.caption, weight: .medium)) + .foregroundStyle(Theme.textPrimary(colorScheme)) + + if !result.address.isEmpty { + Text(result.address) + .font(.system(size: Theme.FontSize.micro)) + .foregroundStyle(Theme.textMuted(colorScheme)) + } + } + + Spacer() + } + .padding(.vertical, Theme.Spacing.sm) + .padding(.horizontal, Theme.Spacing.xs) + } + .buttonStyle(.plain) + + if result.id != suggestions.last?.id { + Divider() + .overlay(Theme.surfaceGlow(colorScheme)) + } + } + } + .padding(.top, Theme.Spacing.xs) + .background(Theme.cardBackgroundElevated(colorScheme)) + .clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.small)) + } + private var gameBrowserSection: some View { ThemedSection(title: "Select Games") { if viewModel.isLoadingGames || viewModel.availableGames.isEmpty { HStack(spacing: Theme.Spacing.sm) { - ProgressView() - .tint(Theme.warmOrange) + ThemedSpinnerCompact(size: 20) Text("Loading games...") .font(.system(size: Theme.FontSize.body)) .foregroundStyle(Theme.textSecondary(colorScheme)) } .frame(maxWidth: .infinity, alignment: .center) .padding(.vertical, Theme.Spacing.md) - .task { + .task(id: viewModel.selectedSports) { + // Re-run when sports selection changes if viewModel.availableGames.isEmpty { await viewModel.loadGamesForBrowsing() } @@ -290,6 +457,16 @@ struct TripCreationView: View { Text("\(viewModel.mustSeeGameIds.count) game(s) selected") .font(.system(size: Theme.FontSize.body, weight: .medium)) .foregroundStyle(Theme.textPrimary(colorScheme)) + + Spacer() + + Button { + viewModel.deselectAllGames() + } label: { + Text("Deselect All") + .font(.system(size: Theme.FontSize.caption, weight: .medium)) + .foregroundStyle(.red) + } } // Show selected games preview @@ -927,8 +1104,7 @@ struct LocationSearchSheet: View { .textFieldStyle(.plain) .autocorrectionDisabled() if isSearching { - ProgressView() - .scaleEffect(0.8) + ThemedSpinnerCompact(size: 16) } else if !searchText.isEmpty { Button { searchText = "" @@ -1260,8 +1436,7 @@ struct TripOptionCard: View { .transition(.opacity) } else if isLoadingDescription { HStack(spacing: 4) { - ProgressView() - .scaleEffect(0.6) + ThemedSpinnerCompact(size: 12) Text("Generating...") .font(.system(size: 11)) .foregroundStyle(Theme.textMuted(colorScheme)) @@ -1806,5 +1981,5 @@ struct SportSelectionChip: View { } #Preview { - TripCreationView() + TripCreationView(viewModel: TripCreationViewModel()) } diff --git a/SportsTime/Features/Trip/Views/TripDetailView.swift b/SportsTime/Features/Trip/Views/TripDetailView.swift index 69a4e36..dfc1961 100644 --- a/SportsTime/Features/Trip/Views/TripDetailView.swift +++ b/SportsTime/Features/Trip/Views/TripDetailView.swift @@ -198,8 +198,7 @@ struct TripDetailView: View { // Loading indicator if isLoadingRoutes { - ProgressView() - .tint(Theme.warmOrange) + ThemedSpinnerCompact(size: 24) .padding(.bottom, 40) } } diff --git a/SportsTime/Resources/league_structure.json b/SportsTime/Resources/league_structure.json new file mode 100644 index 0000000..a18393c --- /dev/null +++ b/SportsTime/Resources/league_structure.json @@ -0,0 +1,227 @@ +[ + { + "id": "mlb_league", + "sport": "MLB", + "type": "league", + "name": "Major League Baseball", + "abbreviation": "MLB", + "parent_id": null, + "display_order": 0 + }, + { + "id": "mlb_al", + "sport": "MLB", + "type": "conference", + "name": "American League", + "abbreviation": "AL", + "parent_id": "mlb_league", + "display_order": 1 + }, + { + "id": "mlb_nl", + "sport": "MLB", + "type": "conference", + "name": "National League", + "abbreviation": "NL", + "parent_id": "mlb_league", + "display_order": 2 + }, + { + "id": "mlb_al_east", + "sport": "MLB", + "type": "division", + "name": "AL East", + "abbreviation": null, + "parent_id": "mlb_al", + "display_order": 1 + }, + { + "id": "mlb_al_central", + "sport": "MLB", + "type": "division", + "name": "AL Central", + "abbreviation": null, + "parent_id": "mlb_al", + "display_order": 2 + }, + { + "id": "mlb_al_west", + "sport": "MLB", + "type": "division", + "name": "AL West", + "abbreviation": null, + "parent_id": "mlb_al", + "display_order": 3 + }, + { + "id": "mlb_nl_east", + "sport": "MLB", + "type": "division", + "name": "NL East", + "abbreviation": null, + "parent_id": "mlb_nl", + "display_order": 1 + }, + { + "id": "mlb_nl_central", + "sport": "MLB", + "type": "division", + "name": "NL Central", + "abbreviation": null, + "parent_id": "mlb_nl", + "display_order": 2 + }, + { + "id": "mlb_nl_west", + "sport": "MLB", + "type": "division", + "name": "NL West", + "abbreviation": null, + "parent_id": "mlb_nl", + "display_order": 3 + }, + { + "id": "nba_league", + "sport": "NBA", + "type": "league", + "name": "National Basketball Association", + "abbreviation": "NBA", + "parent_id": null, + "display_order": 0 + }, + { + "id": "nba_eastern", + "sport": "NBA", + "type": "conference", + "name": "Eastern Conference", + "abbreviation": "East", + "parent_id": "nba_league", + "display_order": 1 + }, + { + "id": "nba_western", + "sport": "NBA", + "type": "conference", + "name": "Western Conference", + "abbreviation": "West", + "parent_id": "nba_league", + "display_order": 2 + }, + { + "id": "nba_atlantic", + "sport": "NBA", + "type": "division", + "name": "Atlantic", + "abbreviation": null, + "parent_id": "nba_eastern", + "display_order": 1 + }, + { + "id": "nba_central", + "sport": "NBA", + "type": "division", + "name": "Central", + "abbreviation": null, + "parent_id": "nba_eastern", + "display_order": 2 + }, + { + "id": "nba_southeast", + "sport": "NBA", + "type": "division", + "name": "Southeast", + "abbreviation": null, + "parent_id": "nba_eastern", + "display_order": 3 + }, + { + "id": "nba_northwest", + "sport": "NBA", + "type": "division", + "name": "Northwest", + "abbreviation": null, + "parent_id": "nba_western", + "display_order": 1 + }, + { + "id": "nba_pacific", + "sport": "NBA", + "type": "division", + "name": "Pacific", + "abbreviation": null, + "parent_id": "nba_western", + "display_order": 2 + }, + { + "id": "nba_southwest", + "sport": "NBA", + "type": "division", + "name": "Southwest", + "abbreviation": null, + "parent_id": "nba_western", + "display_order": 3 + }, + { + "id": "nhl_league", + "sport": "NHL", + "type": "league", + "name": "National Hockey League", + "abbreviation": "NHL", + "parent_id": null, + "display_order": 0 + }, + { + "id": "nhl_eastern", + "sport": "NHL", + "type": "conference", + "name": "Eastern Conference", + "abbreviation": "East", + "parent_id": "nhl_league", + "display_order": 1 + }, + { + "id": "nhl_western", + "sport": "NHL", + "type": "conference", + "name": "Western Conference", + "abbreviation": "West", + "parent_id": "nhl_league", + "display_order": 2 + }, + { + "id": "nhl_atlantic", + "sport": "NHL", + "type": "division", + "name": "Atlantic", + "abbreviation": null, + "parent_id": "nhl_eastern", + "display_order": 1 + }, + { + "id": "nhl_metropolitan", + "sport": "NHL", + "type": "division", + "name": "Metropolitan", + "abbreviation": null, + "parent_id": "nhl_eastern", + "display_order": 2 + }, + { + "id": "nhl_central", + "sport": "NHL", + "type": "division", + "name": "Central", + "abbreviation": null, + "parent_id": "nhl_western", + "display_order": 1 + }, + { + "id": "nhl_pacific", + "sport": "NHL", + "type": "division", + "name": "Pacific", + "abbreviation": null, + "parent_id": "nhl_western", + "display_order": 2 + } +] diff --git a/SportsTime/Resources/team_aliases.json b/SportsTime/Resources/team_aliases.json new file mode 100644 index 0000000..8ce793a --- /dev/null +++ b/SportsTime/Resources/team_aliases.json @@ -0,0 +1,306 @@ +[ + { + "id": "alias_nba_brk_njn", + "team_canonical_id": "team_nba_brk", + "alias_type": "abbreviation", + "alias_value": "NJN", + "valid_from": null, + "valid_until": "2012-05-01T00:00:00Z" + }, + { + "id": "alias_nba_brk_nj_nets", + "team_canonical_id": "team_nba_brk", + "alias_type": "name", + "alias_value": "New Jersey Nets", + "valid_from": null, + "valid_until": "2012-05-01T00:00:00Z" + }, + { + "id": "alias_nba_brk_nj_city", + "team_canonical_id": "team_nba_brk", + "alias_type": "city", + "alias_value": "New Jersey", + "valid_from": null, + "valid_until": "2012-05-01T00:00:00Z" + }, + { + "id": "alias_nba_okc_sea", + "team_canonical_id": "team_nba_okc", + "alias_type": "abbreviation", + "alias_value": "SEA", + "valid_from": null, + "valid_until": "2008-07-01T00:00:00Z" + }, + { + "id": "alias_nba_okc_sonics", + "team_canonical_id": "team_nba_okc", + "alias_type": "name", + "alias_value": "Seattle SuperSonics", + "valid_from": null, + "valid_until": "2008-07-01T00:00:00Z" + }, + { + "id": "alias_nba_okc_seattle", + "team_canonical_id": "team_nba_okc", + "alias_type": "city", + "alias_value": "Seattle", + "valid_from": null, + "valid_until": "2008-07-01T00:00:00Z" + }, + { + "id": "alias_nba_mem_van", + "team_canonical_id": "team_nba_mem", + "alias_type": "abbreviation", + "alias_value": "VAN", + "valid_from": null, + "valid_until": "2001-05-01T00:00:00Z" + }, + { + "id": "alias_nba_mem_vancouver", + "team_canonical_id": "team_nba_mem", + "alias_type": "name", + "alias_value": "Vancouver Grizzlies", + "valid_from": null, + "valid_until": "2001-05-01T00:00:00Z" + }, + { + "id": "alias_nba_nop_noh", + "team_canonical_id": "team_nba_nop", + "alias_type": "abbreviation", + "alias_value": "NOH", + "valid_from": "2002-01-01T00:00:00Z", + "valid_until": "2013-05-01T00:00:00Z" + }, + { + "id": "alias_nba_nop_hornets", + "team_canonical_id": "team_nba_nop", + "alias_type": "name", + "alias_value": "New Orleans Hornets", + "valid_from": "2002-01-01T00:00:00Z", + "valid_until": "2013-05-01T00:00:00Z" + }, + { + "id": "alias_nba_cho_cha_old", + "team_canonical_id": "team_nba_cho", + "alias_type": "abbreviation", + "alias_value": "CHA", + "valid_from": "2014-01-01T00:00:00Z", + "valid_until": null + }, + { + "id": "alias_nba_was_wsh", + "team_canonical_id": "team_nba_was", + "alias_type": "abbreviation", + "alias_value": "WSH", + "valid_from": null, + "valid_until": null + }, + { + "id": "alias_nba_uta_utj", + "team_canonical_id": "team_nba_uta", + "alias_type": "abbreviation", + "alias_value": "UTJ", + "valid_from": null, + "valid_until": null + }, + { + "id": "alias_nba_pho_phx", + "team_canonical_id": "team_nba_pho", + "alias_type": "abbreviation", + "alias_value": "PHX", + "valid_from": null, + "valid_until": null + }, + { + "id": "alias_mlb_mia_fla", + "team_canonical_id": "team_mlb_mia", + "alias_type": "abbreviation", + "alias_value": "FLA", + "valid_from": null, + "valid_until": "2012-01-01T00:00:00Z" + }, + { + "id": "alias_mlb_mia_marlins_fl", + "team_canonical_id": "team_mlb_mia", + "alias_type": "name", + "alias_value": "Florida Marlins", + "valid_from": null, + "valid_until": "2012-01-01T00:00:00Z" + }, + { + "id": "alias_mlb_mia_florida", + "team_canonical_id": "team_mlb_mia", + "alias_type": "city", + "alias_value": "Florida", + "valid_from": null, + "valid_until": "2012-01-01T00:00:00Z" + }, + { + "id": "alias_mlb_laa_ana", + "team_canonical_id": "team_mlb_laa", + "alias_type": "abbreviation", + "alias_value": "ANA", + "valid_from": null, + "valid_until": "2005-01-01T00:00:00Z" + }, + { + "id": "alias_mlb_laa_angels_ana", + "team_canonical_id": "team_mlb_laa", + "alias_type": "name", + "alias_value": "Anaheim Angels", + "valid_from": null, + "valid_until": "2005-01-01T00:00:00Z" + }, + { + "id": "alias_mlb_laa_california", + "team_canonical_id": "team_mlb_laa", + "alias_type": "name", + "alias_value": "California Angels", + "valid_from": null, + "valid_until": "1996-01-01T00:00:00Z" + }, + { + "id": "alias_mlb_tbr_tbd", + "team_canonical_id": "team_mlb_tbr", + "alias_type": "abbreviation", + "alias_value": "TBD", + "valid_from": null, + "valid_until": "2008-01-01T00:00:00Z" + }, + { + "id": "alias_mlb_tbr_devil_rays", + "team_canonical_id": "team_mlb_tbr", + "alias_type": "name", + "alias_value": "Tampa Bay Devil Rays", + "valid_from": null, + "valid_until": "2008-01-01T00:00:00Z" + }, + { + "id": "alias_mlb_was_mon", + "team_canonical_id": "team_mlb_was", + "alias_type": "abbreviation", + "alias_value": "MON", + "valid_from": null, + "valid_until": "2005-01-01T00:00:00Z" + }, + { + "id": "alias_mlb_was_expos", + "team_canonical_id": "team_mlb_was", + "alias_type": "name", + "alias_value": "Montreal Expos", + "valid_from": null, + "valid_until": "2005-01-01T00:00:00Z" + }, + { + "id": "alias_mlb_was_montreal", + "team_canonical_id": "team_mlb_was", + "alias_type": "city", + "alias_value": "Montreal", + "valid_from": null, + "valid_until": "2005-01-01T00:00:00Z" + }, + { + "id": "alias_nhl_ari_pho", + "team_canonical_id": "team_nhl_uta", + "alias_type": "abbreviation", + "alias_value": "ARI", + "valid_from": "1996-01-01T00:00:00Z", + "valid_until": "2024-05-01T00:00:00Z" + }, + { + "id": "alias_nhl_ari_coyotes", + "team_canonical_id": "team_nhl_uta", + "alias_type": "name", + "alias_value": "Arizona Coyotes", + "valid_from": "1996-01-01T00:00:00Z", + "valid_until": "2024-05-01T00:00:00Z" + }, + { + "id": "alias_nhl_ari_phoenix", + "team_canonical_id": "team_nhl_uta", + "alias_type": "name", + "alias_value": "Phoenix Coyotes", + "valid_from": "1996-01-01T00:00:00Z", + "valid_until": "2014-01-01T00:00:00Z" + }, + { + "id": "alias_nhl_wpg_atl", + "team_canonical_id": "team_nhl_wpg", + "alias_type": "abbreviation", + "alias_value": "ATL", + "valid_from": "1999-01-01T00:00:00Z", + "valid_until": "2011-05-01T00:00:00Z" + }, + { + "id": "alias_nhl_wpg_thrashers", + "team_canonical_id": "team_nhl_wpg", + "alias_type": "name", + "alias_value": "Atlanta Thrashers", + "valid_from": "1999-01-01T00:00:00Z", + "valid_until": "2011-05-01T00:00:00Z" + }, + { + "id": "alias_nhl_car_htf", + "team_canonical_id": "team_nhl_car", + "alias_type": "abbreviation", + "alias_value": "HTF", + "valid_from": null, + "valid_until": "1997-01-01T00:00:00Z" + }, + { + "id": "alias_nhl_car_whalers", + "team_canonical_id": "team_nhl_car", + "alias_type": "name", + "alias_value": "Hartford Whalers", + "valid_from": null, + "valid_until": "1997-01-01T00:00:00Z" + }, + { + "id": "alias_nhl_col_que", + "team_canonical_id": "team_nhl_col", + "alias_type": "abbreviation", + "alias_value": "QUE", + "valid_from": null, + "valid_until": "1995-05-01T00:00:00Z" + }, + { + "id": "alias_nhl_col_nordiques", + "team_canonical_id": "team_nhl_col", + "alias_type": "name", + "alias_value": "Quebec Nordiques", + "valid_from": null, + "valid_until": "1995-05-01T00:00:00Z" + }, + { + "id": "alias_nhl_dal_mns", + "team_canonical_id": "team_nhl_dal", + "alias_type": "abbreviation", + "alias_value": "MNS", + "valid_from": null, + "valid_until": "1993-05-01T00:00:00Z" + }, + { + "id": "alias_nhl_dal_north_stars", + "team_canonical_id": "team_nhl_dal", + "alias_type": "name", + "alias_value": "Minnesota North Stars", + "valid_from": null, + "valid_until": "1993-05-01T00:00:00Z" + }, + { + "id": "alias_nhl_ana_mda", + "team_canonical_id": "team_nhl_ana", + "alias_type": "name", + "alias_value": "Mighty Ducks of Anaheim", + "valid_from": null, + "valid_until": "2006-06-01T00:00:00Z" + }, + { + "id": "alias_nhl_vgk_lv", + "team_canonical_id": "team_nhl_vgk", + "alias_type": "abbreviation", + "alias_value": "LV", + "valid_from": null, + "valid_until": null + } +] diff --git a/SportsTime/SportsTimeApp.swift b/SportsTime/SportsTimeApp.swift index 9475ea6..83da253 100644 --- a/SportsTime/SportsTimeApp.swift +++ b/SportsTime/SportsTimeApp.swift @@ -12,10 +12,24 @@ import SwiftData struct SportsTimeApp: App { var sharedModelContainer: ModelContainer = { let schema = Schema([ + // User data models SavedTrip.self, TripVote.self, UserPreferences.self, CachedSchedule.self, + // Stadium progress models + StadiumVisit.self, + VisitPhotoMetadata.self, + Achievement.self, + CachedGameScore.self, + // Canonical data models + SyncState.self, + CanonicalStadium.self, + StadiumAlias.self, + CanonicalTeam.self, + TeamAlias.self, + LeagueStructureModel.self, + CanonicalGame.self, ]) let modelConfiguration = ModelConfiguration( schema: schema, @@ -32,8 +46,100 @@ struct SportsTimeApp: App { var body: some Scene { WindowGroup { - HomeView() + BootstrappedContentView(modelContainer: sharedModelContainer) } .modelContainer(sharedModelContainer) } } + +// MARK: - Bootstrapped Content View + +/// Wraps the main content with bootstrap logic. +/// Shows a loading indicator until bootstrap completes, then shows HomeView. +struct BootstrappedContentView: View { + let modelContainer: ModelContainer + + @State private var isBootstrapping = true + @State private var bootstrapError: Error? + + var body: some View { + Group { + if isBootstrapping { + BootstrapLoadingView() + } else if let error = bootstrapError { + BootstrapErrorView(error: error) { + Task { + await performBootstrap() + } + } + } else { + HomeView() + } + } + .task { + await performBootstrap() + } + } + + @MainActor + private func performBootstrap() async { + isBootstrapping = true + bootstrapError = nil + + let context = modelContainer.mainContext + let bootstrapService = BootstrapService() + + do { + try await bootstrapService.bootstrapIfNeeded(context: context) + isBootstrapping = false + } catch { + bootstrapError = error + isBootstrapping = false + } + } +} + +// MARK: - Bootstrap Loading View + +struct BootstrapLoadingView: View { + var body: some View { + VStack(spacing: 20) { + ThemedSpinner(size: 50, lineWidth: 4) + + Text("Setting up SportsTime...") + .font(.headline) + .foregroundStyle(.secondary) + } + } +} + +// MARK: - Bootstrap Error View + +struct BootstrapErrorView: View { + let error: Error + let onRetry: () -> Void + + var body: some View { + VStack(spacing: 20) { + Image(systemName: "exclamationmark.triangle.fill") + .font(.system(size: 50)) + .foregroundStyle(.orange) + + Text("Setup Failed") + .font(.title2) + .fontWeight(.semibold) + + Text(error.localizedDescription) + .font(.subheadline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal) + + Button("Try Again") { + onRetry() + } + .buttonStyle(.borderedProminent) + } + .padding() + } +} diff --git a/SportsTimeTests/ScenarioAPlannerSwiftTests.swift b/SportsTimeTests/ScenarioAPlannerSwiftTests.swift index 5632583..842ae05 100644 --- a/SportsTimeTests/ScenarioAPlannerSwiftTests.swift +++ b/SportsTimeTests/ScenarioAPlannerSwiftTests.swift @@ -21,7 +21,8 @@ struct ScenarioAPlannerSwiftTests { id: UUID = UUID(), city: String, latitude: Double, - longitude: Double + longitude: Double, + sport: Sport = .mlb ) -> Stadium { Stadium( id: id, @@ -30,7 +31,8 @@ struct ScenarioAPlannerSwiftTests { state: "ST", latitude: latitude, longitude: longitude, - capacity: 40000 + capacity: 40000, + sport: sport ) } diff --git a/SportsTimeTests/ScenarioBPlannerTests.swift b/SportsTimeTests/ScenarioBPlannerTests.swift index 6407372..98bb342 100644 --- a/SportsTimeTests/ScenarioBPlannerTests.swift +++ b/SportsTimeTests/ScenarioBPlannerTests.swift @@ -22,7 +22,8 @@ struct ScenarioBPlannerTests { city: String, state: String, latitude: Double, - longitude: Double + longitude: Double, + sport: Sport = .mlb ) -> Stadium { Stadium( id: id, @@ -31,7 +32,8 @@ struct ScenarioBPlannerTests { state: state, latitude: latitude, longitude: longitude, - capacity: 40000 + capacity: 40000, + sport: sport ) } diff --git a/SportsTimeTests/ScenarioCPlannerTests.swift b/SportsTimeTests/ScenarioCPlannerTests.swift index 96b7357..163283a 100644 --- a/SportsTimeTests/ScenarioCPlannerTests.swift +++ b/SportsTimeTests/ScenarioCPlannerTests.swift @@ -23,7 +23,8 @@ struct ScenarioCPlannerTests { city: String, state: String, latitude: Double, - longitude: Double + longitude: Double, + sport: Sport = .mlb ) -> Stadium { Stadium( id: id, @@ -32,7 +33,8 @@ struct ScenarioCPlannerTests { state: state, latitude: latitude, longitude: longitude, - capacity: 40000 + capacity: 40000, + sport: sport ) } diff --git a/SportsTimeTests/SportsTimeTests.swift b/SportsTimeTests/SportsTimeTests.swift index 650b4a4..f6713af 100644 --- a/SportsTimeTests/SportsTimeTests.swift +++ b/SportsTimeTests/SportsTimeTests.swift @@ -53,7 +53,8 @@ struct DayCardTests { state: "ST", latitude: 40.0, longitude: -100.0, - capacity: 40000 + capacity: 40000, + sport: game.sport ) return RichGame(game: game, homeTeam: homeTeam, awayTeam: awayTeam, stadium: stadium) } @@ -350,7 +351,7 @@ struct DayCardTests { /// Tests for handling duplicate game IDs without crashing (regression test for fatal error) struct DuplicateGameIdTests { - private func makeStadium() -> Stadium { + private func makeStadium(sport: Sport = .mlb) -> Stadium { Stadium( id: UUID(), name: "Test Stadium", @@ -358,7 +359,8 @@ struct DuplicateGameIdTests { state: "TS", latitude: 40.0, longitude: -100.0, - capacity: 40000 + capacity: 40000, + sport: sport ) } diff --git a/docs/STADIUM_PROGRESS_SPEC.md b/docs/STADIUM_PROGRESS_SPEC.md new file mode 100644 index 0000000..a6cb547 --- /dev/null +++ b/docs/STADIUM_PROGRESS_SPEC.md @@ -0,0 +1,841 @@ +# Stadium Progress & Achievement System + +## Overview + +Track stadium visits, visualize progress on a map, earn achievement badges, and share progress cards. Supports MLB, NBA, NHL with historical visit logging and photo attachments synced to iCloud. + +## User Requirements + +- **Visual Progress Map**: Interactive map showing visited/unvisited stadiums, filterable by league +- **Shareable Progress Cards**: Social media export with stats and optional map snapshot +- **Achievement Badges**: Count-based, regional (division), journey-based, league completion +- **Visit Logging**: Auto-fill from app games, manual/historical entry, stadium-only visits (tours) +- **Photo Attachments**: Multiple photos per visit, iCloud sync for backup + +## Design Decisions + +| Decision | Choice | +|----------|--------| +| Photo storage | iCloud sync (CloudKit private database) | +| Historical games | Manual entry fallback + stadium-only visits allowed | +| Achievement policy | Recalculate & revoke when visits deleted | + +--- + +## Data Models + +### SwiftData Models + +**File**: `SportsTime/Core/Models/Local/StadiumProgress.swift` + +```swift +@Model +final class StadiumVisit { + @Attribute(.unique) var id: UUID + var canonicalStadiumId: UUID // Stable ID across renames + var stadiumNameAtVisit: String // Frozen at visit time + var visitDate: Date + var sport: String // Sport.rawValue + var visitType: String // "game" | "tour" | "other" + + // Game info (optional) + var gameId: UUID? + var homeTeamId: UUID? + var awayTeamId: UUID? + var homeTeamName: String? // For display when team lookup fails + var awayTeamName: String? + var finalScore: String? // "5-3" format + var manualGameDescription: String? // User's description if game not found + + // Resolution tracking + var scoreSource: String // "app" | "api" | "scraped" | "user" + var dataSource: String // "automatic" | "partial_manual" | "fully_manual" | "user_corrected" + var scoreResolutionPending: Bool // true if background retry needed + + // User data + var seatLocation: String? + var notes: String? + + // Photos + @Relationship(deleteRule: .cascade) + var photoMetadata: [VisitPhotoMetadata]? + + // Photo import metadata (preserved for debugging/re-matching) + var photoLatitude: Double? + var photoLongitude: Double? + var photoCaptureDate: Date? + + var createdAt: Date + var source: String // "trip" | "manual" | "photo_import" +} + +@Model +final class VisitPhotoMetadata { + @Attribute(.unique) var id: UUID + var visitId: UUID + var cloudKitAssetId: String + var thumbnailData: Data? // 200x200 JPEG + var caption: String? + var orderIndex: Int + var uploadStatus: String // "pending" | "uploaded" | "failed" +} + +@Model +final class Achievement { + @Attribute(.unique) var id: UUID + var achievementTypeId: String // "mlb_all_30" | "nl_west" + var sport: String? + var earnedAt: Date + var revokedAt: Date? // Non-nil if visits deleted + var visitIdsSnapshot: Data // [UUID] that earned this +} +``` + +### Domain Models + +**File**: `SportsTime/Core/Models/Domain/Division.swift` + +```swift +struct Division: Identifiable, Codable { + let id: String // "MLB_NL_WEST" + let name: String // "NL West" + let conference: String // "National League" + let sport: Sport + let teamIds: [UUID] +} + +enum LeagueStructure { + static let mlbDivisions: [Division] // 6 divisions + static let nbaDivisions: [Division] // 6 divisions + static let nhlDivisions: [Division] // 4 divisions + + static func divisions(for sport: Sport) -> [Division] + static func division(forTeam teamId: UUID) -> Division? +} +``` + +**File**: `SportsTime/Core/Models/Domain/Progress.swift` + +```swift +struct LeagueProgress { + let sport: Sport + let totalStadiums: Int + let visitedStadiums: Int + let stadiumsVisited: [Stadium] + let stadiumsRemaining: [Stadium] + let completionPercentage: Double + let divisionProgress: [DivisionProgress] +} + +enum VisitType: String, Codable, CaseIterable { + case game, tour, other +} +``` + +--- + +## Stadium Identity Strategy + +**Problem**: Stadiums rename (SBC Park → AT&T Park → Oracle Park). Same physical location should count as one visit. + +**Solution**: Canonical stadium IDs stored in bundled JSON. + +**File**: `SportsTime/Core/Services/StadiumIdentityService.swift` + +```swift +actor StadiumIdentityService { + static let shared = StadiumIdentityService() + + func canonicalId(for stadiumId: UUID) -> UUID + func canonicalId(forName name: String) -> UUID? + func isSameStadium(_ id1: UUID, _ id2: UUID) -> Bool +} +``` + +**File**: `Resources/stadium_identities.json` + +```json +[{ + "canonicalId": "...", + "currentName": "Oracle Park", + "allNames": ["Oracle Park", "AT&T Park", "SBC Park"], + "stadiumUUIDs": ["uuid1", "uuid2"], + "sport": "MLB", + "openedYear": 2000, + "closedYear": null +}] +``` + +| Scenario | Handling | +|----------|----------| +| Stadium renamed | All names map to same canonicalId | +| Team relocated | Old stadium gets closedYear, new is separate | +| Demolished stadium | Still counts for historical visits | +| Shared stadium (Jets/Giants) | Single canonicalId, multiple teamIds | + +--- + +## Achievement System + +**File**: `SportsTime/Core/Models/Domain/AchievementDefinitions.swift` + +### Achievement Types + +| Category | Examples | +|----------|----------| +| Count | "First Pitch" (1), "Double Digits" (10), "Veteran Fan" (20) | +| Division | "NL West Champion", "AFC North Complete" | +| Conference | "National League Complete" | +| League | "Diamond Collector" (all 30 MLB) | +| Journey | "Road Warrior" (5 in 7 days), "Triple Threat" (3 leagues) | + +### Achievement Engine + +**File**: `SportsTime/Core/Services/AchievementEngine.swift` + +```swift +actor AchievementEngine { + /// Full recalculation (call after visit deleted) + func recalculateAllAchievements() async throws -> AchievementDelta + + /// Quick check after new visit + func checkAchievementsForNewVisit(_ visit: StadiumVisit) async throws -> [AchievementDefinition] +} +``` + +**Recalculation triggers**: +- Visit added → incremental check +- Visit deleted → full recalculation (may revoke) +- App update with new achievements → full recalculation + +--- + +## Canonical Game Identity + +**Problem**: Scraped games, API games, and app games will disagree on IDs, team naming, and sometimes scores (corrections). Need a stable, derived key. + +**File**: `SportsTime/Core/Models/Domain/CanonicalGameKey.swift` + +```swift +/// Derived, stable key that prevents duplicates and handles score drift +struct CanonicalGameKey: Hashable, Codable { + let sport: Sport + let stadiumCanonicalId: UUID + let gameDate: Date // Normalized to local stadium date + let homeTeamCanonicalId: UUID +} + +/// Resolved game with source tracking +struct ResolvedGame { + let canonicalKey: CanonicalGameKey + let rawSourceId: String? // Original ID from source + let resolutionSource: ResolutionSource + + // Game data + let homeTeamName: String + let awayTeamName: String + let homeScore: Int? + let awayScore: Int? + + // Audit trail + let resolvedAt: Date + let sourceVersion: String? // API version or scrape date +} +``` + +**Key normalization rules**: +- `gameDate` normalized to stadium's local timezone midnight +- `stadiumCanonicalId` from StadiumIdentityService +- `homeTeamCanonicalId` from team alias mapping + +Every resolution path (app data, API, scrape, user) must map to `CanonicalGameKey`. + +--- + +## Historical Game Resolution + +**File**: `SportsTime/Core/Services/HistoricalGameService.swift` + +**Strategy** (in order): +1. **Bundled indexes** - Date → stadium → home team lookup (no scores) +2. **Lazy-fetch scores** - From API/scrape on first access, cache forever +3. **Manual entry** - User describes game, marked "user verified" + +```swift +actor HistoricalGameService { + func searchGames(query: HistoricalGameQuery) async throws -> [HistoricalGameResult] + func resolveCanonicalKey(from result: HistoricalGameResult) -> CanonicalGameKey +} + +struct HistoricalGameQuery { + let sport: Sport + let date: Date + let stadiumCanonicalId: UUID? // If known from photo + let homeTeamName: String? + let awayTeamName: String? +} +``` + +### Bundled Data Strategy (Size-Conscious) + +**Problem**: `historical_games_mlb.json` at ~5MB/sport will explode over time. + +**Solution**: Bundle indexes only, lazy-fetch scores. + +**File**: `Resources/historical_game_index_mlb.json` + +```json +{ + "2010-06-15": { + "stadium-uuid-oracle-park": { + "homeTeamId": "team-uuid-sfg", + "awayTeamId": "team-uuid-lad" + } + } +} +``` + +**Size**: ~500KB/sport (dates + stadium + teams only) + +**Score fetching**: On first lookup, fetch from API/scrape, cache in `CachedGameScore` forever. + +--- + +## Photo-Based Import Pipeline + +**Primary method for logging historical visits**: Import photos from library, extract metadata, auto-resolve game. + +### Photo Metadata Extraction + +**File**: `SportsTime/Core/Services/PhotoMetadataExtractor.swift` + +```swift +struct PhotoMetadata { + let captureDate: Date? + let coordinates: CLLocationCoordinate2D? + let hasValidLocation: Bool + let hasValidDate: Bool +} + +actor PhotoMetadataExtractor { + /// Extract EXIF data from PHAsset + func extractMetadata(from asset: PHAsset) async -> PhotoMetadata + + /// Extract from UIImage with ImageIO (fallback) + func extractMetadata(from imageData: Data) -> PhotoMetadata +} +``` + +**Extraction sources** (in order): +1. `PHAsset.location` and `PHAsset.creationDate` (preferred) +2. EXIF via ImageIO: `kCGImagePropertyGPSLatitude`, `kCGImagePropertyExifDateTimeOriginal` +3. File creation date (last resort, unreliable) + +### Stadium Proximity Matching + +**File**: `SportsTime/Core/Services/StadiumProximityMatcher.swift` + +```swift +struct StadiumMatch { + let stadium: Stadium + let distance: CLLocationDistance + let confidence: MatchConfidence +} + +enum MatchConfidence { + case high // < 500m from stadium center + case medium // 500m - 2km + case low // 2km - 5km + case none // > 5km or no coordinates +} + +actor StadiumProximityMatcher { + /// Find stadiums within radius of coordinates + func findNearbyStadiums( + coordinates: CLLocationCoordinate2D, + radius: CLLocationDistance = 5000 // 5km default + ) async -> [StadiumMatch] +} +``` + +**Configurable parameters**: +- `highConfidenceRadius`: 500m (auto-select threshold) +- `searchRadius`: 5km (maximum search distance) +- `dateToleranceDays`: 1 (for doubleheaders, timezone issues) + +### Temporal Confidence + +**Problem**: Photos taken hours before game, next morning, or during tailgating can still be valid. + +```swift +enum TemporalConfidence { + case exactDay // Same local date as game + case adjacentDay // ±1 day (tailgating, next morning) + case outOfRange // >1 day difference +} + +struct PhotoMatchConfidence { + let spatial: MatchConfidence // Distance-based + let temporal: TemporalConfidence // Time-based + let combined: CombinedConfidence // Final score +} + +enum CombinedConfidence { + case autoSelect // High spatial + exactDay → auto-select + case userConfirm // Medium spatial OR adjacentDay → user confirms + case manualOnly // Low spatial OR outOfRange → manual entry +} +``` + +**Combination rules**: + +| Spatial | Temporal | Result | +|---------|----------|--------| +| high | exactDay | autoSelect | +| high | adjacentDay | userConfirm | +| medium | exactDay | userConfirm | +| medium | adjacentDay | userConfirm | +| low | * | manualOnly | +| * | outOfRange | manualOnly | + +### Deterministic Game Matching + +**File**: `SportsTime/Core/Services/GameMatcher.swift` + +```swift +enum GameMatchResult { + case singleMatch(HistoricalGameResult) // Auto-select + case multipleMatches([HistoricalGameResult]) // User selects + case noMatches(reason: NoMatchReason) // Manual entry +} + +enum NoMatchReason { + case noStadiumNearby + case noGamesOnDate + case metadataMissing +} + +actor GameMatcher { + /// Match photo metadata to a game + func matchGame( + metadata: PhotoMetadata, + sport: Sport? + ) async -> GameMatchResult +} +``` + +**Resolution rules** (deterministic, never guess): + +| Scenario | Action | +|----------|--------| +| 1 game at 1 stadium on date | Auto-select | +| Multiple games same stadium (doubleheader) | User selects | +| Multiple stadiums nearby | User selects stadium first | +| 0 games found | Manual entry allowed | +| Missing GPS | Manual entry required | +| Missing date | Manual entry required | + +--- + +## Score Resolution Strategy + +**Layered approach using FREE data sources only**. + +### Tier 1: App Schedule Data + +Check if game exists in app's schedule database (current season + cached historical). + +```swift +// In HistoricalGameService +func resolveFromAppData(query: HistoricalGameQuery) async -> HistoricalGameResult? +``` + +### Tier 2: Free Sports APIs + +**File**: `SportsTime/Core/Services/FreeScoreAPI.swift` + +```swift +enum ProviderReliability { + case official // MLB Stats, NHL Stats - stable, documented + case unofficial // ESPN API - works but may break + case scraped // Sports-Reference - HTML parsing, fragile +} + +protocol ScoreAPIProvider { + var name: String { get } + var supportedSports: Set { get } + var rateLimit: TimeInterval { get } + var reliability: ProviderReliability { get } + + func fetchGame(query: HistoricalGameQuery) async throws -> ResolvedGame? +} + +actor FreeScoreAPI { + private let providers: [ScoreAPIProvider] + private var disabledProviders: [String: Date] = [:] // provider → disabled until + private var failureCounts: [String: Int] = [:] + + /// Try each provider in order: official > unofficial > scraped + func resolveScore(query: HistoricalGameQuery) async -> ScoreResolutionResult + + /// Auto-disable logic for unreliable providers + private func recordFailure(for provider: ScoreAPIProvider) + private func isDisabled(_ provider: ScoreAPIProvider) -> Bool +} +``` + +**Provider failure handling**: +- Official providers: Retry on failure, never auto-disable +- Unofficial providers: After 3 failures in 1 hour → disable for 24h +- Scraped providers: After 2 failures in 1 hour → disable for 24h + +**Provider priority order** (always prefer stability): +1. Official APIs (MLB Stats, NHL Stats) +2. Unofficial APIs (ESPN, NBA Stats) +3. Scraped sources (Sports-Reference) + +**Free API providers** (implement ScoreAPIProvider): + +| Provider | Sports | Rate Limit | Reliability | Notes | +|----------|--------|------------|-------------|-------| +| MLB Stats API | MLB | 10 req/sec | official | Documented, stable | +| NHL Stats API | NHL | 5 req/sec | official | Documented, stable | +| NBA Stats API | NBA | 2 req/sec | unofficial | Requires headers, may break | +| ESPN API | All | 1 req/sec | unofficial | Undocumented, good depth | + +### Tier 3: Reference Site Scraping + +**File**: `SportsTime/Core/Services/ReferenceScrapingService.swift` + +```swift +actor ReferenceScrapingService { + /// Scrape game data from sports-reference sites + func scrapeGame(query: HistoricalGameQuery) async throws -> HistoricalGameResult? +} +``` + +**Sources**: +- Baseball-Reference.com (MLB, 1876-present) +- Basketball-Reference.com (NBA, 1946-present) +- Hockey-Reference.com (NHL, 1917-present) +- Pro-Football-Reference.com (NFL, 1920-present) + +**Scraping rules**: +- **Cache aggressively**: Historical scores never change +- **Rate limit**: Max 1 request per 3 seconds per domain +- **Respect robots.txt**: Check before scraping +- **User-Agent**: Identify as SportsTime app + +### Tier 4: User Confirmation + +If all automated resolution fails: + +```swift +struct UserConfirmedGame { + let homeTeam: String + let awayTeam: String + let finalScore: String? // Optional - user may not remember + let isUserConfirmed: Bool // Always true for this tier +} +``` + +**UI flow**: +1. Show "We couldn't find this game automatically" +2. Ask for home team, away team (autocomplete from known teams) +3. Optionally ask for score +4. Mark entry as `source = "user_confirmed"` + +--- + +## Score Resolution Result + +```swift +enum ScoreResolutionResult { + case resolved(HistoricalGameResult, source: ResolutionSource) + case pending // Background retry queued + case requiresUserInput // All tiers failed +} + +enum ResolutionSource: String, Codable { + case appData = "app" + case freeAPI = "api" + case scraped = "scraped" + case userConfirmed = "user" +} +``` + +--- + +## Photo Storage & Sync + +**File**: `SportsTime/Core/Services/VisitPhotoService.swift` + +- **Thumbnails**: Stored locally as Data in SwiftData (fast loading) +- **Full images**: CloudKit CKAsset in user's private database +- **Upload**: Background task, retry on failure +- **Download**: On-demand with local caching + +```swift +actor VisitPhotoService { + func addPhoto(to visit: StadiumVisit, image: UIImage, caption: String?) async throws -> VisitPhotoMetadata + func fetchFullImage(for metadata: VisitPhotoMetadata) async throws -> UIImage + func deletePhoto(_ metadata: VisitPhotoMetadata) async throws +} +``` + +--- + +## Caching & Rate Limiting + +**File**: `SportsTime/Core/Services/ScoreResolutionCache.swift` + +```swift +@Model +final class CachedGameScore { + @Attribute(.unique) var cacheKey: String // "MLB_2010-06-15_SFG_LAD" + var homeTeam: String + var awayTeam: String + var homeScore: Int + var awayScore: Int + var source: String + var fetchedAt: Date + var expiresAt: Date? // nil = never expires (historical data) +} + +actor ScoreResolutionCache { + func getCached(query: HistoricalGameQuery) async -> HistoricalGameResult? + func cache(result: HistoricalGameResult, query: HistoricalGameQuery) async +} +``` + +**Cache policy**: +- Historical games (>30 days old): Cache forever +- Recent games: Cache 24 hours (scores might update) +- Failed lookups: Cache 7 days (avoid repeated failures) + +**Rate limiter**: + +```swift +actor RateLimiter { + private var lastRequestTimes: [String: Date] = [:] + + func waitIfNeeded(for provider: String, interval: TimeInterval) async +} +``` + +--- + +## Sharing & Export + +**File**: `SportsTime/Export/Services/ProgressCardGenerator.swift` + +```swift +@MainActor +final class ProgressCardGenerator { + func generateCard(progress: LeagueProgress, options: ProgressCardOptions) async throws -> UIImage + func generateProgressMap(visited: [Stadium], remaining: [Stadium]) async throws -> UIImage +} +``` + +**Card contents**: +- League logo +- Progress ring (X/30) +- Stats row +- Optional username +- Mini map snapshot +- App branding footer + +**Export size**: 1080x1920 (Instagram story) + +--- + +## Files to Create + +### Core Models +- `SportsTime/Core/Models/Local/StadiumProgress.swift` - SwiftData models +- `SportsTime/Core/Models/Domain/Progress.swift` - Domain structs +- `SportsTime/Core/Models/Domain/Division.swift` - League structure +- `SportsTime/Core/Models/Domain/AchievementDefinitions.swift` - Badge registry +- `SportsTime/Core/Models/Domain/CanonicalGameKey.swift` - Stable game identity + ResolvedGame + +### Services +- `SportsTime/Core/Services/StadiumIdentityService.swift` - Canonical ID resolution +- `SportsTime/Core/Services/AchievementEngine.swift` - Achievement computation +- `SportsTime/Core/Services/HistoricalGameService.swift` - Historical lookup orchestrator +- `SportsTime/Core/Services/VisitPhotoService.swift` - CloudKit photo sync + +### Photo Import Pipeline +- `SportsTime/Core/Services/PhotoMetadataExtractor.swift` - EXIF extraction from PHAsset +- `SportsTime/Core/Services/StadiumProximityMatcher.swift` - GPS-to-stadium matching +- `SportsTime/Core/Services/GameMatcher.swift` - Deterministic game resolution + +### Score Resolution +- `SportsTime/Core/Services/FreeScoreAPI.swift` - Multi-provider API facade +- `SportsTime/Core/Services/ScoreAPIProviders/ESPNProvider.swift` - ESPN unofficial API +- `SportsTime/Core/Services/ScoreAPIProviders/MLBStatsProvider.swift` - MLB Stats API +- `SportsTime/Core/Services/ScoreAPIProviders/NHLStatsProvider.swift` - NHL Stats API +- `SportsTime/Core/Services/ScoreAPIProviders/NBAStatsProvider.swift` - NBA Stats API +- `SportsTime/Core/Services/ReferenceScrapingService.swift` - Sports-Reference fallback +- `SportsTime/Core/Services/ScoreResolutionCache.swift` - SwiftData cache for scores +- `SportsTime/Core/Services/RateLimiter.swift` - Per-provider rate limiting + +### Features +- `SportsTime/Features/Progress/Views/ProgressTabView.swift` - Main tab +- `SportsTime/Features/Progress/Views/ProgressMapView.swift` - Interactive map +- `SportsTime/Features/Progress/Views/StadiumVisitSheet.swift` - Log visit (manual entry) +- `SportsTime/Features/Progress/Views/PhotoImportView.swift` - Photo picker + metadata extraction +- `SportsTime/Features/Progress/Views/GameMatchConfirmationView.swift` - Disambiguate multiple matches +- `SportsTime/Features/Progress/Views/VisitDetailView.swift` - View/edit visit +- `SportsTime/Features/Progress/Views/AchievementsListView.swift` - Badge gallery +- `SportsTime/Features/Progress/Views/ProgressShareView.swift` - Share preview +- `SportsTime/Features/Progress/ViewModels/ProgressViewModel.swift` - Main state +- `SportsTime/Features/Progress/ViewModels/PhotoImportViewModel.swift` - Photo import orchestration + +### Export +- `SportsTime/Export/Services/ProgressCardGenerator.swift` - Shareable cards + +### Resources +- `Resources/stadium_identities.json` - Canonical stadium mapping +- `Resources/league_structure.json` - Division/conference data +- `Resources/historical_game_index_mlb.json` - Game index (date → stadium → teams, no scores) +- `Resources/historical_game_index_nba.json` - Game index (~500KB) +- `Resources/historical_game_index_nhl.json` - Game index (~500KB) +- `Resources/team_aliases.json` - Team name normalization (old names → canonical IDs) + +## Files to Modify + +- `SportsTime/SportsTimeApp.swift` - Add new models to SwiftData schema +- `SportsTime/Features/Home/Views/HomeView.swift` - Add Progress tab + +--- + +## Implementation Phases + +### Phase 1: Data Foundation +1. Create SwiftData models (StadiumVisit, Achievement, VisitPhotoMetadata, CachedGameScore) +2. Create Division.swift with LeagueStructure +3. Build StadiumIdentityService with bundled JSON +4. Create CanonicalGameKey and ResolvedGame +5. Create RateLimiter actor +6. Update SportsTimeApp.swift schema + +### Phase 2: Core Progress UI +1. Create ProgressViewModel +2. Build ProgressTabView with league selector +3. Implement ProgressMapView with MKMapView +4. Add Progress tab to HomeView + +### Phase 3: Manual Visit Logging +1. Create StadiumVisitSheet for manual entry +2. Implement auto-fill from trip games +3. Build VisitDetailView for viewing/editing + +### Phase 4: Photo Import Pipeline +1. Build PhotoMetadataExtractor (EXIF from PHAsset) +2. Create StadiumProximityMatcher (GPS → stadium) +3. Implement GameMatcher (deterministic rules + confidence scoring) +4. Build PhotoImportView with picker +5. Create GameMatchConfirmationView for disambiguation +6. Integrate into ProgressViewModel + +### Phase 5: Score Resolution +1. Create ScoreAPIProvider protocol with reliability +2. Implement MLB Stats API provider (official) +3. Implement NHL Stats API provider (official) +4. Implement NBA Stats API provider (unofficial) +5. Implement ESPN API provider (unofficial, fallback) +6. Build ReferenceScrapingService (Tier 3, scraped) +7. Create ScoreResolutionCache +8. Wire up FreeScoreAPI orchestrator with auto-disable + +### Phase 6: Achievements +1. Create AchievementDefinitions registry +2. Build AchievementEngine with computation logic +3. Create achievement UI components +4. Wire up achievement earned notifications + +### Phase 7: Photos & Sharing +1. Implement VisitPhotoService with CloudKit +2. Build photo gallery UI +3. Create ProgressCardGenerator +4. Implement share sheet integration + +--- + +## Failure Modes & Recovery + +**Explicit handling for all failure scenarios — no silent failures.** + +### Photo Import Failures + +| Failure | Detection | Recovery | +|---------|-----------|----------| +| Missing GPS in photo | `PhotoMetadata.hasValidLocation == false` | Show "Location not found" → manual stadium selection | +| Missing date in photo | `PhotoMetadata.hasValidDate == false` | Show "Date not found" → manual date picker | +| Photo library access denied | `PHPhotoLibrary.authorizationStatus()` | Show settings deep link, explain why needed | +| PHAsset fetch fails | `PHImageManager` error | Show error, allow retry or skip | + +### Game Matching Failures + +| Failure | Detection | Recovery | +|---------|-----------|----------| +| No stadium within 5km | `StadiumProximityMatcher` returns empty | Show "No stadium found nearby" → manual stadium picker | +| No games on date | `GameMatcher` returns `.noMatches` | Show "No games found" → allow manual entry with team autocomplete | +| Ambiguous match (multiple games) | `GameMatcher` returns `.multipleMatches` | Show picker: "Which game?" with team matchups | +| Ambiguous stadium (multiple nearby) | Multiple stadiums in radius | Show picker: "Which stadium?" with distances | + +### Score Resolution Failures + +| Failure | Detection | Recovery | +|---------|-----------|----------| +| All API tiers fail | `ScoreResolutionResult.requiresUserInput` | Show "Score not found" → optional manual score entry | +| Rate limited by provider | HTTP 429 | Queue for background retry, show "pending" state | +| Network offline | URLError.notConnectedToInternet | Cache partial visit, retry score on reconnect | +| Unofficial provider breaks | 3 failures in 1 hour | Auto-disable for 24h, use next tier | +| Scraped provider breaks | 2 failures in 1 hour | Auto-disable for 24h, use next tier | + +### Data Integrity Failures + +| Failure | Detection | Recovery | +|---------|-----------|----------| +| CloudKit upload fails | CKError | Store locally with `uploadStatus = "failed"`, retry queue | +| SwiftData save fails | ModelContext error | Show error, don't dismiss sheet, allow retry | +| Corrupt cached score | JSON decode fails | Delete cache entry, refetch | +| Duplicate visit detected | Same stadium + date | Warn user, allow anyway (doubleheader edge case) | + +### User Data Marking + +All user-provided data is explicitly marked: + +```swift +// In StadiumVisit +var dataSource: DataSource + +enum DataSource: String, Codable { + case automatic // All data from photo + API + case partialManual // Photo metadata + manual game selection + case fullyManual // User entered everything + case userCorrected // Was automatic, user edited +} +``` + +--- + +## Edge Cases + +| Scenario | Handling | +|----------|----------| +| Stadium renamed after visit | canonicalStadiumId stable, stadiumNameAtVisit frozen | +| User visits same stadium twice | Both logged, unique stadiums counted once | +| Visit without game (tour) | visitType = "tour", no game fields required | +| Historical game not found | Manual description, source = "user_confirmed" | +| Photo upload fails | uploadStatus = "failed", retry on next launch | +| Achievement revoked | revokedAt set, not shown in earned list | +| Team relocates mid-tracking | Old stadium still counts, new is separate | +| Doubleheader same day | Both games shown, user selects correct one | +| Photo from parking lot (1km away) | Medium confidence, still matches if only stadium nearby | +| Multiple sports same stadium | Filter by sport if provided, else show all | +| Timezone mismatch (night game shows wrong date) | Use ±1 day tolerance in matching | +| User edits auto-resolved data | Mark as `dataSource = .userCorrected`, preserve original | +| API returns different team name | Fuzzy match via team_aliases.json | +| Score correction after caching | Cache key includes source version, can refresh |