fix: resolve specificStadium achievement ID mismatch

The Green Monster (Fenway) and Ivy League (Wrigley) achievements
weren't working because:
1. Symbolic IDs use lowercase sport (stadium_mlb_bos)
2. Sport enum uses uppercase raw values (MLB)
3. Visits store stadium UUIDs, not symbolic IDs

Added resolveSymbolicStadiumId() helper that:
- Uppercases the sport string before Sport(rawValue:)
- Looks up team by abbreviation and sport
- Returns the team's stadiumId as UUID string

Also fixed:
- getStadiumIdsForLeague returns UUID strings (not symbolic IDs)
- AchievementProgress.isEarned computed from progress OR stored record
- getStadiumIdsForDivision queries CanonicalTeam properly

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-01-11 22:22:29 -06:00
parent dcd5edb229
commit 5c13650742
20 changed files with 1619 additions and 141 deletions

View File

@@ -0,0 +1,21 @@
name: read-to-do
description: Read and summarize TO-DOS.md before any coding
trigger:
command: "/read-to-do"
actions:
- type: read_file
path: "TO-DOS.md"
- type: prompt
content: |
You have read TO-DOS.md in full.
Summarize:
- Goal
- Current Phase
- Active Tasks
Do NOT write any code.
Do NOT suggest solutions.
Stop after the summary.

View File

@@ -11,7 +11,11 @@
"Bash(xcrun simctl install:*)", "Bash(xcrun simctl install:*)",
"Skill(frontend-design:frontend-design)", "Skill(frontend-design:frontend-design)",
"Bash(xcrun simctl io:*)", "Bash(xcrun simctl io:*)",
"Bash(python cloudkit_import.py:*)" "Bash(python cloudkit_import.py:*)",
"Bash(python -m sportstime_parser scrape:*)",
"Bash(pip install:*)",
"Bash(python -m pytest:*)",
"Skill(superpowers:brainstorming)"
] ]
} }
} }

View File

@@ -6,6 +6,10 @@
objectVersion = 77; objectVersion = 77;
objects = { objects = {
/* Begin PBXBuildFile section */
1CC750E42F148BA4007D870A /* SwiftSoup in Frameworks */ = {isa = PBXBuildFile; productRef = 1CC750E32F148BA4007D870A /* SwiftSoup */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */ /* Begin PBXContainerItemProxy section */
1CA7F9052F0D647300490ABD /* PBXContainerItemProxy */ = { 1CA7F9052F0D647300490ABD /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy; isa = PBXContainerItemProxy;
@@ -65,6 +69,7 @@
isa = PBXFrameworksBuildPhase; isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
1CC750E42F148BA4007D870A /* SwiftSoup in Frameworks */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@@ -91,6 +96,7 @@
1CA7F8F52F0D647100490ABD /* SportsTime */, 1CA7F8F52F0D647100490ABD /* SportsTime */,
1CA7F9072F0D647300490ABD /* SportsTimeTests */, 1CA7F9072F0D647300490ABD /* SportsTimeTests */,
1CA7F9112F0D647400490ABD /* SportsTimeUITests */, 1CA7F9112F0D647400490ABD /* SportsTimeUITests */,
1CC750E22F148BA4007D870A /* Frameworks */,
1CA7F8F42F0D647100490ABD /* Products */, 1CA7F8F42F0D647100490ABD /* Products */,
); );
sourceTree = "<group>"; sourceTree = "<group>";
@@ -105,6 +111,13 @@
name = Products; name = Products;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
1CC750E22F148BA4007D870A /* Frameworks */ = {
isa = PBXGroup;
children = (
);
name = Frameworks;
sourceTree = "<group>";
};
/* End PBXGroup section */ /* End PBXGroup section */
/* Begin PBXNativeTarget section */ /* Begin PBXNativeTarget section */
@@ -125,6 +138,7 @@
); );
name = SportsTime; name = SportsTime;
packageProductDependencies = ( packageProductDependencies = (
1CC750E32F148BA4007D870A /* SwiftSoup */,
); );
productName = SportsTime; productName = SportsTime;
productReference = 1CA7F8F32F0D647100490ABD /* SportsTime.app */; productReference = 1CA7F8F32F0D647100490ABD /* SportsTime.app */;
@@ -208,6 +222,9 @@
); );
mainGroup = 1CA7F8EA2F0D647100490ABD; mainGroup = 1CA7F8EA2F0D647100490ABD;
minimizedProjectReferenceProxies = 1; minimizedProjectReferenceProxies = 1;
packageReferences = (
1CC750E12F1487A8007D870A /* XCRemoteSwiftPackageReference "SwiftSoup" */,
);
preferredProjectObjectVersion = 77; preferredProjectObjectVersion = 77;
productRefGroup = 1CA7F8F42F0D647100490ABD /* Products */; productRefGroup = 1CA7F8F42F0D647100490ABD /* Products */;
projectDirPath = ""; projectDirPath = "";
@@ -597,6 +614,25 @@
defaultConfigurationName = Release; defaultConfigurationName = Release;
}; };
/* End XCConfigurationList section */ /* End XCConfigurationList section */
/* Begin XCRemoteSwiftPackageReference section */
1CC750E12F1487A8007D870A /* XCRemoteSwiftPackageReference "SwiftSoup" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/scinfu/SwiftSoup";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 2.11.3;
};
};
/* End XCRemoteSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
1CC750E32F148BA4007D870A /* SwiftSoup */ = {
isa = XCSwiftPackageProductDependency;
package = 1CC750E12F1487A8007D870A /* XCRemoteSwiftPackageReference "SwiftSoup" */;
productName = SwiftSoup;
};
/* End XCSwiftPackageProductDependency section */
}; };
rootObject = 1CA7F8EB2F0D647100490ABD /* Project object */; rootObject = 1CA7F8EB2F0D647100490ABD /* Project object */;
} }

View File

@@ -118,11 +118,18 @@ struct VisitSummary: Identifiable {
let visitDate: Date let visitDate: Date
let visitType: VisitType let visitType: VisitType
let sport: Sport let sport: Sport
let matchup: String? let homeTeamName: String?
let awayTeamName: String?
let score: String? let score: String?
let photoCount: Int let photoCount: Int
let notes: String? let notes: String?
/// Combined matchup for backwards compatibility
var matchup: String? {
guard let home = homeTeamName, let away = awayTeamName else { return nil }
return "\(away) @ \(home)"
}
var dateDescription: String { var dateDescription: String {
let formatter = DateFormatter() let formatter = DateFormatter()
formatter.dateStyle = .medium formatter.dateStyle = .medium

View File

@@ -39,7 +39,7 @@ struct Team: Identifiable, Codable, Hashable {
} }
var fullName: String { var fullName: String {
"\(city) \(name)" city.isEmpty ? name : "\(city) \(name)"
} }
} }

View File

