diff --git a/README.md b/README.md index 07a60d5..3b57e71 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,8 @@ npm run dev Data files in `static/data/` are tracked in git. The only gitignored output is `scripts/output/minei.json` (33MB Chetyi-Minei source). +**Data version**: `DATA_VERSION` in `src/ui/app.ts` controls cache invalidation. Bump it when restructuring data (new fields, reorganized bundles, updated sources) to force users to re-fetch cached data on next app load. + ```bash npm run build # production build npm run preview # preview production build diff --git a/README_RUS.md b/README_RUS.md index b9b9e93..1c41304 100644 --- a/README_RUS.md +++ b/README_RUS.md @@ -34,6 +34,8 @@ npm run dev Файлы данных в `static/data/` отслеживаются в git. Единственный игнорируемый файл вывода — `scripts/output/minei.json` (33МБ источник Четьи-Миней). +**Версия данных**: `DATA_VERSION` в `src/ui/app.ts` управляет инвалидацией кэша. Увеличивайте её при реструктуризации данных (новые поля, перегруппированные пачки, обновлённые источники), чтобы пользователи получили свежие данные при следующей загрузке приложения. + ```bash npm run build # продуктивная сборка npm run preview # предпросмотр продуктивной сборки diff --git a/src/core/data-cache.ts b/src/core/data-cache.ts index 0f27f0b..c159053 100644 --- a/src/core/data-cache.ts +++ b/src/core/data-cache.ts @@ -16,7 +16,7 @@ const DB_NAME = 'ponomar-cache'; const DB_VERSION = 1; const STORE_NAME = 'data'; -const DEFAULT_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days +const DEFAULT_TTL_MS = Infinity; // never expire — static data interface CacheEntry { key: string; diff --git a/src/core/i18n.ts b/src/core/i18n.ts index 02096a0..69c6b66 100644 --- a/src/core/i18n.ts +++ b/src/core/i18n.ts @@ -492,8 +492,14 @@ export interface Translations { gregorian: string; about: string; aboutText: string; + aboutAppName: string; aboutLicense: string; installPwa: string; + offlineOfferTitle: string; + offlineOfferText: string; + offlineOfferPreloadAll: string; + offlineOfferChoose: string; + offlineOfferSkip: string; offlineContent: string; offlineLangs: string; offlineDataTypes: string; @@ -1046,9 +1052,15 @@ const translations: Record = { julian: 'Julian', gregorian: 'Gregorian', about: 'About Ponomar', - aboutText: 'Ponomar is an Orthodox Church calendar application providing liturgical information, service texts, and Bible readings in multiple languages.', + aboutText: ' is an Orthodox Church calendar application providing liturgical information, service texts, and Bible readings in multiple languages.', + aboutAppName: 'Ponomar', aboutLicense: 'Based on the Ponomar Java desktop application by Aleksandr Andreev and Yuri Shardt. Licensed under GPL v3.', installPwa: 'Install Ponomar', + offlineOfferTitle: 'Offline Data', + offlineOfferText: 'If you plan to use the application without internet access, you should preload data for offline usage.', + offlineOfferPreloadAll: 'Pre-load everything', + offlineOfferChoose: 'Choose data to preload', + offlineOfferSkip: 'Don\'t preload', offlineContent: 'Offline Content', offlineLangs: 'Languages to cache', offlineDataTypes: 'Data to cache', @@ -1063,7 +1075,7 @@ const translations: Record = { offlineSelectLang: 'Please select at least one language.', offlineSelectAll: 'Select all', offlineCleared: 'Cache cleared.', - offlineCacheInfo: 'Cache: {0} / {1} ({2} files)', + offlineCacheInfo: '{0}, {2} files', locationTitle: 'Location', latitude: 'Latitude', longitude: 'Longitude', @@ -1599,9 +1611,15 @@ const translations: Record = { julian: 'Юлианский', gregorian: 'Григорианский', about: 'О Понома́ре', - aboutText: 'Понома́рь — приложение православного календаря, предоставляющее литургическую информацию, тексты служб и библейские чтения на нескольких языках.', + aboutText: ' — приложение православного календаря, предоставляющее литургическую информацию, тексты служб и библейские чтения на нескольких языках.', + aboutAppName: 'Пономарь', aboutLicense: 'Основано на Java-приложении Понома́рь от Александра Андреева и Юрия Шардт. Лицензия GPL v3.', installPwa: 'Установить Пономарь', + offlineOfferTitle: 'Офлайн-данные', + offlineOfferText: 'Если вы планируете использовать приложение без интернета, предзагрузите данные для офлайн-использования.', + offlineOfferPreloadAll: 'Загрузить всё', + offlineOfferChoose: 'Выбрать данные', + offlineOfferSkip: 'Не загружать', offlineContent: 'Офлайн-контент', offlineLangs: 'Языки для кэширования', offlineDataTypes: 'Типы данных', @@ -1616,7 +1634,7 @@ const translations: Record = { offlineSelectLang: 'Пожалуйста, выберите хотя бы один язык.', offlineSelectAll: 'Выбрать все', offlineCleared: 'Кэш очищен.', - offlineCacheInfo: 'Кэш: {0} / {1} ({2} файлов)', + offlineCacheInfo: '{0}, {2} файлов', locationTitle: 'Местоположение', latitude: 'Широта', longitude: 'Долгота', @@ -2152,9 +2170,15 @@ const translations: Record = { julian: 'Юліанскій', gregorian: 'Григоріанскій', about: 'О Понома́ри', - aboutText: 'Понома́рь — приложенiе православнаго календаря, предоставляющее литургическiю информацию, тексты службъ и библейскія чтения на нескольких языкахъ.', + aboutText: ' — приложенiе православнаго календаря, предоставляющее литургическiю информацию, тексты службъ и библейскія чтения на нескольких языкахъ.', + aboutAppName: 'Пономарь', aboutLicense: 'Основано на Java-приложенiи Понома́рь отъ Александра Андреева и Юрия Шардт. Лицензия GPL v3.', installPwa: 'Оустановити Понома́рь', + offlineOfferTitle: 'Офлайн-данныѧ', + offlineOfferText: 'А҆́ще плани́рꙋете и҆спо́льзовати приложе́нїе без̾ и҆нтернета, предзагризите да́нныѧ длѧ офлайн-потребле́нїѧ.', + offlineOfferPreloadAll: 'Загризи́ти всѧ̑', + offlineOfferChoose: 'И҆збра́ти да́нныѧ', + offlineOfferSkip: 'Не загрꙋжа́ти', offlineContent: 'Офлайн-контентъ', offlineLangs: 'Языки для кеширования', offlineDataTypes: 'Типы данныхъ', @@ -2169,7 +2193,7 @@ const translations: Record = { offlineSelectLang: 'Пожалуйста, изберите хотя бы единъ языкъ.', offlineSelectAll: 'И҆збра́ти всѧ̑', offlineCleared: 'Кешъ очищенъ.', - offlineCacheInfo: 'Кешъ: {0} / {1} ({2} фа́йлѡвъ)', + offlineCacheInfo: '{0}, {2} фа́йловъ', locationTitle: 'Мѣстоположе́нїе', latitude: 'Широта̀', longitude: 'Долгота̀', diff --git a/src/core/offline-manager.ts b/src/core/offline-manager.ts index ed7a6c2..8d3af6f 100644 --- a/src/core/offline-manager.ts +++ b/src/core/offline-manager.ts @@ -146,6 +146,10 @@ export class OfflineManager { progress.current++; progress.file = file; + // Skip if already cached + const cacheKey = `url:${file}`; + if (await DataCache.get(cacheKey) !== null) continue; + // Use DataCache.fetch to cache the file in IndexedDB // The service worker will also cache it via runtime caching try { diff --git a/src/ui/app.ts b/src/ui/app.ts index 3c8626f..0f61a7f 100644 --- a/src/ui/app.ts +++ b/src/ui/app.ts @@ -12,13 +12,14 @@ import { loadSettings, type Settings } from './settings-view'; import { getTranslations, type LanguageCode } from '../core/i18n'; import { DataCache } from '../core/data-cache'; -type View = 'calendar' | 'bible' | 'prayer' | 'akathists' | 'parimii' | 'horologion' | 'sbornik' | 'paraclete' | 'irmologion' | 'menaion' | 'triodion' | 'settings'; +type View = 'calendar' | 'bible' | 'prayer' | 'akathists' | 'parimii' | 'horologion' | 'sbornik' | 'paraclete' | 'irmologion' | 'menaion' | 'triodion' | 'lives' | 'settings'; export class App { private container: HTMLElement; private currentView: View = 'calendar'; private currentDate: JDate; private settings: Settings; + private routeParam: string = ''; constructor(container: HTMLElement) { this.container = container; @@ -36,12 +37,28 @@ export class App { } } + private async checkDataVersion() { + const DATA_VERSION = '2026-08-04'; // bump when data structure changes + try { + const cachedVer = await DataCache.get('__data_version__') as string | null; + if (cachedVer !== DATA_VERSION) { + await DataCache.clear(); + await DataCache.set('__data_version__', DATA_VERSION); + } + } catch { + // Silently fail — cache is best-effort + } + } + init() { this.container.innerHTML = ''; this.render(); this.handleRoute(); window.addEventListener('hashchange', () => this.handleRoute()); + // Data version check — bump DATA_VERSION when static data structure changes + this.checkDataVersion(); + // Close dropdown menus on click/touch outside const closeOpenDropdowns = (e: Event) => { const target = e.target as Node; @@ -71,6 +88,18 @@ export class App { (window as any).__deferredPrompt = e; }); + // Clear first-launch flag on re-install + window.addEventListener('appinstalled', () => { + localStorage.removeItem('ponomar-installed'); + }); + + // Show offline offer on first standalone launch + const isStandalone = () => window.matchMedia('(display-mode: standalone)').matches || (window.navigator as any).standalone; + if (isStandalone() && !localStorage.getItem('ponomar-installed')) { + localStorage.setItem('ponomar-installed', '1'); + this.showOfflineOfferModal(); + } + // Auto-preload essential data for the current language this.autoPreload(); } @@ -82,13 +111,10 @@ export class App { const lang = this.settings.language as LanguageCode; const languages = lang === 'ru' ? ['ru', 'cu'] : [lang]; for (const l of languages) { - // Preload menaion bundle await DataCache.fetch(`/data/${l}/menaion-bundle.json`).catch(() => {}); - // Preload current month lives const mm = String(JDate.today().getMonth()).padStart(2, '0'); await DataCache.fetch(`/data/${l}/lives/${mm}.json`).catch(() => {}); } - // Preload calendar data await DataCache.fetch('/data/shared/fasting.json').catch(() => {}); localStorage.setItem(key, '1'); } catch { @@ -96,10 +122,42 @@ export class App { } } + private showOfflineOfferModal() { + const t = getTranslations(this.settings.language as LanguageCode); + const overlay = document.createElement('div'); + overlay.id = 'offline-offer-overlay'; + overlay.className = 'fixed inset-0 z-50 flex items-center justify-center bg-black/50'; + overlay.innerHTML = ` +
+

