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:
saravanakumardb1 2026-02-28 00:25:35 -08:00
parent 8b6f44ac9a
commit af33a2c86d
5 changed files with 1398 additions and 0 deletions

View File

@ -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)

View File

@ -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?
)

View 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"
}
}
}

View 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
View 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,
};
}