/** * Offline store — mutation queue + vision (Gemini) retry queue. * * This store is the backbone of the tech `/j` (mobile) workflow: techs work * in basements, elevators, and under couches where LTE drops for seconds to * minutes. We can't afford to lose a scan or a "job completed" tap, so both * mutations AND vision photos are persisted to IndexedDB and retried in the * background when connectivity returns. * * Two queues, different retry strategies: * * ┌─ queue (ERPNext mutations) ──────────────────────────────────────┐ * │ { type: 'create'|'update', doctype, name?, data, ts, id } │ * │ flush on `online` event → replay createDoc/updateDoc. │ * │ Failed items stay queued until next online flip. │ * └──────────────────────────────────────────────────────────────────┘ * * ┌─ visionQueue (Gemini photo OCR) ─────────────────────────────────┐ * │ { id, image (base64), ts, status } │ * │ Retries are time-driven (scheduleVisionRetry), not connectivity│ * │ -driven, because `navigator.onLine` lies in weak-signal zones │ * │ (reports true on a captive 2-bar LTE that can't actually │ * │ reach msg.gigafibre.ca). First retry at 5s, backoff to 30s. │ * │ │ * │ Successful scans land in `scanResults` and the `useScanner` │ * │ composable merges them back into the UI via a watcher. │ * └──────────────────────────────────────────────────────────────────┘ * * IndexedDB keys (idb-keyval, no schema): * - `offline-queue` → mutation queue * - `vision-queue` → pending photos * - `vision-results` → completed scans waiting for the UI to consume * - `cache-{key}` → generic read cache (used for read-through patterns) * * Ported from apps/field/src/stores/offline.js as part of the field→ops * unification (see docs/architecture/overview.md §"Legacy Retirement Plan"). */ import { defineStore } from 'pinia' import { ref, computed } from 'vue' import { get, set } from 'idb-keyval' import { createDoc, updateDoc } from 'src/api/erp' import { scanBarcodes } from 'src/api/ocr' export const useOfflineStore = defineStore('offline', () => { // ─── Mutation queue ────────────────────────────────────────────── const queue = ref([]) const syncing = ref(false) const online = ref(navigator.onLine) const pendingCount = computed(() => queue.value.length) // ─── Vision queue ──────────────────────────────────────────────── const visionQueue = ref([]) // { id, image (base64), ts, status } const scanResults = ref([]) // { id, barcodes: string[], ts } const pendingVisionCount = computed(() => visionQueue.value.length) let retryTimer = null let visionSyncing = false // Listen to connectivity changes. We kick off BOTH queues on `online` // because a reconnect is the cheapest signal we have that things might // work now — worst case the retries fail again and we stay queued. window.addEventListener('online', () => { online.value = true syncQueue() syncVisionQueue() }) window.addEventListener('offline', () => { online.value = false }) async function loadQueue () { try { const stored = await get('offline-queue') queue.value = stored || [] } catch { queue.value = [] } } async function saveQueue () { // Pinia refs aren't structured-clonable directly (proxies); JSON // round-trip is the simplest way to get a plain copy for IndexedDB. await set('offline-queue', JSON.parse(JSON.stringify(queue.value))) } async function loadVisionQueue () { try { visionQueue.value = (await get('vision-queue')) || [] scanResults.value = (await get('vision-results')) || [] } catch { visionQueue.value = [] scanResults.value = [] } // If we're restoring a non-empty queue (app was closed with pending // scans), give the network 5s to settle before the first retry. if (visionQueue.value.length) scheduleVisionRetry(5000) } async function saveVisionQueue () { await set('vision-queue', JSON.parse(JSON.stringify(visionQueue.value))) } async function saveScanResults () { await set('vision-results', JSON.parse(JSON.stringify(scanResults.value))) } /** * Enqueue a mutation to be synced later. * @param {{ type: 'create'|'update', doctype: string, name?: string, data: object }} action */ async function enqueue (action) { action.ts = Date.now() action.id = action.ts + '-' + Math.random().toString(36).slice(2, 8) queue.value.push(action) await saveQueue() if (online.value) syncQueue() return action } async function syncQueue () { if (syncing.value || queue.value.length === 0) return syncing.value = true const failed = [] for (const action of [...queue.value]) { try { if (action.type === 'create') { await createDoc(action.doctype, action.data) } else if (action.type === 'update') { await updateDoc(action.doctype, action.name, action.data) } } catch { failed.push(action) } } queue.value = failed await saveQueue() syncing.value = false } /** * Enqueue a photo whose Gemini scan couldn't complete (timeout / offline). * Called by useScanner when scanBarcodes exceeds SCAN_TIMEOUT_MS or throws * a network error. Returns the queued entry so the caller can display a * "scan en attente" chip in the UI. * * @param {{ image: string }} opts — base64 (data URI) of the optimized image */ async function enqueueVisionScan ({ image }) { const entry = { id: Date.now() + '-' + Math.random().toString(36).slice(2, 8), image, ts: Date.now(), status: 'queued', } visionQueue.value.push(entry) await saveVisionQueue() scheduleVisionRetry(5000) return entry } /** * Retry each queued photo. Success → move to scanResults, fail → stay * queued with a bumped retry schedule. We drive retries off the queue * itself, not off `online`, because navigator.onLine can report true * even on weak LTE that can't reach the hub. */ async function syncVisionQueue () { if (visionSyncing) return if (retryTimer) { clearTimeout(retryTimer); retryTimer = null } if (visionQueue.value.length === 0) return visionSyncing = true const remaining = [] try { for (const entry of [...visionQueue.value]) { try { entry.status = 'syncing' const result = await scanBarcodes(entry.image) scanResults.value.push({ id: entry.id, barcodes: result.barcodes || [], ts: Date.now(), }) } catch { entry.status = 'queued' remaining.push(entry) } } visionQueue.value = remaining await Promise.all([saveVisionQueue(), saveScanResults()]) if (remaining.length) scheduleVisionRetry(30000) } finally { visionSyncing = false } } function scheduleVisionRetry (delay) { if (retryTimer) return retryTimer = setTimeout(() => { retryTimer = null syncVisionQueue() }, delay) } /** * Consumer (ScanPage / TechScanPage) calls this after merging a result * into the UI so the same serial doesn't reappear next time the page * mounts from persisted state. */ async function consumeScanResult (id) { scanResults.value = scanResults.value.filter(r => r.id !== id) await saveScanResults() } // ─── Generic read cache (used by list pages for offline browse) ── async function cacheData (key, data) { await set('cache-' + key, { data, ts: Date.now() }) } async function getCached (key) { try { const entry = await get('cache-' + key) return entry?.data || null } catch { return null } } // Kick off initial loads (fire-and-forget — refs start empty and fill // in once IndexedDB resolves, which is fine for the UI). loadQueue() loadVisionQueue() return { // mutation queue queue, syncing, online, pendingCount, enqueue, syncQueue, // vision queue visionQueue, scanResults, pendingVisionCount, enqueueVisionScan, syncVisionQueue, consumeScanResult, // read cache cacheData, getCached, loadQueue, } })