Add Stadium Progress system and themed loading spinners

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 <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-01-08 20:20:03 -06:00
parent 2281440bf8
commit 92d808caf5
55 changed files with 14348 additions and 61 deletions

View File

@@ -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()

View File

@@ -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
}
]

View File

@@ -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"
}
]

View File

@@ -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()

View File

@@ -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
)
}
}

View File

@@ -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 }
}
}

View File

@@ -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
}
}
}

View File

@@ -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")
}
}
}
}

View File

@@ -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
}

View File

@@ -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<String> = []
return stops.compactMap { stop in
guard !seen.contains(stop.city) else { return nil }
seen.insert(stop.city)
return stop.city
}
}
var uniqueSports: Set<Sport> { preferences.sports }
var startDate: Date { stops.first?.arrivalDate ?? preferences.startDate }
var endDate: Date { stops.last?.departureDate ?? preferences.endDate }

View File

@@ -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<SyncState>(
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()
}

View File

@@ -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)"
}
}

View File

@@ -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<String> = []
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<String>
) -> 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<String>) -> 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<String>) -> 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<String>) -> 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<String>
) -> (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<StadiumVisit>(
sortBy: [SortDescriptor(\.visitDate, order: .forward)]
)
return try modelContext.fetch(descriptor)
}
private func fetchEarnedAchievements() throws -> [Achievement] {
let descriptor = FetchDescriptor<Achievement>(
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)"
}
}

View File

@@ -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<CanonicalStadium>()
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<String>()
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] ?? ""
}
}

View File

@@ -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<Sport>, 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<CanonicalGame>(
predicate: #Predicate<CanonicalGame> { 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<CanonicalGame>(
predicate: #Predicate<CanonicalGame> { 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<Sport>, 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<StadiumAlias>(
predicate: #Predicate<StadiumAlias> { 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<CanonicalStadium>(
predicate: #Predicate<CanonicalStadium> { 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<CanonicalTeam>(
predicate: #Predicate<CanonicalTeam> { 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
}
}
}

View File

@@ -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<CanonicalStadium>(
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<CanonicalTeam>(
predicate: #Predicate { $0.canonicalId == canonicalId }
)
let existing = try context.fetch(descriptor).first
// Find stadium canonical ID
let remoteStadiumId = remote.stadiumId
let stadiumDescriptor = FetchDescriptor<CanonicalStadium>(
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<CanonicalGame>(
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<CanonicalTeam>(
predicate: #Predicate { $0.uuid == remoteHomeTeamId }
)
let awayTeamDescriptor = FetchDescriptor<CanonicalTeam>(
predicate: #Predicate { $0.uuid == remoteAwayTeamId }
)
let stadiumDescriptor = FetchDescriptor<CanonicalStadium>(
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<LeagueStructureModel>(
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<TeamAlias>(
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
}
}
}

View File

@@ -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()
}
}

View File

@@ -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<Sport> { 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
}
}
}

View File

@@ -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> = 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
)
}
}

View File

@@ -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)
}
}
}
}

View File

@@ -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 }
}
}
}

View File

@@ -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<Sport> = [.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 {}

View File

@@ -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<Sport> = [.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 {}

View File

@@ -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<Sport> = [.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 {}

View File

@@ -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<CachedGameScore>(
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<CachedGameScore>(
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<CachedGameScore>(
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<CachedGameScore>(
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<CachedGameScore>()
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<CachedGameScore>()
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]
}

View File

@@ -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<CanonicalStadium>(
predicate: #Predicate<CanonicalStadium> { 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<StadiumAlias>(
predicate: #Predicate<StadiumAlias> { 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<CanonicalStadium>()
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<CanonicalStadium>(
predicate: #Predicate<CanonicalStadium> { 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<CanonicalStadium>(
predicate: #Predicate<CanonicalStadium> { 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<StadiumAlias>(
predicate: #Predicate<StadiumAlias> { 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<CanonicalStadium>(
predicate: #Predicate<CanonicalStadium> { 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
}
}

View File

@@ -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
}
}

View File

@@ -194,6 +194,7 @@ actor StubDataProvider: DataProvider {
latitude: json.latitude,
longitude: json.longitude,
capacity: json.capacity,
sport: parseSport(json.sport),
yearOpened: json.year_opened
)
}

View File

@@ -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:

View File

@@ -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<VisitPhotoMetadata>(
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<VisitPhotoMetadata>()
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
}
}

View File

@@ -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"
)
}
}

View File

@@ -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: []
))
}

View File

@@ -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()
}
}

View File

@@ -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<UUID> = []
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
}
}

View File

@@ -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<StadiumVisit>(
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
)
}
}

View File

@@ -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)
}

View File

@@ -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: {}
)
}

View File

@@ -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)
}

View File

@@ -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()
}

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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")
}
}

View File

@@ -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)
}

View File

@@ -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

View File

@@ -348,6 +348,10 @@ final class TripCreationViewModel {
}
}
func deselectAllGames() {
mustSeeGameIds.removeAll()
}
func switchPlanningMode(_ mode: PlanningMode) {
planningMode = mode
// Clear mode-specific selections when switching

View File

@@ -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<Void, Never>?
@State private var endSearchTask: Task<Void, Never>?
@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())
}

View File

@@ -198,8 +198,7 @@ struct TripDetailView: View {
// Loading indicator
if isLoadingRoutes {
ProgressView()
.tint(Theme.warmOrange)
ThemedSpinnerCompact(size: 24)
.padding(.bottom, 40)
}
}

View File

@@ -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
}
]

View File

@@ -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
}
]

View File

@@ -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()
}
}

View File

@@ -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
)
}

View File

@@ -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
)
}

View File

@@ -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
)
}

View File

@@ -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
)
}

View File

@@ -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<Sport> { 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 |