diff --git a/.claude/commands/read-to-do.md b/.claude/commands/read-to-do.md new file mode 100644 index 0000000..4695391 --- /dev/null +++ b/.claude/commands/read-to-do.md @@ -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. diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 853d28f..3388174 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -11,7 +11,11 @@ "Bash(xcrun simctl install:*)", "Skill(frontend-design:frontend-design)", "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)" ] } } diff --git a/SportsTime.xcodeproj/project.pbxproj b/SportsTime.xcodeproj/project.pbxproj index eb5df82..698993f 100644 --- a/SportsTime.xcodeproj/project.pbxproj +++ b/SportsTime.xcodeproj/project.pbxproj @@ -6,6 +6,10 @@ objectVersion = 77; objects = { +/* Begin PBXBuildFile section */ + 1CC750E42F148BA4007D870A /* SwiftSoup in Frameworks */ = {isa = PBXBuildFile; productRef = 1CC750E32F148BA4007D870A /* SwiftSoup */; }; +/* End PBXBuildFile section */ + /* Begin PBXContainerItemProxy section */ 1CA7F9052F0D647300490ABD /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; @@ -65,6 +69,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 1CC750E42F148BA4007D870A /* SwiftSoup in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -91,6 +96,7 @@ 1CA7F8F52F0D647100490ABD /* SportsTime */, 1CA7F9072F0D647300490ABD /* SportsTimeTests */, 1CA7F9112F0D647400490ABD /* SportsTimeUITests */, + 1CC750E22F148BA4007D870A /* Frameworks */, 1CA7F8F42F0D647100490ABD /* Products */, ); sourceTree = ""; @@ -105,6 +111,13 @@ name = Products; sourceTree = ""; }; + 1CC750E22F148BA4007D870A /* Frameworks */ = { + isa = PBXGroup; + children = ( + ); + name = Frameworks; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -125,6 +138,7 @@ ); name = SportsTime; packageProductDependencies = ( + 1CC750E32F148BA4007D870A /* SwiftSoup */, ); productName = SportsTime; productReference = 1CA7F8F32F0D647100490ABD /* SportsTime.app */; @@ -208,6 +222,9 @@ ); mainGroup = 1CA7F8EA2F0D647100490ABD; minimizedProjectReferenceProxies = 1; + packageReferences = ( + 1CC750E12F1487A8007D870A /* XCRemoteSwiftPackageReference "SwiftSoup" */, + ); preferredProjectObjectVersion = 77; productRefGroup = 1CA7F8F42F0D647100490ABD /* Products */; projectDirPath = ""; @@ -597,6 +614,25 @@ defaultConfigurationName = Release; }; /* 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 */; } diff --git a/SportsTime/Core/Models/Domain/Progress.swift b/SportsTime/Core/Models/Domain/Progress.swift index ffc1b9b..ee24978 100644 --- a/SportsTime/Core/Models/Domain/Progress.swift +++ b/SportsTime/Core/Models/Domain/Progress.swift @@ -118,11 +118,18 @@ struct VisitSummary: Identifiable { let visitDate: Date let visitType: VisitType let sport: Sport - let matchup: String? + let homeTeamName: String? + let awayTeamName: String? let score: String? let photoCount: Int 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 { let formatter = DateFormatter() formatter.dateStyle = .medium diff --git a/SportsTime/Core/Models/Domain/Team.swift b/SportsTime/Core/Models/Domain/Team.swift index a0ac4c4..4dc982e 100644 --- a/SportsTime/Core/Models/Domain/Team.swift +++ b/SportsTime/Core/Models/Domain/Team.swift @@ -39,7 +39,7 @@ struct Team: Identifiable, Codable, Hashable { } var fullName: String { - "\(city) \(name)" + city.isEmpty ? name : "\(city) \(name)" } } diff --git a/SportsTime/Core/Services/AchievementEngine.swift b/SportsTime/Core/Services/AchievementEngine.swift index 605ae12..5e2cccf 100644 --- a/SportsTime/Core/Services/AchievementEngine.swift +++ b/SportsTime/Core/Services/AchievementEngine.swift @@ -165,14 +165,14 @@ final class AchievementEngine { visitedStadiumIds: visitedStadiumIds ) - let isEarned = earnedIds.contains(definition.id) + let hasStoredAchievement = earnedIds.contains(definition.id) let earnedAt = earnedAchievements.first(where: { $0.achievementTypeId == definition.id })?.earnedAt progress.append(AchievementProgress( definition: definition, currentProgress: current, totalRequired: total, - isEarned: isEarned, + hasStoredAchievement: hasStoredAchievement, earnedAt: earnedAt )) } @@ -214,11 +214,41 @@ final class AchievementEngine { case .multipleLeagues(let leagueCount): return checkMultipleLeagues(visits: visits, requiredLeagues: leagueCount) - case .specificStadium(let stadiumId): - return visitedStadiumIds.contains(stadiumId) + case .specificStadium(let symbolicId): + // 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) -> Bool { 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) }) return (leagues.count, leagueCount) - case .specificStadium(let stadiumId): - return (visitedStadiumIds.contains(stadiumId) ? 1 : 0, 1) + case .specificStadium(let symbolicId): + // 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 [] - case .specificStadium(let stadiumId): - return visits.filter { $0.canonicalStadiumId == stadiumId }.map { $0.id } + case .specificStadium(let symbolicId): + // 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 private func getStadiumIdsForDivision(_ divisionId: String) -> [String] { - // Get teams in division, then their stadiums - let teams = dataProvider.teams.filter { team in - // Match division by checking team's division assignment - // This would normally come from CanonicalTeam.divisionId - // For now, return based on division structure - return false // Will be populated when division data is linked + // Query CanonicalTeam to find teams in this division + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.divisionId == divisionId && $0.deprecatedAt == nil } + ) + + guard let canonicalTeams = try? modelContext.fetch(descriptor) else { + return [] } - // For now, return hardcoded counts based on typical division sizes - // This should be replaced with actual team-to-stadium mapping - return [] + // Get stadium UUIDs for these teams + // CanonicalTeam has stadiumCanonicalId, we need to find the corresponding Stadium UUID + 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] { @@ -391,7 +434,7 @@ final class AchievementEngine { } 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 .filter { stadium in // Check if stadium hosts teams of this sport @@ -399,7 +442,7 @@ final class AchievementEngine { team.stadiumId == stadium.id && team.sport == sport } } - .map { "stadium_\(sport.rawValue.lowercased())_\($0.id.uuidString)" } + .map { $0.id.uuidString } } // MARK: - Data Fetching @@ -425,11 +468,17 @@ struct AchievementProgress: Identifiable { let definition: AchievementDefinition let currentProgress: Int let totalRequired: Int - let isEarned: Bool + let hasStoredAchievement: Bool // Whether an Achievement record exists in SwiftData let earnedAt: Date? 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 { guard totalRequired > 0 else { return 0 } return Double(currentProgress) / Double(totalRequired) diff --git a/SportsTime/Core/Services/GameMatcher.swift b/SportsTime/Core/Services/GameMatcher.swift index bb8cac2..fa0caab 100644 --- a/SportsTime/Core/Services/GameMatcher.swift +++ b/SportsTime/Core/Services/GameMatcher.swift @@ -50,6 +50,10 @@ struct GameMatchCandidate: Identifiable, Sendable { let awayTeam: Team 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) { self.id = game.id self.game = game @@ -57,6 +61,57 @@ struct GameMatchCandidate: Identifiable, Sendable { self.homeTeam = homeTeam self.awayTeam = awayTeam 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 { @@ -67,6 +122,18 @@ struct GameMatchCandidate: Identifiable, Sendable { "\(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 { let formatter = DateFormatter() formatter.dateStyle = .medium @@ -220,6 +287,14 @@ final class GameMatcher { metadata: PhotoMetadata, sport: Sport? = nil ) 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 var stadiumMatches: [StadiumMatch] = [] if let coordinates = metadata.coordinates { @@ -227,9 +302,53 @@ final class GameMatcher { coordinates: coordinates, 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( metadata: metadata, diff --git a/SportsTime/Core/Services/HistoricalGameScraper.swift b/SportsTime/Core/Services/HistoricalGameScraper.swift new file mode 100644 index 0000000..298fb94 --- /dev/null +++ b/SportsTime/Core/Services/HistoricalGameScraper.swift @@ -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() + } +} diff --git a/SportsTime/Core/Services/PhotoMetadataExtractor.swift b/SportsTime/Core/Services/PhotoMetadataExtractor.swift index 55bf9cc..1119c97 100644 --- a/SportsTime/Core/Services/PhotoMetadataExtractor.swift +++ b/SportsTime/Core/Services/PhotoMetadataExtractor.swift @@ -43,18 +43,42 @@ actor PhotoMetadataExtractor { /// Extract metadata from PHAsset (preferred method) /// Uses PHAsset's location and creationDate properties 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 let coordinates: CLLocationCoordinate2D? if let location = asset.location { coordinates = location.coordinate + print("πŸ“Έ [PhotoMetadata] βœ… Location found: \(coordinates!.latitude), \(coordinates!.longitude)") } else { 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, coordinates: coordinates ) + print("πŸ“Έ [PhotoMetadata] Final result - hasDate: \(result.hasValidDate), hasLocation: \(result.hasValidLocation)") + print("πŸ“Έ [PhotoMetadata] ════════════════════════════════════════") + return result } /// Extract metadata from multiple PHAssets @@ -72,14 +96,58 @@ actor PhotoMetadataExtractor { /// Extract metadata from raw image data using ImageIO /// Useful when PHAsset is not available func extractMetadata(from imageData: Data) -> PhotoMetadata { - guard let source = CGImageSourceCreateWithData(imageData as CFData, nil), - let properties = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any] else { + print("πŸ“Έ [ImageIO] Extracting from image data (\(imageData.count) bytes)") + + guard let source = CGImageSourceCreateWithData(imageData as CFData, nil) else { + print("πŸ“Έ [ImageIO] ❌ Failed to create CGImageSource") 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 coordinates = extractCoordinates(from: properties) + print("πŸ“Έ [ImageIO] Extracted date: \(captureDate?.description ?? "nil")") + print("πŸ“Έ [ImageIO] Extracted coordinates: \(coordinates?.latitude ?? 0), \(coordinates?.longitude ?? 0)") + return PhotoMetadata( captureDate: captureDate, coordinates: coordinates diff --git a/SportsTime/Features/Progress/ViewModels/PhotoImportViewModel.swift b/SportsTime/Features/Progress/ViewModels/PhotoImportViewModel.swift index 17451da..e90291a 100644 --- a/SportsTime/Features/Progress/ViewModels/PhotoImportViewModel.swift +++ b/SportsTime/Features/Progress/ViewModels/PhotoImportViewModel.swift @@ -47,6 +47,9 @@ final class PhotoImportViewModel { func processSelectedPhotos(_ items: [PhotosPickerItem]) async { guard !items.isEmpty else { return } + print("πŸ“· [PhotoImport] ════════════════════════════════════════════════") + print("πŸ“· [PhotoImport] Starting photo import with \(items.count) items") + isProcessing = true totalCount = items.count processedCount = 0 @@ -57,33 +60,69 @@ final class PhotoImportViewModel { // Load PHAssets from PhotosPickerItems 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 { let fetchResult = PHAsset.fetchAssets(withLocalIdentifiers: [assetId], options: nil) + print("πŸ“· [PhotoImport] PHAsset fetch result count: \(fetchResult.count)") + 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) + } else { + print("πŸ“· [PhotoImport] ⚠️ No PHAsset found for identifier") } + } else { + print("πŸ“· [PhotoImport] ⚠️ No itemIdentifier on PhotosPickerItem") } processedCount += 1 } + print("πŸ“· [PhotoImport] ────────────────────────────────────────────────") + print("πŸ“· [PhotoImport] Loaded \(assets.count) PHAssets, extracting metadata...") + // Extract metadata from all 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 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) processedPhotos.append(candidate) // Auto-confirm high-confidence matches if candidate.canAutoProcess { confirmedImports.insert(candidate.id) + print("πŸ“· [PhotoImport] βœ… Auto-confirmed match") } processedCount += 1 } + print("πŸ“· [PhotoImport] ════════════════════════════════════════════════") + print("πŸ“· [PhotoImport] Import complete: \(processedPhotos.count) photos, \(confirmedImports.count) auto-confirmed") + isProcessing = false } @@ -143,8 +182,8 @@ final class PhotoImportViewModel { visitType: .game, homeTeamName: match.homeTeam.fullName, awayTeamName: match.awayTeam.fullName, - finalScore: nil, - scoreSource: nil, + finalScore: match.formattedFinalScore, + scoreSource: match.formattedFinalScore != nil ? .scraped : nil, dataSource: .automatic, seatLocation: nil, notes: nil, diff --git a/SportsTime/Features/Progress/ViewModels/ProgressViewModel.swift b/SportsTime/Features/Progress/ViewModels/ProgressViewModel.swift index 5a84d69..d9e56f2 100644 --- a/SportsTime/Features/Progress/ViewModels/ProgressViewModel.swift +++ b/SportsTime/Features/Progress/ViewModels/ProgressViewModel.swift @@ -77,7 +77,8 @@ final class ProgressViewModel { visitDate: visit.visitDate, visitType: visit.visitType, sport: selectedSport, - matchup: visit.matchupDescription, + homeTeamName: visit.homeTeamName, + awayTeamName: visit.awayTeamName, score: visit.finalScore, photoCount: visit.photoMetadata?.count ?? 0, notes: visit.notes @@ -123,7 +124,8 @@ final class ProgressViewModel { visitDate: visit.visitDate, visitType: visit.visitType, sport: sport, - matchup: visit.matchupDescription, + homeTeamName: visit.homeTeamName, + awayTeamName: visit.awayTeamName, score: visit.finalScore, photoCount: visit.photoMetadata?.count ?? 0, notes: visit.notes diff --git a/SportsTime/Features/Progress/Views/AchievementsListView.swift b/SportsTime/Features/Progress/Views/AchievementsListView.swift index f58ffc9..6eb6005 100644 --- a/SportsTime/Features/Progress/Views/AchievementsListView.swift +++ b/SportsTime/Features/Progress/Views/AchievementsListView.swift @@ -14,7 +14,7 @@ struct AchievementsListView: View { @State private var achievements: [AchievementProgress] = [] @State private var isLoading = true - @State private var selectedCategory: AchievementCategory? + @State private var selectedSport: Sport? // nil = All sports @State private var selectedAchievement: AchievementProgress? var body: some View { @@ -24,8 +24,8 @@ struct AchievementsListView: View { achievementSummary .staggeredAnimation(index: 0) - // Category filter - categoryFilter + // Sport filter + sportFilter .staggeredAnimation(index: 1) // Achievements grid @@ -48,27 +48,58 @@ struct AchievementsListView: View { // MARK: - Achievement Summary private var achievementSummary: some View { - let earned = achievements.filter { $0.isEarned }.count - let total = achievements.count + // Use filtered achievements to show relevant counts + 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) { - // Trophy icon + // Trophy icon with progress ring ZStack { + // Background circle Circle() - .fill(Theme.warmOrange.opacity(0.15)) - .frame(width: 70, height: 70) + .stroke(Theme.cardBackgroundElevated(colorScheme), lineWidth: 6) + .frame(width: 76, height: 76) - Image(systemName: "trophy.fill") - .font(.system(size: 32)) - .foregroundStyle(Theme.warmOrange) + // Progress ring + Circle() + .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) { - Text("\(earned) / \(total)") - .font(.largeTitle) - .foregroundStyle(Theme.textPrimary(colorScheme)) + HStack(alignment: .firstTextBaseline, spacing: 4) { + Text("\(earned)") + .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) .foregroundStyle(Theme.textSecondary(colorScheme)) @@ -78,7 +109,14 @@ struct AchievementsListView: View { Text("All achievements unlocked!") } .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)) .overlay { 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) { HStack(spacing: Theme.Spacing.sm) { - CategoryFilterButton( + // All sports + SportFilterButton( title: "All", - icon: "square.grid.2x2", - isSelected: selectedCategory == nil + icon: "star.fill", + color: Theme.warmOrange, + isSelected: selectedSport == nil ) { withAnimation(Theme.Animation.spring) { - selectedCategory = nil + selectedSport = nil } } - ForEach(AchievementCategory.allCases, id: \.self) { category in - CategoryFilterButton( - title: category.displayName, - icon: category.iconName, - isSelected: selectedCategory == category + // Sport-specific filters + ForEach(Sport.supported) { sport in + SportFilterButton( + title: sport.displayName, + icon: sport.iconName, + color: sport.themeColor, + isSelected: selectedSport == sport ) { withAnimation(Theme.Animation.spring) { - selectedCategory = category + selectedSport = sport } } } @@ -143,22 +185,26 @@ struct AchievementsListView: View { } private var filteredAchievements: [AchievementProgress] { - guard let category = selectedCategory else { - return achievements.sorted { first, second in - // Earned first, then by progress - if first.isEarned != second.isEarned { - return first.isEarned - } - return first.progressPercentage > second.progressPercentage + let filtered: [AchievementProgress] + + if let sport = selectedSport { + // Filter to achievements for this sport only + filtered = achievements.filter { achievement in + // Include if achievement is sport-specific and matches, OR if it's cross-sport (nil) + achievement.definition.sport == sport } + } else { + // "All" - show all achievements + filtered = achievements } - return achievements.filter { $0.definition.category == category } - .sorted { first, second in - if first.isEarned != second.isEarned { - return first.isEarned - } - return first.progressPercentage > second.progressPercentage + + return filtered.sorted { first, second in + // Earned first, then by progress percentage + if first.isEarned != second.isEarned { + return first.isEarned } + return first.progressPercentage > second.progressPercentage + } } // 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 icon: String + let color: Color let isSelected: Bool let action: () -> Void @@ -193,15 +240,16 @@ struct CategoryFilterButton: View { .font(.subheadline) Text(title) .font(.subheadline) + .fontWeight(isSelected ? .semibold : .regular) } .padding(.horizontal, Theme.Spacing.md) .padding(.vertical, Theme.Spacing.sm) - .background(isSelected ? Theme.warmOrange : Theme.cardBackground(colorScheme)) + .background(isSelected ? color : Theme.cardBackground(colorScheme)) .foregroundStyle(isSelected ? .white : Theme.textPrimary(colorScheme)) .clipShape(Capsule()) .overlay { Capsule() - .stroke(isSelected ? Color.clear : Theme.surfaceGlow(colorScheme), lineWidth: 1) + .stroke(isSelected ? Color.clear : color.opacity(0.3), lineWidth: 1) } } .buttonStyle(.plain) @@ -215,6 +263,9 @@ struct AchievementCard: View { @Environment(\.colorScheme) private var colorScheme + // Gold color for completed achievements + private let completedGold = Color(hex: "FFD700") + var body: some View { VStack(spacing: Theme.Spacing.sm) { // Badge icon @@ -223,6 +274,13 @@ struct AchievementCard: View { .fill(badgeBackgroundColor) .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) .font(.system(size: 28)) .foregroundStyle(badgeIconColor) @@ -241,6 +299,7 @@ struct AchievementCard: View { // Title Text(achievement.definition.name) .font(.subheadline) + .fontWeight(achievement.isEarned ? .semibold : .regular) .foregroundStyle(achievement.isEarned ? Theme.textPrimary(colorScheme) : Theme.textMuted(colorScheme)) .multilineTextAlignment(.center) .lineLimit(2) @@ -248,16 +307,22 @@ struct AchievementCard: View { // Progress or earned date if achievement.isEarned { - if let earnedAt = achievement.earnedAt { - Text(earnedAt.formatted(date: .abbreviated, time: .omitted)) + HStack(spacing: 4) { + Image(systemName: "checkmark.seal.fill") .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 { // Progress bar VStack(spacing: 4) { ProgressView(value: achievement.progressPercentage) - .progressViewStyle(AchievementProgressStyle()) + .progressViewStyle(AchievementProgressStyle(category: achievement.definition.category)) Text(achievement.progressText) .font(.caption) @@ -267,26 +332,26 @@ struct AchievementCard: View { } .padding(Theme.Spacing.md) .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)) .overlay { 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) } private var badgeBackgroundColor: Color { if achievement.isEarned { - return categoryColor.opacity(0.2) + return completedGold.opacity(0.2) } return Theme.cardBackgroundElevated(colorScheme) } private var badgeIconColor: Color { if achievement.isEarned { - return categoryColor + return completedGold } return Theme.textMuted(colorScheme) } @@ -312,21 +377,44 @@ struct AchievementCard: View { // MARK: - Achievement Progress Style struct AchievementProgressStyle: ProgressViewStyle { + let category: AchievementCategory @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 { GeometryReader { geometry in ZStack(alignment: .leading) { - RoundedRectangle(cornerRadius: 2) + RoundedRectangle(cornerRadius: 3) .fill(Theme.cardBackgroundElevated(colorScheme)) - .frame(height: 4) + .frame(height: 6) - RoundedRectangle(cornerRadius: 2) - .fill(Theme.warmOrange) - .frame(width: geometry.size.width * CGFloat(configuration.fractionCompleted ?? 0), height: 4) + RoundedRectangle(cornerRadius: 3) + .fill(progressGradient) + .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(\.dismiss) private var dismiss + // Gold color for completed achievements + private let completedGold = Color(hex: "FFD700") + var body: some View { NavigationStack { VStack(spacing: Theme.Spacing.xl) { @@ -348,9 +439,18 @@ struct AchievementDetailSheet: View { .frame(width: 120, height: 120) if achievement.isEarned { + // Gold ring with glow effect Circle() - .stroke(Theme.warmOrange, lineWidth: 4) + .stroke( + LinearGradient( + colors: [completedGold, Color(hex: "FFA500")], + startPoint: .topLeading, + endPoint: .bottomTrailing + ), + lineWidth: 5 + ) .frame(width: 130, height: 130) + .shadow(color: completedGold.opacity(0.5), radius: 10) } Image(systemName: achievement.definition.iconName) @@ -372,7 +472,8 @@ struct AchievementDetailSheet: View { VStack(spacing: Theme.Spacing.sm) { Text(achievement.definition.name) .font(.title2) - .foregroundStyle(Theme.textPrimary(colorScheme)) + .fontWeight(achievement.isEarned ? .bold : .regular) + .foregroundStyle(achievement.isEarned ? completedGold : Theme.textPrimary(colorScheme)) Text(achievement.definition.description) .font(.body) @@ -385,26 +486,33 @@ struct AchievementDetailSheet: View { Text(achievement.definition.category.displayName) } .font(.subheadline) - .foregroundStyle(categoryColor) + .foregroundStyle(achievement.isEarned ? completedGold : categoryColor) .padding(.horizontal, Theme.Spacing.sm) .padding(.vertical, Theme.Spacing.xs) - .background(categoryColor.opacity(0.15)) + .background((achievement.isEarned ? completedGold : categoryColor).opacity(0.15)) .clipShape(Capsule()) } // Status section if achievement.isEarned { - if let earnedAt = achievement.earnedAt { - VStack(spacing: 4) { - Image(systemName: "checkmark.seal.fill") - .font(.system(size: 24)) - .foregroundStyle(.green) + VStack(spacing: 8) { + Image(systemName: "checkmark.seal.fill") + .font(.system(size: 32)) + .foregroundStyle(completedGold) + if let earnedAt = achievement.earnedAt { Text("Earned on \(earnedAt.formatted(date: .long, time: .omitted))") .font(.body) .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 { // Progress section VStack(spacing: Theme.Spacing.sm) { @@ -450,14 +558,14 @@ struct AchievementDetailSheet: View { private var badgeBackgroundColor: Color { if achievement.isEarned { - return categoryColor.opacity(0.2) + return completedGold.opacity(0.2) } return Theme.cardBackgroundElevated(colorScheme) } private var badgeIconColor: Color { if achievement.isEarned { - return categoryColor + return completedGold } return Theme.textMuted(colorScheme) } @@ -485,19 +593,27 @@ struct AchievementDetailSheet: View { struct LargeProgressStyle: ProgressViewStyle { @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 { GeometryReader { geometry in ZStack(alignment: .leading) { RoundedRectangle(cornerRadius: 4) .fill(Theme.cardBackgroundElevated(colorScheme)) - .frame(height: 8) + .frame(height: 10) RoundedRectangle(cornerRadius: 4) - .fill(Theme.warmOrange) - .frame(width: geometry.size.width * CGFloat(configuration.fractionCompleted ?? 0), height: 8) + .fill(progressGradient) + .frame(width: geometry.size.width * CGFloat(configuration.fractionCompleted ?? 0), height: 10) } } - .frame(height: 8) + .frame(height: 10) } } diff --git a/SportsTime/Features/Progress/Views/GameMatchConfirmationView.swift b/SportsTime/Features/Progress/Views/GameMatchConfirmationView.swift index 48e1af2..1bff8c0 100644 --- a/SportsTime/Features/Progress/Views/GameMatchConfirmationView.swift +++ b/SportsTime/Features/Progress/Views/GameMatchConfirmationView.swift @@ -215,7 +215,7 @@ struct GameMatchConfirmationView: View { HStack { VStack(alignment: .leading, spacing: 4) { HStack { - Text(match.matchupDescription) + Text(match.fullMatchupDescription) .font(.body) .foregroundStyle(Theme.textPrimary(colorScheme)) diff --git a/SportsTime/Features/Progress/Views/PhotoImportView.swift b/SportsTime/Features/Progress/Views/PhotoImportView.swift index 6c42342..6578547 100644 --- a/SportsTime/Features/Progress/Views/PhotoImportView.swift +++ b/SportsTime/Features/Progress/Views/PhotoImportView.swift @@ -433,7 +433,7 @@ struct PhotoImportCandidateCard: View { private func matchRow(_ match: GameMatchCandidate) -> some View { VStack(alignment: .leading, spacing: 4) { HStack { - Text(match.matchupDescription) + Text(match.fullMatchupDescription) .font(.body) .foregroundStyle(Theme.textPrimary(colorScheme)) diff --git a/SportsTime/Features/Progress/Views/ProgressTabView.swift b/SportsTime/Features/Progress/Views/ProgressTabView.swift index 14d6755..eda05bb 100644 --- a/SportsTime/Features/Progress/Views/ProgressTabView.swift +++ b/SportsTime/Features/Progress/Views/ProgressTabView.swift @@ -477,11 +477,13 @@ struct RecentVisitRow: View { .font(.body) .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) - if let matchup = visit.matchup { - Text("β€’") - Text(matchup) + if let away = visit.awayTeamName, let home = visit.homeTeamName { + Text(away) + Text("@") + Text(home) } } .font(.subheadline) @@ -490,15 +492,6 @@ struct RecentVisitRow: View { Spacer() - if visit.photoCount > 0 { - HStack(spacing: 4) { - Image(systemName: "photo") - Text("\(visit.photoCount)") - } - .font(.caption) - .foregroundStyle(Theme.textMuted(colorScheme)) - } - Image(systemName: "chevron.right") .font(.caption) .foregroundStyle(Theme.textMuted(colorScheme)) diff --git a/SportsTime/Features/Progress/Views/StadiumVisitSheet.swift b/SportsTime/Features/Progress/Views/StadiumVisitSheet.swift index a464011..f1761e0 100644 --- a/SportsTime/Features/Progress/Views/StadiumVisitSheet.swift +++ b/SportsTime/Features/Progress/Views/StadiumVisitSheet.swift @@ -33,6 +33,8 @@ struct StadiumVisitSheet: View { // UI state @State private var showStadiumPicker = 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 showAwayTeamSuggestions = false @State private var showHomeTeamSuggestions = false @@ -220,10 +222,36 @@ struct StadiumVisitSheet: View { .frame(width: 50) .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: { Text("Game Info") } 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)) } @@ -320,6 +348,36 @@ struct StadiumVisitSheet: View { // 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() { guard let stadium = selectedStadium else { errorMessage = "Please select a stadium" @@ -340,8 +398,8 @@ struct StadiumVisitSheet: View { homeTeamName: homeTeamName.isEmpty ? nil : homeTeamName, awayTeamName: awayTeamName.isEmpty ? nil : awayTeamName, finalScore: finalScoreString, - scoreSource: finalScoreString != nil ? .user : nil, - dataSource: .fullyManual, + scoreSource: finalScoreString != nil ? (scoreFromScraper ? .scraped : .user) : nil, + dataSource: scoreFromScraper ? .automatic : .fullyManual, seatLocation: seatLocation.isEmpty ? nil : seatLocation, notes: notes.isEmpty ? nil : notes, source: .manual diff --git a/SportsTime/Features/Progress/Views/VisitDetailView.swift b/SportsTime/Features/Progress/Views/VisitDetailView.swift index d903cb3..5ad359f 100644 --- a/SportsTime/Features/Progress/Views/VisitDetailView.swift +++ b/SportsTime/Features/Progress/Views/VisitDetailView.swift @@ -194,25 +194,25 @@ struct VisitDetailView: View { } if let matchup = visit.matchupDescription { - HStack { + VStack(alignment: .leading, spacing: 4) { Text("Matchup") .foregroundStyle(Theme.textSecondary(colorScheme)) - Spacer() Text(matchup) .foregroundStyle(Theme.textPrimary(colorScheme)) .fontWeight(.medium) } + .frame(maxWidth: .infinity, alignment: .leading) } if let score = visit.finalScore { - HStack { + VStack(alignment: .leading, spacing: 4) { Text("Final Score") .foregroundStyle(Theme.textSecondary(colorScheme)) - Spacer() Text(score) .foregroundStyle(Theme.textPrimary(colorScheme)) .fontWeight(.bold) } + .frame(maxWidth: .infinity, alignment: .leading) } } .font(.body) @@ -238,42 +238,42 @@ struct VisitDetailView: View { } // Date - HStack { + VStack(alignment: .leading, spacing: 4) { Text("Date") .foregroundStyle(Theme.textSecondary(colorScheme)) - Spacer() Text(formattedDate) .foregroundStyle(Theme.textPrimary(colorScheme)) } + .frame(maxWidth: .infinity, alignment: .leading) // Seat location if let seat = visit.seatLocation, !seat.isEmpty { - HStack { + VStack(alignment: .leading, spacing: 4) { Text("Seat") .foregroundStyle(Theme.textSecondary(colorScheme)) - Spacer() Text(seat) .foregroundStyle(Theme.textPrimary(colorScheme)) } + .frame(maxWidth: .infinity, alignment: .leading) } // Source - HStack { + VStack(alignment: .leading, spacing: 4) { Text("Source") .foregroundStyle(Theme.textSecondary(colorScheme)) - Spacer() Text(visit.source.displayName) .foregroundStyle(Theme.textMuted(colorScheme)) } + .frame(maxWidth: .infinity, alignment: .leading) // Created date - HStack { + VStack(alignment: .leading, spacing: 4) { Text("Logged") .foregroundStyle(Theme.textSecondary(colorScheme)) - Spacer() Text(formattedCreatedDate) .foregroundStyle(Theme.textMuted(colorScheme)) } + .frame(maxWidth: .infinity, alignment: .leading) } .font(.body) .padding(Theme.Spacing.lg) diff --git a/SportsTimeTests/Progress/AchievementEngineTests.swift b/SportsTimeTests/Progress/AchievementEngineTests.swift new file mode 100644 index 0000000..b4a2045 --- /dev/null +++ b/SportsTimeTests/Progress/AchievementEngineTests.swift @@ -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") + } +} diff --git a/TO-DOS.md b/TO-DOS.md index 6effa93..7e91c8a 100644 --- a/TO-DOS.md +++ b/TO-DOS.md @@ -2,4 +2,18 @@ 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 -9. use frontend-design skill to redesign the app \ No newline at end of file +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 doesn’t find any game +Issue: I’m 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 \ No newline at end of file diff --git a/docs/PLAN-REMOVE-CBB.md b/docs/PLAN-REMOVE-CBB.md new file mode 100644 index 0000000..4245dc6 --- /dev/null +++ b/docs/PLAN-REMOVE-CBB.md @@ -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 ` 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 | +