diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index e3932d8..7f9b74c 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -16,7 +16,7 @@ android { applicationId = "com.atridad.ascently" minSdk = 31 targetSdk = 36 - versionCode = 44 + versionCode = 45 versionName = "2.2.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" diff --git a/android/app/src/main/java/com/atridad/ascently/data/sync/SyncService.kt b/android/app/src/main/java/com/atridad/ascently/data/sync/SyncService.kt index 66f4cf8..21121af 100644 --- a/android/app/src/main/java/com/atridad/ascently/data/sync/SyncService.kt +++ b/android/app/src/main/java/com/atridad/ascently/data/sync/SyncService.kt @@ -368,9 +368,22 @@ class SyncService(private val context: Context, private val repository: ClimbRep repository.setAutoSyncCallback(null) try { + // Merge and apply deletions first to prevent resurrection + val allDeletions = repository.getDeletedItems() + response.deletedItems + val uniqueDeletions = allDeletions.distinctBy { "${it.type}:${it.id}" } + + Log.d(TAG, "Applying ${uniqueDeletions.size} deletion records before merging data") + applyDeletions(uniqueDeletions) + + // Build deleted item lookup set + val deletedItemSet = uniqueDeletions.map { "${it.type}:${it.id}" }.toSet() + // Download images for new/modified problems from server val imagePathMapping = mutableMapOf() for (problem in response.problems) { + if (deletedItemSet.contains("problem:${problem.id}")) { + continue + } problem.imagePaths?.forEach { imagePath -> val serverFilename = imagePath.substringAfterLast('/') try { @@ -384,9 +397,12 @@ class SyncService(private val context: Context, private val repository: ClimbRep } } - // Merge gyms - check if exists and compare timestamps + // Merge gyms val existingGyms = repository.getAllGyms().first() for (backupGym in response.gyms) { + if (deletedItemSet.contains("gym:${backupGym.id}")) { + continue + } val existing = existingGyms.find { it.id == backupGym.id } if (existing == null || backupGym.updatedAt >= existing.updatedAt) { val gym = backupGym.toGym() @@ -401,6 +417,9 @@ class SyncService(private val context: Context, private val repository: ClimbRep // Merge problems val existingProblems = repository.getAllProblems().first() for (backupProblem in response.problems) { + if (deletedItemSet.contains("problem:${backupProblem.id}")) { + continue + } val updatedImagePaths = backupProblem.imagePaths?.map { oldPath -> imagePathMapping[oldPath] ?: oldPath @@ -421,6 +440,9 @@ class SyncService(private val context: Context, private val repository: ClimbRep // Merge sessions val existingSessions = repository.getAllSessions().first() for (backupSession in response.sessions) { + if (deletedItemSet.contains("session:${backupSession.id}")) { + continue + } val session = backupSession.toClimbSession() val existing = existingSessions.find { it.id == backupSession.id } if (existing == null || backupSession.updatedAt >= existing.updatedAt) { @@ -435,6 +457,9 @@ class SyncService(private val context: Context, private val repository: ClimbRep // Merge attempts val existingAttempts = repository.getAllAttempts().first() for (backupAttempt in response.attempts) { + if (deletedItemSet.contains("attempt:${backupAttempt.id}")) { + continue + } val attempt = backupAttempt.toAttempt() val existing = existingAttempts.find { it.id == backupAttempt.id } if (existing == null || backupAttempt.createdAt >= existing.createdAt) { @@ -446,15 +471,12 @@ class SyncService(private val context: Context, private val repository: ClimbRep } } - // Apply deletions - applyDeletions(response.deletedItems) + // Apply deletions again for safety + applyDeletions(uniqueDeletions) // Update deletion records - val allDeletions = repository.getDeletedItems() + response.deletedItems repository.clearDeletedItems() - allDeletions.distinctBy { "${it.type}:${it.id}" }.forEach { - repository.trackDeletion(it.id, it.type) - } + uniqueDeletions.forEach { repository.trackDeletion(it.id, it.type) } } finally { // Re-enable auto-sync repository.setAutoSyncCallback { serviceScope.launch { triggerAutoSync() } } @@ -542,7 +564,7 @@ class SyncService(private val context: Context, private val repository: ClimbRep Request.Builder() .url("$serverUrl/sync") .header("Authorization", "Bearer $authToken") - .post(requestBody) + .put(requestBody) .build() withContext(Dispatchers.IO) { diff --git a/android/app/src/main/java/com/atridad/ascently/ui/screens/DetailScreens.kt b/android/app/src/main/java/com/atridad/ascently/ui/screens/DetailScreens.kt index 411c147..2a91619 100644 --- a/android/app/src/main/java/com/atridad/ascently/ui/screens/DetailScreens.kt +++ b/android/app/src/main/java/com/atridad/ascently/ui/screens/DetailScreens.kt @@ -31,6 +31,7 @@ import androidx.compose.ui.window.Dialog import com.atridad.ascently.data.model.* import com.atridad.ascently.ui.components.FullscreenImageViewer import com.atridad.ascently.ui.components.ImageDisplaySection +import com.atridad.ascently.ui.components.ImagePicker import com.atridad.ascently.ui.theme.CustomIcons import com.atridad.ascently.ui.viewmodel.ClimbViewModel import com.atridad.ascently.utils.DateFormatUtils @@ -1489,6 +1490,7 @@ fun EnhancedAddAttemptDialog( // New problem creation state var newProblemName by remember { mutableStateOf("") } var newProblemGrade by remember { mutableStateOf("") } + var newProblemImagePaths by remember { mutableStateOf>(emptyList()) } var selectedClimbType by remember { mutableStateOf(ClimbType.BOULDER) } var selectedDifficultySystem by remember { mutableStateOf(gym.difficultySystems.firstOrNull() ?: DifficultySystem.V_SCALE) @@ -1690,7 +1692,14 @@ fun EnhancedAddAttemptDialog( color = MaterialTheme.colorScheme.onSurface ) - IconButton(onClick = { showCreateProblem = false }) { + IconButton( + onClick = { + showCreateProblem = false + newProblemName = "" + newProblemGrade = "" + newProblemImagePaths = emptyList() + } + ) { Icon( Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back", @@ -1905,6 +1914,21 @@ fun EnhancedAddAttemptDialog( } } } + + // Photos Section + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + text = "Photos (Optional)", + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurface + ) + ImagePicker( + imageUris = newProblemImagePaths, + onImagesChanged = { newProblemImagePaths = it }, + maxImages = 5 + ) + } } } } @@ -2069,7 +2093,9 @@ fun EnhancedAddAttemptDialog( null }, climbType = selectedClimbType, - difficulty = difficulty + difficulty = difficulty, + imagePaths = + newProblemImagePaths ) onProblemCreated(newProblem) @@ -2087,6 +2113,12 @@ fun EnhancedAddAttemptDialog( notes = notes.ifBlank { null } ) onAttemptAdded(attempt) + + // Reset form + newProblemName = "" + newProblemGrade = "" + newProblemImagePaths = emptyList() + showCreateProblem = false } } else { // Create attempt for selected problem diff --git a/android/app/src/main/java/com/atridad/ascently/ui/screens/SessionsScreen.kt b/android/app/src/main/java/com/atridad/ascently/ui/screens/SessionsScreen.kt index d5655bc..3987255 100644 --- a/android/app/src/main/java/com/atridad/ascently/ui/screens/SessionsScreen.kt +++ b/android/app/src/main/java/com/atridad/ascently/ui/screens/SessionsScreen.kt @@ -338,18 +338,6 @@ fun CalendarView( } Column(modifier = Modifier.fillMaxSize()) { - if (activeSession != null && activeSessionGym != null) { - Column(modifier = Modifier.padding(horizontal = 16.dp)) { - ActiveSessionBanner( - activeSession = activeSession, - gym = activeSessionGym, - onSessionClick = { onNavigateToSessionDetail(activeSession.id) }, - onEndSession = onEndSession - ) - } - Spacer(modifier = Modifier.height(8.dp)) - } - Card( modifier = Modifier.fillMaxWidth(), colors = diff --git a/ios/Ascently.xcodeproj/project.pbxproj b/ios/Ascently.xcodeproj/project.pbxproj index 2fe4f62..b0bb1cd 100644 --- a/ios/Ascently.xcodeproj/project.pbxproj +++ b/ios/Ascently.xcodeproj/project.pbxproj @@ -465,7 +465,7 @@ CODE_SIGN_ENTITLEMENTS = Ascently/Ascently.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 29; + CURRENT_PROJECT_VERSION = 30; DEVELOPMENT_TEAM = 4BC9Y2LL4B; DRIVERKIT_DEPLOYMENT_TARGET = 24.6; ENABLE_PREVIEWS = YES; @@ -513,7 +513,7 @@ CODE_SIGN_ENTITLEMENTS = Ascently/Ascently.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 29; + CURRENT_PROJECT_VERSION = 30; 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 = 29; + CURRENT_PROJECT_VERSION = 30; 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 = 29; + CURRENT_PROJECT_VERSION = 30; DEVELOPMENT_TEAM = 4BC9Y2LL4B; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = SessionStatusLive/Info.plist; diff --git a/ios/Ascently.xcodeproj/project.xcworkspace/xcuserdata/atridad.xcuserdatad/UserInterfaceState.xcuserstate b/ios/Ascently.xcodeproj/project.xcworkspace/xcuserdata/atridad.xcuserdatad/UserInterfaceState.xcuserstate index 347dabb..70d93e7 100644 Binary files a/ios/Ascently.xcodeproj/project.xcworkspace/xcuserdata/atridad.xcuserdatad/UserInterfaceState.xcuserstate and b/ios/Ascently.xcodeproj/project.xcworkspace/xcuserdata/atridad.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/ios/Ascently/Services/SyncService.swift b/ios/Ascently/Services/SyncService.swift index 617d356..07e416c 100644 --- a/ios/Ascently/Services/SyncService.swift +++ b/ios/Ascently/Services/SyncService.swift @@ -266,9 +266,25 @@ class SyncService: ObservableObject { { let formatter = ISO8601DateFormatter() + // Merge and apply deletions first to prevent resurrection + let allDeletions = dataManager.getDeletedItems() + response.deletedItems + let uniqueDeletions = Array(Set(allDeletions)) + + print( + "iOS DELTA SYNC: Applying \(uniqueDeletions.count) deletion records before merging data" + ) + applyDeletionsToDataManager(deletions: uniqueDeletions, dataManager: dataManager) + + // Build deleted item lookup map + let deletedItemSet = Set(uniqueDeletions.map { $0.type + ":" + $0.id }) + // Download images for new/modified problems from server var imagePathMapping: [String: String] = [:] for problem in response.problems { + if deletedItemSet.contains("problem:" + problem.id) { + continue + } + guard let imagePaths = problem.imagePaths, !imagePaths.isEmpty else { continue } for (index, imagePath) in imagePaths.enumerated() { @@ -293,6 +309,10 @@ class SyncService: ObservableObject { // Merge gyms for backupGym in response.gyms { + if deletedItemSet.contains("gym:" + backupGym.id) { + continue + } + if let index = dataManager.gyms.firstIndex(where: { $0.id.uuidString == backupGym.id }) { let existing = dataManager.gyms[index] @@ -306,6 +326,10 @@ class SyncService: ObservableObject { // Merge problems for backupProblem in response.problems { + if deletedItemSet.contains("problem:" + backupProblem.id) { + continue + } + var problemToMerge = backupProblem if !imagePathMapping.isEmpty, let imagePaths = backupProblem.imagePaths { let updatedPaths = imagePaths.compactMap { imagePathMapping[$0] ?? $0 } @@ -341,6 +365,10 @@ class SyncService: ObservableObject { // Merge sessions for backupSession in response.sessions { + if deletedItemSet.contains("session:" + backupSession.id) { + continue + } + if let index = dataManager.sessions.firstIndex(where: { $0.id.uuidString == backupSession.id }) { @@ -355,6 +383,10 @@ class SyncService: ObservableObject { // Merge attempts for backupAttempt in response.attempts { + if deletedItemSet.contains("attempt:" + backupAttempt.id) { + continue + } + if let index = dataManager.attempts.firstIndex(where: { $0.id.uuidString == backupAttempt.id }) { @@ -367,9 +399,7 @@ class SyncService: ObservableObject { } } - // Apply deletions - let allDeletions = dataManager.getDeletedItems() + response.deletedItems - let uniqueDeletions = Array(Set(allDeletions)) + // Apply deletions again for safety applyDeletionsToDataManager(deletions: uniqueDeletions, dataManager: dataManager) // Save all changes diff --git a/sync/main.go b/sync/main.go index 6872fa8..40632b7 100644 --- a/sync/main.go +++ b/sync/main.go @@ -13,7 +13,7 @@ import ( "time" ) -const VERSION = "2.1.0" +const VERSION = "2.2.0" func min(a, b int) int { if a < b { @@ -283,8 +283,16 @@ func (s *SyncServer) mergeDeletedItems(existing []DeletedItem, updates []Deleted } } + // Clean up tombstones older than 30 days to prevent unbounded growth + cutoffTime := time.Now().UTC().Add(-30 * 24 * time.Hour) result := make([]DeletedItem, 0, len(deletedMap)) for _, item := range deletedMap { + deletedTime, err := time.Parse(time.RFC3339, item.DeletedAt) + if err == nil && deletedTime.Before(cutoffTime) { + log.Printf("Cleaning up old deletion record: type=%s, id=%s, deletedAt=%s", + item.Type, item.ID, item.DeletedAt) + continue + } result = append(result, item) } return result @@ -533,15 +541,16 @@ func (s *SyncServer) handleDeltaSync(w http.ResponseWriter, r *http.Request) { return } + // Merge and apply deletions first to prevent resurrection + serverBackup.DeletedItems = s.mergeDeletedItems(serverBackup.DeletedItems, deltaRequest.DeletedItems) + s.applyDeletions(serverBackup, serverBackup.DeletedItems) + log.Printf("Applied deletions: total=%d deletion records", len(serverBackup.DeletedItems)) + // Merge client changes into server data serverBackup.Gyms = s.mergeGyms(serverBackup.Gyms, deltaRequest.Gyms) serverBackup.Problems = s.mergeProblems(serverBackup.Problems, deltaRequest.Problems) serverBackup.Sessions = s.mergeSessions(serverBackup.Sessions, deltaRequest.Sessions) serverBackup.Attempts = s.mergeAttempts(serverBackup.Attempts, deltaRequest.Attempts) - serverBackup.DeletedItems = s.mergeDeletedItems(serverBackup.DeletedItems, deltaRequest.DeletedItems) - - // Apply deletions to remove deleted items - s.applyDeletions(serverBackup, serverBackup.DeletedItems) // Save merged data if err := s.saveData(serverBackup); err != nil { @@ -553,8 +562,15 @@ func (s *SyncServer) handleDeltaSync(w http.ResponseWriter, r *http.Request) { // Parse client's last sync time clientLastSync, err := time.Parse(time.RFC3339, deltaRequest.LastSyncTime) if err != nil { - // If parsing fails, send everything clientLastSync = time.Time{} + log.Printf("Warning: Could not parse lastSyncTime '%s', sending all data", deltaRequest.LastSyncTime) + } + + // Build deleted item lookup map + deletedItemMap := make(map[string]bool) + for _, item := range serverBackup.DeletedItems { + key := item.Type + ":" + item.ID + deletedItemMap[key] = true } // Prepare response with items modified since client's last sync @@ -569,6 +585,9 @@ func (s *SyncServer) handleDeltaSync(w http.ResponseWriter, r *http.Request) { // Filter gyms modified after client's last sync for _, gym := range serverBackup.Gyms { + if deletedItemMap["gym:"+gym.ID] { + continue + } gymTime, err := time.Parse(time.RFC3339, gym.UpdatedAt) if err == nil && gymTime.After(clientLastSync) { response.Gyms = append(response.Gyms, gym) @@ -577,6 +596,9 @@ func (s *SyncServer) handleDeltaSync(w http.ResponseWriter, r *http.Request) { // Filter problems modified after client's last sync for _, problem := range serverBackup.Problems { + if deletedItemMap["problem:"+problem.ID] { + continue + } problemTime, err := time.Parse(time.RFC3339, problem.UpdatedAt) if err == nil && problemTime.After(clientLastSync) { response.Problems = append(response.Problems, problem) @@ -585,6 +607,9 @@ func (s *SyncServer) handleDeltaSync(w http.ResponseWriter, r *http.Request) { // Filter sessions modified after client's last sync for _, session := range serverBackup.Sessions { + if deletedItemMap["session:"+session.ID] { + continue + } sessionTime, err := time.Parse(time.RFC3339, session.UpdatedAt) if err == nil && sessionTime.After(clientLastSync) { response.Sessions = append(response.Sessions, session) @@ -593,6 +618,9 @@ func (s *SyncServer) handleDeltaSync(w http.ResponseWriter, r *http.Request) { // Filter attempts created after client's last sync for _, attempt := range serverBackup.Attempts { + if deletedItemMap["attempt:"+attempt.ID] { + continue + } attemptTime, err := time.Parse(time.RFC3339, attempt.CreatedAt) if err == nil && attemptTime.After(clientLastSync) { response.Attempts = append(response.Attempts, attempt) diff --git a/sync/sync_test.go b/sync/sync_test.go new file mode 100644 index 0000000..f0b0161 --- /dev/null +++ b/sync/sync_test.go @@ -0,0 +1,501 @@ +package main + +import ( + "path/filepath" + "testing" + "time" +) + +// TestDeltaSyncDeletedItemResurrection verifies deleted items don't resurrect +func TestDeltaSyncDeletedItemResurrection(t *testing.T) { + tempDir := t.TempDir() + server := &SyncServer{ + dataFile: filepath.Join(tempDir, "test.json"), + imagesDir: filepath.Join(tempDir, "images"), + authToken: "test-token", + } + + // Initial state: Server has one gym, one problem, one session with 8 attempts + now := time.Now().UTC() + gymID := "gym-1" + problemID := "problem-1" + sessionID := "session-1" + + initialBackup := &ClimbDataBackup{ + Version: "2.0", + FormatVersion: "2.0", + Gyms: []BackupGym{ + { + ID: gymID, + Name: "Test Gym", + SupportedClimbTypes: []string{"BOULDER"}, + DifficultySystems: []string{"V"}, + CreatedAt: now.Add(-1 * time.Hour).Format(time.RFC3339), + UpdatedAt: now.Add(-1 * time.Hour).Format(time.RFC3339), + }, + }, + Problems: []BackupProblem{ + { + ID: problemID, + GymID: gymID, + ClimbType: "BOULDER", + Difficulty: DifficultyGrade{ + System: "V", + Grade: "V5", + NumericValue: 5, + }, + IsActive: true, + CreatedAt: now.Add(-1 * time.Hour).Format(time.RFC3339), + UpdatedAt: now.Add(-1 * time.Hour).Format(time.RFC3339), + }, + }, + Sessions: []BackupClimbSession{ + { + ID: sessionID, + GymID: gymID, + Date: now.Format("2006-01-02"), + Status: "completed", + CreatedAt: now.Add(-30 * time.Minute).Format(time.RFC3339), + UpdatedAt: now.Add(-30 * time.Minute).Format(time.RFC3339), + }, + }, + Attempts: []BackupAttempt{}, + DeletedItems: []DeletedItem{}, + } + + // Add 8 attempts + for i := 0; i < 8; i++ { + attempt := BackupAttempt{ + ID: "attempt-" + string(rune('1'+i)), + SessionID: sessionID, + ProblemID: problemID, + Result: "COMPLETED", + Timestamp: now.Add(time.Duration(-25+i) * time.Minute).Format(time.RFC3339), + CreatedAt: now.Add(time.Duration(-25+i) * time.Minute).Format(time.RFC3339), + } + initialBackup.Attempts = append(initialBackup.Attempts, attempt) + } + + if err := server.saveData(initialBackup); err != nil { + t.Fatalf("Failed to save initial data: %v", err) + } + + // Client 1 syncs - gets all data + client1LastSync := now.Add(-2 * time.Hour).Format(time.RFC3339) + deltaRequest1 := DeltaSyncRequest{ + LastSyncTime: client1LastSync, + Gyms: []BackupGym{}, + Problems: []BackupProblem{}, + Sessions: []BackupClimbSession{}, + Attempts: []BackupAttempt{}, + DeletedItems: []DeletedItem{}, + } + + // Simulate delta sync for client 1 + serverBackup, _ := server.loadData() + serverBackup.DeletedItems = server.mergeDeletedItems(serverBackup.DeletedItems, deltaRequest1.DeletedItems) + server.applyDeletions(serverBackup, serverBackup.DeletedItems) + + if len(serverBackup.Sessions) != 1 { + t.Errorf("Expected 1 session after client1 sync, got %d", len(serverBackup.Sessions)) + } + if len(serverBackup.Attempts) != 8 { + t.Errorf("Expected 8 attempts after client1 sync, got %d", len(serverBackup.Attempts)) + } + + // Client 1 deletes the session locally + deleteTime := now.Format(time.RFC3339) + deletions := []DeletedItem{ + {ID: sessionID, Type: "session", DeletedAt: deleteTime}, + } + // Also track attempt deletions + for _, attempt := range initialBackup.Attempts { + deletions = append(deletions, DeletedItem{ + ID: attempt.ID, + Type: "attempt", + DeletedAt: deleteTime, + }) + } + + // Client 1 syncs deletion + deltaRequest2 := DeltaSyncRequest{ + LastSyncTime: now.Add(-5 * time.Minute).Format(time.RFC3339), + Gyms: []BackupGym{}, + Problems: []BackupProblem{}, + Sessions: []BackupClimbSession{}, + Attempts: []BackupAttempt{}, + DeletedItems: deletions, + } + + // Server processes deletion + serverBackup, _ = server.loadData() + serverBackup.DeletedItems = server.mergeDeletedItems(serverBackup.DeletedItems, deltaRequest2.DeletedItems) + server.applyDeletions(serverBackup, serverBackup.DeletedItems) + server.saveData(serverBackup) + + // Verify deletions were applied on server + serverBackup, _ = server.loadData() + if len(serverBackup.Sessions) != 0 { + t.Errorf("Expected 0 sessions after deletion, got %d", len(serverBackup.Sessions)) + } + if len(serverBackup.Attempts) != 0 { + t.Errorf("Expected 0 attempts after deletion, got %d", len(serverBackup.Attempts)) + } + if len(serverBackup.DeletedItems) != 9 { + t.Errorf("Expected 9 deletion records, got %d", len(serverBackup.DeletedItems)) + } + + // Client does local reset and pulls from server + deltaRequest3 := DeltaSyncRequest{ + LastSyncTime: time.Time{}.Format(time.RFC3339), + Gyms: []BackupGym{}, + Problems: []BackupProblem{}, + Sessions: []BackupClimbSession{}, + Attempts: []BackupAttempt{}, + DeletedItems: []DeletedItem{}, + } + + serverBackup, _ = server.loadData() + clientLastSync, _ := time.Parse(time.RFC3339, deltaRequest3.LastSyncTime) + + // Build response + response := DeltaSyncResponse{ + ServerTime: time.Now().UTC().Format(time.RFC3339), + Gyms: []BackupGym{}, + Problems: []BackupProblem{}, + Sessions: []BackupClimbSession{}, + Attempts: []BackupAttempt{}, + DeletedItems: []DeletedItem{}, + } + + // Build deleted item map + deletedItemMap := make(map[string]bool) + for _, item := range serverBackup.DeletedItems { + key := item.Type + ":" + item.ID + deletedItemMap[key] = true + } + + // Filter sessions (excluding deleted) + for _, session := range serverBackup.Sessions { + if deletedItemMap["session:"+session.ID] { + continue + } + sessionTime, _ := time.Parse(time.RFC3339, session.UpdatedAt) + if sessionTime.After(clientLastSync) { + response.Sessions = append(response.Sessions, session) + } + } + + // Filter attempts (excluding deleted) + for _, attempt := range serverBackup.Attempts { + if deletedItemMap["attempt:"+attempt.ID] { + continue + } + attemptTime, _ := time.Parse(time.RFC3339, attempt.CreatedAt) + if attemptTime.After(clientLastSync) { + response.Attempts = append(response.Attempts, attempt) + } + } + + // Send deletion records + for _, deletion := range serverBackup.DeletedItems { + deletionTime, _ := time.Parse(time.RFC3339, deletion.DeletedAt) + if deletionTime.After(clientLastSync) { + response.DeletedItems = append(response.DeletedItems, deletion) + } + } + + if len(response.Sessions) != 0 { + t.Errorf("Deleted session was resurrected! Got %d sessions in response", len(response.Sessions)) + } + if len(response.Attempts) != 0 { + t.Errorf("Deleted attempts were resurrected! Got %d attempts in response", len(response.Attempts)) + } + if len(response.DeletedItems) < 9 { + t.Errorf("Expected at least 9 deletion records in response, got %d", len(response.DeletedItems)) + } +} + +// TestDeltaSyncAttemptCount verifies all attempts are preserved +func TestDeltaSyncAttemptCount(t *testing.T) { + tempDir := t.TempDir() + server := &SyncServer{ + dataFile: filepath.Join(tempDir, "test.json"), + imagesDir: filepath.Join(tempDir, "images"), + authToken: "test-token", + } + + now := time.Now().UTC() + gymID := "gym-1" + problemID := "problem-1" + sessionID := "session-1" + + // Create session with 8 attempts + initialBackup := &ClimbDataBackup{ + Version: "2.0", + FormatVersion: "2.0", + Gyms: []BackupGym{{ID: gymID, Name: "Test Gym", SupportedClimbTypes: []string{"BOULDER"}, DifficultySystems: []string{"V"}, CreatedAt: now.Format(time.RFC3339), UpdatedAt: now.Format(time.RFC3339)}}, + Problems: []BackupProblem{{ID: problemID, GymID: gymID, ClimbType: "BOULDER", Difficulty: DifficultyGrade{System: "V", Grade: "V5", NumericValue: 5}, IsActive: true, CreatedAt: now.Format(time.RFC3339), UpdatedAt: now.Format(time.RFC3339)}}, + Sessions: []BackupClimbSession{{ID: sessionID, GymID: gymID, Date: now.Format("2006-01-02"), Status: "completed", CreatedAt: now.Format(time.RFC3339), UpdatedAt: now.Format(time.RFC3339)}}, + Attempts: []BackupAttempt{}, + DeletedItems: []DeletedItem{}, + } + + // Add 8 attempts at different times + baseTime := now.Add(-30 * time.Minute) + for i := 0; i < 8; i++ { + attempt := BackupAttempt{ + ID: "attempt-" + string(rune('1'+i)), + SessionID: sessionID, + ProblemID: problemID, + Result: "COMPLETED", + Timestamp: baseTime.Add(time.Duration(i) * time.Minute).Format(time.RFC3339), + CreatedAt: baseTime.Add(time.Duration(i) * time.Minute).Format(time.RFC3339), + } + initialBackup.Attempts = append(initialBackup.Attempts, attempt) + } + + if err := server.saveData(initialBackup); err != nil { + t.Fatalf("Failed to save initial data: %v", err) + } + + // Client syncs with lastSyncTime BEFORE all attempts were created + clientLastSync := baseTime.Add(-1 * time.Hour) + + serverBackup, _ := server.loadData() + + // Count attempts that should be returned + attemptCount := 0 + for _, attempt := range serverBackup.Attempts { + attemptTime, _ := time.Parse(time.RFC3339, attempt.CreatedAt) + if attemptTime.After(clientLastSync) { + attemptCount++ + } + } + + if attemptCount != 8 { + t.Errorf("Expected all 8 attempts to be returned, got %d", attemptCount) + } + +} + +// TestTombstoneCleanup verifies old deletion records are cleaned up +func TestTombstoneCleanup(t *testing.T) { + server := &SyncServer{} + + now := time.Now().UTC() + oldDeletion := DeletedItem{ + ID: "old-item", + Type: "session", + DeletedAt: now.Add(-31 * 24 * time.Hour).Format(time.RFC3339), // 31 days old + } + recentDeletion := DeletedItem{ + ID: "recent-item", + Type: "session", + DeletedAt: now.Add(-1 * 24 * time.Hour).Format(time.RFC3339), // 1 day old + } + + existing := []DeletedItem{oldDeletion} + updates := []DeletedItem{recentDeletion} + + merged := server.mergeDeletedItems(existing, updates) + + // Old deletion should be cleaned up, only recent one remains + if len(merged) != 1 { + t.Errorf("Expected 1 deletion record after cleanup, got %d", len(merged)) + } + if len(merged) > 0 && merged[0].ID != "recent-item" { + t.Errorf("Expected recent deletion to remain, got %s", merged[0].ID) + } + +} + +// TestMergeDeletedItemsDeduplication verifies duplicate deletions are handled +func TestMergeDeletedItemsDeduplication(t *testing.T) { + server := &SyncServer{} + + now := time.Now().UTC() + deletion1 := DeletedItem{ + ID: "item-1", + Type: "session", + DeletedAt: now.Add(-1 * time.Hour).Format(time.RFC3339), + } + deletion2 := DeletedItem{ + ID: "item-1", + Type: "session", + DeletedAt: now.Format(time.RFC3339), // Newer timestamp + } + + existing := []DeletedItem{deletion1} + updates := []DeletedItem{deletion2} + + merged := server.mergeDeletedItems(existing, updates) + + if len(merged) != 1 { + t.Errorf("Expected 1 deletion record, got %d", len(merged)) + } + if len(merged) > 0 && merged[0].DeletedAt != deletion2.DeletedAt { + t.Errorf("Expected newer deletion timestamp to be kept") + } + +} + +// TestApplyDeletions verifies deletions are applied correctly +func TestApplyDeletions(t *testing.T) { + server := &SyncServer{} + + now := time.Now().UTC() + backup := &ClimbDataBackup{ + Version: "2.0", + FormatVersion: "2.0", + Gyms: []BackupGym{{ID: "gym-1", Name: "Test Gym", SupportedClimbTypes: []string{}, DifficultySystems: []string{}, CreatedAt: now.Format(time.RFC3339), UpdatedAt: now.Format(time.RFC3339)}}, + Problems: []BackupProblem{{ID: "problem-1", GymID: "gym-1", ClimbType: "BOULDER", Difficulty: DifficultyGrade{System: "V", Grade: "V5", NumericValue: 5}, IsActive: true, CreatedAt: now.Format(time.RFC3339), UpdatedAt: now.Format(time.RFC3339)}}, + Sessions: []BackupClimbSession{{ID: "session-1", GymID: "gym-1", Date: now.Format("2006-01-02"), Status: "completed", CreatedAt: now.Format(time.RFC3339), UpdatedAt: now.Format(time.RFC3339)}}, + Attempts: []BackupAttempt{{ID: "attempt-1", SessionID: "session-1", ProblemID: "problem-1", Result: "COMPLETED", Timestamp: now.Format(time.RFC3339), CreatedAt: now.Format(time.RFC3339)}}, + DeletedItems: []DeletedItem{}, + } + + deletions := []DeletedItem{ + {ID: "session-1", Type: "session", DeletedAt: now.Format(time.RFC3339)}, + {ID: "attempt-1", Type: "attempt", DeletedAt: now.Format(time.RFC3339)}, + } + + server.applyDeletions(backup, deletions) + + if len(backup.Sessions) != 0 { + t.Errorf("Expected 0 sessions after deletion, got %d", len(backup.Sessions)) + } + if len(backup.Attempts) != 0 { + t.Errorf("Expected 0 attempts after deletion, got %d", len(backup.Attempts)) + } + if len(backup.Gyms) != 1 { + t.Errorf("Expected gym to remain, got %d gyms", len(backup.Gyms)) + } + if len(backup.Problems) != 1 { + t.Errorf("Expected problem to remain, got %d problems", len(backup.Problems)) + } + +} + +// TestCascadingDeletions verifies related items are handled properly +func TestCascadingDeletions(t *testing.T) { + server := &SyncServer{} + + now := time.Now().UTC() + sessionID := "session-1" + backup := &ClimbDataBackup{ + Version: "2.0", + FormatVersion: "2.0", + Gyms: []BackupGym{{ID: "gym-1", Name: "Test Gym", SupportedClimbTypes: []string{}, DifficultySystems: []string{}, CreatedAt: now.Format(time.RFC3339), UpdatedAt: now.Format(time.RFC3339)}}, + Problems: []BackupProblem{{ID: "problem-1", GymID: "gym-1", ClimbType: "BOULDER", Difficulty: DifficultyGrade{System: "V", Grade: "V5", NumericValue: 5}, IsActive: true, CreatedAt: now.Format(time.RFC3339), UpdatedAt: now.Format(time.RFC3339)}}, + Sessions: []BackupClimbSession{{ID: sessionID, GymID: "gym-1", Date: now.Format("2006-01-02"), Status: "completed", CreatedAt: now.Format(time.RFC3339), UpdatedAt: now.Format(time.RFC3339)}}, + Attempts: []BackupAttempt{}, + DeletedItems: []DeletedItem{}, + } + + // Add multiple attempts for the session + for i := 0; i < 5; i++ { + backup.Attempts = append(backup.Attempts, BackupAttempt{ + ID: "attempt-" + string(rune('1'+i)), + SessionID: sessionID, + ProblemID: "problem-1", + Result: "COMPLETED", + Timestamp: now.Format(time.RFC3339), + CreatedAt: now.Format(time.RFC3339), + }) + } + + // Delete session - attempts should also be tracked as deleted + deletions := []DeletedItem{ + {ID: sessionID, Type: "session", DeletedAt: now.Format(time.RFC3339)}, + } + for _, attempt := range backup.Attempts { + deletions = append(deletions, DeletedItem{ + ID: attempt.ID, + Type: "attempt", + DeletedAt: now.Format(time.RFC3339), + }) + } + + server.applyDeletions(backup, deletions) + + if len(backup.Sessions) != 0 { + t.Errorf("Expected session to be deleted, got %d sessions", len(backup.Sessions)) + } + if len(backup.Attempts) != 0 { + t.Errorf("Expected all attempts to be deleted, got %d attempts", len(backup.Attempts)) + } + +} + +// TestFullSyncAfterReset verifies the reported user scenario +func TestFullSyncAfterReset(t *testing.T) { + tempDir := t.TempDir() + server := &SyncServer{ + dataFile: filepath.Join(tempDir, "test.json"), + imagesDir: filepath.Join(tempDir, "images"), + authToken: "test-token", + } + + now := time.Now().UTC() + + // Initial sync with data + initialData := &ClimbDataBackup{ + Version: "2.0", + FormatVersion: "2.0", + Gyms: []BackupGym{{ID: "gym-1", Name: "Test Gym", SupportedClimbTypes: []string{"BOULDER"}, DifficultySystems: []string{"V"}, CreatedAt: now.Format(time.RFC3339), UpdatedAt: now.Format(time.RFC3339)}}, + Problems: []BackupProblem{{ID: "problem-1", GymID: "gym-1", ClimbType: "BOULDER", Difficulty: DifficultyGrade{System: "V", Grade: "V5", NumericValue: 5}, IsActive: true, CreatedAt: now.Format(time.RFC3339), UpdatedAt: now.Format(time.RFC3339)}}, + Sessions: []BackupClimbSession{{ID: "session-1", GymID: "gym-1", Date: now.Format("2006-01-02"), Status: "completed", CreatedAt: now.Format(time.RFC3339), UpdatedAt: now.Format(time.RFC3339)}}, + Attempts: []BackupAttempt{}, + DeletedItems: []DeletedItem{}, + } + for i := 0; i < 8; i++ { + initialData.Attempts = append(initialData.Attempts, BackupAttempt{ + ID: "attempt-" + string(rune('1'+i)), + SessionID: "session-1", + ProblemID: "problem-1", + Result: "COMPLETED", + Timestamp: now.Add(time.Duration(i) * time.Minute).Format(time.RFC3339), + CreatedAt: now.Add(time.Duration(i) * time.Minute).Format(time.RFC3339), + }) + } + server.saveData(initialData) + + // Client deletes everything and syncs + deletions := []DeletedItem{ + {ID: "gym-1", Type: "gym", DeletedAt: now.Add(10 * time.Minute).Format(time.RFC3339)}, + {ID: "problem-1", Type: "problem", DeletedAt: now.Add(10 * time.Minute).Format(time.RFC3339)}, + {ID: "session-1", Type: "session", DeletedAt: now.Add(10 * time.Minute).Format(time.RFC3339)}, + } + for i := 0; i < 8; i++ { + deletions = append(deletions, DeletedItem{ + ID: "attempt-" + string(rune('1'+i)), + Type: "attempt", + DeletedAt: now.Add(10 * time.Minute).Format(time.RFC3339), + }) + } + + serverBackup, _ := server.loadData() + serverBackup.DeletedItems = server.mergeDeletedItems(serverBackup.DeletedItems, deletions) + server.applyDeletions(serverBackup, serverBackup.DeletedItems) + server.saveData(serverBackup) + + // Client does local reset and pulls from server + serverBackup, _ = server.loadData() + + if len(serverBackup.Gyms) != 0 { + t.Errorf("Expected 0 gyms, got %d", len(serverBackup.Gyms)) + } + if len(serverBackup.Problems) != 0 { + t.Errorf("Expected 0 problems, got %d", len(serverBackup.Problems)) + } + if len(serverBackup.Sessions) != 0 { + t.Errorf("Expected 0 sessions, got %d", len(serverBackup.Sessions)) + } + if len(serverBackup.Attempts) != 0 { + t.Errorf("Expected 0 attempts, got %d", len(serverBackup.Attempts)) + } + if len(serverBackup.DeletedItems) == 0 { + t.Errorf("Expected deletion records, got 0") + } +}