diff --git a/src/core/data-cache.ts b/src/core/data-cache.ts index dce18cc..9236fbd 100644 --- a/src/core/data-cache.ts +++ b/src/core/data-cache.ts @@ -93,6 +93,24 @@ export class DataCache { } } + /** + * Tri-state presence probe: true/false when known, null on read error. + * Unlike get(), lets callers distinguish "absent" from "unreadable". + */ + static async has(key: string): Promise { + try { + const db = await getDB(); + return await new Promise((resolve) => { + const tx = db.transaction(STORE_NAME, 'readonly'); + const request = tx.objectStore(STORE_NAME).count(IDBKeyRange.only(key)); + request.onsuccess = () => resolve(request.result > 0); + request.onerror = () => resolve(null); + }); + } catch { + return null; + } + } + /** * Store a value in the cache. */ diff --git a/src/core/diag-log.ts b/src/core/diag-log.ts new file mode 100644 index 0000000..bbafde2 --- /dev/null +++ b/src/core/diag-log.ts @@ -0,0 +1,50 @@ +/** + * Forensic event log — append-only ring buffer in localStorage. + * + * Deliberately stored OUTSIDE IndexedDB / CacheStorage so it survives + * DataCache.clear(), service-worker cache purges and version wipes. + * Used to diagnose transient offline/precache issues after the fact. + */ + +const DIAG_KEY = 'ponomar-diag-log'; +const MAX_EVENTS = 30; + +export interface DiagEvent { + t: string; + event: string; + [key: string]: unknown; +} + +export function diagLog(event: string, detail?: Record) { + try { + const raw = localStorage.getItem(DIAG_KEY); + const arr: DiagEvent[] = raw ? JSON.parse(raw) : []; + arr.push({ t: new Date().toISOString(), event, ...(detail ?? {}) }); + while (arr.length > MAX_EVENTS) arr.shift(); + localStorage.setItem(DIAG_KEY, JSON.stringify(arr)); + } catch { /* best-effort */ } +} + +export function getDiagLog(): DiagEvent[] { + try { + const raw = localStorage.getItem(DIAG_KEY); + const arr = raw ? JSON.parse(raw) : []; + return Array.isArray(arr) ? arr : []; + } catch { + return []; + } +} + +function formatEvent(e: DiagEvent): string { + const time = (e.t || '').slice(11, 19); + const parts: string[] = []; + for (const [k, v] of Object.entries(e)) { + if (k === 't' || k === 'event') continue; + parts.push(`${k}=${String(v)}`); + } + return `${time} ${e.event}${parts.length ? ' ' + parts.join(' ') : ''}`; +} + +export function diagTail(): string { + return getDiagLog().map(formatEvent).join('\n'); +} diff --git a/src/core/i18n.ts b/src/core/i18n.ts index 734ff58..ee94972 100644 --- a/src/core/i18n.ts +++ b/src/core/i18n.ts @@ -515,6 +515,7 @@ export interface Translations { offlineMenaion: string; offlineBible: string; offlinePreload: string; + offlineAborted: string; offlineClearCache: string; offlineCalculating: string; offlineDone: string; @@ -1084,6 +1085,7 @@ const translations: Record = { offlineMenaion: 'Menaion', offlineBible: 'Bible', offlinePreload: 'Preload', + offlineAborted: 'Preloading stopped: network unavailable. Check your connection and try again.', offlineClearCache: 'Clear cache', offlineCalculating: 'Calculating storage...', offlineDone: 'Done! Data cached for offline use.', @@ -1651,6 +1653,7 @@ const translations: Record = { offlineMenaion: 'Минея', offlineBible: 'Библия', offlinePreload: 'Загрузить', + offlineAborted: 'Загрузка прервана: сеть недоступна. Проверьте соединение и повторите попытку.', offlineClearCache: 'Очистить кэш', offlineCalculating: 'Расчёт размера...', offlineDone: 'Готово! Данные закэшированы для офлайн-использования.', @@ -2218,6 +2221,7 @@ const translations: Record = { offlineMenaion: 'Минїа', offlineBible: 'Би́блїа', offlinePreload: 'Загрузити', + offlineAborted: 'Загрузка прервана: сеть недостꙋпна. Про̀верьте соедине́нiе и҆ повтори́те попы́ткꙋ.', offlineClearCache: 'Очистити кешъ', offlineCalculating: 'Расчётъ размѣра...', offlineDone: 'Готово! Данныя закешированы для офлайн-потребленїя.', diff --git a/src/core/offline-manager.ts b/src/core/offline-manager.ts index ee20534..f42a6b0 100644 --- a/src/core/offline-manager.ts +++ b/src/core/offline-manager.ts @@ -15,6 +15,7 @@ */ import { DataCache } from './data-cache'; +import { diagLog } from './diag-log'; export interface PreloadOptions { languages: string[]; @@ -29,6 +30,7 @@ export interface PreloadProgress { done: boolean; failed: number; failedFiles?: string[]; + aborted?: boolean; } export interface CacheStats { @@ -60,7 +62,14 @@ function loadManifest(): Promise> | null } catch { return null; } - })(); + })().then((m) => { + // Don't cache a failure forever — allow retry on next call + if (m === null) { + diagLog('manifest-unavailable'); + manifestPromise = null; + } + return m; + }); } return manifestPromise; } @@ -112,6 +121,7 @@ export class OfflineManager { * Returns a PreloadProgress object that can be polled for progress. */ static async preload(options: PreloadOptions): Promise { + const startedAt = Date.now(); const filesToFetch: string[] = []; const m = await loadManifest(); @@ -149,7 +159,16 @@ export class OfflineManager { }; OfflineManager._progress = progress; + diagLog('preload-start', { + langs: options.languages.join(','), + types: options.types.join(','), + bibles: (options.bibleTranslations || []).length, + total: progress.total, + manifest: m !== null, + }); + // Fetch files sequentially to avoid overwhelming the network + let netFailStreak = 0; for (const file of uniqueFiles) { progress.current++; progress.file = file; @@ -186,12 +205,28 @@ export class OfflineManager { } if (!success && !notFound) { progress.failed++; + netFailStreak++; if (!progress.failedFiles) progress.failedFiles = []; if (progress.failedFiles.length < 10) progress.failedFiles.push(file); + if (netFailStreak >= 5) { + // Circuit breaker — network appears dead; abort instead of grinding + progress.aborted = true; + diagLog('preload-abort', { failed: progress.failed, current: progress.current }); + break; + } + } else { + // Success or 404 both prove connectivity + netFailStreak = 0; } } progress.done = true; + diagLog('preload-done', { + total: progress.total, + failed: progress.failed, + ms: Date.now() - startedAt, + aborted: progress.aborted === true, + }); OfflineManager._progress = null; return progress; } diff --git a/src/ui/app.ts b/src/ui/app.ts index a35418d..24484c9 100644 --- a/src/ui/app.ts +++ b/src/ui/app.ts @@ -11,6 +11,7 @@ import { JDate } from '../core/jdate'; import { loadSettings, type Settings } from './settings-view'; import { getTranslations, type LanguageCode } from '../core/i18n'; import { DataCache } from '../core/data-cache'; +import { diagLog } from '../core/diag-log'; type View = 'calendar' | 'bible' | 'prayer' | 'akathists' | 'parimii' | 'horologion' | 'sbornik' | 'paraclete' | 'irmologion' | 'menaion' | 'triodion' | 'lives' | 'settings'; @@ -75,13 +76,25 @@ export class App { private async checkDataVersion() { const DATA_VERSION = '2026-08-04'; // bump when data structure changes try { + const present = await DataCache.has('__data_version__'); + if (present === null) { + // Unreadable IDB — never wipe the cache on a flaky read + diagLog('version-check', { result: 'read-error', action: 'skip' }); + return; + } + if (!present) { + await DataCache.set('__data_version__', DATA_VERSION); + diagLog('version-init'); + return; + } const cachedVer = await DataCache.get('__data_version__') as string | null; if (cachedVer !== DATA_VERSION) { await DataCache.clear(); await DataCache.set('__data_version__', DATA_VERSION); + diagLog('version-clear', { old: cachedVer }); } - } catch { - // Silently fail — cache is best-effort + } catch (err) { + diagLog('idb-error', { where: 'checkDataVersion', msg: String(err) }); } } diff --git a/src/ui/settings-view.ts b/src/ui/settings-view.ts index 109d368..a9dff67 100644 --- a/src/ui/settings-view.ts +++ b/src/ui/settings-view.ts @@ -5,6 +5,7 @@ import { getTranslations, type LanguageCode } from '../core/i18n'; import { OfflineManager } from '../core/offline-manager'; import { DataCache } from '../core/data-cache'; +import { diagTail } from '../core/diag-log'; const LANGUAGES = [ { code: 'en', name: 'English', local: 'English' }, @@ -303,6 +304,7 @@ export class SettingsView {

0 / 0 ${t.settings.offlineDataTypes}

+

         
 
         
@@ -552,13 +554,17 @@ export class SettingsView {
       }).then((prog) => {
         if (progressBar) progressBar.style.width = '100%';
         if (progressText) {
-          if (prog.failed > 0) {
+          if (prog.aborted) {
+            progressText.textContent = t.settings.offlineAborted;
+          } else if (prog.failed > 0) {
             const failList = prog.failedFiles?.length ? `: ${prog.failedFiles.join(', ')}` : '';
             progressText.textContent = `${t.settings.offlineDone} ${prog.failed} ${t.settings.offlineFailed}${failList}`;
           } else {
             progressText.textContent = t.settings.offlineDone;
           }
         }
+        const diagEl = document.getElementById('diag-log');
+        if (diagEl) diagEl.textContent = diagTail();
         OfflineManager.getStats().then(stats => {
           const statsEl = document.getElementById('offline-stats');
           if (statsEl) {
@@ -601,6 +607,9 @@ export class SettingsView {
       }
     });
 
+    const diagEl = document.getElementById('diag-log');
+    if (diagEl) diagEl.textContent = diagTail();
+
     // Check cache status for each data type and set checkbox states
     (async () => {
       const checkCache = async (key: string): Promise => {
@@ -678,10 +687,14 @@ export class SettingsView {
           if (progressBar) progressBar.style.width = '100%';
           if (progressText) {
             const failList = prog.failedFiles?.length ? `: ${prog.failedFiles.join(', ')}` : '';
-            progressText.textContent = prog.failed > 0
-              ? `${t.settings.offlineDone} ${prog.failed} ${t.settings.offlineFailed}${failList}`
-              : t.settings.offlineDone;
+            progressText.textContent = prog.aborted
+              ? t.settings.offlineAborted
+              : prog.failed > 0
+                ? `${t.settings.offlineDone} ${prog.failed} ${t.settings.offlineFailed}${failList}`
+                : t.settings.offlineDone;
           }
+          const diagEl = document.getElementById('diag-log');
+          if (diagEl) diagEl.textContent = diagTail();
           OfflineManager.getStats().then(stats => {
             const statsEl = document.getElementById('offline-stats');
             if (statsEl) {