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:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user