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