diff --git a/__tests__/lib/cache-manager.test.ts b/__tests__/lib/cache-manager.test.ts new file mode 100644 index 0000000..833f9a2 --- /dev/null +++ b/__tests__/lib/cache-manager.test.ts @@ -0,0 +1,172 @@ +import { initDatabase, cacheChapter, getCachedChapter, clearExpiredCache } from '@/lib/cache-manager' +import { BibleChapter } from '@/types' + +// Mock IndexedDB for testing +const mockIndexedDB = (() => { + let stores: Record> = {} + let dbVersion = 0 + + return { + open: (name: string, version: number) => { + const request: any = { + result: null, + error: null, + onsuccess: null, + onerror: null, + onupgradeneeded: null, + } + + setTimeout(() => { + if (version > dbVersion) { + dbVersion = version + const upgradeEvent: any = { + target: { + result: { + objectStoreNames: { + contains: (name: string) => !!stores[name] + }, + createObjectStore: (storeName: string, options: any) => { + stores[storeName] = {} + return { + createIndex: () => {} + } + } + } + } + } + request.onupgradeneeded?.(upgradeEvent) + } + + request.result = { + transaction: (storeNames: string[], mode: string) => { + const storeName = storeNames[0] + return { + objectStore: (name: string) => { + if (!stores[name]) stores[name] = {} + return { + get: (key: string) => { + const req: any = { + result: stores[name][key], + onsuccess: null, + onerror: null + } + setTimeout(() => req.onsuccess?.(), 0) + return req + }, + put: (value: any) => { + const key = value.chapterId + stores[name][key] = value + const req: any = { + onsuccess: null, + onerror: null + } + setTimeout(() => req.onsuccess?.(), 0) + return req + }, + count: () => { + const req: any = { + result: Object.keys(stores[name]).length, + onsuccess: null + } + setTimeout(() => req.onsuccess?.(), 0) + return req + }, + index: (indexName: string) => { + return { + openCursor: (range?: any) => { + const req: any = { + result: null, + onsuccess: null + } + setTimeout(() => req.onsuccess?.({ target: req }), 0) + return req + } + } + } + } + } + } + } + } + request.onsuccess?.() + }, 0) + + return request + } + } +})() + +// Setup mock for tests +beforeAll(() => { + ;(global as any).indexedDB = mockIndexedDB +}) + +describe('cache-manager', () => { + const mockChapter: BibleChapter = { + id: '1-1', + bookId: 1, + bookName: 'Genesis', + chapter: 1, + verses: [ + { + id: 'v1', + chapterId: '1-1', + verseNum: 1, + text: 'In the beginning God created the heaven and the earth.', + version: 'KJV', + chapter: { + chapterNum: 1, + book: { + name: 'Genesis' + } + } + } + ] + } + + describe('initDatabase', () => { + it('initializes the database successfully', async () => { + const db = await initDatabase() + expect(db).toBeDefined() + expect(db.transaction).toBeDefined() + }) + }) + + describe('cacheChapter', () => { + it('caches a chapter successfully', async () => { + await cacheChapter(mockChapter) + // If no error thrown, test passes + expect(true).toBe(true) + }) + + it('creates cache entry with expiration', async () => { + await cacheChapter(mockChapter) + const cached = await getCachedChapter('1-1') + expect(cached).toBeDefined() + expect(cached?.id).toBe('1-1') + }) + }) + + describe('getCachedChapter', () => { + it('returns cached chapter if not expired', async () => { + await cacheChapter(mockChapter) + const result = await getCachedChapter('1-1') + expect(result).not.toBeNull() + expect(result?.bookName).toBe('Genesis') + expect(result?.chapter).toBe(1) + }) + + it('returns null for non-existent chapter', async () => { + const result = await getCachedChapter('999-999') + expect(result).toBeNull() + }) + }) + + describe('clearExpiredCache', () => { + it('runs without error', async () => { + await clearExpiredCache() + // If no error thrown, test passes + expect(true).toBe(true) + }) + }) +}) diff --git a/lib/cache-manager.ts b/lib/cache-manager.ts new file mode 100644 index 0000000..20327b7 --- /dev/null +++ b/lib/cache-manager.ts @@ -0,0 +1,109 @@ +// IndexedDB cache management +import { BibleChapter, CacheEntry } from '@/types' + +const DB_NAME = 'BibleReaderDB' +const DB_VERSION = 1 +const STORE_NAME = 'chapters' +const CACHE_DURATION_MS = 30 * 24 * 60 * 60 * 1000 // 30 days +const MAX_CACHE_SIZE = 50 // keep last 50 chapters + +let db: IDBDatabase | null = null + +export async function initDatabase(): Promise { + return new Promise((resolve, reject) => { + const request = indexedDB.open(DB_NAME, DB_VERSION) + + request.onerror = () => reject(request.error) + request.onsuccess = () => { + db = request.result + resolve(db) + } + + request.onupgradeneeded = (event) => { + const database = (event.target as IDBOpenDBRequest).result + if (!database.objectStoreNames.contains(STORE_NAME)) { + const store = database.createObjectStore(STORE_NAME, { keyPath: 'chapterId' }) + store.createIndex('timestamp', 'timestamp', { unique: false }) + } + } + }) +} + +export async function cacheChapter(chapter: BibleChapter): Promise { + if (!db) await initDatabase() + + const entry: CacheEntry = { + chapterId: chapter.id, + data: chapter, + timestamp: Date.now(), + expiresAt: Date.now() + CACHE_DURATION_MS + } + + return new Promise((resolve, reject) => { + const transaction = db!.transaction([STORE_NAME], 'readwrite') + const store = transaction.objectStore(STORE_NAME) + + // Delete oldest entries if over limit + const countRequest = store.count() + countRequest.onsuccess = () => { + if (countRequest.result >= MAX_CACHE_SIZE) { + const index = store.index('timestamp') + const oldestRequest = index.openCursor() + oldestRequest.onsuccess = (event) => { + const cursor = (event.target as IDBRequest).result + if (cursor) { + cursor.delete() + } + } + } + } + + const request = store.put(entry) + request.onerror = () => reject(request.error) + request.onsuccess = () => resolve() + }) +} + +export async function getCachedChapter(chapterId: string): Promise { + if (!db) await initDatabase() + + return new Promise((resolve, reject) => { + const transaction = db!.transaction([STORE_NAME], 'readonly') + const store = transaction.objectStore(STORE_NAME) + const request = store.get(chapterId) + + request.onerror = () => reject(request.error) + request.onsuccess = () => { + const entry = request.result as CacheEntry | undefined + if (entry && entry.expiresAt > Date.now()) { + resolve(entry.data) + } else { + resolve(null) + } + } + }) +} + +export async function clearExpiredCache(): Promise { + if (!db) await initDatabase() + + return new Promise((resolve, reject) => { + const transaction = db!.transaction([STORE_NAME], 'readwrite') + const store = transaction.objectStore(STORE_NAME) + const index = store.index('timestamp') + const range = IDBKeyRange.upperBound(Date.now() - CACHE_DURATION_MS) + const request = index.openCursor(range) + + request.onsuccess = (event) => { + const cursor = (event.target as IDBRequest).result + if (cursor) { + cursor.delete() + cursor.continue() + } else { + resolve() + } + } + + request.onerror = () => reject(request.error) + }) +} diff --git a/types/index.ts b/types/index.ts index c090e40..9e87d59 100644 --- a/types/index.ts +++ b/types/index.ts @@ -54,3 +54,43 @@ export interface PrayerRequest { createdAt: Date updatedAt: Date } + +// Bible Reader 2025 Types +export interface BibleChapter { + id: string + bookId: number + bookName: string + chapter: number + verses: BibleVerse[] + timestamp?: number +} + +export interface ReadingPreference { + fontFamily: string // 'georgia', 'inter', 'atkinson', etc. + fontSize: number // 12-32 + lineHeight: number // 1.4-2.2 + letterSpacing: number // 0-0.15 + textAlign: 'left' | 'center' | 'justify' + backgroundColor: string // color code + textColor: string // color code + margin: 'narrow' | 'normal' | 'wide' + preset: 'default' | 'dyslexia' | 'highContrast' | 'minimal' | 'custom' +} + +export interface UserAnnotation { + id: string + verseId: string + chapterId: string + type: 'bookmark' | 'highlight' | 'note' | 'crossRef' + content?: string + color?: string // for highlights + timestamp: number + synced: boolean +} + +export interface CacheEntry { + chapterId: string + data: BibleChapter + timestamp: number + expiresAt: number +}