@@ -165,14 +165,14 @@ final class AchievementEngine {
visitedStadiumIds: visitedStadiumIds visitedStadiumIds: visitedStadiumIds
) )
let isEarned = earnedIds.contains(definition.id) let hasStoredAchievement = earnedIds.contains(definition.id)
let earnedAt = earnedAchievements.first(where: { $0.achievementTypeId == definition.id })?.earnedAt let earnedAt = earnedAchievements.first(where: { $0.achievementTypeId == definition.id })?.earnedAt
progress.append(AchievementProgress( progress.append(AchievementProgress(
definition: definition, definition: definition,
currentProgress: current, currentProgress: current,
totalRequired: total, totalRequired: total,
isEarned: isEarned, hasStoredAchievement: hasStoredAchievement,
earnedAt: earnedAt earnedAt: earnedAt
)) ))
} }
@@ -214,11 +214,41 @@ final class AchievementEngine {
case .multipleLeagues(let leagueCount): case .multipleLeagues(let leagueCount):
return checkMultipleLeagues(visits: visits, requiredLeagues: leagueCount) return checkMultipleLeagues(visits: visits, requiredLeagues: leagueCount)
case .specificStadium(let stadiumId): case .specificStadium(let symbolicId):
return visitedStadiumIds.contains(stadiumId) // Resolve symbolic ID (e.g., "stadium_mlb_bos") to actual UUID string
guard let resolvedId = resolveSymbolicStadiumId(symbolicId) else { return false }
return visitedStadiumIds.contains(resolvedId)
} }
} }
/// Resolves symbolic stadium IDs (e.g., "stadium_mlb_bos") to actual stadium UUID strings
private func resolveSymbolicStadiumId(_ symbolicId: String) -> String? {
// Parse symbolic ID format: "stadium_{sport}_{teamAbbrev}"
let parts = symbolicId.split(separator: "_")
guard parts.count == 3,
parts[0] == "stadium" else {
return nil
}
// Sport raw values are uppercase (e.g., "MLB"), but symbolic IDs use lowercase
let sportString = String(parts[1]).uppercased()
guard let sport = Sport(rawValue: sportString) else {
return nil
}
let teamAbbrev = String(parts[2]).uppercased()
// Find team by abbreviation and sport
guard let team = dataProvider.teams.first(where: {
$0.abbreviation.uppercased() == teamAbbrev && $0.sport == sport
}) else {
return nil
}
// Return the stadium UUID as string (matches visit's canonicalStadiumId format)
return team.stadiumId.uuidString
}
private func checkDivisionComplete(_ divisionId: String, visitedStadiumIds: Set<String>) -> Bool { private func checkDivisionComplete(_ divisionId: String, visitedStadiumIds: Set<String>) -> Bool {
guard let division = LeagueStructure.division(byId: divisionId) else { return false } guard let division = LeagueStructure.division(byId: divisionId) else { return false }
@@ -318,8 +348,10 @@ final class AchievementEngine {
let leagues = Set(visits.compactMap { Sport(rawValue: $0.sport) }) let leagues = Set(visits.compactMap { Sport(rawValue: $0.sport) })
return (leagues.count, leagueCount) return (leagues.count, leagueCount)
case .specificStadium(let stadiumId): case .specificStadium(let symbolicId):
return (visitedStadiumIds.contains(stadiumId) ? 1 : 0, 1) // Resolve symbolic ID to actual UUID string
guard let resolvedId = resolveSymbolicStadiumId(symbolicId) else { return (0, 1) }
return (visitedStadiumIds.contains(resolvedId) ? 1 : 0, 1)
} }
} }
@@ -359,25 +391,36 @@ final class AchievementEngine {
} }
return [] return []
case .specificStadium(let stadiumId): case .specificStadium(let symbolicId):
return visits.filter { $0.canonicalStadiumId == stadiumId }.map { $0.id } // Resolve symbolic ID to actual UUID string
guard let resolvedId = resolveSymbolicStadiumId(symbolicId) else { return [] }
return visits.filter { $0.canonicalStadiumId == resolvedId }.map { $0.id }
} }
} }
// MARK: - Stadium Lookups // MARK: - Stadium Lookups
private func getStadiumIdsForDivision(_ divisionId: String) -> [String] { private func getStadiumIdsForDivision(_ divisionId: String) -> [String] {
// Get teams in division, then their stadiums // Query CanonicalTeam to find teams in this division
let teams = dataProvider.teams.filter { team in let descriptor = FetchDescriptor<CanonicalTeam>(
// Match division by checking team's division assignment predicate: #Predicate { $0.divisionId == divisionId && $0.deprecatedAt == nil }
// This would normally come from CanonicalTeam.divisionId )
// For now, return based on division structure
return false // Will be populated when division data is linked guard let canonicalTeams = try? modelContext.fetch(descriptor) else {
return []
} }
// For now, return hardcoded counts based on typical division sizes // Get stadium UUIDs for these teams
// This should be replaced with actual team-to-stadium mapping // CanonicalTeam has stadiumCanonicalId, we need to find the corresponding Stadium UUID
return [] var stadiumIds: [String] = []
for canonicalTeam in canonicalTeams {
// Find the domain team by matching name/abbreviation to get stadium UUID
if let team = dataProvider.teams.first(where: { $0.abbreviation == canonicalTeam.abbreviation && $0.sport.rawValue == canonicalTeam.sport }) {
stadiumIds.append(team.stadiumId.uuidString)
}
}
return stadiumIds
} }
private func getStadiumIdsForConference(_ conferenceId: String) -> [String] { private func getStadiumIdsForConference(_ conferenceId: String) -> [String] {
@@ -391,7 +434,7 @@ final class AchievementEngine {
} }
private func getStadiumIdsForLeague(_ sport: Sport) -> [String] { private func getStadiumIdsForLeague(_ sport: Sport) -> [String] {
// Get all stadiums for this sport // Get all stadiums for this sport - return UUID strings to match visit format
return dataProvider.stadiums return dataProvider.stadiums
.filter { stadium in .filter { stadium in
// Check if stadium hosts teams of this sport // Check if stadium hosts teams of this sport
@@ -399,7 +442,7 @@ final class AchievementEngine {
team.stadiumId == stadium.id && team.sport == sport team.stadiumId == stadium.id && team.sport == sport
} }
} }
.map { "stadium_\(sport.rawValue.lowercased())_\($0.id.uuidString)" } .map { $0.id.uuidString }
} }
// MARK: - Data Fetching // MARK: - Data Fetching
@@ -425,11 +468,17 @@ struct AchievementProgress: Identifiable {
let definition: AchievementDefinition let definition: AchievementDefinition
let currentProgress: Int let currentProgress: Int
let totalRequired: Int let totalRequired: Int
let isEarned: Bool let hasStoredAchievement: Bool // Whether an Achievement record exists in SwiftData
let earnedAt: Date? let earnedAt: Date?
var id: String { definition.id } var id: String { definition.id }
/// Whether the achievement is earned (either stored or computed from progress)
var isEarned: Bool {
// Earned if we have a stored record OR if progress is complete
hasStoredAchievement || (totalRequired > 0 && currentProgress >= totalRequired)
}
var progressPercentage: Double { var progressPercentage: Double {
guard totalRequired > 0 else { return 0 } guard totalRequired > 0 else { return 0 }
return Double(currentProgress) / Double(totalRequired) return Double(currentProgress) / Double(totalRequired)

View File

@@ -50,6 +50,10 @@ struct GameMatchCandidate: Identifiable, Sendable {
let awayTeam: Team let awayTeam: Team
let confidence: PhotoMatchConfidence let confidence: PhotoMatchConfidence
// Scraped score components (stored separately for flexible formatting)
let scrapedHomeScore: Int?
let scrapedAwayScore: Int?
init(game: Game, stadium: Stadium, homeTeam: Team, awayTeam: Team, confidence: PhotoMatchConfidence) { init(game: Game, stadium: Stadium, homeTeam: Team, awayTeam: Team, confidence: PhotoMatchConfidence) {
self.id = game.id self.id = game.id
self.game = game self.game = game
@@ -57,6 +61,57 @@ struct GameMatchCandidate: Identifiable, Sendable {
self.homeTeam = homeTeam self.homeTeam = homeTeam
self.awayTeam = awayTeam self.awayTeam = awayTeam
self.confidence = confidence self.confidence = confidence
self.scrapedHomeScore = nil
self.scrapedAwayScore = nil
}
/// Initialize from a scraped historical game
init(scrapedGame: ScrapedGame, stadium: Stadium) {
self.id = UUID()
self.stadium = stadium
// Create synthetic Team objects from scraped names
// Scraped names already include city (e.g., "Chicago Cubs"), so we use empty city
// to avoid duplication in fullName computed property
self.homeTeam = Team(
id: UUID(),
name: scrapedGame.homeTeam,
abbreviation: String(scrapedGame.homeTeam.suffix(3)).uppercased(),
sport: scrapedGame.sport,
city: "",
stadiumId: stadium.id
)
self.awayTeam = Team(
id: UUID(),
name: scrapedGame.awayTeam,
abbreviation: String(scrapedGame.awayTeam.suffix(3)).uppercased(),
sport: scrapedGame.sport,
city: "",
stadiumId: stadium.id
)
// Create synthetic Game object
let year = Calendar.current.component(.year, from: scrapedGame.date)
self.game = Game(
id: self.id,
homeTeamId: self.homeTeam.id,
awayTeamId: self.awayTeam.id,
stadiumId: stadium.id,
dateTime: scrapedGame.date,
sport: scrapedGame.sport,
season: "\(year)"
)
// High confidence since we found the exact game from web
self.confidence = PhotoMatchConfidence(
spatial: .high,
temporal: .exactDay
)
// Store scraped scores separately for flexible formatting
self.scrapedHomeScore = scrapedGame.homeScore
self.scrapedAwayScore = scrapedGame.awayScore
} }
var matchupDescription: String { var matchupDescription: String {
@@ -67,6 +122,18 @@ struct GameMatchCandidate: Identifiable, Sendable {
"\(awayTeam.fullName) at \(homeTeam.fullName)" "\(awayTeam.fullName) at \(homeTeam.fullName)"
} }
/// Final score in "away-home" format for storage (e.g., "5-3")
var formattedFinalScore: String? {
guard let home = scrapedHomeScore, let away = scrapedAwayScore else { return nil }
return "\(away)-\(home)"
}
/// Score for display with team names (e.g., "Pirates 5\nCubs 3")
var displayScore: String? {
guard let home = scrapedHomeScore, let away = scrapedAwayScore else { return nil }
return "\(awayTeam.fullName) \(away)\n\(homeTeam.fullName) \(home)"
}
var gameDateTime: String { var gameDateTime: String {
let formatter = DateFormatter() let formatter = DateFormatter()
formatter.dateStyle = .medium formatter.dateStyle = .medium
@@ -220,6 +287,14 @@ final class GameMatcher {
metadata: PhotoMetadata, metadata: PhotoMetadata,
sport: Sport? = nil sport: Sport? = nil
) async -> PhotoImportCandidate { ) async -> PhotoImportCandidate {
print("🎯 [GameMatcher] Processing photo for import")
print("🎯 [GameMatcher] - hasValidDate: \(metadata.hasValidDate)")
print("🎯 [GameMatcher] - captureDate: \(metadata.captureDate?.description ?? "nil")")
print("🎯 [GameMatcher] - hasValidLocation: \(metadata.hasValidLocation)")
if let coords = metadata.coordinates {
print("🎯 [GameMatcher] - coordinates: \(coords.latitude), \(coords.longitude)")
}
// Get stadium matches regardless of game matching // Get stadium matches regardless of game matching
var stadiumMatches: [StadiumMatch] = [] var stadiumMatches: [StadiumMatch] = []
if let coordinates = metadata.coordinates { if let coordinates = metadata.coordinates {
@@ -227,9 +302,53 @@ final class GameMatcher {
coordinates: coordinates, coordinates: coordinates,
sport: sport sport: sport
) )
print("🎯 [GameMatcher] - nearby stadiums found: \(stadiumMatches.count)")
for match in stadiumMatches.prefix(3) {
print("🎯 [GameMatcher] • \(match.stadium.name) (\(String(format: "%.1f", match.distance / 1609.34)) mi)")
}
} else {
print("🎯 [GameMatcher] - no coordinates, skipping stadium proximity search")
} }
let matchResult = await matchGame(metadata: metadata, sport: sport) var matchResult = await matchGame(metadata: metadata, sport: sport)
switch matchResult {
case .singleMatch(let match):
print("🎯 [GameMatcher] - result: singleMatch - \(match.homeTeam.fullName) vs \(match.awayTeam.fullName)")
case .multipleMatches(let matches):
print("🎯 [GameMatcher] - result: multipleMatches (\(matches.count) games)")
case .noMatches(let reason):
print("🎯 [GameMatcher] - result: noMatches - \(reason)")
// FALLBACK: Try scraping historical game data if we have stadium + date
// Try all nearby stadiums (important for multi-sport venues like American Airlines Center)
if let captureDate = metadata.captureDate, !stadiumMatches.isEmpty {
print("🎯 [GameMatcher] - Trying historical scraper fallback...")
for stadiumMatch in stadiumMatches {
let stadium = stadiumMatch.stadium
print("🎯 [GameMatcher] - Trying \(stadium.name) (\(stadium.sport.rawValue))...")
if let scrapedGame = await HistoricalGameScraper.shared.scrapeGame(
stadium: stadium,
date: captureDate
) {
print("🎯 [GameMatcher] - ✅ Scraper found: \(scrapedGame.awayTeam) @ \(scrapedGame.homeTeam)")
// Convert ScrapedGame to a match result
matchResult = .singleMatch(GameMatchCandidate(
scrapedGame: scrapedGame,
stadium: stadium
))
break // Found a game, stop searching
}
}
if case .noMatches = matchResult {
print("🎯 [GameMatcher] - Scraper found no game at any stadium")
}
}
}
return PhotoImportCandidate( return PhotoImportCandidate(
metadata: metadata, metadata: metadata,

View File

@@ -0,0 +1,309 @@
//
// HistoricalGameScraper.swift
// SportsTime
//
// Scrapes historical game data from sports reference sites when local database has no match.
// Runs on-device so scales to unlimited users with no server costs.
//
import Foundation
import SwiftSoup
// MARK: - Scraped Game Result
struct ScrapedGame: Sendable {
let date: Date
let homeTeam: String
let awayTeam: String
let homeScore: Int?
let awayScore: Int?
let stadiumName: String
let sport: Sport
var formattedScore: String? {
guard let home = homeScore, let away = awayScore else { return nil }
return "\(awayTeam) \(away) - \(homeTeam) \(home)"
}
}
// MARK: - Historical Game Scraper
actor HistoricalGameScraper {
static let shared = HistoricalGameScraper()
// In-memory cache to avoid redundant network requests during a session
private var cache: [String: ScrapedGame?] = [:]
private init() {}
// MARK: - Public API
/// Scrape historical game data for a stadium on a specific date
func scrapeGame(stadium: Stadium, date: Date) async -> ScrapedGame? {
let cacheKey = "\(stadium.id)-\(dateKey(date))"
// Check cache first
if let cached = cache[cacheKey] {
print("🌐 [Scraper] Cache hit for \(stadium.name) on \(dateKey(date))")
return cached
}
print("🌐 [Scraper] ════════════════════════════════════════")
print("🌐 [Scraper] Scraping game for \(stadium.name) on \(dateKey(date))")
print("🌐 [Scraper] Sport: \(stadium.sport.rawValue)")
let result: ScrapedGame?
switch stadium.sport {
case .mlb:
result = await scrapeBaseballReference(stadium: stadium, date: date)
case .nba:
result = await scrapeBasketballReference(stadium: stadium, date: date)
case .nhl:
result = await scrapeHockeyReference(stadium: stadium, date: date)
case .nfl:
result = await scrapeFootballReference(stadium: stadium, date: date)
case .mls, .wnba, .nwsl:
print("🌐 [Scraper] ⚠️ \(stadium.sport.rawValue) scraping not yet implemented")
result = nil
}
cache[cacheKey] = result
if let game = result {
print("🌐 [Scraper] ✅ Found: \(game.awayTeam) @ \(game.homeTeam)")
if let score = game.formattedScore {
print("🌐 [Scraper] Score: \(score)")
}
} else {
print("🌐 [Scraper] ❌ No game found")
}
print("🌐 [Scraper] ════════════════════════════════════════")
return result
}
// MARK: - Sport-Specific Scrapers
private func scrapeBaseballReference(stadium: Stadium, date: Date) async -> ScrapedGame? {
let (month, day, year) = dateComponents(date)
let urlString = "https://www.baseball-reference.com/boxes/?month=\(month)&day=\(day)&year=\(year)"
return await scrapeReferencesSite(urlString: urlString, stadium: stadium, date: date, sport: .mlb)
}
private func scrapeBasketballReference(stadium: Stadium, date: Date) async -> ScrapedGame? {
let (month, day, year) = dateComponents(date)
let urlString = "https://www.basketball-reference.com/boxscores/?month=\(month)&day=\(day)&year=\(year)"
return await scrapeReferencesSite(urlString: urlString, stadium: stadium, date: date, sport: .nba)
}
private func scrapeHockeyReference(stadium: Stadium, date: Date) async -> ScrapedGame? {
let (month, day, year) = dateComponents(date)
let urlString = "https://www.hockey-reference.com/boxscores/?month=\(month)&day=\(day)&year=\(year)"
return await scrapeReferencesSite(urlString: urlString, stadium: stadium, date: date, sport: .nhl)
}
private func scrapeFootballReference(stadium: Stadium, date: Date) async -> ScrapedGame? {
print("🌐 [Scraper] ⚠️ NFL scraping not yet implemented")
return nil
}
// MARK: - Generic Scraper (SwiftSoup)
private func scrapeReferencesSite(
urlString: String,
stadium: Stadium,
date: Date,
sport: Sport
) async -> ScrapedGame? {
print("🌐 [Scraper] Fetching: \(urlString)")
guard let url = URL(string: urlString) else { return nil }
do {
var request = URLRequest(url: url)
request.setValue("Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)", forHTTPHeaderField: "User-Agent")
let (data, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 200,
let html = String(data: data, encoding: .utf8) else {
print("🌐 [Scraper] ❌ Failed to fetch page")
return nil
}
print("🌐 [Scraper] Parsing HTML (\(data.count) bytes)")
let targetTeams = findTeamNamesForStadium(stadium)
guard !targetTeams.isEmpty else {
print("🌐 [Scraper] ❌ No team mapping for stadium: \(stadium.name)")
return nil
}
print("🌐 [Scraper] Looking for home team: \(targetTeams.first ?? "unknown")")
// Parse with SwiftSoup
let doc = try SwiftSoup.parse(html)
let gameSummaries = try doc.select("div.game_summary")
print("🌐 [Scraper] Found \(gameSummaries.count) games on this date")
for game in gameSummaries {
let rows = try game.select("table.teams tbody tr")
if rows.count >= 2 {
let awayRow = rows[0]
let homeRow = rows[1]
let awayTeam = try awayRow.select("td a").first()?.text() ?? ""
let homeTeam = try homeRow.select("td a").first()?.text() ?? ""
let awayScoreText = try awayRow.select("td.right").first()?.text() ?? ""
let homeScoreText = try homeRow.select("td.right").first()?.text() ?? ""
print("🌐 [Scraper] Game: \(awayTeam) @ \(homeTeam)")
// Check if any of our target team names match the home team
let isMatch = targetTeams.contains { targetName in
homeTeam.lowercased().contains(targetName.lowercased()) ||
targetName.lowercased().contains(homeTeam.lowercased())
}
if isMatch {
return ScrapedGame(
date: date,
homeTeam: homeTeam,
awayTeam: awayTeam,
homeScore: Int(homeScoreText),
awayScore: Int(awayScoreText),
stadiumName: stadium.name,
sport: sport
)
}
}
}
print("🌐 [Scraper] ⚠️ No game found for team: \(targetTeams.first ?? "unknown")")
} catch {
print("🌐 [Scraper] ❌ Error: \(error.localizedDescription)")
}
return nil
}
// MARK: - Helpers
private func dateComponents(_ date: Date) -> (Int, Int, Int) {
let calendar = Calendar.current
return (
calendar.component(.month, from: date),
calendar.component(.day, from: date),
calendar.component(.year, from: date)
)
}
private func dateKey(_ date: Date) -> String {
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd"
return formatter.string(from: date)
}
/// Returns an array of possible names for the home team (team name + city variations)
private func findTeamNamesForStadium(_ stadium: Stadium) -> [String] {
// Map stadium name to array of possible names (team name, city, abbreviations)
// Sports-Reference sites often use city names instead of team names
let stadiumTeamMap: [String: [String]] = [
// MLB
"Chase Field": ["Diamondbacks", "Arizona", "ARI"],
"Truist Park": ["Braves", "Atlanta", "ATL"],
"Oriole Park at Camden Yards": ["Orioles", "Baltimore", "BAL"],
"Fenway Park": ["Red Sox", "Boston", "BOS"],
"Wrigley Field": ["Cubs", "Chicago", "CHC"],
"Guaranteed Rate Field": ["White Sox", "Chicago", "CHW"],
"Great American Ball Park": ["Reds", "Cincinnati", "CIN"],
"Progressive Field": ["Guardians", "Cleveland", "Indians", "CLE"],
"Coors Field": ["Rockies", "Colorado", "COL"],
"Comerica Park": ["Tigers", "Detroit", "DET"],
"Minute Maid Park": ["Astros", "Houston", "HOU"],
"Kauffman Stadium": ["Royals", "Kansas City", "KCR"],
"Angel Stadium": ["Angels", "LA Angels", "Los Angeles", "Anaheim", "LAA"],
"Dodger Stadium": ["Dodgers", "LA Dodgers", "Los Angeles", "LAD"],
"loanDepot park": ["Marlins", "Miami", "Florida", "MIA"],
"American Family Field": ["Brewers", "Milwaukee", "MIL"],
"Target Field": ["Twins", "Minnesota", "MIN"],
"Citi Field": ["Mets", "NY Mets", "New York", "NYM"],
"Yankee Stadium": ["Yankees", "NY Yankees", "New York", "NYY"],
"Oakland Coliseum": ["Athletics", "Oakland", "OAK"],
"Citizens Bank Park": ["Phillies", "Philadelphia", "PHI"],
"PNC Park": ["Pirates", "Pittsburgh", "PIT"],
"Petco Park": ["Padres", "San Diego", "SDP"],
"Oracle Park": ["Giants", "San Francisco", "SF Giants", "SFG"],
"T-Mobile Park": ["Mariners", "Seattle", "SEA"],
"Busch Stadium": ["Cardinals", "St. Louis", "STL"],
"Tropicana Field": ["Rays", "Tampa Bay", "TBR"],
"Globe Life Field": ["Rangers", "Texas", "TEX"],
"Rogers Centre": ["Blue Jays", "Toronto", "TOR"],
"Nationals Park": ["Nationals", "Washington", "WSN"],
// NBA
"Footprint Center": ["Suns", "Phoenix", "PHX"],
"State Farm Arena": ["Hawks", "Atlanta", "ATL"],
"TD Garden": ["Celtics", "Boston", "BOS"],
"Barclays Center": ["Nets", "Brooklyn", "BKN"],
"Spectrum Center": ["Hornets", "Charlotte", "CHA"],
"United Center": ["Bulls", "Chicago", "CHI"],
"Rocket Mortgage FieldHouse": ["Cavaliers", "Cavs", "Cleveland", "CLE"],
"American Airlines Center": ["Mavericks", "Mavs", "Dallas", "DAL"],
"Ball Arena": ["Nuggets", "Denver", "DEN"],
"Little Caesars Arena": ["Pistons", "Detroit", "DET"],
"Chase Center": ["Warriors", "Golden State", "GSW"],
"Toyota Center": ["Rockets", "Houston", "HOU"],
"Gainbridge Fieldhouse": ["Pacers", "Indiana", "IND"],
"Crypto.com Arena": ["Lakers", "LA Lakers", "Los Angeles", "LAL"],
"FedExForum": ["Grizzlies", "Memphis", "MEM"],
"Kaseya Center": ["Heat", "Miami", "MIA"],
"Fiserv Forum": ["Bucks", "Milwaukee", "MIL"],
"Target Center": ["Timberwolves", "Wolves", "Minnesota", "MIN"],
"Smoothie King Center": ["Pelicans", "New Orleans", "NOP"],
"Madison Square Garden": ["Knicks", "NY Knicks", "New York", "NYK"],
"Paycom Center": ["Thunder", "Oklahoma City", "OKC"],
"Amway Center": ["Magic", "Orlando", "ORL"],
"Wells Fargo Center": ["76ers", "Sixers", "Philadelphia", "PHI"],
"Moda Center": ["Trail Blazers", "Blazers", "Portland", "POR"],
"Golden 1 Center": ["Kings", "Sacramento", "SAC"],
"AT&T Center": ["Spurs", "San Antonio", "SAS"],
"Scotiabank Arena": ["Raptors", "Toronto", "TOR"],
"Delta Center": ["Jazz", "Utah", "UTA"],
"Capital One Arena": ["Wizards", "Washington", "WAS"],
// NHL
"Mullett Arena": ["Coyotes", "Arizona", "ARI"],
"Climate Pledge Arena": ["Kraken", "Seattle", "SEA"],
"Prudential Center": ["Devils", "New Jersey", "NJD"],
"UBS Arena": ["Islanders", "NY Islanders", "New York", "NYI"],
"PPG Paints Arena": ["Penguins", "Pens", "Pittsburgh", "PIT"],
"Amerant Bank Arena": ["Panthers", "Florida", "FLA"],
"Amalie Arena": ["Lightning", "Tampa Bay", "TBL"],
"Enterprise Center": ["Blues", "St. Louis", "STL"],
"Canada Life Centre": ["Jets", "Winnipeg", "WPG"],
"Rogers Place": ["Oilers", "Edmonton", "EDM"],
"Scotiabank Saddledome": ["Flames", "Calgary", "CGY"],
"Rogers Arena": ["Canucks", "Vancouver", "VAN"],
"SAP Center": ["Sharks", "San Jose", "SJS"],
"Honda Center": ["Ducks", "Anaheim", "ANA"],
"T-Mobile Arena": ["Golden Knights", "Vegas", "Las Vegas", "VGK"],
"Xcel Energy Center": ["Wild", "Minnesota", "MIN"],
"Bridgestone Arena": ["Predators", "Preds", "Nashville", "NSH"],
"PNC Arena": ["Hurricanes", "Canes", "Carolina", "CAR"],
"Nationwide Arena": ["Blue Jackets", "Columbus", "CBJ"],
"KeyBank Center": ["Sabres", "Buffalo", "BUF"],
"Bell Centre": ["Canadiens", "Habs", "Montreal", "MTL"],
"Canadian Tire Centre": ["Senators", "Sens", "Ottawa", "OTT"],
]
return stadiumTeamMap[stadium.name] ?? []
}
func clearCache() {
cache.removeAll()
}
}

View File

@@ -43,18 +43,42 @@ actor PhotoMetadataExtractor {
/// Extract metadata from PHAsset (preferred method) /// Extract metadata from PHAsset (preferred method)
/// Uses PHAsset's location and creationDate properties /// Uses PHAsset's location and creationDate properties
func extractMetadata(from asset: PHAsset) async -> PhotoMetadata { func extractMetadata(from asset: PHAsset) async -> PhotoMetadata {
print("📸 [PhotoMetadata] ════════════════════════════════════════")
print("📸 [PhotoMetadata] Extracting from PHAsset: \(asset.localIdentifier)")
print("📸 [PhotoMetadata] Asset mediaType: \(asset.mediaType.rawValue) (1=image, 2=video)")
print("📸 [PhotoMetadata] Asset creationDate: \(asset.creationDate?.description ?? "nil")")
print("📸 [PhotoMetadata] Asset modificationDate: \(asset.modificationDate?.description ?? "nil")")
print("📸 [PhotoMetadata] Asset location: \(asset.location?.description ?? "nil")")
// PHAsset provides location and date directly // PHAsset provides location and date directly
let coordinates: CLLocationCoordinate2D? let coordinates: CLLocationCoordinate2D?
if let location = asset.location { if let location = asset.location {
coordinates = location.coordinate coordinates = location.coordinate
print("📸 [PhotoMetadata] ✅ Location found: \(coordinates!.latitude), \(coordinates!.longitude)")
} else { } else {
coordinates = nil coordinates = nil
print("📸 [PhotoMetadata] ⚠️ No location in PHAsset, trying ImageIO fallback...")
// Try ImageIO extraction as fallback
if let imageData = await loadImageData(from: asset) {
print("📸 [PhotoMetadata] Loaded image data: \(imageData.count) bytes")
let fallbackMetadata = extractMetadata(from: imageData)
if fallbackMetadata.hasValidLocation || fallbackMetadata.hasValidDate {
print("📸 [PhotoMetadata] ✅ ImageIO fallback found data - date: \(fallbackMetadata.captureDate?.description ?? "nil"), coords: \(fallbackMetadata.coordinates?.latitude ?? 0), \(fallbackMetadata.coordinates?.longitude ?? 0)")
return fallbackMetadata
} else {
print("📸 [PhotoMetadata] ⚠️ ImageIO fallback found no metadata either")
}
}
} }
return PhotoMetadata( let result = PhotoMetadata(
captureDate: asset.creationDate, captureDate: asset.creationDate,
coordinates: coordinates coordinates: coordinates
) )
print("📸 [PhotoMetadata] Final result - hasDate: \(result.hasValidDate), hasLocation: \(result.hasValidLocation)")
print("📸 [PhotoMetadata] ════════════════════════════════════════")
return result
} }
/// Extract metadata from multiple PHAssets /// Extract metadata from multiple PHAssets
@@ -72,14 +96,58 @@ actor PhotoMetadataExtractor {
/// Extract metadata from raw image data using ImageIO /// Extract metadata from raw image data using ImageIO
/// Useful when PHAsset is not available /// Useful when PHAsset is not available
func extractMetadata(from imageData: Data) -> PhotoMetadata { func extractMetadata(from imageData: Data) -> PhotoMetadata {
guard let source = CGImageSourceCreateWithData(imageData as CFData, nil), print("📸 [ImageIO] Extracting from image data (\(imageData.count) bytes)")
let properties = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any] else {
guard let source = CGImageSourceCreateWithData(imageData as CFData, nil) else {
print("📸 [ImageIO] ❌ Failed to create CGImageSource")
return .empty return .empty
} }
guard let properties = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any] else {
print("📸 [ImageIO] ❌ Failed to copy properties from source")
return .empty
}
// Debug: Print all available property dictionaries
print("📸 [ImageIO] Available property keys: \(properties.keys.map { $0 as String })")
if let exif = properties[kCGImagePropertyExifDictionary] as? [CFString: Any] {
print("📸 [ImageIO] EXIF keys: \(exif.keys.map { $0 as String })")
if let dateOriginal = exif[kCGImagePropertyExifDateTimeOriginal] {
print("📸 [ImageIO] EXIF DateTimeOriginal: \(dateOriginal)")
}
if let dateDigitized = exif[kCGImagePropertyExifDateTimeDigitized] {
print("📸 [ImageIO] EXIF DateTimeDigitized: \(dateDigitized)")
}
} else {
print("📸 [ImageIO] ⚠️ No EXIF dictionary found")
}
if let gps = properties[kCGImagePropertyGPSDictionary] as? [CFString: Any] {
print("📸 [ImageIO] GPS keys: \(gps.keys.map { $0 as String })")
print("📸 [ImageIO] GPS Latitude: \(gps[kCGImagePropertyGPSLatitude] ?? "nil")")
print("📸 [ImageIO] GPS LatitudeRef: \(gps[kCGImagePropertyGPSLatitudeRef] ?? "nil")")
print("📸 [ImageIO] GPS Longitude: \(gps[kCGImagePropertyGPSLongitude] ?? "nil")")
print("📸 [ImageIO] GPS LongitudeRef: \(gps[kCGImagePropertyGPSLongitudeRef] ?? "nil")")
} else {
print("📸 [ImageIO] ⚠️ No GPS dictionary found")
}
if let tiff = properties[kCGImagePropertyTIFFDictionary] as? [CFString: Any] {
print("📸 [ImageIO] TIFF keys: \(tiff.keys.map { $0 as String })")
if let dateTime = tiff[kCGImagePropertyTIFFDateTime] {
print("📸 [ImageIO] TIFF DateTime: \(dateTime)")
}
} else {
print("📸 [ImageIO] ⚠️ No TIFF dictionary found")
}
let captureDate = extractDate(from: properties) let captureDate = extractDate(from: properties)
let coordinates = extractCoordinates(from: properties) let coordinates = extractCoordinates(from: properties)
print("📸 [ImageIO] Extracted date: \(captureDate?.description ?? "nil")")
print("📸 [ImageIO] Extracted coordinates: \(coordinates?.latitude ?? 0), \(coordinates?.longitude ?? 0)")
return PhotoMetadata( return PhotoMetadata(
captureDate: captureDate, captureDate: captureDate,
coordinates: coordinates coordinates: coordinates

View File

@@ -47,6 +47,9 @@ final class PhotoImportViewModel {
func processSelectedPhotos(_ items: [PhotosPickerItem]) async { func processSelectedPhotos(_ items: [PhotosPickerItem]) async {
guard !items.isEmpty else { return } guard !items.isEmpty else { return }
print("📷 [PhotoImport] ════════════════════════════════════════════════")
print("📷 [PhotoImport] Starting photo import with \(items.count) items")
isProcessing = true isProcessing = true
totalCount = items.count totalCount = items.count
processedCount = 0 processedCount = 0
@@ -57,33 +60,69 @@ final class PhotoImportViewModel {
// Load PHAssets from PhotosPickerItems // Load PHAssets from PhotosPickerItems
var assets: [PHAsset] = [] var assets: [PHAsset] = []
for item in items { for (index, item) in items.enumerated() {
print("📷 [PhotoImport] ────────────────────────────────────────────────")
print("📷 [PhotoImport] Processing item \(index + 1)/\(items.count)")
print("📷 [PhotoImport] Item identifier: \(item.itemIdentifier ?? "nil")")
print("📷 [PhotoImport] Item supportedContentTypes: \(item.supportedContentTypes)")
if let assetId = item.itemIdentifier { if let assetId = item.itemIdentifier {
let fetchResult = PHAsset.fetchAssets(withLocalIdentifiers: [assetId], options: nil) let fetchResult = PHAsset.fetchAssets(withLocalIdentifiers: [assetId], options: nil)
print("📷 [PhotoImport] PHAsset fetch result count: \(fetchResult.count)")
if let asset = fetchResult.firstObject { if let asset = fetchResult.firstObject {
print("📷 [PhotoImport] ✅ Found PHAsset")
print("📷 [PhotoImport] - localIdentifier: \(asset.localIdentifier)")
print("📷 [PhotoImport] - mediaType: \(asset.mediaType.rawValue)")
print("📷 [PhotoImport] - creationDate: \(asset.creationDate?.description ?? "nil")")
print("📷 [PhotoImport] - location: \(asset.location?.description ?? "nil")")
print("📷 [PhotoImport] - sourceType: \(asset.sourceType.rawValue)")
print("📷 [PhotoImport] - pixelWidth: \(asset.pixelWidth)")
print("📷 [PhotoImport] - pixelHeight: \(asset.pixelHeight)")
assets.append(asset) assets.append(asset)
} else {
print("📷 [PhotoImport] ⚠️ No PHAsset found for identifier")
} }
} else {
print("📷 [PhotoImport] ⚠️ No itemIdentifier on PhotosPickerItem")
} }
processedCount += 1 processedCount += 1
} }
print("📷 [PhotoImport] ────────────────────────────────────────────────")
print("📷 [PhotoImport] Loaded \(assets.count) PHAssets, extracting metadata...")
// Extract metadata from all assets // Extract metadata from all assets
let metadataList = await metadataExtractor.extractMetadata(from: assets) let metadataList = await metadataExtractor.extractMetadata(from: assets)
print("📷 [PhotoImport] ────────────────────────────────────────────────")
print("📷 [PhotoImport] Extracted \(metadataList.count) metadata records")
// Summarize metadata extraction results
let withLocation = metadataList.filter { $0.hasValidLocation }.count
let withDate = metadataList.filter { $0.hasValidDate }.count
print("📷 [PhotoImport] Photos with location: \(withLocation)/\(metadataList.count)")
print("📷 [PhotoImport] Photos with date: \(withDate)/\(metadataList.count)")
// Process each photo through game matcher // Process each photo through game matcher
processedCount = 0 processedCount = 0
for metadata in metadataList { for (index, metadata) in metadataList.enumerated() {
print("📷 [PhotoImport] Matching photo \(index + 1): date=\(metadata.captureDate?.description ?? "nil"), location=\(metadata.hasValidLocation)")
let candidate = await gameMatcher.processPhotoForImport(metadata: metadata) let candidate = await gameMatcher.processPhotoForImport(metadata: metadata)
processedPhotos.append(candidate) processedPhotos.append(candidate)
// Auto-confirm high-confidence matches // Auto-confirm high-confidence matches
if candidate.canAutoProcess { if candidate.canAutoProcess {
confirmedImports.insert(candidate.id) confirmedImports.insert(candidate.id)
print("📷 [PhotoImport] ✅ Auto-confirmed match")
} }
processedCount += 1 processedCount += 1
} }
print("📷 [PhotoImport] ════════════════════════════════════════════════")
print("📷 [PhotoImport] Import complete: \(processedPhotos.count) photos, \(confirmedImports.count) auto-confirmed")
isProcessing = false isProcessing = false
} }
@@ -143,8 +182,8 @@ final class PhotoImportViewModel {
visitType: .game, visitType: .game,
homeTeamName: match.homeTeam.fullName, homeTeamName: match.homeTeam.fullName,
awayTeamName: match.awayTeam.fullName, awayTeamName: match.awayTeam.fullName,
finalScore: nil, finalScore: match.formattedFinalScore,
scoreSource: nil, scoreSource: match.formattedFinalScore != nil ? .scraped : nil,
dataSource: .automatic, dataSource: .automatic,
seatLocation: nil, seatLocation: nil,
notes: nil, notes: nil,

View File

@@ -77,7 +77,8 @@ final class ProgressViewModel {
visitDate: visit.visitDate, visitDate: visit.visitDate,
visitType: visit.visitType, visitType: visit.visitType,
sport: selectedSport, sport: selectedSport,
matchup: visit.matchupDescription, homeTeamName: visit.homeTeamName,
awayTeamName: visit.awayTeamName,
score: visit.finalScore, score: visit.finalScore,
photoCount: visit.photoMetadata?.count ?? 0, photoCount: visit.photoMetadata?.count ?? 0,
notes: visit.notes notes: visit.notes
@@ -123,7 +124,8 @@ final class ProgressViewModel {
visitDate: visit.visitDate, visitDate: visit.visitDate,
visitType: visit.visitType, visitType: visit.visitType,
sport: sport, sport: sport,
matchup: visit.matchupDescription, homeTeamName: visit.homeTeamName,
awayTeamName: visit.awayTeamName,
score: visit.finalScore, score: visit.finalScore,
photoCount: visit.photoMetadata?.count ?? 0, photoCount: visit.photoMetadata?.count ?? 0,
notes: visit.notes notes: visit.notes

View File

@@ -14,7 +14,7 @@ struct AchievementsListView: View {
@State private var achievements: [AchievementProgress] = [] @State private var achievements: [AchievementProgress] = []
@State private var isLoading = true @State private var isLoading = true
@State private var selectedCategory: AchievementCategory? @State private var selectedSport: Sport? // nil = All sports
@State private var selectedAchievement: AchievementProgress? @State private var selectedAchievement: AchievementProgress?
var body: some View { var body: some View {
@@ -24,8 +24,8 @@ struct AchievementsListView: View {
achievementSummary achievementSummary
.staggeredAnimation(index: 0) .staggeredAnimation(index: 0)
// Category filter // Sport filter
categoryFilter sportFilter
.staggeredAnimation(index: 1) .staggeredAnimation(index: 1)
// Achievements grid // Achievements grid
@@ -48,27 +48,58 @@ struct AchievementsListView: View {
// MARK: - Achievement Summary // MARK: - Achievement Summary
private var achievementSummary: some View { private var achievementSummary: some View {
let earned = achievements.filter { $0.isEarned }.count // Use filtered achievements to show relevant counts
let total = achievements.count let displayAchievements = filteredAchievements
let earned = displayAchievements.filter { $0.isEarned }.count
let total = displayAchievements.count
let progress = total > 0 ? Double(earned) / Double(total) : 0
let completedGold = Color(hex: "FFD700")
let filterTitle = selectedSport?.displayName ?? "All Sports"
let accentColor = selectedSport?.themeColor ?? Theme.warmOrange
return HStack(spacing: Theme.Spacing.lg) { return HStack(spacing: Theme.Spacing.lg) {
// Trophy icon // Trophy icon with progress ring
ZStack { ZStack {
// Background circle
Circle() Circle()
.fill(Theme.warmOrange.opacity(0.15)) .stroke(Theme.cardBackgroundElevated(colorScheme), lineWidth: 6)
.frame(width: 70, height: 70) .frame(width: 76, height: 76)
Image(systemName: "trophy.fill") // Progress ring
.font(.system(size: 32)) Circle()
.foregroundStyle(Theme.warmOrange) .trim(from: 0, to: progress)
.stroke(
LinearGradient(
colors: earned > 0 ? [completedGold, Color(hex: "FFA500")] : [accentColor, accentColor.opacity(0.7)],
startPoint: .topLeading,
endPoint: .bottomTrailing
),
style: StrokeStyle(lineWidth: 6, lineCap: .round)
)
.frame(width: 76, height: 76)
.rotationEffect(.degrees(-90))
// Inner circle
Circle()
.fill(earned > 0 ? completedGold.opacity(0.15) : accentColor.opacity(0.15))
.frame(width: 64, height: 64)
Image(systemName: selectedSport?.iconName ?? "trophy.fill")
.font(.system(size: 28))
.foregroundStyle(earned > 0 ? completedGold : accentColor)
} }
VStack(alignment: .leading, spacing: Theme.Spacing.xs) { VStack(alignment: .leading, spacing: Theme.Spacing.xs) {
Text("\(earned) / \(total)") HStack(alignment: .firstTextBaseline, spacing: 4) {
.font(.largeTitle) Text("\(earned)")
.foregroundStyle(Theme.textPrimary(colorScheme)) .font(.system(size: 36, weight: .bold, design: .rounded))
.foregroundStyle(earned > 0 ? completedGold : Theme.textPrimary(colorScheme))
Text("/ \(total)")
.font(.title2)
.foregroundStyle(Theme.textSecondary(colorScheme))
}
Text("Achievements Earned") Text("\(filterTitle) Achievements")
.font(.body) .font(.body)
.foregroundStyle(Theme.textSecondary(colorScheme)) .foregroundStyle(Theme.textSecondary(colorScheme))
@@ -78,7 +109,14 @@ struct AchievementsListView: View {
Text("All achievements unlocked!") Text("All achievements unlocked!")
} }
.font(.subheadline) .font(.subheadline)
.foregroundStyle(Theme.warmOrange) .foregroundStyle(completedGold)
} else if earned > 0 {
HStack(spacing: 4) {
Image(systemName: "flame.fill")
Text("\(total - earned) more to go!")
}
.font(.subheadline)
.foregroundStyle(accentColor)
} }
} }
@@ -89,34 +127,38 @@ struct AchievementsListView: View {
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.large)) .clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.large))
.overlay { .overlay {
RoundedRectangle(cornerRadius: Theme.CornerRadius.large) RoundedRectangle(cornerRadius: Theme.CornerRadius.large)
.stroke(Theme.surfaceGlow(colorScheme), lineWidth: 1) .stroke(earned > 0 ? completedGold.opacity(0.3) : Theme.surfaceGlow(colorScheme), lineWidth: earned > 0 ? 2 : 1)
} }
.shadow(color: Theme.cardShadow(colorScheme), radius: 10, y: 5) .shadow(color: earned > 0 ? completedGold.opacity(0.2) : Theme.cardShadow(colorScheme), radius: 10, y: 5)
} }
// MARK: - Category Filter // MARK: - Sport Filter
private var categoryFilter: some View { private var sportFilter: some View {
ScrollView(.horizontal, showsIndicators: false) { ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: Theme.Spacing.sm) { HStack(spacing: Theme.Spacing.sm) {
CategoryFilterButton( // All sports
SportFilterButton(
title: "All", title: "All",
icon: "square.grid.2x2", icon: "star.fill",
isSelected: selectedCategory == nil color: Theme.warmOrange,
isSelected: selectedSport == nil
) { ) {
withAnimation(Theme.Animation.spring) { withAnimation(Theme.Animation.spring) {
selectedCategory = nil selectedSport = nil
} }
} }
ForEach(AchievementCategory.allCases, id: \.self) { category in // Sport-specific filters
CategoryFilterButton( ForEach(Sport.supported) { sport in
title: category.displayName, SportFilterButton(
icon: category.iconName, title: sport.displayName,
isSelected: selectedCategory == category icon: sport.iconName,
color: sport.themeColor,
isSelected: selectedSport == sport
) { ) {
withAnimation(Theme.Animation.spring) { withAnimation(Theme.Animation.spring) {
selectedCategory = category selectedSport = sport
} }
} }
} }
@@ -143,22 +185,26 @@ struct AchievementsListView: View {
} }
private var filteredAchievements: [AchievementProgress] { private var filteredAchievements: [AchievementProgress] {
guard let category = selectedCategory else { let filtered: [AchievementProgress]
return achievements.sorted { first, second in
// Earned first, then by progress if let sport = selectedSport {
if first.isEarned != second.isEarned { // Filter to achievements for this sport only
return first.isEarned filtered = achievements.filter { achievement in
} // Include if achievement is sport-specific and matches, OR if it's cross-sport (nil)
return first.progressPercentage > second.progressPercentage achievement.definition.sport == sport
} }
} else {
// "All" - show all achievements
filtered = achievements
} }
return achievements.filter { $0.definition.category == category }
.sorted { first, second in return filtered.sorted { first, second in
if first.isEarned != second.isEarned { // Earned first, then by progress percentage
return first.isEarned if first.isEarned != second.isEarned {
} return first.isEarned
return first.progressPercentage > second.progressPercentage
} }
return first.progressPercentage > second.progressPercentage
}
} }
// MARK: - Data Loading // MARK: - Data Loading
@@ -176,11 +222,12 @@ struct AchievementsListView: View {
} }
} }
// MARK: - Category Filter Button // MARK: - Sport Filter Button
struct CategoryFilterButton: View { struct SportFilterButton: View {
let title: String let title: String
let icon: String let icon: String
let color: Color
let isSelected: Bool let isSelected: Bool
let action: () -> Void let action: () -> Void
@@ -193,15 +240,16 @@ struct CategoryFilterButton: View {
.font(.subheadline) .font(.subheadline)
Text(title) Text(title)
.font(.subheadline) .font(.subheadline)
.fontWeight(isSelected ? .semibold : .regular)
} }
.padding(.horizontal, Theme.Spacing.md) .padding(.horizontal, Theme.Spacing.md)
.padding(.vertical, Theme.Spacing.sm) .padding(.vertical, Theme.Spacing.sm)
.background(isSelected ? Theme.warmOrange : Theme.cardBackground(colorScheme)) .background(isSelected ? color : Theme.cardBackground(colorScheme))
.foregroundStyle(isSelected ? .white : Theme.textPrimary(colorScheme)) .foregroundStyle(isSelected ? .white : Theme.textPrimary(colorScheme))
.clipShape(Capsule()) .clipShape(Capsule())
.overlay { .overlay {
Capsule() Capsule()
.stroke(isSelected ? Color.clear : Theme.surfaceGlow(colorScheme), lineWidth: 1) .stroke(isSelected ? Color.clear : color.opacity(0.3), lineWidth: 1)
} }
} }
.buttonStyle(.plain) .buttonStyle(.plain)
@@ -215,6 +263,9 @@ struct AchievementCard: View {
@Environment(\.colorScheme) private var colorScheme @Environment(\.colorScheme) private var colorScheme
// Gold color for completed achievements
private let completedGold = Color(hex: "FFD700")
var body: some View { var body: some View {
VStack(spacing: Theme.Spacing.sm) { VStack(spacing: Theme.Spacing.sm) {
// Badge icon // Badge icon
@@ -223,6 +274,13 @@ struct AchievementCard: View {
.fill(badgeBackgroundColor) .fill(badgeBackgroundColor)
.frame(width: 60, height: 60) .frame(width: 60, height: 60)
if achievement.isEarned {
// Gold ring for completed
Circle()
.stroke(completedGold, lineWidth: 3)
.frame(width: 64, height: 64)
}
Image(systemName: achievement.definition.iconName) Image(systemName: achievement.definition.iconName)
.font(.system(size: 28)) .font(.system(size: 28))
.foregroundStyle(badgeIconColor) .foregroundStyle(badgeIconColor)
@@ -241,6 +299,7 @@ struct AchievementCard: View {
// Title // Title
Text(achievement.definition.name) Text(achievement.definition.name)
.font(.subheadline) .font(.subheadline)
.fontWeight(achievement.isEarned ? .semibold : .regular)
.foregroundStyle(achievement.isEarned ? Theme.textPrimary(colorScheme) : Theme.textMuted(colorScheme)) .foregroundStyle(achievement.isEarned ? Theme.textPrimary(colorScheme) : Theme.textMuted(colorScheme))
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
.lineLimit(2) .lineLimit(2)
@@ -248,16 +307,22 @@ struct AchievementCard: View {
// Progress or earned date // Progress or earned date
if achievement.isEarned { if achievement.isEarned {
if let earnedAt = achievement.earnedAt { HStack(spacing: 4) {
Text(earnedAt.formatted(date: .abbreviated, time: .omitted)) Image(systemName: "checkmark.seal.fill")
.font(.caption) .font(.caption)
.foregroundStyle(Theme.warmOrange) if let earnedAt = achievement.earnedAt {
Text(earnedAt.formatted(date: .abbreviated, time: .omitted))
} else {
Text("Completed")
}
} }
.font(.caption)
.foregroundStyle(completedGold)
} else { } else {
// Progress bar // Progress bar
VStack(spacing: 4) { VStack(spacing: 4) {
ProgressView(value: achievement.progressPercentage) ProgressView(value: achievement.progressPercentage)
.progressViewStyle(AchievementProgressStyle()) .progressViewStyle(AchievementProgressStyle(category: achievement.definition.category))
Text(achievement.progressText) Text(achievement.progressText)
.font(.caption) .font(.caption)
@@ -267,26 +332,26 @@ struct AchievementCard: View {
} }
.padding(Theme.Spacing.md) .padding(Theme.Spacing.md)
.frame(maxWidth: .infinity, minHeight: 170) .frame(maxWidth: .infinity, minHeight: 170)
.background(Theme.cardBackground(colorScheme)) .background(achievement.isEarned ? completedGold.opacity(0.08) : Theme.cardBackground(colorScheme))
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)) .clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium))
.overlay { .overlay {
RoundedRectangle(cornerRadius: Theme.CornerRadius.medium) RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)
.stroke(achievement.isEarned ? Theme.warmOrange.opacity(0.5) : Theme.surfaceGlow(colorScheme), lineWidth: achievement.isEarned ? 2 : 1) .stroke(achievement.isEarned ? completedGold : Theme.surfaceGlow(colorScheme), lineWidth: achievement.isEarned ? 2 : 1)
} }
.shadow(color: Theme.cardShadow(colorScheme), radius: 5, y: 2) .shadow(color: achievement.isEarned ? completedGold.opacity(0.3) : Theme.cardShadow(colorScheme), radius: achievement.isEarned ? 8 : 5, y: 2)
.opacity(achievement.isEarned ? 1.0 : 0.7) .opacity(achievement.isEarned ? 1.0 : 0.7)
} }
private var badgeBackgroundColor: Color { private var badgeBackgroundColor: Color {
if achievement.isEarned { if achievement.isEarned {
return categoryColor.opacity(0.2) return completedGold.opacity(0.2)
} }
return Theme.cardBackgroundElevated(colorScheme) return Theme.cardBackgroundElevated(colorScheme)
} }
private var badgeIconColor: Color { private var badgeIconColor: Color {
if achievement.isEarned { if achievement.isEarned {
return categoryColor return completedGold
} }
return Theme.textMuted(colorScheme) return Theme.textMuted(colorScheme)
} }
@@ -312,21 +377,44 @@ struct AchievementCard: View {
// MARK: - Achievement Progress Style // MARK: - Achievement Progress Style
struct AchievementProgressStyle: ProgressViewStyle { struct AchievementProgressStyle: ProgressViewStyle {
let category: AchievementCategory
@Environment(\.colorScheme) private var colorScheme @Environment(\.colorScheme) private var colorScheme
init(category: AchievementCategory = .count) {
self.category = category
}
private var progressGradient: LinearGradient {
let colors: [Color] = switch category {
case .count:
[Theme.warmOrange, Color(hex: "FF8C42")]
case .division:
[Theme.routeGold, Color(hex: "FFA500")]
case .conference:
[Theme.routeAmber, Color(hex: "FF6B35")]
case .league:
[Color(hex: "FFD700"), Color(hex: "FFC107")]
case .journey:
[Color(hex: "9B59B6"), Color(hex: "8E44AD")]
case .special:
[Color(hex: "E74C3C"), Color(hex: "C0392B")]
}
return LinearGradient(colors: colors, startPoint: .leading, endPoint: .trailing)
}
func makeBody(configuration: Configuration) -> some View { func makeBody(configuration: Configuration) -> some View {
GeometryReader { geometry in GeometryReader { geometry in
ZStack(alignment: .leading) { ZStack(alignment: .leading) {
RoundedRectangle(cornerRadius: 2) RoundedRectangle(cornerRadius: 3)
.fill(Theme.cardBackgroundElevated(colorScheme)) .fill(Theme.cardBackgroundElevated(colorScheme))
.frame(height: 4) .frame(height: 6)
RoundedRectangle(cornerRadius: 2) RoundedRectangle(cornerRadius: 3)
.fill(Theme.warmOrange) .fill(progressGradient)
.frame(width: geometry.size.width * CGFloat(configuration.fractionCompleted ?? 0), height: 4) .frame(width: geometry.size.width * CGFloat(configuration.fractionCompleted ?? 0), height: 6)
} }
} }
.frame(height: 4) .frame(height: 6)
} }
} }
@@ -338,6 +426,9 @@ struct AchievementDetailSheet: View {
@Environment(\.colorScheme) private var colorScheme @Environment(\.colorScheme) private var colorScheme
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
// Gold color for completed achievements
private let completedGold = Color(hex: "FFD700")
var body: some View { var body: some View {
NavigationStack { NavigationStack {
VStack(spacing: Theme.Spacing.xl) { VStack(spacing: Theme.Spacing.xl) {
@@ -348,9 +439,18 @@ struct AchievementDetailSheet: View {
.frame(width: 120, height: 120) .frame(width: 120, height: 120)
if achievement.isEarned { if achievement.isEarned {
// Gold ring with glow effect
Circle() Circle()
.stroke(Theme.warmOrange, lineWidth: 4) .stroke(
LinearGradient(
colors: [completedGold, Color(hex: "FFA500")],
startPoint: .topLeading,
endPoint: .bottomTrailing
),
lineWidth: 5
)
.frame(width: 130, height: 130) .frame(width: 130, height: 130)
.shadow(color: completedGold.opacity(0.5), radius: 10)
} }
Image(systemName: achievement.definition.iconName) Image(systemName: achievement.definition.iconName)
@@ -372,7 +472,8 @@ struct AchievementDetailSheet: View {
VStack(spacing: Theme.Spacing.sm) { VStack(spacing: Theme.Spacing.sm) {
Text(achievement.definition.name) Text(achievement.definition.name)
.font(.title2) .font(.title2)
.foregroundStyle(Theme.textPrimary(colorScheme)) .fontWeight(achievement.isEarned ? .bold : .regular)
.foregroundStyle(achievement.isEarned ? completedGold : Theme.textPrimary(colorScheme))
Text(achievement.definition.description) Text(achievement.definition.description)
.font(.body) .font(.body)
@@ -385,26 +486,33 @@ struct AchievementDetailSheet: View {
Text(achievement.definition.category.displayName) Text(achievement.definition.category.displayName)
} }
.font(.subheadline) .font(.subheadline)
.foregroundStyle(categoryColor) .foregroundStyle(achievement.isEarned ? completedGold : categoryColor)
.padding(.horizontal, Theme.Spacing.sm) .padding(.horizontal, Theme.Spacing.sm)
.padding(.vertical, Theme.Spacing.xs) .padding(.vertical, Theme.Spacing.xs)
.background(categoryColor.opacity(0.15)) .background((achievement.isEarned ? completedGold : categoryColor).opacity(0.15))
.clipShape(Capsule()) .clipShape(Capsule())
} }
// Status section // Status section
if achievement.isEarned { if achievement.isEarned {
if let earnedAt = achievement.earnedAt { VStack(spacing: 8) {
VStack(spacing: 4) { Image(systemName: "checkmark.seal.fill")
Image(systemName: "checkmark.seal.fill") .font(.system(size: 32))
.font(.system(size: 24)) .foregroundStyle(completedGold)
.foregroundStyle(.green)
if let earnedAt = achievement.earnedAt {
Text("Earned on \(earnedAt.formatted(date: .long, time: .omitted))") Text("Earned on \(earnedAt.formatted(date: .long, time: .omitted))")
.font(.body) .font(.body)
.foregroundStyle(Theme.textPrimary(colorScheme)) .foregroundStyle(Theme.textPrimary(colorScheme))
} else {
Text("Achievement Unlocked!")
.font(.headline)
.foregroundStyle(completedGold)
} }
} }
.padding()
.background(completedGold.opacity(0.1))
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium))
} else { } else {
// Progress section // Progress section
VStack(spacing: Theme.Spacing.sm) { VStack(spacing: Theme.Spacing.sm) {
@@ -450,14 +558,14 @@ struct AchievementDetailSheet: View {
private var badgeBackgroundColor: Color { private var badgeBackgroundColor: Color {
if achievement.isEarned { if achievement.isEarned {
return categoryColor.opacity(0.2) return completedGold.opacity(0.2)
} }
return Theme.cardBackgroundElevated(colorScheme) return Theme.cardBackgroundElevated(colorScheme)
} }
private var badgeIconColor: Color { private var badgeIconColor: Color {
if achievement.isEarned { if achievement.isEarned {
return categoryColor return completedGold
} }
return Theme.textMuted(colorScheme) return Theme.textMuted(colorScheme)
} }
@@ -485,19 +593,27 @@ struct AchievementDetailSheet: View {
struct LargeProgressStyle: ProgressViewStyle { struct LargeProgressStyle: ProgressViewStyle {
@Environment(\.colorScheme) private var colorScheme @Environment(\.colorScheme) private var colorScheme
private var progressGradient: LinearGradient {
LinearGradient(
colors: [Theme.warmOrange, Color(hex: "FF6B35")],
startPoint: .leading,
endPoint: .trailing
)
}
func makeBody(configuration: Configuration) -> some View { func makeBody(configuration: Configuration) -> some View {
GeometryReader { geometry in GeometryReader { geometry in
ZStack(alignment: .leading) { ZStack(alignment: .leading) {
RoundedRectangle(cornerRadius: 4) RoundedRectangle(cornerRadius: 4)
.fill(Theme.cardBackgroundElevated(colorScheme)) .fill(Theme.cardBackgroundElevated(colorScheme))
.frame(height: 8) .frame(height: 10)
RoundedRectangle(cornerRadius: 4) RoundedRectangle(cornerRadius: 4)
.fill(Theme.warmOrange) .fill(progressGradient)
.frame(width: geometry.size.width * CGFloat(configuration.fractionCompleted ?? 0), height: 8) .frame(width: geometry.size.width * CGFloat(configuration.fractionCompleted ?? 0), height: 10)
} }
} }
.frame(height: 8) .frame(height: 10)
} }
} }

View File

@@ -215,7 +215,7 @@ struct GameMatchConfirmationView: View {
HStack { HStack {
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
HStack { HStack {
Text(match.matchupDescription) Text(match.fullMatchupDescription)
.font(.body) .font(.body)
.foregroundStyle(Theme.textPrimary(colorScheme)) .foregroundStyle(Theme.textPrimary(colorScheme))

View File

@@ -433,7 +433,7 @@ struct PhotoImportCandidateCard: View {
private func matchRow(_ match: GameMatchCandidate) -> some View { private func matchRow(_ match: GameMatchCandidate) -> some View {
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
HStack { HStack {
Text(match.matchupDescription) Text(match.fullMatchupDescription)
.font(.body) .font(.body)
.foregroundStyle(Theme.textPrimary(colorScheme)) .foregroundStyle(Theme.textPrimary(colorScheme))

View File

@@ -477,11 +477,13 @@ struct RecentVisitRow: View {
.font(.body) .font(.body)
.foregroundStyle(Theme.textPrimary(colorScheme)) .foregroundStyle(Theme.textPrimary(colorScheme))
HStack(spacing: Theme.Spacing.sm) { // Date, Away @ Home on one line, left aligned
VStack(alignment: .leading, spacing: 4) {
Text(visit.shortDateDescription) Text(visit.shortDateDescription)
if let matchup = visit.matchup { if let away = visit.awayTeamName, let home = visit.homeTeamName {
Text("") Text(away)
Text(matchup) Text("@")
Text(home)
} }
} }
.font(.subheadline) .font(.subheadline)
@@ -490,15 +492,6 @@ struct RecentVisitRow: View {
Spacer() Spacer()
if visit.photoCount > 0 {
HStack(spacing: 4) {
Image(systemName: "photo")
Text("\(visit.photoCount)")
}
.font(.caption)
.foregroundStyle(Theme.textMuted(colorScheme))
}
Image(systemName: "chevron.right") Image(systemName: "chevron.right")
.font(.caption) .font(.caption)
.foregroundStyle(Theme.textMuted(colorScheme)) .foregroundStyle(Theme.textMuted(colorScheme))

View File

@@ -33,6 +33,8 @@ struct StadiumVisitSheet: View {
// UI state // UI state
@State private var showStadiumPicker = false @State private var showStadiumPicker = false
@State private var isSaving = false @State private var isSaving = false
@State private var isLookingUpGame = false
@State private var scoreFromScraper = false // Track if score was auto-filled
@State private var errorMessage: String? @State private var errorMessage: String?
@State private var showAwayTeamSuggestions = false @State private var showAwayTeamSuggestions = false
@State private var showHomeTeamSuggestions = false @State private var showHomeTeamSuggestions = false
@@ -220,10 +222,36 @@ struct StadiumVisitSheet: View {
.frame(width: 50) .frame(width: 50)
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
} }
// Look Up Game Button
if selectedStadium != nil {
Button {
Task {
await lookUpGame()
}
} label: {
HStack {
if isLookingUpGame {
ProgressView()
.scaleEffect(0.8)
} else {
Image(systemName: "magnifyingglass")
}
Text("Look Up Game")
}
.frame(maxWidth: .infinity)
.foregroundStyle(Theme.warmOrange)
}
.disabled(isLookingUpGame)
}
} header: { } header: {
Text("Game Info") Text("Game Info")
} footer: { } footer: {
Text("Leave blank if you don't remember the score") if selectedStadium != nil {
Text("Tap 'Look Up Game' to auto-fill teams and score from historical data")
} else {
Text("Select a stadium to enable game lookup")
}
} }
.listRowBackground(Theme.cardBackground(colorScheme)) .listRowBackground(Theme.cardBackground(colorScheme))
} }
@@ -320,6 +348,36 @@ struct StadiumVisitSheet: View {
// MARK: - Actions // MARK: - Actions
private func lookUpGame() async {
guard let stadium = selectedStadium else { return }
isLookingUpGame = true
errorMessage = nil
// Use the historical game scraper
if let scrapedGame = await HistoricalGameScraper.shared.scrapeGame(
stadium: stadium,
date: visitDate
) {
// Fill in the form with scraped data
awayTeamName = scrapedGame.awayTeam
homeTeamName = scrapedGame.homeTeam
if let away = scrapedGame.awayScore {
awayScore = String(away)
scoreFromScraper = true
}
if let home = scrapedGame.homeScore {
homeScore = String(home)
scoreFromScraper = true
}
} else {
errorMessage = "No game found for \(stadium.name) on this date"
}
isLookingUpGame = false
}
private func saveVisit() { private func saveVisit() {
guard let stadium = selectedStadium else { guard let stadium = selectedStadium else {
errorMessage = "Please select a stadium" errorMessage = "Please select a stadium"
@@ -340,8 +398,8 @@ struct StadiumVisitSheet: View {
homeTeamName: homeTeamName.isEmpty ? nil : homeTeamName, homeTeamName: homeTeamName.isEmpty ? nil : homeTeamName,
awayTeamName: awayTeamName.isEmpty ? nil : awayTeamName, awayTeamName: awayTeamName.isEmpty ? nil : awayTeamName,
finalScore: finalScoreString, finalScore: finalScoreString,
scoreSource: finalScoreString != nil ? .user : nil, scoreSource: finalScoreString != nil ? (scoreFromScraper ? .scraped : .user) : nil,
dataSource: .fullyManual, dataSource: scoreFromScraper ? .automatic : .fullyManual,
seatLocation: seatLocation.isEmpty ? nil : seatLocation, seatLocation: seatLocation.isEmpty ? nil : seatLocation,
notes: notes.isEmpty ? nil : notes, notes: notes.isEmpty ? nil : notes,
source: .manual source: .manual

View File

@@ -194,25 +194,25 @@ struct VisitDetailView: View {
} }
if let matchup = visit.matchupDescription { if let matchup = visit.matchupDescription {
HStack { VStack(alignment: .leading, spacing: 4) {
Text("Matchup") Text("Matchup")
.foregroundStyle(Theme.textSecondary(colorScheme)) .foregroundStyle(Theme.textSecondary(colorScheme))
Spacer()
Text(matchup) Text(matchup)
.foregroundStyle(Theme.textPrimary(colorScheme)) .foregroundStyle(Theme.textPrimary(colorScheme))
.fontWeight(.medium) .fontWeight(.medium)
} }
.frame(maxWidth: .infinity, alignment: .leading)
} }
if let score = visit.finalScore { if let score = visit.finalScore {
HStack { VStack(alignment: .leading, spacing: 4) {
Text("Final Score") Text("Final Score")
.foregroundStyle(Theme.textSecondary(colorScheme)) .foregroundStyle(Theme.textSecondary(colorScheme))
Spacer()
Text(score) Text(score)
.foregroundStyle(Theme.textPrimary(colorScheme)) .foregroundStyle(Theme.textPrimary(colorScheme))
.fontWeight(.bold) .fontWeight(.bold)
} }
.frame(maxWidth: .infinity, alignment: .leading)
} }
} }
.font(.body) .font(.body)
@@ -238,42 +238,42 @@ struct VisitDetailView: View {
} }
// Date // Date
HStack { VStack(alignment: .leading, spacing: 4) {
Text("Date") Text("Date")
.foregroundStyle(Theme.textSecondary(colorScheme)) .foregroundStyle(Theme.textSecondary(colorScheme))
Spacer()
Text(formattedDate) Text(formattedDate)
.foregroundStyle(Theme.textPrimary(colorScheme)) .foregroundStyle(Theme.textPrimary(colorScheme))
} }
.frame(maxWidth: .infinity, alignment: .leading)
// Seat location // Seat location
if let seat = visit.seatLocation, !seat.isEmpty { if let seat = visit.seatLocation, !seat.isEmpty {
HStack { VStack(alignment: .leading, spacing: 4) {
Text("Seat") Text("Seat")
.foregroundStyle(Theme.textSecondary(colorScheme)) .foregroundStyle(Theme.textSecondary(colorScheme))
Spacer()
Text(seat) Text(seat)
.foregroundStyle(Theme.textPrimary(colorScheme)) .foregroundStyle(Theme.textPrimary(colorScheme))
} }
.frame(maxWidth: .infinity, alignment: .leading)
} }
// Source // Source
HStack { VStack(alignment: .leading, spacing: 4) {
Text("Source") Text("Source")
.foregroundStyle(Theme.textSecondary(colorScheme)) .foregroundStyle(Theme.textSecondary(colorScheme))
Spacer()
Text(visit.source.displayName) Text(visit.source.displayName)
.foregroundStyle(Theme.textMuted(colorScheme)) .foregroundStyle(Theme.textMuted(colorScheme))
} }
.frame(maxWidth: .infinity, alignment: .leading)
// Created date // Created date
HStack { VStack(alignment: .leading, spacing: 4) {
Text("Logged") Text("Logged")
.foregroundStyle(Theme.textSecondary(colorScheme)) .foregroundStyle(Theme.textSecondary(colorScheme))
Spacer()
Text(formattedCreatedDate) Text(formattedCreatedDate)
.foregroundStyle(Theme.textMuted(colorScheme)) .foregroundStyle(Theme.textMuted(colorScheme))
} }
.frame(maxWidth: .infinity, alignment: .leading)
} }
.font(.body) .font(.body)
.padding(Theme.Spacing.lg) .padding(Theme.Spacing.lg)

View File

@@ -0,0 +1,486 @@
//
// AchievementEngineTests.swift
// SportsTimeTests
//
// TDD tests for AchievementEngine - all achievement requirement types.
// Tests the bug fix where specificStadium achievements use symbolic IDs
// (e.g., "stadium_mlb_bos") that need to be resolved to actual stadium UUIDs.
//
import XCTest
import SwiftUI
import SwiftData
@testable import SportsTime
/// Tests for AchievementEngine that don't require full AppDataProvider setup
@MainActor
final class AchievementEngineTests: XCTestCase {
// MARK: - Basic Tests
/// Verify the AchievementRegistry contains specificStadium achievements
func test_registry_containsSpecificStadiumAchievements() {
let fenwayAchievement = AchievementRegistry.achievement(byId: "special_fenway")
let wrigleyAchievement = AchievementRegistry.achievement(byId: "special_wrigley")
let msgAchievement = AchievementRegistry.achievement(byId: "special_msg")
XCTAssertNotNil(fenwayAchievement, "Green Monster achievement should exist")
XCTAssertNotNil(wrigleyAchievement, "Ivy League achievement should exist")
XCTAssertNotNil(msgAchievement, "MSG achievement should exist")
// Verify they use specificStadium requirement
if case .specificStadium(let id) = fenwayAchievement!.requirement {
XCTAssertEqual(id, "stadium_mlb_bos", "Fenway should use stadium_mlb_bos")
} else {
XCTFail("Fenway achievement should have specificStadium requirement")
}
if case .specificStadium(let id) = wrigleyAchievement!.requirement {
XCTAssertEqual(id, "stadium_mlb_chc", "Wrigley should use stadium_mlb_chc")
} else {
XCTFail("Wrigley achievement should have specificStadium requirement")
}
if case .specificStadium(let id) = msgAchievement!.requirement {
XCTAssertEqual(id, "stadium_nba_nyk", "MSG should use stadium_nba_nyk")
} else {
XCTFail("MSG achievement should have specificStadium requirement")
}
}
/// Verify all achievements are defined in registry
func test_registry_containsAllAchievementTypes() {
let all = AchievementRegistry.all
// Should have achievements for each type
let hasFirstVisit = all.contains { if case .firstVisit = $0.requirement { return true }; return false }
let hasVisitCount = all.contains { if case .visitCount = $0.requirement { return true }; return false }
let hasSpecificStadium = all.contains { if case .specificStadium = $0.requirement { return true }; return false }
let hasMultipleLeagues = all.contains { if case .multipleLeagues = $0.requirement { return true }; return false }
let hasVisitsInDays = all.contains { if case .visitsInDays = $0.requirement { return true }; return false }
XCTAssertTrue(hasFirstVisit, "Registry should have firstVisit achievement")
XCTAssertTrue(hasVisitCount, "Registry should have visitCount achievement")
XCTAssertTrue(hasSpecificStadium, "Registry should have specificStadium achievement")
XCTAssertTrue(hasMultipleLeagues, "Registry should have multipleLeagues achievement")
XCTAssertTrue(hasVisitsInDays, "Registry should have visitsInDays achievement")
}
/// Verify AchievementProgress isEarned logic
func test_achievementProgress_isEarnedCalculation() {
let definition = AchievementDefinition(
id: "test",
name: "Test",
description: "Test",
category: .special,
sport: nil,
iconName: "star",
iconColor: .orange,
requirement: .visitCount(5)
)
// 0/5 - not earned
let progress0 = AchievementProgress(
definition: definition,
currentProgress: 0,
totalRequired: 5,
hasStoredAchievement: false,
earnedAt: nil
)
XCTAssertFalse(progress0.isEarned)
// 3/5 - not earned
let progress3 = AchievementProgress(
definition: definition,
currentProgress: 3,
totalRequired: 5,
hasStoredAchievement: false,
earnedAt: nil
)
XCTAssertFalse(progress3.isEarned)
// 5/5 - earned (computed)
let progress5 = AchievementProgress(
definition: definition,
currentProgress: 5,
totalRequired: 5,
hasStoredAchievement: false,
earnedAt: nil
)
XCTAssertTrue(progress5.isEarned, "5/5 should be earned")
// 3/5 but has stored achievement - earned
let progressStored = AchievementProgress(
definition: definition,
currentProgress: 3,
totalRequired: 5,
hasStoredAchievement: true,
earnedAt: Date()
)
XCTAssertTrue(progressStored.isEarned, "Should be earned if stored")
}
/// Verify AchievementProgress percentage calculation
func test_achievementProgress_percentageCalculation() {
let definition = AchievementDefinition(
id: "test",
name: "Test",
description: "Test",
category: .special,
sport: nil,
iconName: "star",
iconColor: .orange,
requirement: .visitCount(10)
)
let progress = AchievementProgress(
definition: definition,
currentProgress: 5,
totalRequired: 10,
hasStoredAchievement: false,
earnedAt: nil
)
XCTAssertEqual(progress.progressPercentage, 0.5, accuracy: 0.001)
XCTAssertEqual(progress.progressText, "5/10")
}
/// Verify AchievementDelta hasChanges
func test_achievementDelta_hasChanges() {
let definition = AchievementDefinition(
id: "test",
name: "Test",
description: "Test",
category: .special,
sport: nil,
iconName: "star",
iconColor: .orange,
requirement: .firstVisit
)
let emptyDelta = AchievementDelta(newlyEarned: [], revoked: [], stillEarned: [])
XCTAssertFalse(emptyDelta.hasChanges, "Empty delta should have no changes")
let newEarnedDelta = AchievementDelta(newlyEarned: [definition], revoked: [], stillEarned: [])
XCTAssertTrue(newEarnedDelta.hasChanges, "Delta with newly earned should have changes")
let revokedDelta = AchievementDelta(newlyEarned: [], revoked: [definition], stillEarned: [])
XCTAssertTrue(revokedDelta.hasChanges, "Delta with revoked should have changes")
let stillEarnedDelta = AchievementDelta(newlyEarned: [], revoked: [], stillEarned: [definition])
XCTAssertFalse(stillEarnedDelta.hasChanges, "Delta with only stillEarned should have no changes")
}
}
// MARK: - Integration Tests (require AppDataProvider)
/// Integration tests that test the full achievement engine with real data
@MainActor
final class AchievementEngineIntegrationTests: XCTestCase {
var modelContainer: ModelContainer!
var modelContext: ModelContext!
// Test UUIDs for stadiums
let fenwayUUID = UUID(uuidString: "11111111-1111-1111-1111-111111111111")!
let wrigleyUUID = UUID(uuidString: "22222222-2222-2222-2222-222222222222")!
let msgUUID = UUID(uuidString: "33333333-3333-3333-3333-333333333333")!
override func setUp() async throws {
try await super.setUp()
// Create container with ALL models the app uses
let schema = Schema([
// User data models
StadiumVisit.self,
Achievement.self,
VisitPhotoMetadata.self,
CachedGameScore.self,
// Canonical models
CanonicalTeam.self,
CanonicalStadium.self,
CanonicalGame.self,
LeagueStructureModel.self,
TeamAlias.self,
StadiumAlias.self,
SyncState.self,
// Trip models
SavedTrip.self,
TripVote.self,
UserPreferences.self,
CachedSchedule.self
])
let config = ModelConfiguration(isStoredInMemoryOnly: true)
modelContainer = try ModelContainer(for: schema, configurations: [config])
modelContext = modelContainer.mainContext
// Setup minimal test data
await setupTestData()
// Configure and load AppDataProvider
AppDataProvider.shared.configure(with: modelContext)
await AppDataProvider.shared.loadInitialData()
}
override func tearDown() async throws {
modelContainer = nil
modelContext = nil
try await super.tearDown()
}
private func setupTestData() async {
// Create Fenway Park stadium
let fenway = CanonicalStadium(
canonicalId: "stadium_mlb_bos",
uuid: fenwayUUID,
name: "Fenway Park",
city: "Boston",
state: "MA",
latitude: 42.3467,
longitude: -71.0972,
capacity: 37755,
yearOpened: 1912,
sport: "mlb"
)
// Create Wrigley Field stadium
let wrigley = CanonicalStadium(
canonicalId: "stadium_mlb_chc",
uuid: wrigleyUUID,
name: "Wrigley Field",
city: "Chicago",
state: "IL",
latitude: 41.9484,
longitude: -87.6553,
capacity: 41649,
yearOpened: 1914,
sport: "mlb"
)
// Create MSG stadium
let msg = CanonicalStadium(
canonicalId: "stadium_nba_nyk",
uuid: msgUUID,
name: "Madison Square Garden",
city: "New York",
state: "NY",
latitude: 40.7505,
longitude: -73.9934,
capacity: 19812,
yearOpened: 1968,
sport: "nba"
)
modelContext.insert(fenway)
modelContext.insert(wrigley)
modelContext.insert(msg)
// Create Red Sox team (plays at Fenway)
let redSox = CanonicalTeam(
canonicalId: "team_mlb_bos",
name: "Red Sox",
abbreviation: "BOS",
sport: "mlb",
city: "Boston",
stadiumCanonicalId: "stadium_mlb_bos"
)
// Create Cubs team (plays at Wrigley)
let cubs = CanonicalTeam(
canonicalId: "team_mlb_chc",
name: "Cubs",
abbreviation: "CHC",
sport: "mlb",
city: "Chicago",
stadiumCanonicalId: "stadium_mlb_chc"
)
// Create Knicks team (plays at MSG)
let knicks = CanonicalTeam(
canonicalId: "team_nba_nyk",
name: "Knicks",
abbreviation: "NYK",
sport: "nba",
city: "New York",
stadiumCanonicalId: "stadium_nba_nyk"
)
modelContext.insert(redSox)
modelContext.insert(cubs)
modelContext.insert(knicks)
try? modelContext.save()
}
/// Test: Visit Fenway Park, check Green Monster achievement progress
func test_fenwayVisit_earnsGreenMonster() async throws {
// Create engine
let engine = AchievementEngine(modelContext: modelContext, dataProvider: AppDataProvider.shared)
// Create a visit to Fenway using the actual stadium UUID format
let visit = StadiumVisit(
canonicalStadiumId: fenwayUUID.uuidString,
stadiumUUID: fenwayUUID,
stadiumNameAtVisit: "Fenway Park",
visitDate: Date(),
sport: .mlb
)
modelContext.insert(visit)
try modelContext.save()
// Get progress
let progress = try await engine.getProgress()
// Find Green Monster achievement
let greenMonster = progress.first { $0.definition.id == "special_fenway" }
XCTAssertNotNil(greenMonster, "Green Monster achievement should be in progress list")
if let gm = greenMonster {
XCTAssertEqual(gm.currentProgress, 1, "Progress should be 1 after visiting Fenway")
XCTAssertEqual(gm.totalRequired, 1, "Total required should be 1")
XCTAssertTrue(gm.isEarned, "Green Monster should be earned after visiting Fenway")
}
}
/// Test: Visit Wrigley, check Ivy League achievement progress
func test_wrigleyVisit_earnsIvyLeague() async throws {
let engine = AchievementEngine(modelContext: modelContext, dataProvider: AppDataProvider.shared)
let visit = StadiumVisit(
canonicalStadiumId: wrigleyUUID.uuidString,
stadiumUUID: wrigleyUUID,
stadiumNameAtVisit: "Wrigley Field",
visitDate: Date(),
sport: .mlb
)
modelContext.insert(visit)
try modelContext.save()
let progress = try await engine.getProgress()
let ivyLeague = progress.first { $0.definition.id == "special_wrigley" }
XCTAssertNotNil(ivyLeague, "Ivy League achievement should exist")
XCTAssertTrue(ivyLeague!.isEarned, "Ivy League should be earned after visiting Wrigley")
}
/// Test: No visits, specificStadium achievements show 0 progress
func test_noVisits_showsZeroProgress() async throws {
let engine = AchievementEngine(modelContext: modelContext, dataProvider: AppDataProvider.shared)
let progress = try await engine.getProgress()
let greenMonster = progress.first { $0.definition.id == "special_fenway" }
XCTAssertNotNil(greenMonster)
XCTAssertEqual(greenMonster!.currentProgress, 0, "Progress should be 0 with no visits")
XCTAssertFalse(greenMonster!.isEarned, "Should not be earned with no visits")
}
/// Test: First visit achievement
func test_firstVisit_earnsAchievement() async throws {
let engine = AchievementEngine(modelContext: modelContext, dataProvider: AppDataProvider.shared)
// No visits - first visit not earned
var progress = try await engine.getProgress()
var firstVisit = progress.first { $0.definition.id == "first_visit" }
XCTAssertFalse(firstVisit!.isEarned, "First visit should not be earned initially")
// Add a visit
let visit = StadiumVisit(
canonicalStadiumId: fenwayUUID.uuidString,
stadiumUUID: fenwayUUID,
stadiumNameAtVisit: "Fenway Park",
visitDate: Date(),
sport: .mlb
)
modelContext.insert(visit)
try modelContext.save()
// Now first visit should be earned
progress = try await engine.getProgress()
firstVisit = progress.first { $0.definition.id == "first_visit" }
XCTAssertTrue(firstVisit!.isEarned, "First visit should be earned after any visit")
}
/// Test: Multiple leagues achievement
func test_multipleLeagues_requiresThreeSports() async throws {
let engine = AchievementEngine(modelContext: modelContext, dataProvider: AppDataProvider.shared)
// Add TD Garden for NHL
let tdGardenUUID = UUID()
let tdGarden = CanonicalStadium(
canonicalId: "stadium_nhl_bos",
uuid: tdGardenUUID,
name: "TD Garden",
city: "Boston",
state: "MA",
latitude: 42.3662,
longitude: -71.0621,
capacity: 17850,
yearOpened: 1995,
sport: "nhl"
)
modelContext.insert(tdGarden)
let bruins = CanonicalTeam(
canonicalId: "team_nhl_bos",
name: "Bruins",
abbreviation: "BOS",
sport: "nhl",
city: "Boston",
stadiumCanonicalId: "stadium_nhl_bos"
)
modelContext.insert(bruins)
try modelContext.save()
// Reload data provider
await AppDataProvider.shared.loadInitialData()
// Visit MLB stadium only - not enough
let mlbVisit = StadiumVisit(
canonicalStadiumId: fenwayUUID.uuidString,
stadiumUUID: fenwayUUID,
stadiumNameAtVisit: "Fenway Park",
visitDate: Date(),
sport: .mlb
)
modelContext.insert(mlbVisit)
try modelContext.save()
var progress = try await engine.getProgress()
var tripleThreat = progress.first { $0.definition.id == "journey_triple_threat" }
XCTAssertEqual(tripleThreat!.currentProgress, 1, "Should have 1 league after MLB visit")
XCTAssertFalse(tripleThreat!.isEarned, "Triple threat needs 3 leagues")
// Visit NBA stadium - still not enough
let nbaVisit = StadiumVisit(
canonicalStadiumId: msgUUID.uuidString,
stadiumUUID: msgUUID,
stadiumNameAtVisit: "MSG",
visitDate: Date(),
sport: .nba
)
modelContext.insert(nbaVisit)
try modelContext.save()
progress = try await engine.getProgress()
tripleThreat = progress.first { $0.definition.id == "journey_triple_threat" }
XCTAssertEqual(tripleThreat!.currentProgress, 2, "Should have 2 leagues")
XCTAssertFalse(tripleThreat!.isEarned, "Triple threat needs 3 leagues")
// Visit NHL stadium - now earned!
let nhlVisit = StadiumVisit(
canonicalStadiumId: tdGardenUUID.uuidString,
stadiumUUID: tdGardenUUID,
stadiumNameAtVisit: "TD Garden",
visitDate: Date(),
sport: .nhl
)
modelContext.insert(nhlVisit)
try modelContext.save()
progress = try await engine.getProgress()
tripleThreat = progress.first { $0.definition.id == "journey_triple_threat" }
XCTAssertEqual(tripleThreat!.currentProgress, 3, "Should have 3 leagues")
XCTAssertTrue(tripleThreat!.isEarned, "Triple threat should be earned with 3 leagues")
}
}

View File

@@ -3,3 +3,17 @@
7. build ios in app purchase for storekit 7. build ios in app purchase for storekit
8. build complete receipt checking system to check if the user has purchased any subscriptions setting a value that can easily be checked anywhere in the app 8. build complete receipt checking system to check if the user has purchased any subscriptions setting a value that can easily be checked anywhere in the app
9. use frontend-design skill to redesign the app 9. use frontend-design skill to redesign the app
Issue: Follow team start/end city does not work
Issue: By game gives error date range required but no date range shows
Issue: Date range should always show current selected shit
Issue: By dates with Chicago selected as must stop doesnt find any game
Issue: Im not sure scenario is updating when switched
Issue: The follow team start/end should have the same lookup as must see
Issue: Need to add most cities as sort on all trips views
Issue: Must stop needs to be home team. Just did a search with Chicago as must stop and it had Chi as away team
Issue: Coast to coast on home should filter by most stops
Issue: Importing photo from iPhone, taken with iPhone does not import meta data
Issue: No map should be movable on any screen. It should show North America only and should not be movable
Issue: In schedule view today should be highlighted
Issue: Redesign all loading screens in the app - current loading spinner is ugly and feels unpolished

157
docs/PLAN-REMOVE-CBB.md Normal file
View File

@@ -0,0 +1,157 @@
# Plan: Remove College Basketball (CBB) from iOS App
**Status**: Complete
**Created**: 2026-01-11
**Last Updated**: 2026-01-11
---
## Overview
**Goal**: Permanently remove all traces of College Basketball (CBB) from the SportsTime iOS app.
**Reason**: Too many games (~5,000+ per season with ~360 Division I teams) creates complexity without sufficient user value.
**Scope**:
- Remove CBB from Sport enum and all related properties
- Remove CBB theme colors
- Remove CBB from documentation
- No backend/CloudKit changes required
- No saved trip migration needed (no users have CBB trips)
---
## Pre-Implementation Checklist
- [ ] Confirm no active users have saved trips with CBB games
- [ ] Verify CloudKit has no CBB data that needs cleanup
---
## Phase 1: Core Model Removal
**Status**: Not Started
Remove CBB from the Sport enum and its associated properties.
| Status | Task | File | Details |
|--------|------|------|---------|
| [ ] | 1.1 Remove `.cbb` enum case | `Core/Models/Domain/Sport.swift` | Line 17: `case cbb = "CBB"` |
| [ ] | 1.2 Remove CBB displayName | `Core/Models/Domain/Sport.swift` | Line 30: `"College Basketball"` in displayName computed property |
| [ ] | 1.3 Remove CBB iconName | `Core/Models/Domain/Sport.swift` | Line 43: `"basketball.fill"` in iconName computed property |
| [ ] | 1.4 Remove CBB color | `Core/Models/Domain/Sport.swift` | Line 56: `.mint` in color computed property |
| [ ] | 1.5 Remove CBB seasonMonths | `Core/Models/Domain/Sport.swift` | Line 70: `(11, 4)` in seasonMonths computed property |
| [ ] | 1.6 Remove CBB from `Sport.supported` | `Core/Models/Domain/Sport.swift` | Line 90: Remove `.cbb` from supported array |
**Verification**: Build succeeds with no compiler errors about missing switch cases.
---
## Phase 2: Theme Cleanup
**Status**: Not Started
Remove CBB-specific theme colors and view modifier mappings.
| Status | Task | File | Details |
|--------|------|------|---------|
| [ ] | 2.1 Remove `cbbMint` color constant | `Core/Theme/Theme.swift` | Line 131: `static let cbbMint = Color(hex: "3EB489")` |
| [ ] | 2.2 Remove CBB case from `themeColor` | `Core/Theme/ViewModifiers.swift` | Line 220: `case .cbb: return Theme.cbbMint` |
**Verification**: Build succeeds, no references to `Theme.cbbMint` remain.
---
## Phase 3: Documentation Updates
**Status**: Not Started
Update all documentation to reflect CBB removal.
| Status | Task | File | Details |
|--------|------|------|---------|
| [ ] | 3.1 Remove CBB from Supported Leagues table | `README.md` | Line 29: Remove CBB row from table |
| [ ] | 3.2 Update league count in features | `README.md` | Line 7: Change "8 professional and college leagues" to reflect new count |
| [ ] | 3.3 Remove CBB section from data scraping docs | `docs/DATA_SCRAPING.md` | Lines 114-125: Remove entire CBB section |
| [ ] | 3.4 Update TO-DOS.md | `TO-DOS.md` | Remove or mark complete task #10 |
**Verification**: Documentation accurately reflects supported sports.
---
## Phase 4: Verification & Testing
**Status**: Not Started
Ensure the app builds, runs, and all tests pass.
| Status | Task | Command/Action | Expected Result |
|--------|------|----------------|-----------------|
| [ ] | 4.1 Full build | `xcodebuild ... build` | BUILD SUCCEEDED |
| [ ] | 4.2 Run all tests | `xcodebuild ... test` | All tests pass |
| [ ] | 4.3 Manual UI verification | Launch in Simulator | CBB does not appear in any sport picker, filter, or settings |
| [ ] | 4.4 Verify Sport.supported count | Code inspection | Array has correct number of sports (7 remaining) |
**Build Command**:
```bash
xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' build
```
**Test Command**:
```bash
xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' test
```
---
## Phase 5: Commit & Cleanup
**Status**: Not Started
| Status | Task | Details |
|--------|------|---------|
| [ ] | 5.1 Stage all changes | `git add -A` |
| [ ] | 5.2 Create commit | Message: `chore: remove college basketball (CBB) from iOS app` |
| [ ] | 5.3 Push to remote | If on feature branch |
| [ ] | 5.4 Mark plan complete | Update this file's status to "Complete" |
---
## Files Changed Summary
| File | Change Type | Lines Affected |
|------|-------------|----------------|
| `Core/Models/Domain/Sport.swift` | Modify | Remove CBB from enum + 5 computed properties |
| `Core/Theme/Theme.swift` | Modify | Remove 1 color constant |
| `Core/Theme/ViewModifiers.swift` | Modify | Remove 1 switch case |
| `README.md` | Modify | Update table + feature count |
| `docs/DATA_SCRAPING.md` | Modify | Remove ~12 lines (CBB section) |
| `TO-DOS.md` | Modify | Remove/update task #10 |
**Total**: 6 files
---
## Rollback Plan
If issues arise, CBB can be restored by:
1. `git revert <commit-hash>` the removal commit
2. No data migration needed (CBB had no data)
---
## Notes
- Views using `Sport.supported` will automatically exclude CBB once removed from the array
- No CBB-specific views, view models, or tests exist
- Python scraper already has no CBB implementation
- No bundled JSON contains CBB data
---
## Progress Log
| Date | Phase | Notes |
|------|-------|-------|
| 2026-01-11 | Planning | Plan created |