offline: Infinity TTL, skip cached files, data version, first-launch preload offer, cache stats

This commit is contained in:
catarrh committed 2026-08-04 01:18:29 +03:00
1 parent ea947ede7a
commit e4ddee924b
7 files changed
+121 -15

No files matched your search

+2
View File
@@ -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
+2
View File
@@ -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 # предпросмотр продуктивной сборки
+1 -1
View File
@@ -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;
+30 -6
View File
@@ -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<LanguageCode, Translations> = {
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<LanguageCode, Translations> = {
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<LanguageCode, Translations> = {
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<LanguageCode, Translations> = {
offlineSelectLang: 'Пожалуйста, выберите хотя бы один язык.',
offlineSelectAll: 'Выбрать все',
offlineCleared: 'Кэш очищен.',
offlineCacheInfo: 'Кэш: {0} / {1} ({2} файлов)',
offlineCacheInfo: '{0}, {2} файлов',
locationTitle: 'Местоположение',
latitude: 'Широта',
longitude: 'Долгота',
@@ -2152,9 +2170,15 @@ const translations: Record<LanguageCode, Translations> = {
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<LanguageCode, Translations> = {
offlineSelectLang: 'Пожалуйста, изберите хотя бы единъ языкъ.',
offlineSelectAll: 'И҆збра́ти всѧ̑',
offlineCleared: 'Кешъ очищенъ.',
offlineCacheInfo: 'Кешъ: {0} / {1} ({2} фа́йлѡвъ)',
offlineCacheInfo: '{0}, {2} фа́йловъ',
locationTitle: 'Мѣстоположе́нїе',
latitude: 'Широта̀',
longitude: 'Долгота̀',
+4
View File
@@ -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 {
+63 -5
View File
@@ -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 = `
<div class="bg-surface rounded-xl shadow-2xl p-6 max-w-md mx-4">
<h3 class="text-lg font-bold text-navy mb-2">${t.settings.offlineOfferTitle}</h3>
<p class="text-sm text-navy mb-4">${t.settings.offlineOfferText}</p>
<div class="flex flex-col gap-2">
<button id="offline-offer-all" class="w-full bg-navy text-parchment rounded-lg px-4 py-2 font-bold hover:bg-navy-light transition-colors">${t.settings.offlineOfferPreloadAll}</button>
<button id="offline-offer-choose" class="w-full bg-gold text-navy rounded-lg px-4 py-2 font-bold hover:bg-gold-light transition-colors">${t.settings.offlineOfferChoose}</button>
<button id="offline-offer-skip" class="w-full text-sm text-navy-light underline hover:text-navy text-center cursor-pointer">${t.settings.offlineOfferSkip}</button>
</div>
</div>
`;
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;
}
}
+19 -3
View File
@@ -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 {
<div class="mt-8 pt-6 border-t border-gold/20">
<h3 class="text-lg font-bold text-red mb-2">${t.settings.about}</h3>
<p class="text-sm text-navy-light">
${t.settings.aboutText}
<a href="https://github.com/anttro/ponomar-pwa" target="_blank" rel="noopener" class="underline hover:text-gold">${t.settings.aboutAppName}</a>${t.settings.aboutText}
</p>
<p class="text-xs text-navy-light mt-2">
<a href="https://github.com/typiconman/ponomar" target="_blank" rel="noopener" class="underline hover:text-gold">
@@ -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`;
}
}
}