File size: 8,200 Bytes
793e953
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
/**
 * 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<IDBDatabase> {
  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<T>(
  mode: IDBTransactionMode,
  callback: (store: IDBObjectStore) => IDBRequest<T>,
): Promise<T> {
  const db = await openDB()
  return new Promise<T>((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<void> {
  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<IDBCursorWithValue>).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<void> {
  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<CachedConsultation | null> {
  try {
    const result = await withStore<CachedConsultation | undefined>(
      '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<CachedConsultation[]> {
  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<IDBCursorWithValue>).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<void> {
  await withStore('readwrite', (store) => store.delete(id))
}

/**
 * Remove all cached consultations from the store.
 */
export async function clearAll(): Promise<void> {
  await withStore('readwrite', (store) => store.clear())
}