gigafibre-fsm/apps/ops/src/stores/offline.js
louispaulb 0fdfec7a59 docs: reorganize into architecture/features/reference/archive folders
All docs moved with git mv so --follow preserves history. Flattens the
single-folder layout into goal-oriented folders and adds a README.md index
at every level.

- docs/README.md — new landing page with "I want to…" intent table
- docs/architecture/ — overview, data-model, app-design
- docs/features/ — billing-payments, cpe-management, vision-ocr, flow-editor
- docs/reference/ — erpnext-item-diff, legacy-wizard/
- docs/archive/ — HANDOFF-2026-04-18, MIGRATION, status-snapshots/
- docs/assets/ — pptx sources, build scripts (fixed hardcoded path)
- roadmap.md gains a "Modules in production" section with clickable
  URLs for every ops/tech/portal route and admin surface
- Phase 4 (Customer Portal) flipped to "Largely Shipped" based on
  audit of services/targo-hub/lib/payments.js (16 endpoints, webhook,
  PPA cron, Klarna BNPL all live)
- Archive files get an "ARCHIVED" banner so stale links inside them
  don't mislead readers

Code comments + nginx configs rewritten to use new doc paths. Root
README.md documentation table replaced with intent-oriented index.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 11:51:33 -04:00

237 lines
8.9 KiB
JavaScript

/**
* 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,
}
})