[All Platforms] 2.1.0 - Sync Optimizations
All checks were successful
Ascently - Sync Deploy / build-and-push (push) Successful in 2m31s
Ascently - Docs Deploy / build-and-push (push) Successful in 3m30s

This commit is contained in:
2025-10-15 18:17:19 -06:00
parent 01d85a4add
commit 23de8a6fc6
32 changed files with 1538 additions and 409 deletions

View File

@@ -136,6 +136,314 @@ class SyncService: ObservableObject {
}
}
func performDeltaSync(dataManager: ClimbingDataManager) async throws {
guard isConfigured else {
throw SyncError.notConfigured
}
guard let url = URL(string: "\(serverURL)/sync/delta") else {
throw SyncError.invalidURL
}
// Get last sync time, or use epoch if never synced
let lastSync = lastSyncTime ?? Date(timeIntervalSince1970: 0)
let formatter = ISO8601DateFormatter()
let lastSyncString = formatter.string(from: lastSync)
// Collect items modified since last sync
let modifiedGyms = dataManager.gyms.filter { gym in
gym.updatedAt > lastSync
}.map { BackupGym(from: $0) }
let modifiedProblems = dataManager.problems.filter { problem in
problem.updatedAt > lastSync
}.map { problem -> BackupProblem in
var backupProblem = BackupProblem(from: problem)
if !problem.imagePaths.isEmpty {
let normalizedPaths = problem.imagePaths.enumerated().map { index, _ in
ImageNamingUtils.generateImageFilename(
problemId: problem.id.uuidString, imageIndex: index)
}
return BackupProblem(
id: backupProblem.id,
gymId: backupProblem.gymId,
name: backupProblem.name,
description: backupProblem.description,
climbType: backupProblem.climbType,
difficulty: backupProblem.difficulty,
tags: backupProblem.tags,
location: backupProblem.location,
imagePaths: normalizedPaths,
isActive: backupProblem.isActive,
dateSet: backupProblem.dateSet,
notes: backupProblem.notes,
createdAt: backupProblem.createdAt,
updatedAt: backupProblem.updatedAt
)
}
return backupProblem
}
let modifiedSessions = dataManager.sessions.filter { session in
session.status != .active && session.updatedAt > lastSync
}.map { BackupClimbSession(from: $0) }
let activeSessionIds = Set(
dataManager.sessions.filter { $0.status == .active }.map { $0.id })
let modifiedAttempts = dataManager.attempts.filter { attempt in
!activeSessionIds.contains(attempt.sessionId) && attempt.createdAt > lastSync
}.map { BackupAttempt(from: $0) }
let modifiedDeletions = dataManager.getDeletedItems().filter { item in
if let deletedDate = formatter.date(from: item.deletedAt) {
return deletedDate > lastSync
}
return false
}
print(
"iOS DELTA SYNC: Sending gyms=\(modifiedGyms.count), problems=\(modifiedProblems.count), sessions=\(modifiedSessions.count), attempts=\(modifiedAttempts.count), deletions=\(modifiedDeletions.count)"
)
// Create delta request
let deltaRequest = DeltaSyncRequest(
lastSyncTime: lastSyncString,
gyms: modifiedGyms,
problems: modifiedProblems,
sessions: modifiedSessions,
attempts: modifiedAttempts,
deletedItems: modifiedDeletions
)
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
let jsonData = try encoder.encode(deltaRequest)
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("Bearer \(authToken)", forHTTPHeaderField: "Authorization")
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue("application/json", forHTTPHeaderField: "Accept")
request.httpBody = jsonData
let (data, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
throw SyncError.invalidResponse
}
switch httpResponse.statusCode {
case 200:
break
case 401:
throw SyncError.unauthorized
default:
throw SyncError.serverError(httpResponse.statusCode)
}
let decoder = JSONDecoder()
let deltaResponse = try decoder.decode(DeltaSyncResponse.self, from: data)
print(
"iOS DELTA SYNC: Received gyms=\(deltaResponse.gyms.count), problems=\(deltaResponse.problems.count), sessions=\(deltaResponse.sessions.count), attempts=\(deltaResponse.attempts.count), deletions=\(deltaResponse.deletedItems.count)"
)
// Apply server changes to local data
try await applyDeltaResponse(deltaResponse, dataManager: dataManager)
// Sync only modified problem images
try await syncModifiedImages(modifiedProblems: modifiedProblems, dataManager: dataManager)
// Update last sync time to server time
if let serverTime = formatter.date(from: deltaResponse.serverTime) {
lastSyncTime = serverTime
userDefaults.set(lastSyncTime, forKey: Keys.lastSyncTime)
}
}
private func applyDeltaResponse(_ response: DeltaSyncResponse, dataManager: ClimbingDataManager)
async throws
{
let formatter = ISO8601DateFormatter()
// Download images for new/modified problems from server
var imagePathMapping: [String: String] = [:]
for problem in response.problems {
guard let imagePaths = problem.imagePaths, !imagePaths.isEmpty else { continue }
for (index, imagePath) in imagePaths.enumerated() {
let serverFilename = URL(fileURLWithPath: imagePath).lastPathComponent
do {
let imageData = try await downloadImage(filename: serverFilename)
let consistentFilename = ImageNamingUtils.generateImageFilename(
problemId: problem.id, imageIndex: index)
let imageManager = ImageManager.shared
_ = try imageManager.saveImportedImage(imageData, filename: consistentFilename)
imagePathMapping[serverFilename] = consistentFilename
} catch SyncError.imageNotFound {
print("Image not found on server: \(serverFilename)")
continue
} catch {
print("Failed to download image \(serverFilename): \(error)")
continue
}
}
}
// Merge gyms
for backupGym in response.gyms {
if let index = dataManager.gyms.firstIndex(where: { $0.id.uuidString == backupGym.id })
{
let existing = dataManager.gyms[index]
if backupGym.updatedAt >= formatter.string(from: existing.updatedAt) {
dataManager.gyms[index] = try backupGym.toGym()
}
} else {
dataManager.gyms.append(try backupGym.toGym())
}
}
// Merge problems
for backupProblem in response.problems {
var problemToMerge = backupProblem
if !imagePathMapping.isEmpty, let imagePaths = backupProblem.imagePaths {
let updatedPaths = imagePaths.compactMap { imagePathMapping[$0] ?? $0 }
problemToMerge = BackupProblem(
id: backupProblem.id,
gymId: backupProblem.gymId,
name: backupProblem.name,
description: backupProblem.description,
climbType: backupProblem.climbType,
difficulty: backupProblem.difficulty,
tags: backupProblem.tags,
location: backupProblem.location,
imagePaths: updatedPaths,
isActive: backupProblem.isActive,
dateSet: backupProblem.dateSet,
notes: backupProblem.notes,
createdAt: backupProblem.createdAt,
updatedAt: backupProblem.updatedAt
)
}
if let index = dataManager.problems.firstIndex(where: {
$0.id.uuidString == problemToMerge.id
}) {
let existing = dataManager.problems[index]
if problemToMerge.updatedAt >= formatter.string(from: existing.updatedAt) {
dataManager.problems[index] = try problemToMerge.toProblem()
}
} else {
dataManager.problems.append(try problemToMerge.toProblem())
}
}
// Merge sessions
for backupSession in response.sessions {
if let index = dataManager.sessions.firstIndex(where: {
$0.id.uuidString == backupSession.id
}) {
let existing = dataManager.sessions[index]
if backupSession.updatedAt >= formatter.string(from: existing.updatedAt) {
dataManager.sessions[index] = try backupSession.toClimbSession()
}
} else {
dataManager.sessions.append(try backupSession.toClimbSession())
}
}
// Merge attempts
for backupAttempt in response.attempts {
if let index = dataManager.attempts.firstIndex(where: {
$0.id.uuidString == backupAttempt.id
}) {
let existing = dataManager.attempts[index]
if backupAttempt.createdAt >= formatter.string(from: existing.createdAt) {
dataManager.attempts[index] = try backupAttempt.toAttempt()
}
} else {
dataManager.attempts.append(try backupAttempt.toAttempt())
}
}
// Apply deletions
let allDeletions = dataManager.getDeletedItems() + response.deletedItems
let uniqueDeletions = Array(Set(allDeletions))
applyDeletionsToDataManager(deletions: uniqueDeletions, dataManager: dataManager)
// Save all changes
dataManager.saveGyms()
dataManager.saveProblems()
dataManager.saveSessions()
dataManager.saveAttempts()
// Update deletion records
dataManager.clearDeletedItems()
if let data = try? JSONEncoder().encode(uniqueDeletions) {
UserDefaults.standard.set(data, forKey: "ascently_deleted_items")
}
DataStateManager.shared.updateDataState()
}
private func applyDeletionsToDataManager(
deletions: [DeletedItem], dataManager: ClimbingDataManager
) {
let deletedGymIds = Set(deletions.filter { $0.type == "gym" }.map { $0.id })
let deletedProblemIds = Set(deletions.filter { $0.type == "problem" }.map { $0.id })
let deletedSessionIds = Set(deletions.filter { $0.type == "session" }.map { $0.id })
let deletedAttemptIds = Set(deletions.filter { $0.type == "attempt" }.map { $0.id })
dataManager.gyms.removeAll { deletedGymIds.contains($0.id.uuidString) }
dataManager.problems.removeAll { deletedProblemIds.contains($0.id.uuidString) }
dataManager.sessions.removeAll { deletedSessionIds.contains($0.id.uuidString) }
dataManager.attempts.removeAll { deletedAttemptIds.contains($0.id.uuidString) }
}
private func syncModifiedImages(
modifiedProblems: [BackupProblem], dataManager: ClimbingDataManager
) async throws {
guard !modifiedProblems.isEmpty else { return }
print("iOS DELTA SYNC: Syncing images for \(modifiedProblems.count) modified problems")
for backupProblem in modifiedProblems {
guard
let problem = dataManager.problems.first(where: {
$0.id.uuidString == backupProblem.id
})
else {
continue
}
for (index, imagePath) in problem.imagePaths.enumerated() {
let filename = URL(fileURLWithPath: imagePath).lastPathComponent
let consistentFilename = ImageNamingUtils.generateImageFilename(
problemId: problem.id.uuidString, imageIndex: index)
let imageManager = ImageManager.shared
let fullPath = imageManager.imagesDirectory.appendingPathComponent(filename).path
if let imageData = imageManager.loadImageData(fromPath: fullPath) {
do {
if filename != consistentFilename {
let newPath = imageManager.imagesDirectory.appendingPathComponent(
consistentFilename
).path
try? FileManager.default.moveItem(atPath: fullPath, toPath: newPath)
}
try await uploadImage(filename: consistentFilename, imageData: imageData)
print("Uploaded modified problem image: \(consistentFilename)")
} catch {
print("Failed to upload image \(consistentFilename): \(error)")
}
}
}
}
}
func uploadImage(filename: String, imageData: Data) async throws {
guard isConfigured else {
throw SyncError.notConfigured
@@ -246,6 +554,17 @@ class SyncService: ObservableObject {
!serverBackup.gyms.isEmpty || !serverBackup.problems.isEmpty
|| !serverBackup.sessions.isEmpty || !serverBackup.attempts.isEmpty
// If both client and server have been synced before, use delta sync
if hasLocalData && hasServerData && lastSyncTime != nil {
print("iOS SYNC: Using delta sync for incremental updates")
try await performDeltaSync(dataManager: dataManager)
// Update last sync time
lastSyncTime = Date()
userDefaults.set(lastSyncTime, forKey: Keys.lastSyncTime)
return
}
if !hasLocalData && hasServerData {
// Case 1: No local data - do full restore from server
print("iOS SYNC: Case 1 - No local data, performing full restore from server")
@@ -286,7 +605,6 @@ class SyncService: ObservableObject {
}
}
/// Parses ISO8601 timestamp to milliseconds for comparison
private func parseISO8601ToMillis(timestamp: String) -> Int64 {
let formatter = ISO8601DateFormatter()
if let date = formatter.date(from: timestamp) {
@@ -1150,7 +1468,6 @@ class SyncService: ObservableObject {
// Get active session IDs to protect their attempts
let activeSessionIds = Set(
local.compactMap { attempt in
// This is a simplified check - in a real implementation you'd want to cross-reference with sessions
return attempt.sessionId
}.filter { sessionId in
// Check if this session ID belongs to an active session