offline: Infinity TTL, skip cached files, data version, first-launch preload offer, cache stats
This commit is contained in:
1 parent
ea947ede7a
commit
e4ddee924b
7 files changed
+121
-15
No files matched your search
@@ -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
@@ -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: 'Долгота̀',
|
||||
|
||||
@@ -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
@@ -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
@@ -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`;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user