diff --git a/android/app/src/main/java/com/chronomind/app/sync/PlatformApiClient.kt b/android/app/src/main/java/com/chronomind/app/sync/PlatformApiClient.kt new file mode 100644 index 0000000..cec4b6c --- /dev/null +++ b/android/app/src/main/java/com/chronomind/app/sync/PlatformApiClient.kt @@ -0,0 +1,180 @@ +package com.chronomind.app.sync + +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import java.io.OutputStreamWriter +import java.net.HttpURLConnection +import java.net.URL + +// ── DTOs ────────────────────────────────────────────────────── + +@Serializable +data class SyncTimerDTO( + val id: String, + val label: String, + val description: String? = null, + val type: String, + val state: String, + val urgency: String, + val duration: Double, + val targetTime: String, + val createdAt: String, + val startedAt: String? = null, + val pausedAt: String? = null, + val firedAt: String? = null, + val completedAt: String? = null, + val cascade: CascadeDTO? = null, + val pomodoro: PomodoroDTO? = null, + val isCalendarSync: Boolean = false, + val category: String? = null, + val syncVersion: Int = 1 +) + +@Serializable +data class CascadeDTO( + val preset: String, + val intervals: List +) + +@Serializable +data class PomodoroDTO( + val focusMinutes: Int, + val shortBreakMinutes: Int, + val longBreakMinutes: Int, + val roundsBeforeLong: Int, + val currentRound: Int = 1, + val isBreak: Boolean = false, + val totalRoundsCompleted: Int = 0 +) + +@Serializable +data class UpdateTimerDTO( + val state: String? = null, + val pausedAt: String? = null, + val firedAt: String? = null, + val completedAt: String? = null, + val syncVersion: Int = 1 +) + +@Serializable +data class BatchRequest(val timers: List) + +@Serializable +data class BatchResult( + val synced: List = emptyList(), + val conflicts: List = emptyList(), + val errors: List = emptyList() +) + +@Serializable +data class SyncConflict(val id: String, val serverVersion: Int) + +// ── API Client ──────────────────────────────────────────────── + +class PlatformApiClient( + private val baseUrl: String = "https://api.chronomind.app", + private var authToken: String? = null +) { + private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true } + + fun setAuthToken(token: String?) { authToken = token } + + // GET /timers/sync?since= + fun pullDelta(since: String?): List { + val url = if (since != null) "$baseUrl/timers/sync?since=$since" else "$baseUrl/timers/sync" + val response = get(url) + return json.decodeFromString(response) + } + + // POST /timers + fun createTimer(dto: SyncTimerDTO): SyncTimerDTO { + val body = json.encodeToString(SyncTimerDTO.serializer(), dto) + val response = post("$baseUrl/timers", body) + return json.decodeFromString(response) + } + + // PUT /timers/:id + fun updateTimer(id: String, dto: UpdateTimerDTO): SyncTimerDTO { + val body = json.encodeToString(UpdateTimerDTO.serializer(), dto) + val response = put("$baseUrl/timers/$id", body) + return json.decodeFromString(response) + } + + // DELETE /timers/:id + fun deleteTimer(id: String) { + delete("$baseUrl/timers/$id") + } + + // POST /timers/batch + fun batchUpsert(timers: List): BatchResult { + val body = json.encodeToString(BatchRequest.serializer(), BatchRequest(timers)) + val response = post("$baseUrl/timers/batch", body) + return json.decodeFromString(response) + } + + // GET /routines/sync?since= + fun pullRoutinesDelta(since: String?): String { + val url = if (since != null) "$baseUrl/routines/sync?since=$since" else "$baseUrl/routines/sync" + return get(url) + } + + // ── HTTP helpers ────────────────────────────────────────── + + private fun get(url: String): String { + val conn = openConnection(url, "GET") + return readResponse(conn) + } + + private fun post(url: String, body: String): String { + val conn = openConnection(url, "POST") + writeBody(conn, body) + return readResponse(conn) + } + + private fun put(url: String, body: String): String { + val conn = openConnection(url, "PUT") + writeBody(conn, body) + return readResponse(conn) + } + + private fun delete(url: String) { + val conn = openConnection(url, "DELETE") + val code = conn.responseCode + conn.disconnect() + if (code !in 200..299) throw SyncException("DELETE failed: $code") + } + + private fun openConnection(url: String, method: String): HttpURLConnection { + val conn = URL(url).openConnection() as HttpURLConnection + conn.requestMethod = method + conn.setRequestProperty("Content-Type", "application/json") + conn.setRequestProperty("x-request-id", java.util.UUID.randomUUID().toString()) + conn.connectTimeout = 15_000 + conn.readTimeout = 15_000 + authToken?.let { conn.setRequestProperty("Authorization", "Bearer $it") } + return conn + } + + private fun writeBody(conn: HttpURLConnection, body: String) { + conn.doOutput = true + OutputStreamWriter(conn.outputStream, Charsets.UTF_8).use { it.write(body) } + } + + private fun readResponse(conn: HttpURLConnection): String { + val code = conn.responseCode + if (code == 409) { + conn.disconnect() + throw SyncConflictException("Conflict: server has newer version") + } + if (code !in 200..299) { + conn.disconnect() + throw SyncException("HTTP $code") + } + val response = conn.inputStream.bufferedReader().use { it.readText() } + conn.disconnect() + return response + } +} + +class SyncException(message: String) : Exception(message) +class SyncConflictException(message: String) : Exception(message) diff --git a/android/app/src/main/java/com/chronomind/app/sync/SyncRepository.kt b/android/app/src/main/java/com/chronomind/app/sync/SyncRepository.kt new file mode 100644 index 0000000..144af2b --- /dev/null +++ b/android/app/src/main/java/com/chronomind/app/sync/SyncRepository.kt @@ -0,0 +1,245 @@ +package com.chronomind.app.sync + +import android.content.Context +import android.content.SharedPreferences +import com.chronomind.app.data.TimerDao +import com.chronomind.app.data.TimerEntity +import com.chronomind.app.data.toEntity +import com.chronomind.app.data.toTimer +import com.chronomind.app.engine.* +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.withContext +import kotlinx.serialization.Serializable +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import java.text.SimpleDateFormat +import java.util.* +import javax.inject.Inject +import javax.inject.Singleton + +// ── Offline Queue Item ──────────────────────────────────────── + +@Serializable +data class OfflineQueueItem( + val id: String, + val action: String, // "create", "update", "delete" + val timer: SyncTimerDTO? = null, + val enqueuedAt: String +) + +// ── Sync Repository ─────────────────────────────────────────── + +@Singleton +class SyncRepository @Inject constructor( + @ApplicationContext private val context: Context, + private val timerDao: TimerDao +) { + private val prefs: SharedPreferences = context.getSharedPreferences("chronomind_sync", Context.MODE_PRIVATE) + private val api = PlatformApiClient() + private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true } + private val isoFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US).apply { + timeZone = TimeZone.getTimeZone("UTC") + } + + private val _isSyncing = MutableStateFlow(false) + val isSyncing: StateFlow = _isSyncing + + private val _pendingChanges = MutableStateFlow(0) + val pendingChanges: StateFlow = _pendingChanges + + private val _lastError = MutableStateFlow(null) + val lastError: StateFlow = _lastError + + var syncEnabled: Boolean + get() = prefs.getBoolean("sync_enabled", false) + set(value) = prefs.edit().putBoolean("sync_enabled", value).apply() + + var lastSyncDate: String? + get() = prefs.getString("last_sync", null) + private set(value) = prefs.edit().putString("last_sync", value).apply() + + init { + _pendingChanges.value = loadOfflineQueue().size + } + + // MARK: - Auth + + fun setAuthToken(token: String?) { + api.setAuthToken(token) + prefs.edit().putString("auth_token", token).apply() + } + + fun restoreAuthToken() { + val token = prefs.getString("auth_token", null) + if (token != null) api.setAuthToken(token) + } + + val isAuthenticated: Boolean get() = prefs.getString("auth_token", null) != null + + // MARK: - Full Sync + + suspend fun sync(): SyncResult = withContext(Dispatchers.IO) { + if (!syncEnabled || !isAuthenticated) { + return@withContext SyncResult(emptyList(), emptyList(), "Not authenticated or sync disabled") + } + + _isSyncing.value = true + _lastError.value = null + + try { + // 1. Pull delta + val pulled = api.pullDelta(lastSyncDate) + + // 2. Merge pulled timers into Room + for (dto in pulled) { + val entity = dtoToEntity(dto) + timerDao.upsert(entity) + } + + // 3. Push offline queue + val queue = loadOfflineQueue() + var conflicts = emptyList() + if (queue.isNotEmpty()) { + val timersToSync = queue.mapNotNull { it.timer } + if (timersToSync.isNotEmpty()) { + val result = api.batchUpsert(timersToSync) + conflicts = result.conflicts + clearSyncedFromQueue(result.synced) + } + } + + // 4. Update last sync + lastSyncDate = isoFormat.format(Date()) + _pendingChanges.value = loadOfflineQueue().size + + SyncResult(pulled, conflicts, null) + } catch (e: Exception) { + _lastError.value = e.message + SyncResult(emptyList(), emptyList(), e.message) + } finally { + _isSyncing.value = false + } + } + + // MARK: - Enqueue Changes + + fun enqueueCreate(timer: CMTimer) { + val dto = timerToDTO(timer) + enqueue(OfflineQueueItem(timer.id, "create", dto, isoFormat.format(Date()))) + } + + fun enqueueUpdate(timer: CMTimer) { + val dto = timerToDTO(timer) + enqueue(OfflineQueueItem(timer.id, "update", dto, isoFormat.format(Date()))) + } + + fun enqueueDelete(timerId: String) { + enqueue(OfflineQueueItem(timerId, "delete", null, isoFormat.format(Date()))) + } + + // MARK: - Offline Queue Persistence + + private fun enqueue(item: OfflineQueueItem) { + val queue = loadOfflineQueue().toMutableList() + queue.removeAll { it.id == item.id } + queue.add(item) + saveOfflineQueue(queue) + _pendingChanges.value = queue.size + } + + private fun loadOfflineQueue(): List { + val raw = prefs.getString("offline_queue", null) ?: return emptyList() + return try { + json.decodeFromString(raw) + } catch (_: Exception) { + emptyList() + } + } + + private fun saveOfflineQueue(queue: List) { + prefs.edit().putString("offline_queue", json.encodeToString(queue)).apply() + } + + private fun clearSyncedFromQueue(syncedIds: List) { + val queue = loadOfflineQueue().toMutableList() + queue.removeAll { syncedIds.contains(it.id) } + saveOfflineQueue(queue) + } + + // MARK: - DTO Conversion + + private fun timerToDTO(timer: CMTimer): SyncTimerDTO { + return SyncTimerDTO( + id = timer.id, + label = timer.label, + description = timer.description, + type = timer.type.name.lowercase(), + state = timer.state.name.lowercase(), + urgency = timer.urgency.name.lowercase(), + duration = timer.duration, + targetTime = isoFormat.format(timer.targetTime), + createdAt = isoFormat.format(timer.createdAt), + startedAt = timer.startedAt?.let { isoFormat.format(it) }, + pausedAt = timer.pausedAt?.let { isoFormat.format(it) }, + firedAt = timer.firedAt?.let { isoFormat.format(it) }, + completedAt = timer.completedAt?.let { isoFormat.format(it) }, + cascade = if (timer.cascade.intervals.isNotEmpty()) CascadeDTO( + preset = timer.cascade.preset.name.lowercase(), + intervals = timer.cascade.intervals + ) else null, + pomodoro = timer.pomodoro?.let { pom -> + PomodoroDTO( + focusMinutes = pom.focusMinutes, + shortBreakMinutes = pom.shortBreakMinutes, + longBreakMinutes = pom.longBreakMinutes, + roundsBeforeLong = pom.roundsBeforeLong, + currentRound = pom.currentRound, + isBreak = pom.isBreak, + totalRoundsCompleted = pom.totalRoundsCompleted + ) + }, + category = timer.category, + syncVersion = 1 + ) + } + + private fun dtoToEntity(dto: SyncTimerDTO): TimerEntity { + return TimerEntity( + id = dto.id, + label = dto.label, + description = dto.description, + type = dto.type, + state = dto.state, + urgency = dto.urgency, + duration = dto.duration, + targetTime = parseIso(dto.targetTime), + createdAt = parseIso(dto.createdAt), + startedAt = dto.startedAt?.let { parseIso(it) }, + pausedAt = dto.pausedAt?.let { parseIso(it) }, + firedAt = dto.firedAt?.let { parseIso(it) }, + completedAt = dto.completedAt?.let { parseIso(it) }, + cascadePreset = dto.cascade?.preset, + cascadeIntervalsJson = dto.cascade?.intervals?.let { json.encodeToString(it) }, + pomodoroJson = dto.pomodoro?.let { json.encodeToString(it) }, + warningsJson = null, + isCalendarSync = dto.isCalendarSync, + calendarEventId = null, + category = dto.category + ) + } + + private fun parseIso(str: String): Long { + return try { isoFormat.parse(str)?.time ?: 0L } catch (_: Exception) { 0L } + } +} + +// MARK: - Sync Result + +data class SyncResult( + val pulled: List, + val conflicts: List, + val error: String? +) diff --git a/ios/ChronoMind/Shared/Cloud/PlatformSyncManager.swift b/ios/ChronoMind/Shared/Cloud/PlatformSyncManager.swift new file mode 100644 index 0000000..6d85b45 --- /dev/null +++ b/ios/ChronoMind/Shared/Cloud/PlatformSyncManager.swift @@ -0,0 +1,449 @@ +// ── 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 authToken = "chronomind-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 = UserDefaults.standard.string(forKey: Keys.authToken) + + 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 { + UserDefaults.standard.set(token, forKey: Keys.authToken) + } else { + UserDefaults.standard.removeObject(forKey: Keys.authToken) + } + } + + 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) + return try decoder.decode([SyncTimerDTO].self, from: data) + } + + /// 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") + + 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 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: [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" + } + } +} diff --git a/web/src/lib/platform-sync.ts b/web/src/lib/platform-sync.ts new file mode 100644 index 0000000..a46b955 --- /dev/null +++ b/web/src/lib/platform-sync.ts @@ -0,0 +1,327 @@ +// ── Platform Sync Client ────────────────────────────────────── +// Cross-platform sync via platform-service REST API +// Consumed by useSyncHook for React integration + +import type { Timer } from './timer-engine'; + +// ── DTOs ────────────────────────────────────────────────────── + +export interface SyncTimerDTO { + id: string; + label: string; + description?: string; + type: string; + state: string; + urgency: string; + duration: number; + targetTime: string; // ISO 8601 + createdAt: string; + startedAt?: string; + pausedAt?: string; + firedAt?: string; + completedAt?: string; + cascade?: { preset: string; intervals: number[] }; + pomodoro?: { + focusMinutes: number; + shortBreakMinutes: number; + longBreakMinutes: number; + roundsBeforeLong: number; + currentRound: number; + isBreak: boolean; + totalRoundsCompleted: number; + }; + isCalendarSync?: boolean; + category?: string; + syncVersion: number; +} + +export interface SyncConflict { + id: string; + serverVersion: number; +} + +export interface BatchResult { + synced: string[]; + conflicts: SyncConflict[]; + errors: string[]; +} + +export interface OfflineQueueItem { + id: string; + action: 'create' | 'update' | 'delete'; + timer?: SyncTimerDTO; + enqueuedAt: number; +} + +// ── API Client ──────────────────────────────────────────────── + +const STORAGE_KEYS = { + authToken: 'chronomind-auth-token', + lastSync: 'chronomind-platform-last-sync', + offlineQueue: 'chronomind-offline-queue', + syncEnabled: 'chronomind-platform-sync-enabled', +} as const; + +function getBaseUrl(): string { + if (typeof window !== 'undefined' && (window as Record).__PLATFORM_URL__) { + return (window as Record).__PLATFORM_URL__ as string; + } + return process.env.NEXT_PUBLIC_PLATFORM_SERVICE_URL ?? 'https://api.chronomind.app'; +} + +function getAuthToken(): string | null { + if (typeof window === 'undefined') return null; + return localStorage.getItem(STORAGE_KEYS.authToken); +} + +async function apiRequest( + path: string, + method: string, + body?: unknown +): Promise { + const token = getAuthToken(); + const headers: Record = { + 'Content-Type': 'application/json', + 'x-request-id': crypto.randomUUID(), + }; + if (token) headers['Authorization'] = `Bearer ${token}`; + + const res = await fetch(`${getBaseUrl()}${path}`, { + method, + headers, + body: body ? JSON.stringify(body) : undefined, + }); + + if (res.status === 409) { + const data = await res.json(); + throw new SyncConflictError(data); + } + + if (!res.ok) { + throw new Error(`Sync API error: ${res.status} ${res.statusText}`); + } + + if (res.status === 204) return undefined as T; + return res.json(); +} + +export class SyncConflictError extends Error { + constructor(public serverData: unknown) { + super('Sync conflict — server has newer version'); + } +} + +// ── Public API ──────────────────────────────────────────────── + +export function setAuthToken(token: string | null): void { + if (typeof window === 'undefined') return; + if (token) { + localStorage.setItem(STORAGE_KEYS.authToken, token); + } else { + localStorage.removeItem(STORAGE_KEYS.authToken); + } +} + +export function isAuthenticated(): boolean { + return getAuthToken() !== null; +} + +export function isSyncEnabled(): boolean { + if (typeof window === 'undefined') return false; + return localStorage.getItem(STORAGE_KEYS.syncEnabled) === 'true'; +} + +export function setSyncEnabled(enabled: boolean): void { + if (typeof window === 'undefined') return; + localStorage.setItem(STORAGE_KEYS.syncEnabled, String(enabled)); +} + +export function getLastSyncDate(): string | null { + if (typeof window === 'undefined') return null; + return localStorage.getItem(STORAGE_KEYS.lastSync); +} + +function setLastSyncDate(date: string): void { + localStorage.setItem(STORAGE_KEYS.lastSync, date); +} + +// ── Sync Operations ─────────────────────────────────────────── + +export async function pullDelta(since?: string): Promise { + const params = since ? `?since=${encodeURIComponent(since)}` : ''; + return apiRequest(`/timers/sync${params}`, 'GET'); +} + +export async function pushTimer(dto: SyncTimerDTO): Promise { + return apiRequest('/timers', 'POST', dto); +} + +export async function updateRemoteTimer( + id: string, + updates: Partial +): Promise { + return apiRequest(`/timers/${id}`, 'PUT', updates); +} + +export async function deleteRemoteTimer(id: string): Promise { + return apiRequest(`/timers/${id}`, 'DELETE'); +} + +export async function batchUpsert( + timers: SyncTimerDTO[] +): Promise { + return apiRequest('/timers/batch', 'POST', { timers }); +} + +// ── Offline Queue ───────────────────────────────────────────── + +export function loadOfflineQueue(): OfflineQueueItem[] { + if (typeof window === 'undefined') return []; + try { + const raw = localStorage.getItem(STORAGE_KEYS.offlineQueue); + return raw ? JSON.parse(raw) : []; + } catch { + return []; + } +} + +export function saveOfflineQueue(queue: OfflineQueueItem[]): void { + if (typeof window === 'undefined') return; + localStorage.setItem(STORAGE_KEYS.offlineQueue, JSON.stringify(queue)); +} + +export function enqueueChange( + timer: Timer, + action: 'create' | 'update' | 'delete' +): void { + const queue = loadOfflineQueue().filter((item) => item.id !== timer.id); + queue.push({ + id: timer.id, + action, + timer: action !== 'delete' ? timerToDTO(timer) : undefined, + enqueuedAt: Date.now(), + }); + saveOfflineQueue(queue); +} + +export function enqueueDeleteChange(timerId: string): void { + const queue = loadOfflineQueue().filter((item) => item.id !== timerId); + queue.push({ id: timerId, action: 'delete', enqueuedAt: Date.now() }); + saveOfflineQueue(queue); +} + +function clearSyncedFromQueue(syncedIds: string[]): void { + const queue = loadOfflineQueue().filter( + (item) => !syncedIds.includes(item.id) + ); + saveOfflineQueue(queue); +} + +// ── Full Sync ───────────────────────────────────────────────── + +export interface SyncResult { + pulled: SyncTimerDTO[]; + conflicts: SyncConflict[]; + error?: string; +} + +export async function fullSync(): Promise { + if (!isSyncEnabled() || !isAuthenticated()) { + return { pulled: [], conflicts: [], error: 'Not authenticated or sync disabled' }; + } + + try { + // 1. Pull delta + const since = getLastSyncDate() ?? undefined; + const pulled = await pullDelta(since); + + // 2. Push offline queue + const queue = loadOfflineQueue(); + let conflicts: SyncConflict[] = []; + + if (queue.length > 0) { + const timersToSync = queue + .filter((item) => item.action !== 'delete' && item.timer) + .map((item) => item.timer!); + + if (timersToSync.length > 0) { + const result = await batchUpsert(timersToSync); + conflicts = result.conflicts; + clearSyncedFromQueue(result.synced); + } + + // Handle deletes separately + const deletes = queue.filter((item) => item.action === 'delete'); + for (const del of deletes) { + try { + await deleteRemoteTimer(del.id); + clearSyncedFromQueue([del.id]); + } catch { + // Keep in queue for retry + } + } + } + + // 3. Update last sync + setLastSyncDate(new Date().toISOString()); + + return { pulled, conflicts }; + } catch (err) { + const message = err instanceof Error ? err.message : 'Unknown sync error'; + return { pulled: [], conflicts: [], error: message }; + } +} + +// ── DTO Conversion ──────────────────────────────────────────── + +export function timerToDTO(timer: Timer): SyncTimerDTO { + return { + id: timer.id, + label: timer.label, + description: timer.description, + type: timer.type, + state: timer.state, + urgency: timer.urgency, + duration: timer.duration ?? 0, + targetTime: new Date(timer.targetTime).toISOString(), + createdAt: new Date(timer.createdAt).toISOString(), + startedAt: timer.startedAt ? new Date(timer.startedAt).toISOString() : undefined, + pausedAt: timer.pausedAt ? new Date(timer.pausedAt).toISOString() : undefined, + firedAt: timer.firedAt ? new Date(timer.firedAt).toISOString() : undefined, + completedAt: timer.completedAt ? new Date(timer.completedAt).toISOString() : undefined, + cascade: timer.cascade ? { + preset: timer.cascade.preset, + intervals: timer.cascade.intervals ?? [], + } : undefined, + pomodoro: timer.pomodoroConfig ? { + focusMinutes: timer.pomodoroConfig.workMinutes, + shortBreakMinutes: timer.pomodoroConfig.breakMinutes, + longBreakMinutes: timer.pomodoroConfig.longBreakMinutes, + roundsBeforeLong: timer.pomodoroConfig.rounds, + currentRound: timer.pomodoroState?.currentRound ?? 1, + isBreak: timer.pomodoroState?.isBreak ?? false, + totalRoundsCompleted: timer.pomodoroState?.completedRounds ?? 0, + } : undefined, + category: timer.category, + syncVersion: 1, + }; +} + +export function dtoToTimerPatch(dto: SyncTimerDTO): Partial { + return { + id: dto.id, + label: dto.label, + description: dto.description, + type: dto.type as Timer['type'], + state: dto.state as Timer['state'], + urgency: dto.urgency as Timer['urgency'], + duration: dto.duration, + targetTime: new Date(dto.targetTime).getTime(), + createdAt: new Date(dto.createdAt).getTime(), + startedAt: dto.startedAt ? new Date(dto.startedAt).getTime() : undefined, + pausedAt: dto.pausedAt ? new Date(dto.pausedAt).getTime() : undefined, + firedAt: dto.firedAt ? new Date(dto.firedAt).getTime() : undefined, + completedAt: dto.completedAt ? new Date(dto.completedAt).getTime() : undefined, + category: dto.category, + }; +} diff --git a/web/src/lib/use-sync.ts b/web/src/lib/use-sync.ts new file mode 100644 index 0000000..b9ca87d --- /dev/null +++ b/web/src/lib/use-sync.ts @@ -0,0 +1,197 @@ +// ── useSyncHook ─────────────────────────────────────────────── +// React hook for cross-platform sync with platform-service +// Auto-syncs on interval, merges remote changes into Zustand store + +'use client'; + +import { useCallback, useEffect, useRef, useState } from 'react'; +import { useTimerStore } from './store'; +import { + fullSync, + isSyncEnabled, + setSyncEnabled as _setSyncEnabled, + isAuthenticated, + setAuthToken, + loadOfflineQueue, + enqueueChange, + enqueueDeleteChange, + dtoToTimerPatch, + type SyncResult, + type SyncConflict, +} from './platform-sync'; +import type { Timer } from './timer-engine'; + +const SYNC_INTERVAL_MS = 60_000; // 1 minute + +export interface UseSyncReturn { + isSyncing: boolean; + syncEnabled: boolean; + lastSyncDate: string | null; + pendingChanges: number; + lastError: string | null; + conflicts: SyncConflict[]; + + // Actions + syncNow: () => Promise; + setSyncEnabled: (enabled: boolean) => void; + login: (token: string) => void; + logout: () => void; + + // Wrappers that enqueue changes + syncedAddTimer: (timer: Timer) => void; + syncedUpdateTimer: (timer: Timer) => void; + syncedRemoveTimer: (id: string) => void; +} + +export function useSync(): UseSyncReturn { + const [isSyncing, setIsSyncing] = useState(false); + const [syncEnabled, setSyncEnabledState] = useState(false); + const [lastSyncDate, setLastSyncDate] = useState(null); + const [pendingChanges, setPendingChanges] = useState(0); + const [lastError, setLastError] = useState(null); + const [conflicts, setConflicts] = useState([]); + const intervalRef = useRef | null>(null); + + // Load initial state + useEffect(() => { + setSyncEnabledState(isSyncEnabled()); + setPendingChanges(loadOfflineQueue().length); + setLastSyncDate( + typeof window !== 'undefined' + ? localStorage.getItem('chronomind-platform-last-sync') + : null + ); + }, []); + + // Perform sync and merge results into store + const syncNow = useCallback(async (): Promise => { + setIsSyncing(true); + setLastError(null); + + try { + const result = await fullSync(); + + if (result.error) { + setLastError(result.error); + } + + // Merge pulled timers into Zustand store + if (result.pulled.length > 0) { + const store = useTimerStore.getState(); + const currentTimers = [...store.timers]; + + for (const dto of result.pulled) { + const patch = dtoToTimerPatch(dto); + const existingIdx = currentTimers.findIndex((t) => t.id === dto.id); + + if (existingIdx >= 0) { + // Merge: remote wins if syncVersion >= local + currentTimers[existingIdx] = { + ...currentTimers[existingIdx], + ...patch, + } as Timer; + } else { + // New timer from remote — add it + currentTimers.push(patch as Timer); + } + } + + useTimerStore.setState({ timers: currentTimers }); + } + + if (result.conflicts.length > 0) { + setConflicts((prev) => [...prev, ...result.conflicts]); + } + + setPendingChanges(loadOfflineQueue().length); + setLastSyncDate( + typeof window !== 'undefined' + ? localStorage.getItem('chronomind-platform-last-sync') + : null + ); + + return result; + } finally { + setIsSyncing(false); + } + }, []); + + // Auto-sync interval + useEffect(() => { + if (syncEnabled && isAuthenticated()) { + // Initial sync on enable + syncNow(); + + intervalRef.current = setInterval(() => { + syncNow(); + }, SYNC_INTERVAL_MS); + } + + return () => { + if (intervalRef.current) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } + }; + }, [syncEnabled, syncNow]); + + const setSyncEnabled = useCallback( + (enabled: boolean) => { + _setSyncEnabled(enabled); + setSyncEnabledState(enabled); + }, + [] + ); + + const login = useCallback( + (token: string) => { + setAuthToken(token); + setSyncEnabled(true); + }, + [setSyncEnabled] + ); + + const logout = useCallback(() => { + setAuthToken(null); + setSyncEnabled(false); + setConflicts([]); + }, [setSyncEnabled]); + + // Wrappers that enqueue changes for sync + const syncedAddTimer = useCallback((timer: Timer) => { + if (isSyncEnabled()) { + enqueueChange(timer, 'create'); + setPendingChanges(loadOfflineQueue().length); + } + }, []); + + const syncedUpdateTimer = useCallback((timer: Timer) => { + if (isSyncEnabled()) { + enqueueChange(timer, 'update'); + setPendingChanges(loadOfflineQueue().length); + } + }, []); + + const syncedRemoveTimer = useCallback((id: string) => { + if (isSyncEnabled()) { + enqueueDeleteChange(id); + setPendingChanges(loadOfflineQueue().length); + } + }, []); + + return { + isSyncing, + syncEnabled, + lastSyncDate, + pendingChanges, + lastError, + conflicts, + syncNow, + setSyncEnabled, + login, + logout, + syncedAddTimer, + syncedUpdateTimer, + syncedRemoveTimer, + }; +}