import Dexie, { type EntityTable } from 'dexie' import type { CartonLabelMajorityField, CartonLabelScan } from './cartonLabelConformity' import type { LabelFields } from './labelOcr' export const FIELDLENS_SCHEMA_VERSION = 1 as const export const FIELDLENS_SYNC_REQUEST_EVENT = 'fieldlens:sync-request' as const export type ScanRecord = { id: string rawValue: string format: string isDuplicate: boolean scannedAt: string syncStatus: 'local' | 'synced' } export type EanPalletOcrRecord = { ean: string fields: LabelFields rawText: string confidence: number comparisonSource: string comparisonStatus: 'not-run' | 'pass' | 'fail' | 'review' } export type EanPalletSessionRecord = { id: string targetCount: number scans: string[] startedAt: string updatedAt: string completedAt?: string status: 'active' | 'complete' conforms?: boolean consensusEan?: string uniqueEanCount?: number ocr?: EanPalletOcrRecord } export type PalletLabelSessionRecord = { id: string targetCount: number labels: CartonLabelScan[] startedAt: string updatedAt: string completedAt?: string status: 'active' | 'complete' conforms?: boolean resultStatus?: 'pass' | 'fail' | 'review' mismatchCartons?: number majorityFields?: CartonLabelMajorityField[] } export type OutboxRecord = { id: string aggregateId: string eventType: 'scan.captured' | 'pallet.session.started' | 'pallet.observation.captured' | 'pallet.session.completed' schemaVersion: number payload: unknown createdAt: string attempts: number nextAttemptAt?: string syncedAt?: string lastError?: string } export type MetadataRecord = { key: string value: unknown } class FieldLensDatabase extends Dexie { scans!: EntityTable eanPalletSessions!: EntityTable palletLabelSessions!: EntityTable outbox!: EntityTable metadata!: EntityTable constructor() { super('fieldlens-pwa') this.version(1).stores({ scans: 'id, rawValue, format, isDuplicate, scannedAt, syncStatus', }) this.version(2).stores({ scans: 'id, rawValue, format, isDuplicate, scannedAt, syncStatus', eanPalletSessions: 'id, startedAt, updatedAt, completedAt, status', }) this.version(3).stores({ scans: 'id, rawValue, format, isDuplicate, scannedAt, syncStatus', eanPalletSessions: 'id, startedAt, updatedAt, completedAt, status', palletLabelSessions: 'id, startedAt, updatedAt, completedAt, status', }) this.version(4).stores({ scans: 'id, rawValue, format, isDuplicate, scannedAt, syncStatus', eanPalletSessions: 'id, startedAt, updatedAt, completedAt, status', palletLabelSessions: 'id, startedAt, updatedAt, completedAt, status', outbox: 'id, aggregateId, eventType, createdAt, attempts, nextAttemptAt, syncedAt', metadata: 'key', }) } } export const database = new FieldLensDatabase() function requestBackgroundSync() { if (typeof window === 'undefined') return window.dispatchEvent(new Event(FIELDLENS_SYNC_REQUEST_EVENT)) } function outboxEvent( aggregateId: string, eventType: OutboxRecord['eventType'], payload: unknown, createdAt = new Date().toISOString(), ): OutboxRecord { return { id: crypto.randomUUID(), aggregateId, eventType, schemaVersion: FIELDLENS_SCHEMA_VERSION, payload, createdAt, attempts: 0, } } export async function recordScan(rawValue: string, format: string) { const record = await database.transaction('rw', database.scans, database.outbox, async () => { const existing = await database.scans.where('rawValue').equals(rawValue).first() const nextRecord: ScanRecord = { id: crypto.randomUUID(), rawValue, format, isDuplicate: Boolean(existing), scannedAt: new Date().toISOString(), syncStatus: 'local', } await database.scans.add(nextRecord) await database.outbox.add(outboxEvent(nextRecord.id, 'scan.captured', { id: nextRecord.id, rawValue: nextRecord.rawValue, format: nextRecord.format, duplicate: nextRecord.isDuplicate, capturedAt: nextRecord.scannedAt, schemaVersion: FIELDLENS_SCHEMA_VERSION, }, nextRecord.scannedAt)) return nextRecord }) requestBackgroundSync() return record } export async function startEanPalletSession(targetCount: number) { const now = new Date().toISOString() const record: EanPalletSessionRecord = { id: crypto.randomUUID(), targetCount, scans: [], startedAt: now, updatedAt: now, status: 'active', } await database.transaction('rw', database.eanPalletSessions, database.outbox, async () => { await database.eanPalletSessions.add(record) await database.outbox.add(outboxEvent(record.id, 'pallet.session.started', { id: record.id, workflowType: 'ean', targetCount, startedAt: now, status: 'active', schemaVersion: FIELDLENS_SCHEMA_VERSION, }, now)) }) return record } export async function saveEanPalletScans(id: string, scans: string[]) { const now = new Date().toISOString() return database.transaction('rw', database.eanPalletSessions, database.outbox, async () => { const existing = await database.eanPalletSessions.get(id) const previousCount = existing?.scans.length ?? 0 await database.eanPalletSessions.update(id, { scans, updatedAt: now }) for (let index = previousCount; index < scans.length; index += 1) { const ean = scans[index] if (!ean) continue const observationId = crypto.randomUUID() await database.outbox.add(outboxEvent(id, 'pallet.observation.captured', { id: observationId, palletSessionId: id, cartonIndex: index + 1, ean, capturedAt: now, schemaVersion: FIELDLENS_SCHEMA_VERSION, }, now)) } }) } export async function finishEanPalletSession( id: string, details: Pick, ) { const now = new Date().toISOString() return database.transaction('rw', database.eanPalletSessions, database.outbox, async () => { const session = await database.eanPalletSessions.get(id) const updated = { ...details, status: 'complete' as const, completedAt: now, updatedAt: now, } const result = await database.eanPalletSessions.update(id, updated) await database.outbox.add(outboxEvent(id, 'pallet.session.completed', { id, workflowType: 'ean', targetCount: session?.targetCount ?? 0, startedAt: session?.startedAt ?? now, completedAt: now, status: 'complete', conforms: details.conforms, resultStatus: details.conforms ? 'pass' : 'fail', consensusEan: details.consensusEan, uniqueEanCount: details.uniqueEanCount, schemaVersion: FIELDLENS_SCHEMA_VERSION, }, now)) return result }) } export function saveEanPalletOcr(id: string, ocr: EanPalletOcrRecord) { return database.eanPalletSessions.update(id, { ocr, updatedAt: new Date().toISOString(), }) } export async function startPalletLabelSession(targetCount: number) { const now = new Date().toISOString() const record: PalletLabelSessionRecord = { id: crypto.randomUUID(), targetCount, labels: [], startedAt: now, updatedAt: now, status: 'active', } await database.transaction('rw', database.palletLabelSessions, database.outbox, async () => { await database.palletLabelSessions.add(record) await database.outbox.add(outboxEvent(record.id, 'pallet.session.started', { id: record.id, workflowType: 'carton-label', targetCount, startedAt: now, status: 'active', schemaVersion: FIELDLENS_SCHEMA_VERSION, }, now)) }) return record } export async function savePalletLabelScans(id: string, labels: CartonLabelScan[]) { const now = new Date().toISOString() return database.transaction('rw', database.palletLabelSessions, database.outbox, async () => { const existing = await database.palletLabelSessions.get(id) const previousCount = existing?.labels.length ?? 0 await database.palletLabelSessions.update(id, { labels, updatedAt: now }) for (let index = previousCount; index < labels.length; index += 1) { const label = labels[index] if (!label) continue await database.outbox.add(outboxEvent(id, 'pallet.observation.captured', { id: label.id, palletSessionId: id, cartonIndex: index + 1, fieldsJson: label.fields, rawText: label.rawText, confidence: label.confidence, capturedAt: label.capturedAt, schemaVersion: FIELDLENS_SCHEMA_VERSION, }, label.capturedAt)) } }) } export async function finishPalletLabelSession( id: string, details: Pick, ) { const now = new Date().toISOString() return database.transaction('rw', database.palletLabelSessions, database.outbox, async () => { const session = await database.palletLabelSessions.get(id) const updated = { ...details, status: 'complete' as const, completedAt: now, updatedAt: now, } const result = await database.palletLabelSessions.update(id, updated) await database.outbox.add(outboxEvent(id, 'pallet.session.completed', { id, workflowType: 'carton-label', targetCount: session?.targetCount ?? 0, startedAt: session?.startedAt ?? now, completedAt: now, status: 'complete', conforms: details.conforms, resultStatus: details.resultStatus, mismatchCartons: details.mismatchCartons, majorityFields: details.majorityFields, schemaVersion: FIELDLENS_SCHEMA_VERSION, }, now)) return result }) } export async function getInstallationId() { return database.transaction('rw', database.metadata, async () => { const existing = await database.metadata.get('installationId') if (typeof existing?.value === 'string' && existing.value) return existing.value const installationId = crypto.randomUUID() await database.metadata.put({ key: 'installationId', value: installationId }) return installationId }) } export async function setLocalMetadata(key: string, value: unknown) { await database.metadata.put({ key, value }) } export async function getLocalMetadata(key: string) { return (await database.metadata.get(key))?.value as T | undefined } export async function getPendingOutbox(limit = 50, now = new Date()) { const records = await database.outbox.filter((record) => !record.syncedAt && (!record.nextAttemptAt || new Date(record.nextAttemptAt) <= now), ).toArray() return records.sort((left, right) => left.createdAt.localeCompare(right.createdAt)).slice(0, limit) } export async function getOutboxStats() { const records = await database.outbox.toArray() return { pending: records.filter((record) => !record.syncedAt).length, errors: records.filter((record) => !record.syncedAt && Boolean(record.lastError)).length, synced: records.filter((record) => Boolean(record.syncedAt)).length, } } export async function acknowledgeOutboxEvent(id: string) { await database.transaction('rw', database.outbox, database.scans, async () => { const event = await database.outbox.get(id) if (!event) return await database.outbox.update(id, { syncedAt: new Date().toISOString(), lastError: undefined, nextAttemptAt: undefined, }) if (event.eventType === 'scan.captured') { await database.scans.update(event.aggregateId, { syncStatus: 'synced' }) } }) } export async function failOutboxEvent(id: string, error: string) { const record = await database.outbox.get(id) if (!record || record.syncedAt) return const attempts = record.attempts + 1 const delayMs = Math.min(5 * 60_000, 2_000 * (2 ** Math.min(attempts - 1, 7))) await database.outbox.update(id, { attempts, lastError: error.slice(0, 300), nextAttemptAt: new Date(Date.now() + delayMs).toISOString(), }) } export function clearScanHistory() { return database.scans.clear() } export function deleteScan(id: string) { return database.scans.delete(id) } export function restoreScan(scan: ScanRecord) { return database.scans.put(scan) } export function countDistinctScanValues(scans: ScanRecord[]) { return new Set(scans.map((scan) => scan.rawValue)).size } export function serializeScanHistory(scans: ScanRecord[]) { return JSON.stringify(scans, null, 2) }