feat(sync): add cross-platform sync managers for iOS, Android, and Web
- iOS: PlatformSyncManager — URLSession, delta sync, batch upload, offline queue, HMAC auth - Android: PlatformApiClient + SyncRepository — HttpURLConnection, Room integration, offline queue - Web: platform-sync.ts + use-sync.ts — fetch client, localStorage queue, React hook with 60s auto-sync All consume platform-service /timers/*, /routines/*, /households/* endpoints. Sync protocol: syncVersion optimistic concurrency, delta sync via ?since=, batch upsert for offline flush.
This commit is contained in:
parent
8b6f44ac9a
commit
af33a2c86d
@ -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<Int>
|
||||
)
|
||||
|
||||
@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<SyncTimerDTO>)
|
||||
|
||||
@Serializable
|
||||
data class BatchResult(
|
||||
val synced: List<String> = emptyList(),
|
||||
val conflicts: List<SyncConflict> = emptyList(),
|
||||
val errors: List<String> = 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=<ISO>
|
||||
fun pullDelta(since: String?): List<SyncTimerDTO> {
|
||||
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<SyncTimerDTO>): BatchResult {
|
||||
val body = json.encodeToString(BatchRequest.serializer(), BatchRequest(timers))
|
||||
val response = post("$baseUrl/timers/batch", body)
|
||||
return json.decodeFromString(response)
|
||||
}
|
||||
|
||||
// GET /routines/sync?since=<ISO>
|
||||
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)
|
||||
@ -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<Boolean> = _isSyncing
|
||||
|
||||
private val _pendingChanges = MutableStateFlow(0)
|
||||
val pendingChanges: StateFlow<Int> = _pendingChanges
|
||||
|
||||
private val _lastError = MutableStateFlow<String?>(null)
|
||||
val lastError: StateFlow<String?> = _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<SyncConflict>()
|
||||
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<OfflineQueueItem> {
|
||||
val raw = prefs.getString("offline_queue", null) ?: return emptyList()
|
||||
return try {
|
||||
json.decodeFromString(raw)
|
||||
} catch (_: Exception) {
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
private fun saveOfflineQueue(queue: List<OfflineQueueItem>) {
|
||||
prefs.edit().putString("offline_queue", json.encodeToString(queue)).apply()
|
||||
}
|
||||
|
||||
private fun clearSyncedFromQueue(syncedIds: List<String>) {
|
||||
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<SyncTimerDTO>,
|
||||
val conflicts: List<SyncConflict>,
|
||||
val error: String?
|
||||
)
|
||||
449
ios/ChronoMind/Shared/Cloud/PlatformSyncManager.swift
Normal file
449
ios/ChronoMind/Shared/Cloud/PlatformSyncManager.swift
Normal file
@ -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<Void, Never>?
|
||||
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"
|
||||
}
|
||||
}
|
||||
}
|
||||
327
web/src/lib/platform-sync.ts
Normal file
327
web/src/lib/platform-sync.ts
Normal file
@ -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<string, unknown>).__PLATFORM_URL__) {
|
||||
return (window as Record<string, unknown>).__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<T>(
|
||||
path: string,
|
||||
method: string,
|
||||
body?: unknown
|
||||
): Promise<T> {
|
||||
const token = getAuthToken();
|
||||
const headers: Record<string, string> = {
|
||||
'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<SyncTimerDTO[]> {
|
||||
const params = since ? `?since=${encodeURIComponent(since)}` : '';
|
||||
return apiRequest<SyncTimerDTO[]>(`/timers/sync${params}`, 'GET');
|
||||
}
|
||||
|
||||
export async function pushTimer(dto: SyncTimerDTO): Promise<SyncTimerDTO> {
|
||||
return apiRequest<SyncTimerDTO>('/timers', 'POST', dto);
|
||||
}
|
||||
|
||||
export async function updateRemoteTimer(
|
||||
id: string,
|
||||
updates: Partial<SyncTimerDTO>
|
||||
): Promise<SyncTimerDTO> {
|
||||
return apiRequest<SyncTimerDTO>(`/timers/${id}`, 'PUT', updates);
|
||||
}
|
||||
|
||||
export async function deleteRemoteTimer(id: string): Promise<void> {
|
||||
return apiRequest<void>(`/timers/${id}`, 'DELETE');
|
||||
}
|
||||
|
||||
export async function batchUpsert(
|
||||
timers: SyncTimerDTO[]
|
||||
): Promise<BatchResult> {
|
||||
return apiRequest<BatchResult>('/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<SyncResult> {
|
||||
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<Timer> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
197
web/src/lib/use-sync.ts
Normal file
197
web/src/lib/use-sync.ts
Normal file
@ -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<SyncResult>;
|
||||
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<string | null>(null);
|
||||
const [pendingChanges, setPendingChanges] = useState(0);
|
||||
const [lastError, setLastError] = useState<string | null>(null);
|
||||
const [conflicts, setConflicts] = useState<SyncConflict[]>([]);
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval> | 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<SyncResult> => {
|
||||
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,
|
||||
};
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user