// ── Platform Sync Manager ───────────────────────────────────── // Cross-platform sync via platform-service REST API // Replaces iCloud KV store with proper delta sync + conflict resolution // Endpoints: /timers, /timers/sync, /timers/batch, /routines/* import Foundation import Combine @MainActor final class PlatformSyncManager: ObservableObject { static let shared = PlatformSyncManager() // MARK: - Published State @Published var isSyncing = false @Published var lastSyncDate: Date? @Published var syncEnabled: Bool { didSet { UserDefaults.standard.set(syncEnabled, forKey: Keys.syncEnabled) if syncEnabled { schedulePeriodicSync() } else { cancelPeriodicSync() } } } @Published var pendingChanges: Int = 0 @Published var lastError: String? // MARK: - Config private let baseURL: String private let session: URLSession private var authToken: String? private var syncTask: Task? private let encoder: JSONEncoder private let decoder: JSONDecoder private struct Keys { static let syncEnabled = "chronomind-platform-sync-enabled" static let lastSync = "chronomind-platform-last-sync" static let offlineQueue = "chronomind-offline-queue" static let authTokenKeychain = "chronomind-sync-auth-token" } // MARK: - Init private init() { // Default to localhost for dev; production URL via Info.plist or env baseURL = Bundle.main.object(forInfoDictionaryKey: "PLATFORM_SERVICE_URL") as? String ?? "https://api.chronomind.app" let config = URLSessionConfiguration.default config.timeoutIntervalForRequest = 15 config.waitsForConnectivity = true session = URLSession(configuration: config) encoder = JSONEncoder() encoder.dateEncodingStrategy = .iso8601 decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601 syncEnabled = UserDefaults.standard.bool(forKey: Keys.syncEnabled) authToken = KeychainHelper.read(key: Keys.authTokenKeychain) if let date = UserDefaults.standard.object(forKey: Keys.lastSync) as? Date { lastSyncDate = date } loadOfflineQueue() if syncEnabled { schedulePeriodicSync() } } // MARK: - Auth func setAuthToken(_ token: String?) { authToken = token if let token = token { KeychainHelper.save(key: Keys.authTokenKeychain, value: token) } else { KeychainHelper.delete(key: Keys.authTokenKeychain) } } var isAuthenticated: Bool { authToken != nil } // MARK: - Sync Operations /// Full delta sync: pull changes since last sync, push local changes func sync(localTimers: [CMTimer]) async -> SyncResult { guard syncEnabled, isAuthenticated else { return SyncResult(pulled: [], conflicts: [], error: "Not authenticated or sync disabled") } isSyncing = true lastError = nil defer { isSyncing = false } do { // 1. Pull remote changes since last sync let pulled = try await pullDelta() // 2. Push local offline queue let batchResult = try await pushOfflineQueue() // 3. Update last sync timestamp lastSyncDate = Date() UserDefaults.standard.set(lastSyncDate, forKey: Keys.lastSync) return SyncResult( pulled: pulled, conflicts: batchResult.conflicts, error: nil ) } catch { lastError = error.localizedDescription return SyncResult(pulled: [], conflicts: [], error: error.localizedDescription) } } /// Pull timers modified since last sync func pullDelta() async throws -> [SyncTimerDTO] { var urlString = "\(baseURL)/timers/sync" if let lastSync = lastSyncDate { let iso = ISO8601DateFormatter().string(from: lastSync) urlString += "?since=\(iso)" } guard let url = URL(string: urlString) else { throw SyncError.invalidURL } let (data, response) = try await authenticatedRequest(url: url, method: "GET") try validateResponse(response) let wrapper = try decoder.decode(SyncDeltaResponse.self, from: data) return wrapper.timers } /// Push a single timer change to the server func pushTimer(_ timer: CMTimer) async throws -> SyncTimerDTO { guard let url = URL(string: "\(baseURL)/timers") else { throw SyncError.invalidURL } let dto = timerToDTO(timer) let body = try encoder.encode(dto) let (data, response) = try await authenticatedRequest(url: url, method: "POST", body: body) try validateResponse(response) return try decoder.decode(SyncTimerDTO.self, from: data) } /// Update an existing timer on the server func updateTimer(_ timer: CMTimer, syncVersion: Int) async throws -> SyncTimerDTO { guard let url = URL(string: "\(baseURL)/timers/\(timer.id)") else { throw SyncError.invalidURL } var dto = timerToUpdateDTO(timer) dto.syncVersion = syncVersion let body = try encoder.encode(dto) let (data, response) = try await authenticatedRequest(url: url, method: "PUT", body: body) if let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 409 { throw SyncError.conflict } try validateResponse(response) return try decoder.decode(SyncTimerDTO.self, from: data) } /// Delete a timer on the server func deleteTimer(id: String) async throws { guard let url = URL(string: "\(baseURL)/timers/\(id)") else { throw SyncError.invalidURL } let (_, response) = try await authenticatedRequest(url: url, method: "DELETE") try validateResponse(response) } /// Batch upsert (flush offline queue) func pushOfflineQueue() async throws -> BatchResult { let queue = loadOfflineQueueItems() guard !queue.isEmpty else { return BatchResult(synced: [], conflicts: [], errors: []) } guard let url = URL(string: "\(baseURL)/timers/batch") else { throw SyncError.invalidURL } let body = try encoder.encode(["timers": queue]) let (data, response) = try await authenticatedRequest(url: url, method: "POST", body: body) try validateResponse(response) let result = try decoder.decode(BatchResult.self, from: data) // Clear synced items from queue clearOfflineQueue(syncedIds: result.synced) pendingChanges = loadOfflineQueueItems().count return result } // MARK: - Offline Queue func enqueueChange(_ timer: CMTimer, action: OfflineAction) { var queue = loadOfflineQueueItems() // Replace existing entry for same timer ID queue.removeAll { $0.id == timer.id } queue.append(OfflineQueueItem( id: timer.id, action: action, timer: timerToDTO(timer), enqueuedAt: Date() )) saveOfflineQueue(queue) pendingChanges = queue.count } func enqueueDelete(timerId: String) { var queue = loadOfflineQueueItems() queue.removeAll { $0.id == timerId } queue.append(OfflineQueueItem( id: timerId, action: .delete, timer: nil, enqueuedAt: Date() )) saveOfflineQueue(queue) pendingChanges = queue.count } // MARK: - Periodic Sync private func schedulePeriodicSync() { cancelPeriodicSync() syncTask = Task { [weak self] in while !Task.isCancelled { try? await Task.sleep(nanoseconds: 60 * 1_000_000_000) // 60 seconds guard let self = self, self.syncEnabled else { break } _ = await self.sync(localTimers: []) } } } private func cancelPeriodicSync() { syncTask?.cancel() syncTask = nil } // MARK: - Network Helpers private func authenticatedRequest(url: URL, method: String, body: Data? = nil) async throws -> (Data, URLResponse) { var request = URLRequest(url: url) request.httpMethod = method request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.setValue(UUID().uuidString, forHTTPHeaderField: "x-request-id") request.setValue("chronomind", forHTTPHeaderField: "x-product-id") if let token = authToken { request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") } if let body = body { request.httpBody = body } return try await session.data(for: request) } private func validateResponse(_ response: URLResponse) throws { guard let http = response as? HTTPURLResponse else { throw SyncError.invalidResponse } guard (200...299).contains(http.statusCode) else { throw SyncError.httpError(http.statusCode) } } // MARK: - Offline Queue Persistence private func loadOfflineQueue() { pendingChanges = loadOfflineQueueItems().count } private func loadOfflineQueueItems() -> [OfflineQueueItem] { guard let data = UserDefaults.standard.data(forKey: Keys.offlineQueue) else { return [] } return (try? JSONDecoder().decode([OfflineQueueItem].self, from: data)) ?? [] } private func saveOfflineQueue(_ queue: [OfflineQueueItem]) { if let data = try? JSONEncoder().encode(queue) { UserDefaults.standard.set(data, forKey: Keys.offlineQueue) } } private func clearOfflineQueue(syncedIds: [String]) { var queue = loadOfflineQueueItems() queue.removeAll { syncedIds.contains($0.id) } saveOfflineQueue(queue) } // MARK: - DTO Conversion private func timerToDTO(_ timer: CMTimer) -> SyncTimerDTO { SyncTimerDTO( id: timer.id, label: timer.label, description: timer.description, type: timer.type.rawValue, state: timer.state.rawValue, urgency: timer.urgency.rawValue, duration: timer.duration ?? 0, targetTime: timer.targetTime, createdAt: timer.createdAt, startedAt: timer.startedAt, pausedAt: timer.pausedAt, firedAt: timer.firedAt, completedAt: timer.completedAt, cascade: timer.cascade.intervals.isEmpty ? nil : CascadeDTO( preset: timer.cascade.preset.rawValue, intervals: timer.cascade.intervals ), pomodoro: timer.pomodoroConfig.map { config in PomodoroDTO( focusMinutes: config.workMinutes, shortBreakMinutes: config.breakMinutes, longBreakMinutes: config.longBreakMinutes, roundsBeforeLong: config.rounds, currentRound: timer.pomodoroState?.currentRound ?? 1, isBreak: timer.pomodoroState?.isBreak ?? false, totalRoundsCompleted: timer.pomodoroState?.completedRounds ?? 0 ) }, isCalendarSync: false, category: timer.category, syncVersion: 1 ) } private func timerToUpdateDTO(_ timer: CMTimer) -> UpdateTimerDTO { UpdateTimerDTO( state: timer.state.rawValue, pausedAt: timer.pausedAt, firedAt: timer.firedAt, completedAt: timer.completedAt, syncVersion: 1 ) } } // MARK: - DTOs struct SyncDeltaResponse: Codable { let timers: [SyncTimerDTO] let count: Int } struct SyncTimerDTO: Codable { let id: String var label: String var description: String? var type: String var state: String var urgency: String var duration: TimeInterval var targetTime: Date var createdAt: Date var startedAt: Date? var pausedAt: Date? var firedAt: Date? var completedAt: Date? var cascade: CascadeDTO? var pomodoro: PomodoroDTO? var isCalendarSync: Bool? var category: String? var syncVersion: Int } struct CascadeDTO: Codable { let preset: String let intervals: [Int] } struct PomodoroDTO: Codable { let focusMinutes: Int let shortBreakMinutes: Int let longBreakMinutes: Int let roundsBeforeLong: Int var currentRound: Int var isBreak: Bool var totalRoundsCompleted: Int } struct UpdateTimerDTO: Codable { var state: String? var pausedAt: Date? var firedAt: Date? var completedAt: Date? var syncVersion: Int } // MARK: - Sync Results struct SyncResult { let pulled: [SyncTimerDTO] let conflicts: [SyncConflict] let error: String? } struct SyncConflict: Codable { let id: String let serverVersion: Int } struct BatchResult: Codable { let synced: [String] let conflicts: [SyncConflict] let errors: [BatchError] } struct BatchError: Codable { let id: String let error: String } // MARK: - Offline Queue enum OfflineAction: String, Codable { case create case update case delete } struct OfflineQueueItem: Codable { let id: String let action: OfflineAction let timer: SyncTimerDTO? let enqueuedAt: Date } // MARK: - Errors enum SyncError: LocalizedError { case invalidURL case invalidResponse case httpError(Int) case conflict case notAuthenticated var errorDescription: String? { switch self { case .invalidURL: return "Invalid sync URL" case .invalidResponse: return "Invalid server response" case .httpError(let code): return "Server error (\(code))" case .conflict: return "Sync conflict — server has newer version" case .notAuthenticated: return "Not signed in" } } }