${t.settings.offlineOfferTitle}

+

${t.settings.offlineOfferText}

+
+ + + +
+
+ `; + document.body.appendChild(overlay); + + document.getElementById('offline-offer-all')?.addEventListener('click', () => { + overlay.remove(); + window.location.hash = '#settings/autopreload'; + }); + document.getElementById('offline-offer-choose')?.addEventListener('click', () => { + overlay.remove(); + window.location.hash = '#settings'; + }); + document.getElementById('offline-offer-skip')?.addEventListener('click', () => { + overlay.remove(); + }); + } + private handleRoute() { const hash = window.location.hash.slice(1) || 'calendar'; const parts = hash.split('/'); this.currentView = parts[0] as View || 'calendar'; + this.routeParam = parts[1] || ''; this.renderView(); } @@ -234,7 +292,7 @@ export class App { new SettingsView(viewContainer, () => { this.render(); this.renderView(); - }).render(); + }, this.routeParam).render(); break; } } diff --git a/src/ui/settings-view.ts b/src/ui/settings-view.ts index 6bb5b5d..c9e4d90 100644 --- a/src/ui/settings-view.ts +++ b/src/ui/settings-view.ts @@ -108,11 +108,13 @@ export class SettingsView { private container: HTMLElement; private settings: Settings; private onLanguageChange?: () => void; + private action?: string; - constructor(container: HTMLElement, onLanguageChange?: () => void) { + constructor(container: HTMLElement, onLanguageChange?: () => void, action?: string) { this.container = container; this.settings = loadSettings(); this.onLanguageChange = onLanguageChange; + this.action = action; } render() { @@ -336,7 +338,7 @@ export class SettingsView {

${t.settings.about}

- ${t.settings.aboutText} + ${t.settings.aboutAppName}${t.settings.aboutText}

@@ -367,6 +369,7 @@ export class SettingsView { this.settings.fontSize = parseInt((e.target as HTMLInputElement).value, 10); document.getElementById('font-size-value')!.textContent = `${this.settings.fontSize}px`; document.documentElement.style.setProperty('--liturgical-font-size', this.settings.fontSize + 'px'); + this.updateFontPreview(); saveSettings(this.settings); }); @@ -533,7 +536,6 @@ export class SettingsView { if (statsEl) { statsEl.textContent = t.settings.offlineCacheInfo .replace('{0}', OfflineManager.formatBytes(stats.usageBytes)) - .replace('{1}', OfflineManager.formatBytes(stats.quotaBytes)) .replace('{2}', String(stats.cachedFiles)); } }); @@ -622,6 +624,19 @@ export class SettingsView { if (ruChecked && cuCheckbox) { cuCheckbox.checked = true; } + + // Auto-preload everything if triggered from first-launch offer + if (this.action === 'autopreload') { + document.querySelectorAll('.offline-lang').forEach(el => (el as HTMLInputElement).checked = true); + document.querySelectorAll('.offline-type').forEach(el => (el as HTMLInputElement).checked = true); + const bibleChevron = document.getElementById('bible-chevron'); + if (bibleChevron) bibleChevron.click(); + setTimeout(() => { + document.querySelectorAll('.bible-trans').forEach(el => (el as HTMLInputElement).checked = true); + document.getElementById('offline-preload')?.click(); + }, 300); + window.history.replaceState(null, '', '#settings'); + } })(); this.updateFontPreview(); @@ -631,6 +646,7 @@ export class SettingsView { const preview = document.getElementById('font-preview'); if (preview) { preview.className = `${fontClass(this.settings.cuFont)} text-lg`; + preview.style.fontSize = `${this.settings.fontSize}px`; } } }