PWA optimization: vite-plugin-pwa, split misc.json, IndexedDB cache layer

This commit is contained in:
catarrh committed 2026-08-01 21:39:10 +03:00
1 parent c342a5b636
commit 8b27bd4767
70 files changed
+5612 -57852

No files matched your search

+209
View File
@@ -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
View File
@@ -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
View File
@@ -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 {}
}