PWA optimization: vite-plugin-pwa, split misc.json, IndexedDB cache layer
This commit is contained in:
1 parent
c342a5b636
commit
8b27bd4767
70 files changed
+5612
-57852
No files matched your search
@@ -0,0 +1,209 @@
|
||||
/**
|
||||
* Persistent data cache using IndexedDB.
|
||||
*
|
||||
* Provides cache-first fetch with TTL support for JSON and text data.
|
||||
* Works alongside the service worker: SW caches static assets,
|
||||
* IndexedDB caches fetched data for cross-session persistence.
|
||||
*
|
||||
* Usage:
|
||||
* const data = await DataCache.fetch('/data/ru/lives/01.json');
|
||||
* await DataCache.set('my-key', { some: 'data' });
|
||||
* const cached = await DataCache.get('my-key');
|
||||
* await DataCache.clear();
|
||||
* const estimate = await DataCache.estimate();
|
||||
*/
|
||||
|
||||
const DB_NAME = 'ponomar-cache';
|
||||
const DB_VERSION = 1;
|
||||
const STORE_NAME = 'data';
|
||||
const DEFAULT_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
|
||||
|
||||
interface CacheEntry {
|
||||
key: string;
|
||||
data: unknown;
|
||||
contentType: string;
|
||||
storedAt: number;
|
||||
ttl: number;
|
||||
}
|
||||
|
||||
function openDB(): Promise<IDBDatabase> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
||||
request.onupgradeneeded = () => {
|
||||
const db = request.result;
|
||||
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
||||
const store = db.createObjectStore(STORE_NAME, { keyPath: 'key' });
|
||||
store.createIndex('storedAt', 'storedAt', { unique: false });
|
||||
}
|
||||
};
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
export class DataCache {
|
||||
/**
|
||||
* Get a value from the cache.
|
||||
* Returns null if the key doesn't exist or the TTL has expired.
|
||||
*/
|
||||
static async get(key: string): Promise<unknown | null> {
|
||||
try {
|
||||
const db = await openDB();
|
||||
return new Promise<unknown | null>((resolve) => {
|
||||
const tx = db.transaction(STORE_NAME, 'readonly');
|
||||
const store = tx.objectStore(STORE_NAME);
|
||||
const request = store.get(key);
|
||||
request.onsuccess = () => {
|
||||
const entry = request.result as CacheEntry | undefined;
|
||||
if (!entry) {
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
// Check TTL
|
||||
if (Date.now() - entry.storedAt > entry.ttl) {
|
||||
// Expired — remove and return null
|
||||
db.transaction(STORE_NAME, 'readwrite')
|
||||
.objectStore(STORE_NAME)
|
||||
.delete(key);
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
resolve(entry.data);
|
||||
};
|
||||
request.onerror = () => resolve(null);
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a value in the cache.
|
||||
*/
|
||||
static async set(key: string, data: unknown, ttl: number = DEFAULT_TTL_MS): Promise<void> {
|
||||
try {
|
||||
const db = await openDB();
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(STORE_NAME, 'readwrite');
|
||||
const store = tx.objectStore(STORE_NAME);
|
||||
const entry: CacheEntry = {
|
||||
key,
|
||||
data,
|
||||
contentType: typeof data === 'string' ? 'text' : 'json',
|
||||
storedAt: Date.now(),
|
||||
ttl,
|
||||
};
|
||||
store.put(entry);
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => reject(tx.error);
|
||||
});
|
||||
} catch {
|
||||
// Silently fail — cache is best-effort
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove an entry from the cache.
|
||||
*/
|
||||
static async remove(key: string): Promise<void> {
|
||||
try {
|
||||
const db = await openDB();
|
||||
return new Promise((resolve) => {
|
||||
db.transaction(STORE_NAME, 'readwrite')
|
||||
.objectStore(STORE_NAME)
|
||||
.delete(key);
|
||||
resolve();
|
||||
});
|
||||
} catch {
|
||||
// Silently fail
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all cached data.
|
||||
*/
|
||||
static async clear(): Promise<void> {
|
||||
try {
|
||||
const db = await openDB();
|
||||
return new Promise((resolve) => {
|
||||
db.transaction(STORE_NAME, 'readwrite')
|
||||
.objectStore(STORE_NAME)
|
||||
.clear();
|
||||
resolve();
|
||||
});
|
||||
} catch {
|
||||
// Silently fail
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get storage usage estimate.
|
||||
* Returns { usage, quota } in bytes, or null if not available.
|
||||
*/
|
||||
static async estimate(): Promise<{ usage: number; quota: number } | null> {
|
||||
try {
|
||||
if (navigator.storage && navigator.storage.estimate) {
|
||||
const est = await navigator.storage.estimate();
|
||||
return { usage: est.usage ?? 0, quota: est.quota ?? 0 };
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Count entries in the cache.
|
||||
*/
|
||||
static async count(): Promise<number> {
|
||||
try {
|
||||
const db = await openDB();
|
||||
return new Promise((resolve) => {
|
||||
const tx = db.transaction(STORE_NAME, 'readonly');
|
||||
const store = tx.objectStore(STORE_NAME);
|
||||
const request = store.count();
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => resolve(0);
|
||||
});
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache-first fetch: tries IndexedDB first, falls back to network.
|
||||
* If the data is fetched from network, it's cached for future use.
|
||||
*/
|
||||
static async fetch<T = unknown>(url: string, options?: { ttl?: number; type?: 'json' | 'text' }): Promise<T | null> {
|
||||
const cacheKey = `url:${url}`;
|
||||
const cached = await DataCache.get(cacheKey);
|
||||
if (cached !== null) {
|
||||
return cached as T;
|
||||
}
|
||||
|
||||
try {
|
||||
const resp = await fetch(url);
|
||||
if (!resp.ok) {
|
||||
// Don't cache 404s
|
||||
return null;
|
||||
}
|
||||
const ct = resp.headers.get('content-type');
|
||||
if (ct && ct.includes('text/html')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let data: unknown;
|
||||
if (options?.type === 'text') {
|
||||
data = await resp.text();
|
||||
} else {
|
||||
data = await resp.json();
|
||||
}
|
||||
|
||||
// Cache the fetched data
|
||||
await DataCache.set(cacheKey, data, options?.ttl);
|
||||
return data as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-4
@@ -44,10 +44,7 @@ export class App {
|
||||
document.addEventListener('click', closeOpenDropdowns);
|
||||
document.addEventListener('touchstart', closeOpenDropdowns, { passive: true });
|
||||
|
||||
// Register service worker for PWA install
|
||||
if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.register('/sw.js').catch(() => {});
|
||||
}
|
||||
// PWA install prompt
|
||||
window.addEventListener('beforeinstallprompt', (e) => {
|
||||
e.preventDefault();
|
||||
(window as any).__deferredPrompt = e;
|
||||
|
||||
+30
-37
@@ -8,6 +8,7 @@ import { computeDay } from '../core/day-computer';
|
||||
import { evaluateFasting } from '../core/fasting';
|
||||
import type { FastingPeriod } from '../core/fasting';
|
||||
import { getTranslations, type LanguageCode } from '../core/i18n';
|
||||
import { DataCache } from '../core/data-cache';
|
||||
import { fontClass } from './settings-view';
|
||||
import { ServiceView } from './service-view';
|
||||
|
||||
@@ -93,12 +94,12 @@ function formatReadingRef(reading: string, _lang: string): string {
|
||||
async function loadSharedScriptureIndex(): Promise<Record<string, unknown[]> | null> {
|
||||
if (sharedScriptureIndex) return sharedScriptureIndex;
|
||||
try {
|
||||
const resp = await fetch('/data/shared/lives-index.json');
|
||||
if (!resp.ok) return null;
|
||||
const ct = resp.headers.get('content-type');
|
||||
if (ct && ct.includes('text/html')) return null;
|
||||
sharedScriptureIndex = await resp.json();
|
||||
return sharedScriptureIndex;
|
||||
const data = await DataCache.fetch<Record<string, unknown[]>>('/data/shared/lives-index.json');
|
||||
if (data) {
|
||||
sharedScriptureIndex = data;
|
||||
return data;
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -108,13 +109,12 @@ async function loadMenaionBundle(lang: string): Promise<Record<string, unknown>
|
||||
const key = lang === 'cu' ? 'cu' : lang;
|
||||
if (menaionBundles.has(key)) return menaionBundles.get(key)!;
|
||||
try {
|
||||
const resp = await fetch(`/data/${key}/menaion-bundle.json`);
|
||||
if (!resp.ok) return null;
|
||||
const ct = resp.headers.get('content-type');
|
||||
if (ct && ct.includes('text/html')) return null;
|
||||
const bundle = await resp.json() as Record<string, unknown>;
|
||||
menaionBundles.set(key, bundle);
|
||||
return bundle;
|
||||
const bundle = await DataCache.fetch<Record<string, unknown>>(`/data/${key}/menaion-bundle.json`);
|
||||
if (bundle) {
|
||||
menaionBundles.set(key, bundle);
|
||||
return bundle;
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -123,10 +123,12 @@ async function loadMenaionBundle(lang: string): Promise<Record<string, unknown>
|
||||
async function loadFastingPeriods(): Promise<FastingPeriod[]> {
|
||||
if (cachedFastingPeriods) return cachedFastingPeriods;
|
||||
try {
|
||||
const resp = await fetch('/data/shared/fasting.json');
|
||||
if (!resp.ok) return [];
|
||||
cachedFastingPeriods = await resp.json();
|
||||
return cachedFastingPeriods!;
|
||||
const data = await DataCache.fetch<FastingPeriod[]>('/data/shared/fasting.json');
|
||||
if (data) {
|
||||
cachedFastingPeriods = data;
|
||||
return data;
|
||||
}
|
||||
return [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
@@ -237,23 +239,18 @@ async function loadLife(lang: LanguageCode, cid: string): Promise<LifeData | nul
|
||||
const cacheKey = `${lang}/${cid}`;
|
||||
if (lifeCache.has(cacheKey)) return lifeCache.get(cacheKey)!;
|
||||
try {
|
||||
const bundleName = /^(0[1-9]|1[0-2])\d+/.test(cid) ? cid.substring(0, 2) : 'misc';
|
||||
async function tryFetch(prefix: string): Promise<Response | null> {
|
||||
const resp = await fetch(`/data/${prefix}/lives/${bundleName}.json`);
|
||||
if (!resp.ok) return null;
|
||||
const ct = resp.headers.get('content-type');
|
||||
if (ct && ct.includes('text/html')) return null;
|
||||
return resp;
|
||||
const bundleName = /^(0[1-9]|1[0-2])\d+/.test(cid) ? cid.substring(0, 2) : `misc/${cid.charAt(0)}`;
|
||||
async function tryFetch(prefix: string): Promise<Record<string, unknown> | null> {
|
||||
return DataCache.fetch<Record<string, unknown>>(`/data/${prefix}/lives/${bundleName}.json`);
|
||||
}
|
||||
let resp = await tryFetch(lang);
|
||||
if (!resp && lang !== 'cu') {
|
||||
resp = await tryFetch('cu');
|
||||
let bundle = await tryFetch(lang);
|
||||
if (!bundle && lang !== 'cu') {
|
||||
bundle = await tryFetch('cu');
|
||||
}
|
||||
if (!resp) {
|
||||
if (!bundle) {
|
||||
lifeCache.set(cacheKey, null);
|
||||
return null;
|
||||
}
|
||||
const bundle = await resp.json() as Record<string, unknown>;
|
||||
const life = (bundle[cid] || null) as LifeData | null;
|
||||
if (!life) {
|
||||
lifeCache.set(cacheKey, null);
|
||||
@@ -263,14 +260,10 @@ async function loadLife(lang: LanguageCode, cid: string): Promise<LifeData | nul
|
||||
// Merge rank from cu life if language-specific life lacks it
|
||||
if (!life.rank && lang !== 'cu') {
|
||||
try {
|
||||
const cuResp = await fetch(`/data/cu/lives/${bundleName}.json`);
|
||||
if (cuResp.ok) {
|
||||
const ct = cuResp.headers.get('content-type');
|
||||
if (!ct || !ct.includes('text/html')) {
|
||||
const cuBundle = await cuResp.json() as Record<string, unknown>;
|
||||
const cuLife = cuBundle[cid] as LifeData | undefined;
|
||||
if (cuLife?.rank) life.rank = cuLife.rank;
|
||||
}
|
||||
const cuBundle = await DataCache.fetch<Record<string, unknown>>(`/data/cu/lives/${bundleName}.json`);
|
||||
if (cuBundle) {
|
||||
const cuLife = cuBundle[cid] as LifeData | undefined;
|
||||
if (cuLife?.rank) life.rank = cuLife.rank;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user