import { ref } from 'vue' import { db } from 'src/db' // API base URL — configured per environment const API_BASE = import.meta.env.VITE_API_BASE || '/api' const isSyncing = ref(false) const isOnline = ref(navigator.onLine) const lastSyncAt = ref(null) // Track online status window.addEventListener('online', () => { isOnline.value = true processSyncQueue() }) window.addEventListener('offline', () => { isOnline.value = false }) /** * Get the stored OAuth access token. */ async function getToken() { try { const meta = await db.meta.get('accessToken') return meta?.value || null } catch { return null } } /** * Store an OAuth access token. */ async function setToken(token) { await db.meta.put({ key: 'accessToken', value: token }) } /** * Authenticated fetch wrapper. */ async function apiFetch(path, options = {}) { const token = await getToken() if (!token) throw new Error('Not authenticated') const response = await fetch(`${API_BASE}${path}`, { ...options, headers: { 'Content-Type': 'application/json', Accept: 'application/json', Authorization: `Bearer ${token}`, ...options.headers, }, }) if (response.status === 401) { // Token expired — clear it await db.meta.delete('accessToken') throw new Error('Unauthorized') } return response } /** * Process the outbound sync queue (FIFO). * Called on app start, every 30s when online, and on reconnect. */ async function processSyncQueue() { if (!isOnline.value || isSyncing.value) return const token = await getToken() if (!token) return isSyncing.value = true try { const queue = await db.syncQueue.orderBy('queueId').toArray() if (queue.length === 0) { isSyncing.value = false return } // Batch sync: send up to 100 mutations at once const batch = queue.slice(0, 100) const mutations = batch.map((item) => ({ action: item.action, eventId: item.eventId, payload: item.payload, })) const response = await apiFetch('/events/sync', { method: 'POST', body: JSON.stringify({ mutations }), }) if (response.ok) { const data = await response.json() // Remove successfully processed items from queue const processedIds = [] data.results.forEach((result, i) => { if (result.status === 'ok') { processedIds.push(batch[i].queueId) } }) if (processedIds.length > 0) { await db.syncQueue.bulkDelete(processedIds) } // Update syncStatus on local events for (const result of data.results) { if (result.status === 'ok') { const event = await db.events.get(result.eventId) if (event && event.syncStatus !== 'local') { await db.events.update(result.eventId, { syncStatus: 'synced' }) } } } lastSyncAt.value = Date.now() // If there are more items, process next batch if (queue.length > 100) { await processSyncQueue() } } } catch (e) { console.warn('Sync queue processing failed:', e) } finally { isSyncing.value = false } } /** * Pull remote changes since last sync cursor. * Merges with local data using "last write wins" on updatedAt. */ async function pullRemoteChanges() { if (!isOnline.value) return const token = await getToken() if (!token) return try { const lastSync = await db.meta.get('lastSyncCursor') const since = lastSync?.value || null let url = '/events?limit=200' if (since) { url += `&since=${since}` } const response = await apiFetch(url) if (!response.ok) return const data = await response.json() const remoteEvents = data.data || [] for (const remote of remoteEvents) { const local = await db.events.get(remote.id) if (!local) { // New event from server await db.events.put({ id: remote.id, title: remote.title, date: remote.date, emotion: remote.emotion, customColor: remote.customColor, gradientPreset: remote.gradientPreset, image: remote.image, note: remote.note, syncStatus: 'synced', createdAt: remote.createdAt, updatedAt: remote.updatedAt, }) } else if (remote.updatedAt > local.updatedAt && local.syncStatus === 'synced') { // Remote is newer and local hasn't been modified — update await db.events.update(remote.id, { title: remote.title, date: remote.date, emotion: remote.emotion, customColor: remote.customColor, gradientPreset: remote.gradientPreset, image: remote.image, note: remote.note, syncStatus: 'synced', updatedAt: remote.updatedAt, }) } // If local is modified, skip — local changes will be pushed via sync queue } // Update sync cursor await db.meta.put({ key: 'lastSyncCursor', value: new Date().toISOString() }) // Handle pagination (cursor-based) if (data.next_cursor) { // There are more pages — but for now we only pull one batch // Future: iterate through pages } lastSyncAt.value = Date.now() } catch (e) { console.warn('Pull remote changes failed:', e) } } /** * Full sync: push local changes, then pull remote. */ async function fullSync() { await processSyncQueue() await pullRemoteChanges() } // Auto-sync interval (30s) let syncInterval = null function startAutoSync() { if (syncInterval) return syncInterval = setInterval(() => { if (isOnline.value) { fullSync() } }, 30000) // Initial sync fullSync() } function stopAutoSync() { if (syncInterval) { clearInterval(syncInterval) syncInterval = null } } export { isOnline, isSyncing, lastSyncAt, getToken, setToken, apiFetch, processSyncQueue, pullRemoteChanges, fullSync, startAutoSync, stopAutoSync, }