diff --git a/ios/OpenClimb.xcodeproj/project.pbxproj b/ios/OpenClimb.xcodeproj/project.pbxproj index dd8de9b..ace6167 100644 --- a/ios/OpenClimb.xcodeproj/project.pbxproj +++ b/ios/OpenClimb.xcodeproj/project.pbxproj @@ -465,7 +465,7 @@ CODE_SIGN_ENTITLEMENTS = OpenClimb/OpenClimb.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 21; + CURRENT_PROJECT_VERSION = 22; DEVELOPMENT_TEAM = 4BC9Y2LL4B; DRIVERKIT_DEPLOYMENT_TARGET = 24.6; ENABLE_PREVIEWS = YES; @@ -513,7 +513,7 @@ CODE_SIGN_ENTITLEMENTS = OpenClimb/OpenClimb.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 21; + CURRENT_PROJECT_VERSION = 22; DEVELOPMENT_TEAM = 4BC9Y2LL4B; DRIVERKIT_DEPLOYMENT_TARGET = 24.6; ENABLE_PREVIEWS = YES; @@ -602,7 +602,7 @@ ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; CODE_SIGN_ENTITLEMENTS = SessionStatusLiveExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 21; + CURRENT_PROJECT_VERSION = 22; DEVELOPMENT_TEAM = 4BC9Y2LL4B; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = SessionStatusLive/Info.plist; @@ -632,7 +632,7 @@ ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; CODE_SIGN_ENTITLEMENTS = SessionStatusLiveExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 21; + CURRENT_PROJECT_VERSION = 22; DEVELOPMENT_TEAM = 4BC9Y2LL4B; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = SessionStatusLive/Info.plist; diff --git a/ios/OpenClimb.xcodeproj/project.xcworkspace/xcuserdata/atridad.xcuserdatad/UserInterfaceState.xcuserstate b/ios/OpenClimb.xcodeproj/project.xcworkspace/xcuserdata/atridad.xcuserdatad/UserInterfaceState.xcuserstate index d7dfe85..a1c7f67 100644 Binary files a/ios/OpenClimb.xcodeproj/project.xcworkspace/xcuserdata/atridad.xcuserdatad/UserInterfaceState.xcuserstate and b/ios/OpenClimb.xcodeproj/project.xcworkspace/xcuserdata/atridad.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/ios/OpenClimb/ViewModels/ClimbingDataManager.swift b/ios/OpenClimb/ViewModels/ClimbingDataManager.swift index 1617dd1..3ff23c5 100644 --- a/ios/OpenClimb/ViewModels/ClimbingDataManager.swift +++ b/ios/OpenClimb/ViewModels/ClimbingDataManager.swift @@ -96,6 +96,9 @@ class ClimbingDataManager: ObservableObject { loadSessions() loadAttempts() loadActiveSession() + + // Clean up orphaned data after loading + cleanupOrphanedData() } private func loadGyms() { @@ -286,7 +289,16 @@ class ClimbingDataManager: ObservableObject { } func deleteProblem(_ problem: Problem) { - // Delete associated attempts first + // Track deletion of the problem + trackDeletion(itemId: problem.id.uuidString, itemType: "problem") + + // Find and track all attempts for this problem as deleted + let problemAttempts = attempts.filter { $0.problemId == problem.id } + for attempt in problemAttempts { + trackDeletion(itemId: attempt.id.uuidString, itemType: "attempt") + } + + // Delete associated attempts attempts.removeAll { $0.problemId == problem.id } saveAttempts() @@ -295,7 +307,6 @@ class ClimbingDataManager: ObservableObject { // Delete the problem problems.removeAll { $0.id == problem.id } - trackDeletion(itemId: problem.id.uuidString, itemType: "problem") saveProblems() DataStateManager.shared.updateDataState() @@ -410,7 +421,16 @@ class ClimbingDataManager: ObservableObject { } func deleteSession(_ session: ClimbSession) { - // Delete associated attempts first + // Track deletion of the session + trackDeletion(itemId: session.id.uuidString, itemType: "session") + + // Find and track all attempts for this session as deleted + let sessionAttempts = attempts.filter { $0.sessionId == session.id } + for attempt in sessionAttempts { + trackDeletion(itemId: attempt.id.uuidString, itemType: "attempt") + } + + // Delete associated attempts attempts.removeAll { $0.sessionId == session.id } saveAttempts() @@ -422,7 +442,6 @@ class ClimbingDataManager: ObservableObject { // Delete the session sessions.removeAll { $0.id == session.id } - trackDeletion(itemId: session.id.uuidString, itemType: "session") saveSessions() DataStateManager.shared.updateDataState() @@ -548,6 +567,162 @@ class ClimbingDataManager: ObservableObject { return gym(withId: mostUsedGymId) } + /// Clean up orphaned data - removes attempts that reference non-existent sessions + /// and removes duplicate attempts. This ensures data integrity and prevents + /// orphaned attempts from appearing in widgets + private func cleanupOrphanedData() { + let validSessionIds = Set(sessions.map { $0.id }) + let validProblemIds = Set(problems.map { $0.id }) + let validGymIds = Set(gyms.map { $0.id }) + + let initialAttemptCount = attempts.count + + // Remove attempts that reference deleted sessions or problems + let orphanedAttempts = attempts.filter { attempt in + !validSessionIds.contains(attempt.sessionId) + || !validProblemIds.contains(attempt.problemId) + } + + if !orphanedAttempts.isEmpty { + print("🧹 Cleaning up \(orphanedAttempts.count) orphaned attempts") + + // Track these as deleted to prevent sync from re-introducing them + for attempt in orphanedAttempts { + trackDeletion(itemId: attempt.id.uuidString, itemType: "attempt") + } + + // Remove orphaned attempts + attempts.removeAll { attempt in + !validSessionIds.contains(attempt.sessionId) + || !validProblemIds.contains(attempt.problemId) + } + } + + // Remove duplicate attempts (same session, problem, and timestamp within 1 second) + var seenAttempts: Set = [] + var duplicateIds: [UUID] = [] + + for attempt in attempts.sorted(by: { $0.timestamp < $1.timestamp }) { + // Create a unique key based on session, problem, and rounded timestamp + let timestampKey = Int(attempt.timestamp.timeIntervalSince1970) + let key = + "\(attempt.sessionId.uuidString)_\(attempt.problemId.uuidString)_\(timestampKey)" + + if seenAttempts.contains(key) { + duplicateIds.append(attempt.id) + print("🧹 Found duplicate attempt: \(attempt.id)") + } else { + seenAttempts.insert(key) + } + } + + if !duplicateIds.isEmpty { + print("🧹 Removing \(duplicateIds.count) duplicate attempts") + + // Track duplicates as deleted + for attemptId in duplicateIds { + trackDeletion(itemId: attemptId.uuidString, itemType: "attempt") + } + + // Remove duplicates + attempts.removeAll { duplicateIds.contains($0.id) } + } + + if initialAttemptCount != attempts.count { + saveAttempts() + let removedCount = initialAttemptCount - attempts.count + print( + "✅ Cleanup complete. Removed \(removedCount) attempts. Remaining: \(attempts.count)" + ) + } + + // Remove problems that reference deleted gyms + let orphanedProblems = problems.filter { problem in + !validGymIds.contains(problem.gymId) + } + + if !orphanedProblems.isEmpty { + print("🧹 Cleaning up \(orphanedProblems.count) orphaned problems") + + for problem in orphanedProblems { + trackDeletion(itemId: problem.id.uuidString, itemType: "problem") + } + + problems.removeAll { problem in + !validGymIds.contains(problem.gymId) + } + + saveProblems() + } + + // Remove sessions that reference deleted gyms + let orphanedSessions = sessions.filter { session in + !validGymIds.contains(session.gymId) + } + + if !orphanedSessions.isEmpty { + print("🧹 Cleaning up \(orphanedSessions.count) orphaned sessions") + + for session in orphanedSessions { + trackDeletion(itemId: session.id.uuidString, itemType: "session") + } + + sessions.removeAll { session in + !validGymIds.contains(session.gymId) + } + + saveSessions() + } + } + + /// Validate data integrity and return a report + /// This can be called manually to check for issues + func validateDataIntegrity() -> String { + let validSessionIds = Set(sessions.map { $0.id }) + let validProblemIds = Set(problems.map { $0.id }) + let validGymIds = Set(gyms.map { $0.id }) + + let orphanedAttempts = attempts.filter { attempt in + !validSessionIds.contains(attempt.sessionId) + || !validProblemIds.contains(attempt.problemId) + } + + let orphanedProblems = problems.filter { problem in + !validGymIds.contains(problem.gymId) + } + + let orphanedSessions = sessions.filter { session in + !validGymIds.contains(session.gymId) + } + + var report = "Data Integrity Report:\n" + report += "---------------------\n" + report += "Gyms: \(gyms.count)\n" + report += "Problems: \(problems.count)\n" + report += "Sessions: \(sessions.count)\n" + report += "Attempts: \(attempts.count)\n" + report += "\nOrphaned Data:\n" + report += "Orphaned Attempts: \(orphanedAttempts.count)\n" + report += "Orphaned Problems: \(orphanedProblems.count)\n" + report += "Orphaned Sessions: \(orphanedSessions.count)\n" + + if orphanedAttempts.isEmpty && orphanedProblems.isEmpty && orphanedSessions.isEmpty { + report += "\n✅ No integrity issues found" + } else { + report += "\n⚠️ Issues found - run cleanup to fix" + } + + return report + } + + /// Manually trigger cleanup of orphaned data + /// This can be called from settings or debug menu + func manualDataCleanup() { + cleanupOrphanedData() + successMessage = "Data cleanup completed" + clearMessageAfterDelay() + } + func resetAllData(showSuccessMessage: Bool = true) { gyms.removeAll() problems.removeAll() diff --git a/ios/OpenClimbTests/OpenClimbTests.swift b/ios/OpenClimbTests/OpenClimbTests.swift index 51b4159..104c622 100644 --- a/ios/OpenClimbTests/OpenClimbTests.swift +++ b/ios/OpenClimbTests/OpenClimbTests.swift @@ -326,4 +326,203 @@ final class OpenClimbTests: XCTestCase { XCTAssertTrue(hasActiveSession, "Combined sessions should contain active session") XCTAssertTrue(hasCompletedSession, "Combined sessions should contain completed session") } + + // MARK: - Orphaned Data Cleanup Tests + + func testOrphanedAttemptDetection() throws { + // Test that we can detect orphaned attempts (attempts referencing non-existent sessions) + + let validSessionId = UUID() + let deletedSessionId = UUID() + let validProblemId = UUID() + + // Simulate a list of valid sessions + let validSessions = [validSessionId] + + // Simulate attempts - one valid, one orphaned + let validAttempt: [String: Any] = [ + "id": UUID().uuidString, + "sessionId": validSessionId.uuidString, + "problemId": validProblemId.uuidString, + "result": "completed", + ] + + let orphanedAttempt: [String: Any] = [ + "id": UUID().uuidString, + "sessionId": deletedSessionId.uuidString, + "problemId": validProblemId.uuidString, + "result": "completed", + ] + + let allAttempts = [validAttempt, orphanedAttempt] + + // Filter to find orphaned attempts + let orphaned = allAttempts.filter { attempt in + guard let sessionIdString = attempt["sessionId"] as? String, + let sessionId = UUID(uuidString: sessionIdString) + else { + return false + } + return !validSessions.contains(sessionId) + } + + XCTAssertEqual(orphaned.count, 1, "Should detect exactly one orphaned attempt") + XCTAssertEqual(orphaned[0]["sessionId"] as? String, deletedSessionId.uuidString) + } + + func testOrphanedAttemptRemoval() throws { + // Test that orphaned attempts can be properly removed from a list + + let validSessionId = UUID() + let deletedSessionId = UUID() + let problemId = UUID() + + let validSessions = Set([validSessionId]) + + // Create test attempts + var attempts: [[String: Any]] = [ + [ + "id": UUID().uuidString, + "sessionId": validSessionId.uuidString, + "problemId": problemId.uuidString, + "result": "completed", + ], + [ + "id": UUID().uuidString, + "sessionId": deletedSessionId.uuidString, + "problemId": problemId.uuidString, + "result": "failed", + ], + [ + "id": UUID().uuidString, + "sessionId": validSessionId.uuidString, + "problemId": problemId.uuidString, + "result": "flash", + ], + ] + + XCTAssertEqual(attempts.count, 3, "Should start with 3 attempts") + + // Remove orphaned attempts + attempts.removeAll { attempt in + guard let sessionIdString = attempt["sessionId"] as? String, + let sessionId = UUID(uuidString: sessionIdString) + else { + return true + } + return !validSessions.contains(sessionId) + } + + XCTAssertEqual(attempts.count, 2, "Should have 2 attempts after cleanup") + + // Verify remaining attempts are all valid + for attempt in attempts { + if let sessionIdString = attempt["sessionId"] as? String, + let sessionId = UUID(uuidString: sessionIdString) + { + XCTAssertTrue( + validSessions.contains(sessionId), + "All remaining attempts should reference valid sessions") + } + } + } + + func testCascadeDeleteSessionWithAttempts() throws { + // Test that deleting a session properly tracks all its attempts as deleted + + let sessionId = UUID() + let problemId = UUID() + + // Create attempts for this session + let sessionAttempts: [[String: Any]] = [ + [ + "id": UUID().uuidString, "sessionId": sessionId.uuidString, + "problemId": problemId.uuidString, + ], + [ + "id": UUID().uuidString, "sessionId": sessionId.uuidString, + "problemId": problemId.uuidString, + ], + [ + "id": UUID().uuidString, "sessionId": sessionId.uuidString, + "problemId": problemId.uuidString, + ], + ] + + XCTAssertEqual(sessionAttempts.count, 3, "Session should have 3 attempts") + + // Simulate tracking deletions + var deletedItems: [String] = [] + + // Add session to deleted items + deletedItems.append(sessionId.uuidString) + + // Add all attempts to deleted items + for attempt in sessionAttempts { + if let attemptId = attempt["id"] as? String { + deletedItems.append(attemptId) + } + } + + XCTAssertEqual(deletedItems.count, 4, "Should track 1 session + 3 attempts as deleted") + XCTAssertTrue(deletedItems.contains(sessionId.uuidString), "Should track session deletion") + + // Verify all attempt IDs are tracked + let attemptIds = sessionAttempts.compactMap { $0["id"] as? String } + for attemptId in attemptIds { + XCTAssertTrue( + deletedItems.contains(attemptId), "Should track attempt \(attemptId) deletion") + } + } + + func testDataIntegrityValidation() throws { + // Test data integrity validation logic + + let gymId = UUID() + let sessionId = UUID() + let problemId = UUID() + + // Valid data setup + let gyms = [gymId] + let sessions = [(id: sessionId, gymId: gymId)] + let problems = [(id: problemId, gymId: gymId)] + let attempts = [ + (id: UUID(), sessionId: sessionId, problemId: problemId), + (id: UUID(), sessionId: sessionId, problemId: problemId), + ] + + // Validate that all relationships are correct + let validGyms = Set(gyms) + let validSessions = Set(sessions.map { $0.id }) + let validProblems = Set(problems.map { $0.id }) + + // Check sessions reference valid gyms + for session in sessions { + XCTAssertTrue(validGyms.contains(session.gymId), "Session should reference valid gym") + } + + // Check problems reference valid gyms + for problem in problems { + XCTAssertTrue(validGyms.contains(problem.gymId), "Problem should reference valid gym") + } + + // Check attempts reference valid sessions and problems + for attempt in attempts { + XCTAssertTrue( + validSessions.contains(attempt.sessionId), "Attempt should reference valid session") + XCTAssertTrue( + validProblems.contains(attempt.problemId), "Attempt should reference valid problem") + } + + // Test integrity check passes + let hasOrphanedSessions = sessions.contains { !validGyms.contains($0.gymId) } + let hasOrphanedProblems = problems.contains { !validGyms.contains($0.gymId) } + let hasOrphanedAttempts = attempts.contains { + !validSessions.contains($0.sessionId) || !validProblems.contains($0.problemId) + } + + XCTAssertFalse(hasOrphanedSessions, "Should not have orphaned sessions") + XCTAssertFalse(hasOrphanedProblems, "Should not have orphaned problems") + XCTAssertFalse(hasOrphanedAttempts, "Should not have orphaned attempts") + } }