/** * Offline SOAP Cache Utility * * Uses IndexedDB to cache consultation data (consultation info + SOAP + patient) * locally for offline access. Supports up to 50 entries with automatic eviction * of the oldest entries when the limit is exceeded. */ import type { SoapData } from '../api' // ================================================================ // Types // ================================================================ /** Consultation summary stored alongside SOAP and patient data */ export interface CachedConsultationInfo { id: string animal_name: string species: string breed: string age: string sex: string weight: string doctor_name: string status: string created_at: string } /** Patient data stored in the cache */ export interface CachedPatientInfo { animal_name: string species: string breed: string age: string weight: string allergy_history: string } /** Full cache entry matching the shared record pattern */ export interface CachedConsultation { /** Consultation ID (also the cache key) */ id: string /** Consultation summary metadata */ consultation: CachedConsultationInfo /** SOAP data (may be null if not yet generated) */ soap: SoapData | null /** Patient information */ patient: CachedPatientInfo | null /** Unix timestamp (ms) when this entry was cached */ cached_at: number } // ================================================================ // Constants // ================================================================ const DB_NAME = 'vetcopilot_offline_cache' const DB_VERSION = 1 const STORE_NAME = 'consultations' const MAX_ENTRIES = 50 // ================================================================ // IndexedDB helpers // ================================================================ /** Open (or create) the IndexedDB database */ function openDB(): Promise { return new Promise((resolve, reject) => { const request = indexedDB.open(DB_NAME, DB_VERSION) request.onupgradeneeded = (event) => { const db = (event.target as IDBOpenDBRequest).result if (!db.objectStoreNames.contains(STORE_NAME)) { const store = db.createObjectStore(STORE_NAME, { keyPath: 'id' }) // Create an index on cached_at for efficient eviction queries store.createIndex('cached_at', 'cached_at', { unique: false }) } } request.onsuccess = (event) => { resolve((event.target as IDBOpenDBRequest).result) } request.onerror = (event) => { reject( new Error( `Failed to open IndexedDB: ${(event.target as IDBOpenDBRequest).error?.message ?? 'unknown error'}`, ), ) } request.onblocked = () => { reject(new Error('IndexedDB blocked: close other tabs using this database')) } }) } /** Perform a read-write transaction on the store and return its result */ async function withStore( mode: IDBTransactionMode, callback: (store: IDBObjectStore) => IDBRequest, ): Promise { const db = await openDB() return new Promise((resolve, reject) => { const transaction = db.transaction(STORE_NAME, mode) const store = transaction.objectStore(STORE_NAME) const request = callback(store) request.onsuccess = () => { resolve(request.result) } request.onerror = () => { reject( new Error( `IndexedDB request failed: ${request.error?.message ?? 'unknown error'}`, ), ) } // Close DB when transaction completes (or fails) transaction.oncomplete = () => { db.close() } transaction.onerror = () => { db.close() reject( new Error( `IndexedDB transaction failed: ${transaction.error?.message ?? 'unknown error'}`, ), ) } transaction.onabort = () => { db.close() reject(new Error('IndexedDB transaction aborted')) } }) } // ================================================================ // Eviction // ================================================================ /** * Remove the oldest entries if the store exceeds MAX_ENTRIES. * Uses the cached_at index to find and delete the oldest. */ async function evictOldestIfNeeded(): Promise { const db = await openDB() return new Promise((resolve, reject) => { const transaction = db.transaction(STORE_NAME, 'readwrite') const store = transaction.objectStore(STORE_NAME) const countRequest = store.count() countRequest.onsuccess = () => { const count = countRequest.result if (count <= MAX_ENTRIES) { db.close() resolve() return } const excess = count - MAX_ENTRIES const index = store.index('cached_at') const cursorRequest = index.openCursor() let deleted = 0 cursorRequest.onsuccess = (event) => { const cursor = (event.target as IDBRequest).result if (cursor && deleted < excess) { cursor.delete() deleted++ cursor.continue() } // Cursor exhausted or enough deleted } } countRequest.onerror = () => { db.close() reject(new Error('Failed to count cached entries')) } transaction.oncomplete = () => { db.close() resolve() } transaction.onerror = () => { db.close() reject(new Error('Eviction transaction failed')) } transaction.onabort = () => { db.close() reject(new Error('Eviction transaction aborted')) } }) } // ================================================================ // Public API // ================================================================ /** * Cache a full consultation record (consultation info + SOAP + patient). * * If an entry with the same ID already exists, it will be replaced. * After insertion, if total entries exceed MAX_ENTRIES (50), the oldest * entries are automatically evicted. */ export async function cacheConsultation( data: CachedConsultation, ): Promise { await withStore('readwrite', (store) => store.put(data)) await evictOldestIfNeeded() } /** * Retrieve a single cached consultation by its ID. * Returns null if the consultation is not cached. */ export async function getCachedConsultation( id: string, ): Promise { try { const result = await withStore( 'readonly', (store) => store.get(id), ) return result ?? null } catch { return null } } /** * Retrieve all cached consultations ordered by cached_at descending (newest first). */ export async function getAllCachedConsultations(): Promise { try { const db = await openDB() return new Promise((resolve, reject) => { const transaction = db.transaction(STORE_NAME, 'readonly') const store = transaction.objectStore(STORE_NAME) const results: CachedConsultation[] = [] const request = store.openCursor() request.onsuccess = (event) => { const cursor = (event.target as IDBRequest).result if (cursor) { results.push(cursor.value as CachedConsultation) cursor.continue() } else { // Sort newest first results.sort((a, b) => b.cached_at - a.cached_at) resolve(results) } } request.onerror = () => { reject(new Error('Failed to read all cached items')) } transaction.oncomplete = () => { db.close() } transaction.onerror = () => { db.close() reject(new Error('Transaction failed')) } transaction.onabort = () => { db.close() reject(new Error('Transaction aborted')) } }) } catch { return [] } } /** * Remove a single cached consultation by its ID. */ export async function removeConsultation(id: string): Promise { await withStore('readwrite', (store) => store.delete(id)) } /** * Remove all cached consultations from the store. */ export async function clearAll(): Promise { await withStore('readwrite', (store) => store.clear()) }