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:
21
.claude/commands/read-to-do.md
Normal file
21
.claude/commands/read-to-do.md
Normal 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.
|
||||||
@@ -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)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 */;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ struct Team: Identifiable, Codable, Hashable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var fullName: String {
|
var fullName: String {
|
||||||
"\(city) \(name)"
|
city.isEmpty ? name : "\(city) \(name)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
309
SportsTime/Core/Services/HistoricalGameScraper.swift
Normal file
309
SportsTime/Core/Services/HistoricalGameScraper.swift
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
486
SportsTimeTests/Progress/AchievementEngineTests.swift
Normal file
486
SportsTimeTests/Progress/AchievementEngineTests.swift
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
16
TO-DOS.md
16
TO-DOS.md
@@ -2,4 +2,18 @@
|
|||||||
|
|
||||||
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 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
|
||||||
157
docs/PLAN-REMOVE-CBB.md
Normal file
157
docs/PLAN-REMOVE-CBB.md
Normal 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 |
|
||||||
|
|
||||||
Reference in New Issue
Block a user