Compare commits

...
10 Commits
Author SHA1 Message Date
catarrh f534fe6006 docs: language model + liturgical content policies, fix ru/services drift
- .ai-specs/PROJECT_CONTEXT.md: new 'Language Model (UI vs. Content)' section
  (serviceLang ru->cu, fallback chain, scripture, Bible tab independence,
  fonts, calendar authority) + content-policy pointer in Critical Invariants.
- .ai-specs/WORKING_POLICIES.md: new 'Liturgical Content Policies' —
  never translate service/Bible texts (sourced only), cu texts verbatim
  (no normalisation/latinisation), service assembly follows the Typicon.
- DATA_EN.md/DATA_RUS.md: ru/ holds enriched lives only; services fall back
  to cu; cu is the authoritative calendar + service texts.
2026-10-04 17:47:33 +03:00
catarrh 69f3533c41 test(validate): cover cu prayers, shared marker-type constant
- validate-data now scans cu/services/prayers (349 files; 331 -> 680 total).
- marker types (TROPARION/KONTAKION/THEOTOKION/LITANY/COMMUNION) factored
  into PRAYER_NODE_TYPES with a keep-in-sync comment.
- COMMUNION marker (cu COMMUNION/SUN.json) added per the same convention.
2026-10-04 17:36:31 +03:00
catarrh 4f9f55b3f3 fix(validate): allow LITANY/THEOTOKION prayer markers
Both node types are bare classification markers with no payload
(content lives in TEXT/HEADER nodes, matching the TROPARION/KONTAKION
convention already in the allowlist). Clears the 13 pre-existing
validate errors; no data or rendering change.
2026-10-04 17:26:06 +03:00
catarrh d0affe4e83 chore(service): trim dead bibleVersion() entries, drop impossible shared bible fallback
bibleVersion() is only ever indexed with serviceLang (en|cu), so the
fr/el/la/zh/ar entries were unreachable; the shared fallback URL
(/data/shared/bible/...) does not exist on disk. No behavior change.
2026-10-04 17:18:49 +03:00
catarrh 8c442bf7b6 fix(offline): surface degraded/missing preloads, join re-entrant runs, cache manifest to IDB
- loadManifest() goes through DataCache.fetchWithFallback: the file list is
  written to IDB and survives SW-cache eviction; a run with the reduced
  fallback set is flagged and shown as degraded (new i18n en/ru/cu).
- 404 answers are counted as 'missing' (deployment drift) with a
  preload-missing diag entry, instead of silently reporting Done.
- preload() is re-entrancy safe: concurrent calls join the in-flight run.
- versions.json is loaded once per session (was fetched per translation).
- Write-through failures in fetchWithFallback()/fetch() log idb-write-fail.
- preload-manifest.json SW route is NetworkFirst (fresh coverage online).
- Manifest now also covers the shared/lives + shared/commands fallbacks.
2026-10-04 17:14:33 +03:00
catarrh 1201e16d5e fix(cache): complete preload coverage + surface silent write failures
Root cause of 'pre-cache done but offline data missing':
1. Preload manifest never listed the services/{prayers,texts,headers,var}
   subtrees the Service view dereferences (CommonPrayers->prayers, etc.),
   plus only 13 hardcoded commands. Generator now enumerates all of them
   (en 1500->1740, ru->1333, cu 1500->2108 files).
2. DataCache.set silently resolved on IDB write failure (quota exceeded),
   so preload counted un-stored files as success and reported 100% done.
   set() now returns boolean; preload treats failed writes as failures
   that feed the existing circuit breaker (visible abort + diag entry).
3. No persistence request: added best-effort navigator.storage.persist()
   before preload, diag-logged.
4. Dropped dead manifest weight (menaion-common/add, horologionadd) -
   never referenced by any view.
2026-09-08 08:04:13 +03:00
catarrh 4f11e8ca6c feat: bump UI chrome font scale (xs->sm, sm->base)
- text-sm -> text-base (14px -> 16px), then text-xs -> text-sm (12px -> 14px)
  across 75 usages in 8 UI files; nav dropdown links 0.875rem -> 1rem
- Liturgical content untouched (var-based size); verse-number superscripts
  and text-[10px] micro-labels intentionally kept
2026-08-25 00:04:41 +03:00
catarrh 4f6461efad feat: default liturgical text size 16px -> 18px (new installs only) 2026-08-24 23:48:19 +03:00
catarrh 9cef9130cc fix: auto-load first library section on open
PrayerView's select displayed the first option natively while activeSection
stayed null, so content only appeared after manually changing selection.
Initialize to first section and load it on render for all 8 collections.
2026-08-24 23:42:00 +03:00
catarrh f49735b360 feat: explicit PWA update prompt with build stamp
- registerType 'prompt' + skipWaiting/clientsClaim false: new SW waits
  instead of silently hijacking running sessions
- main.ts registers via virtual:pwa-register; onNeedRefresh shows
  dismissible toast (i18n en/ru/cu) with Update button -> updateSW(true)
- No polling — update check only on navigation, zero extra traffic
- Settings About shows __BUILD_DATE__ build stamp for remote debugging
2026-08-24 11:39:47 +03:00
21 changed files with 1536 additions and 539 deletions

No files matched your search

+16
View File
@@ -16,6 +16,19 @@
- **Bundle system**: menaion-bundles, lives bundles (month-prefixed + misc split), service templates
- **File routing**: CIds with `01`-`12` prefix → month files, else `misc/{first-digit}.json`
### Language Model (UI vs. Content)
- **UI strings**: en/ru/cu via `getTranslations()`; completeness checked by `validate-i18n.ts`
- **Service language** (`serviceLang` in `service-view.ts`): `en → en`, `cu → cu`, **`ru → cu`** —
a Russian interface intentionally renders services in Church Slavonic
- **Service content fallback chain**: `serviceLang → cu → shared` (texts, prayers, headers,
lives, octoechos, templates); `ru/services/` carries only `var/`
- **Scripture inside services** follows the service language: en → `en/bible/kjv`,
cu (and ru) → `cu/bible/elis`
- **Bible reader tab** is independent: 13 translations (incl. `ru/bible/synod`), any UI language
- **Fonts**: the Slavonic font setting applies to liturgical text for cu **and** ru
(`getFontClass()`)
- **Calendar authority**: `cu/menaion-bundle.json`; `{lang}-menaion-bundle.json` overrides
### Core Scripts
- `convert-services.ts` — Service XML to JSON conversion
- `enrich-lives.ts` — Fuzzy matching and enrichment pipeline (Chetyi-Minei + Bulgakov)
@@ -44,6 +57,9 @@
- **Bundle CIds must exist**: Menaion CIds require corresponding lives entries
- **Static data is tracked**: `static/data/` in git (except `scripts/output/minei.json`)
- **Validation must pass**: Changes must pass `tsc --noEmit`, `npm run build`
- **Content policies**: service/Bible texts are sourced (never translated), cu text is
verbatim (no normalisation/latinisation), service assembly follows the Typicon — see
`.ai-specs/WORKING_POLICIES.md` ("Liturgical Content Policies")
## Key Script Quick Reference
+21
View File
@@ -16,6 +16,27 @@
- **Forbidden**: Deleting or renaming CIds without proper validation
- **Forbidden**: Changing JSON structure without updating related code
## Liturgical Content Policies
### Never Translate Service or Bible Texts
- All liturgical and scriptural texts must be **sourced** through the data pipeline
(original-language conversions, Chetyi-Minei, Bulgakov, EPUB/PDF imports, etc.)
- Never machine-translate, paraphrase or invent text — not even "just one line"
- Missing text stays empty (the app falls back `serviceLang → cu → shared`) until its
source is converted
### Church Slavonic Texts Are Presented Verbatim
- Never "normalise", latinise, transliterate or strip diacritics/accents/titla from cu texts
- Render the text exactly as stored in the source data; display-layer transformations
are forbidden
- Orthography fixes belong in the conversion pipeline and must preserve the source
### Service Assembly Follows the Typicon
- Service-building logic (templates, ordering, conditional rules, canons) must reproduce
the Typicon
- Whenever service building logic is changed, check the expected structure against the
Typicon (and the pinned service data) before and after the change
## Validation Requirements
### Pre-Commit Checklist
+2 -2
View File
@@ -51,8 +51,8 @@ static/data/
│ ├── menaion/ # Daily menaion entries (366 days)
│ ├── calendar/ # Calendar metadata
│ └── services/ # Service templates
├── cu/ # Church Slavonic (similar structure)
├── ru/ # Russian (lives, services)
├── cu/ # Church Slavonic (authoritative calendar + service texts)
├── ru/ # Russian (enriched lives; services fall back to cu)
├── el/ # Greek (includes mono/)
├── fr/ # French
├── zh/ # Chinese (includes Hans/, Hant/)
+2 -2
View File
@@ -51,8 +51,8 @@ static/data/
│ ├── menaion/ # Ежедневные записи минеи (366 дней)
│ ├── calendar/ # Метаданные календаря
│ └── services/ # Шаблоны богослужений
├── cu/ # Церковнославянский (аналогичная структура)
├── ru/ # Русский (жития, богослужения)
├── cu/ # Церковнославянский (авторитетный календарь и богослужебные тексты)
├── ru/ # Русский (обогащённые жития; богослужения берутся из cu)
├── el/ # Греческий (включает mono/)
├── fr/ # Французский
├── zh/ # Китайский (включает Hans/, Hant/)
+34 -30
View File
@@ -48,6 +48,21 @@ function calendarFiles(lang: string): string[] {
if (exists(f)) files.push(`/data${f}`);
}
// Shared fallback lives/commands — normally served per-language, but the
// runtime chain ends with a 'shared' attempt; keep those copies offline too.
for (const sub of ['lives', 'commands']) {
const subDir = path.join(DATA_DIR, 'shared', sub);
if (!fs.existsSync(subDir)) continue;
const walkShared = (d: string, rel: string): void => {
for (const e of fs.readdirSync(d, { withFileTypes: true })) {
const p = path.join(d, e.name);
if (e.isDirectory()) walkShared(p, `${rel}/${e.name}`);
else if (e.name.endsWith('.json')) files.push(`/data/shared/${sub}${rel}/${e.name}`);
}
};
walkShared(subDir, '');
}
// Menaion bundle
const mb = `/${lang}/menaion-bundle.json`;
if (exists(mb)) files.push(`/data${mb}`);
@@ -123,16 +138,6 @@ function calendarFiles(lang: string): string[] {
if (exists(fp)) files.push(`/data${fp}`);
}
// Menaion common + add
for (let i = 1; i <= 42; i++) {
const fp = `/shared/menaion-common/${String(i).padStart(2, '0')}.json`;
if (exists(fp)) files.push(`/data${fp}`);
}
for (let i = 1; i <= 51; i++) {
const fp = `/shared/menaion-add/${String(i).padStart(2, '0')}.json`;
if (exists(fp)) files.push(`/data${fp}`);
}
// Prayer collections
const prayerDirs: [string, string[]][] = [];
for (const coll of ['akathists', 'horologion', 'prayer-rule', 'sbornik', 'irmologion']) {
@@ -149,19 +154,6 @@ function calendarFiles(lang: string): string[] {
}
}
// Horologion additions
const horAddItems = ['exapostilaria', 'katavasia', 'lamps', 'songs-daily', 'songs-feasts', 'songs-lent',
'theotokia-8tones', 'theotokia-dismissal', 'theotokia-sunday', 'trinity-8tones',
'trop-common', 'trop-daily', 'trop-lent', 'trop-pentecost', 'trop-sunday'];
for (const item of horAddItems) {
const fp = `/shared/horologionadd/${item}/full.json`;
if (exists(fp)) files.push(`/data${fp}`);
}
for (const m of ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec']) {
const fp = `/shared/horologionadd/trop-feasts-${m}/full.json`;
if (exists(fp)) files.push(`/data${fp}`);
}
// Parimii
const parimiiDir = path.join(DATA_DIR, 'shared', 'parimii');
if (fs.existsSync(parimiiDir)) {
@@ -191,14 +183,26 @@ function calendarFiles(lang: string): string[] {
}
}
// Language-specific service commands
for (let tone = 1; tone <= 8; tone++) {
const fp = `/${lang}/services/commands/Tone${tone}.json`;
if (exists(fp)) files.push(`/data${fp}`);
// Language-specific service commands (enumerate all on disk)
const cmdDir = path.join(DATA_DIR, lang, 'services', 'commands');
if (fs.existsSync(cmdDir)) {
for (const c of fs.readdirSync(cmdDir)) {
if (c.endsWith('.json')) files.push(`/data/${lang}/services/commands/${c}`);
}
for (const cmd of ['AfterEach.json', 'Bow.json', 'Prostration.json', 'S1.json', 'S2.json']) {
const fp = `/${lang}/services/commands/${cmd}`;
if (exists(fp)) files.push(`/data${fp}`);
}
// Dereferenceable service subtrees (Services/CommonPrayers -> prayers, etc.)
for (const sub of ['prayers', 'texts', 'headers', 'var']) {
const subDir = path.join(DATA_DIR, lang, 'services', sub);
if (!fs.existsSync(subDir)) continue;
const walk = (d: string, rel: string): void => {
for (const e of fs.readdirSync(d, { withFileTypes: true })) {
const p = path.join(d, e.name);
if (e.isDirectory()) walk(p, `${rel}/${e.name}`);
else if (e.name.endsWith('.json')) files.push(`/data/${lang}/services/${sub}${rel}/${e.name}`);
}
};
walk(subDir, '');
}
return files;
+19 -2
View File
@@ -11,6 +11,16 @@ import { fileURLToPath } from 'url';
const __dirname = new URL('.', import.meta.url).pathname;
const DATA_DIR = join(__dirname, '..', 'static', 'data');
/**
* Node types allowed in prayer files. Besides TEXT/HEADER content nodes,
* imported prayers carry bare classification markers (no payload) that the
* assembler intentionally ignores — keep this list in sync with the data.
*/
const PRAYER_NODE_TYPES = [
'TEXT', 'HEADER',
'TROPARION', 'KONTAKION', 'THEOTOKION', 'LITANY', 'COMMUNION',
];
interface ValidationError {
file: string;
path: string;
@@ -152,8 +162,8 @@ class Validator {
continue;
}
const obj = node as Record<string, unknown>;
if (!obj.type || !['TEXT', 'HEADER', 'TROPARION', 'KONTAKION'].includes(obj.type as string)) {
this.addError(path, `[${i}].type`, 'Type must be TEXT or HEADER');
if (!obj.type || !PRAYER_NODE_TYPES.includes(obj.type as string)) {
this.addError(path, `[${i}].type`, `Type must be one of: ${PRAYER_NODE_TYPES.join(', ')}`);
}
}
}
@@ -263,6 +273,13 @@ function main() {
validator.validateDir(join(enDir, 'services', 'commands'), 'command');
}
// Church Slavonic prayers share the same import format and marker types
const cuDir = join(DATA_DIR, 'cu');
if (existsSync(cuDir)) {
console.log('Validating Church Slavonic data...');
validator.validateDir(join(cuDir, 'services', 'prayers'), 'prayer');
}
validator.printReport();
}
+17 -7
View File
@@ -13,6 +13,8 @@
* const estimate = await DataCache.estimate();
*/
import { diagLog } from './diag-log';
const DB_NAME = 'ponomar-cache';
const DB_VERSION = 1;
const STORE_NAME = 'data';
@@ -113,11 +115,12 @@ export class DataCache {
/**
* Store a value in the cache.
* Resolves true on success; false when the write failed (e.g. quota).
*/
static async set(key: string, data: unknown, ttl: number = DEFAULT_TTL_MS): Promise<void> {
static async set(key: string, data: unknown, ttl: number = DEFAULT_TTL_MS): Promise<boolean> {
try {
const db = await getDB();
return new Promise((resolve, reject) => {
return await new Promise<boolean>((resolve) => {
const tx = db.transaction(STORE_NAME, 'readwrite');
const store = tx.objectStore(STORE_NAME);
const entry: CacheEntry = {
@@ -128,11 +131,13 @@ export class DataCache {
ttl,
};
store.put(entry);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
tx.oncomplete = () => resolve(true);
tx.onerror = () => resolve(false);
tx.onabort = () => resolve(false);
});
} catch {
// Silently fail — cache is best-effort
// Write failed (e.g. quota exceeded) — report as failure.
return false;
}
}
@@ -217,7 +222,11 @@ export class DataCache {
const ct = resp.headers.get('content-type');
if (resp.ok && !(ct && ct.includes('text/html'))) {
const data: unknown = type === 'text' ? await resp.text() : await resp.json();
await DataCache.set(`url:${url}`, data);
const stored = await DataCache.set(`url:${url}`, data);
if (!stored) {
// Online view still gets its data; the offline copy is missing.
diagLog('idb-write-fail', { url });
}
return data as T;
}
} catch { /* offline or invalid response — fall through to IDB */ }
@@ -258,7 +267,8 @@ export class DataCache {
}
// Cache the fetched data
await DataCache.set(cacheKey, data, options?.ttl);
const stored = await DataCache.set(cacheKey, data, options?.ttl);
if (!stored) diagLog('idb-write-fail', { url });
return data as T;
} catch {
return null;
+21
View File
@@ -12,6 +12,10 @@ export interface Translations {
error: string;
notAvailable: string;
// App update
updateAvailable: string;
updateReload: string;
// Navigation
nav: {
calendar: string;
@@ -520,6 +524,8 @@ export interface Translations {
offlineCalculating: string;
offlineDone: string;
offlineFailed: string;
offlineDegraded: string;
offlineMissing: string;
offlineSelectLang: string;
offlineSelectAll: string;
offlineCleared: string;
@@ -548,6 +554,9 @@ const translations: Record<LanguageCode, Translations> = {
error: 'Error',
notAvailable: 'Not available',
updateAvailable: 'New version available',
updateReload: 'Update',
nav: {
calendar: '📅 Calendar',
service: '📖 Service',
@@ -1090,6 +1099,8 @@ const translations: Record<LanguageCode, Translations> = {
offlineCalculating: 'Calculating storage...',
offlineDone: 'Done! Data cached for offline use.',
offlineFailed: 'files failed to load (retry to attempt again)',
offlineDegraded: 'Preload could not read the full file list — a reduced set was cached. Check your connection and try again.',
offlineMissing: 'Note: {0} files were not found on the server.',
offlineSelectLang: 'Please select at least one language.',
offlineSelectAll: 'Select all',
offlineCleared: 'Cache cleared.',
@@ -1116,6 +1127,9 @@ const translations: Record<LanguageCode, Translations> = {
error: 'Ошибка',
notAvailable: 'Недоступно',
updateAvailable: 'Достꙋ́пна новаѧ вєрсі́ѧ',
updateReload: 'Обнови́ти',
nav: {
calendar: '📅 Календарь',
service: '📖 Устав',
@@ -1658,6 +1672,8 @@ const translations: Record<LanguageCode, Translations> = {
offlineCalculating: 'Расчёт размера...',
offlineDone: 'Готово! Данные закэшированы для офлайн-использования.',
offlineFailed: 'файлов не загружено (повторите для повторной попытки)',
offlineDegraded: 'Не удалось загрузить полный список файлов — закэширован сокращённый набор. Проверьте соединение и повторите.',
offlineMissing: 'Примечание: {0} файлов не найдено на сервере.',
offlineSelectLang: 'Пожалуйста, выберите хотя бы один язык.',
offlineSelectAll: 'Выбрать все',
offlineCleared: 'Кэш очищен.',
@@ -1684,6 +1700,9 @@ const translations: Record<LanguageCode, Translations> = {
error: 'Ошибка',
notAvailable: 'Недоступно',
updateAvailable: 'Доступна новая версия',
updateReload: 'Обновить',
nav: {
calendar: '📅 Календарь',
service: '📖 Уставъ',
@@ -2226,6 +2245,8 @@ const translations: Record<LanguageCode, Translations> = {
offlineCalculating: 'Расчётъ размѣра...',
offlineDone: 'Готово! Данныя закешированы для офлайн-потребленїя.',
offlineFailed: 'фа́йловъ не загружено (повтори́те для повторной попытки)',
offlineDegraded: 'Не возмо́жно бѣ̀ прочестѝ по́лный спи́сокъ фа́йловъ — закеши́рованъ сокраще́нный набо́ръ. Провѣ́рьте соедине́нїе и҆ повтори́те.',
offlineMissing: 'Примѣча́нїе: {0} фа́йловъ не ѡ҆брѣте́но на се́рверѣ.',
offlineSelectLang: 'Пожалуйста, изберите хотя бы единъ языкъ.',
offlineSelectAll: 'И҆збра́ти всѧ̑',
offlineCleared: 'Кешъ очищенъ.',
+72 -22
View File
@@ -30,6 +30,10 @@ export interface PreloadProgress {
done: boolean;
failed: number;
failedFiles?: string[];
/** Files the server answered 404 for (deployment drift). */
missing?: number;
/** True when the manifest was unavailable and a reduced fallback set ran. */
manifestMissing?: boolean;
aborted?: boolean;
}
@@ -54,19 +58,17 @@ let manifestPromise: Promise<Record<string, Record<string, string[]>> | null> |
function loadManifest(): Promise<Record<string, Record<string, string[]>> | null> {
if (!manifestPromise) {
manifestPromise = (async () => {
try {
const resp = await fetch('/data/shared/preload-manifest.json');
if (!resp.ok) return null;
return await resp.json();
} catch {
return null;
}
})().then((m) => {
// Network-first with the IDB fallback: a copy written while online
// survives SW-cache eviction, so a later (possibly offline) call can
// still read the full file list instead of the reduced fallback set.
manifestPromise = DataCache
.fetchWithFallback<Record<string, Record<string, string[]>>>('/data/shared/preload-manifest.json')
.then((m) => {
// Don't cache a failure forever — allow retry on next call
if (m === null) {
if (m === null || typeof m !== 'object') {
diagLog('manifest-unavailable');
manifestPromise = null;
return null;
}
return m;
});
@@ -74,6 +76,24 @@ function loadManifest(): Promise<Record<string, Record<string, string[]>> | null
return manifestPromise;
}
/** versions.json loaded once per session (retried on failure). */
let bibleVersionsPromise: Promise<any[] | null> | null = null;
function loadBibleVersions(): Promise<any[] | null> {
if (!bibleVersionsPromise) {
bibleVersionsPromise = DataCache
.fetchWithFallback<any[]>('/data/bible/versions.json')
.then((v) => {
if (!v || !Array.isArray(v) || v.length === 0) {
bibleVersionsPromise = null;
return null;
}
return v;
});
}
return bibleVersionsPromise;
}
function getFallbackCalendarFiles(lang: string): string[] {
const files = [
`/data/${lang}/menaion-bundle.json`,
@@ -89,6 +109,7 @@ function getFallbackCalendarFiles(lang: string): string[] {
export class OfflineManager {
private static _progress: PreloadProgress | null = null;
private static _running: Promise<PreloadProgress> | null = null;
private static _autoPreloadRequested = false;
/** Request an automatic "preload everything" once the Settings view opens. */
@@ -110,28 +131,21 @@ export class OfflineManager {
/** Resolve every Bible translation id from versions.json (single source). */
static async getAllBibleTranslationIds(): Promise<string[]> {
try {
const resp = await fetch('/data/bible/versions.json');
const versions = await resp.json() as any[];
return versions.map((v: any) => v.id);
} catch {
return [];
}
const versions = await loadBibleVersions();
return versions ? versions.map((v: any) => v.id) : [];
}
/** Discover all book files for a Bible translation from versions.json. */
static async getBibleFiles(translationPath: string): Promise<string[]> {
const files: string[] = [];
try {
const resp = await fetch('/data/bible/versions.json');
const versions = await resp.json() as any[];
const versions = await loadBibleVersions();
if (!versions) return files;
const ver = versions.find((v: any) => v.id === translationPath);
if (ver && ver.books) {
for (const book of ver.books) {
files.push(`/data/${translationPath}/${book.id}.text`);
}
}
} catch {}
return files;
}
@@ -143,8 +157,19 @@ export class OfflineManager {
/**
* Preload selected data files for offline use.
* Returns a PreloadProgress object that can be polled for progress.
* Re-entrant calls join the run already in flight.
*/
static async preload(options: PreloadOptions): Promise<PreloadProgress> {
if (OfflineManager._running) return OfflineManager._running;
const run = OfflineManager._preloadImpl(options)
.finally(() => {
if (OfflineManager._running === run) OfflineManager._running = null;
});
OfflineManager._running = run;
return run;
}
private static async _preloadImpl(options: PreloadOptions): Promise<PreloadProgress> {
const startedAt = Date.now();
const filesToFetch: string[] = [];
const m = await loadManifest();
@@ -184,8 +209,20 @@ export class OfflineManager {
done: false,
failed: 0,
};
if (m === null) progress.manifestMissing = true;
OfflineManager._progress = progress;
// Best-effort: request persistent storage so the OS won't evict data
// for LRU pressure (and try to lift legacy quota caps where supported).
if (navigator.storage?.persist) {
try {
const granted = await navigator.storage.persist();
diagLog('storage-persist', { granted });
} catch {
diagLog('storage-persist-error');
}
}
diagLog('preload-start', {
langs: options.languages.join(','),
types: options.types.join(','),
@@ -225,8 +262,15 @@ export class OfflineManager {
} else {
data = await resp.json();
}
await DataCache.set(`url:${file}`, data);
const stored = await DataCache.set(`url:${file}`, data);
if (stored) {
success = true;
} else {
// IDB write failed (e.g. quota exceeded) — the cache is not
// healthy; count as failure so the circuit breaker can abort
// instead of reporting a misleading "done".
diagLog('preload-write-fail', { file });
}
break;
} catch {
// Retry on network error
@@ -244,15 +288,21 @@ export class OfflineManager {
break;
}
} else {
if (notFound) progress.missing = (progress.missing ?? 0) + 1;
// Success or 404 both prove connectivity
netFailStreak = 0;
}
}
if ((progress.missing ?? 0) > 0) {
diagLog('preload-missing', { count: progress.missing });
}
progress.done = true;
diagLog('preload-done', {
total: progress.total,
failed: progress.failed,
missing: progress.missing ?? 0,
ms: Date.now() - startedAt,
aborted: progress.aborted === true,
});
+27
View File
@@ -1,5 +1,32 @@
import './styles/main.css';
import { registerSW } from 'virtual:pwa-register';
import { App } from './ui/app';
import { getTranslations, type LanguageCode } from './core/i18n';
import { loadSettings } from './ui/settings-view';
const updateSW = registerSW({
immediate: true,
onNeedRefresh: () => showUpdateToast(),
});
function showUpdateToast() {
if (document.getElementById('pwa-update-toast')) return;
const t = getTranslations(loadSettings().language as LanguageCode);
const toast = document.createElement('div');
toast.id = 'pwa-update-toast';
toast.className = 'fixed bottom-4 left-1/2 -translate-x-1/2 z-[60] bg-surface border border-gold/40 rounded-lg shadow-xl px-4 py-3 flex items-center gap-3 max-w-[calc(100vw-2rem)]';
toast.innerHTML = `
<span class="text-base text-navy whitespace-nowrap">${t.updateAvailable}</span>
<button id="pwa-update-btn" class="bg-gold text-navy rounded px-3 py-1 text-base font-bold hover:bg-gold-dark transition-colors whitespace-nowrap">${t.updateReload}</button>
<button id="pwa-update-dismiss" class="text-navy-light hover:text-navy text-lg leading-none px-1" aria-label="✕">&times;</button>
`;
document.body.appendChild(toast);
toast.querySelector('#pwa-update-btn')?.addEventListener('click', () => {
(toast.querySelector('#pwa-update-btn') as HTMLButtonElement).disabled = true;
updateSW(true);
});
toast.querySelector('#pwa-update-dismiss')?.addEventListener('click', () => toast.remove());
}
const app = new App(document.getElementById('app')!);
app.init();
+1 -1
View File
@@ -274,7 +274,7 @@ nav details.nav-group a.nav-link {
}
nav details.nav-group details a.nav-link {
padding: 0.5rem 0.5rem 0.5rem 1rem;
font-size: 0.875rem;
font-size: 1rem;
}
/* Mobile: top-level dropdown as fixed overlay; nested in-flow */
+4 -4
View File
@@ -190,11 +190,11 @@ export class App {
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>
<p class="text-base 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>
<button id="offline-offer-skip" class="w-full text-base text-navy-light underline hover:text-navy text-center cursor-pointer">${t.settings.offlineOfferSkip}</button>
</div>
</div>
`;
@@ -252,10 +252,10 @@ export class App {
<img src="/icons/icon-192.png" class="w-6 h-6" alt="">
${t.nav.appTitle}
</span>
<nav class="flex flex-wrap gap-4 text-sm mx-auto">
<nav class="flex flex-wrap gap-4 text-base mx-auto">
<a href="#calendar" class="nav-link hover:text-gold transition-colors" data-view="calendar">${t.nav.calendar}</a>
<details class="nav-group relative" data-nav="library">
<summary class="cursor-pointer list-none hover:text-gold transition-colors flex items-center gap-1">${t.nav.library} <span class="text-xs">▾</span></summary>
<summary class="cursor-pointer list-none hover:text-gold transition-colors flex items-center gap-1">${t.nav.library} <span class="text-sm">▾</span></summary>
<div class="absolute left-0 top-full mt-1 z-50 min-w-56 rounded-lg bg-dropdown-bg shadow-xl p-2 flex flex-col gap-1">
<a href="#bible" class="nav-link hover:text-gold transition-colors px-2 py-1" data-view="bible"><span>📖</span> <span class="font-bold">${t.nav.bible}</span></a>
<a href="#prayer" class="nav-link hover:text-gold transition-colors px-2 py-1" data-view="prayer">${t.nav.prayer}</a>
+10 -10
View File
@@ -172,24 +172,24 @@ export class BibleView {
this.container.innerHTML = `
<div class="flex flex-col h-full">
<div class="bg-surface/50 border border-gold/20 rounded-lg p-3 flex items-center gap-4 flex-wrap">
<select id="version-select" class="appearance-none bg-surface text-navy border border-gold/20 rounded px-2 py-1 text-sm">
<select id="version-select" class="appearance-none bg-surface text-navy border border-gold/20 rounded px-2 py-1 text-base">
${this.versions.map(v =>
`<option value="${v.id}" ${v.id === this.currentVersion ? 'selected' : ''}>${v.name}</option>`
).join('')}
</select>
<select id="book-select" class="appearance-none bg-surface text-navy border border-gold/20 rounded px-2 py-1 text-sm">
<select id="book-select" class="appearance-none bg-surface text-navy border border-gold/20 rounded px-2 py-1 text-base">
${this.getBooksForVersion().map(b =>
`<option value="${b.id}" ${b.id === this.currentBook ? 'selected' : ''}>${b.name}</option>`
).join('')}
</select>
<div class="flex items-center gap-1">
<button id="prev-chapter" class="bg-surface/50 text-navy border border-gold/20 rounded px-2 py-1 text-sm hover:bg-gold/20 disabled:opacity-40">◀</button>
<select id="chapter-select" class="appearance-none bg-surface text-navy border border-gold/20 rounded px-2 py-1 text-sm">
<button id="prev-chapter" class="bg-surface/50 text-navy border border-gold/20 rounded px-2 py-1 text-base hover:bg-gold/20 disabled:opacity-40">◀</button>
<select id="chapter-select" class="appearance-none bg-surface text-navy border border-gold/20 rounded px-2 py-1 text-base">
</select>
<button id="next-chapter" class="bg-surface/50 text-navy border border-gold/20 rounded px-2 py-1 text-sm hover:bg-gold/20 disabled:opacity-40">▶</button>
<button id="next-chapter" class="bg-surface/50 text-navy border border-gold/20 rounded px-2 py-1 text-base hover:bg-gold/20 disabled:opacity-40">▶</button>
</div>
<div class="relative ml-auto">
<button id="bookmark-btn" class="bg-surface/50 text-navy border border-gold/20 rounded px-2 py-1 text-sm hover:bg-gold/20" title="${this.t.bible.bookmarkAdd}">🔖</button>
<button id="bookmark-btn" class="bg-surface/50 text-navy border border-gold/20 rounded px-2 py-1 text-base hover:bg-gold/20" title="${this.t.bible.bookmarkAdd}">🔖</button>
<div id="bookmark-panel" class="${this.bookmarksOpen ? 'flex flex-col' : 'hidden'} absolute right-0 top-full mt-1 z-50 min-w-64 rounded-lg bg-dropdown-bg shadow-xl p-2 gap-1 text-header-text"></div>
</div>
</div>
@@ -308,7 +308,7 @@ export class BibleView {
const t = this.t.bible;
const rows = [...this.bookmarks].sort((a, b) => b.created - a.created).map(b => `
<div class="flex items-center rounded px-1 py-1 hover:bg-white/10">
<button class="bookmark-jump flex-1 min-w-0 text-left text-sm truncate cursor-pointer py-1 pr-2 rounded active:bg-white/15" data-id="${b.id}" title="${escapeHtml(b.name)}">${escapeHtml(b.name)}</button>
<button class="bookmark-jump flex-1 min-w-0 text-left text-base truncate cursor-pointer py-1 pr-2 rounded active:bg-white/15" data-id="${b.id}" title="${escapeHtml(b.name)}">${escapeHtml(b.name)}</button>
<div class="shrink-0 flex items-center gap-1 border-l border-gold/25 pl-2 ml-1">
<button class="bookmark-rename w-8 h-8 flex items-center justify-center text-base rounded-md bg-white/5 border border-white/15 active:bg-white/15 cursor-pointer" data-id="${b.id}">✏</button>
<button class="bookmark-delete w-8 h-8 flex items-center justify-center text-base rounded-md bg-white/5 border border-white/15 active:bg-white/15 cursor-pointer" data-id="${b.id}">✕</button>
@@ -317,8 +317,8 @@ export class BibleView {
`).join('');
panel.innerHTML = `
<button id="bookmark-add" class="w-full text-left bg-white/10 border border-gold/40 rounded px-2 py-1.5 text-sm font-bold hover:bg-white/20 cursor-pointer">+ ${t.bookmarkAdd}</button>
${rows ? `<div class="border-t border-gold/20 my-1"></div>${rows}` : `<p class="text-xs italic opacity-70 px-2 py-1">${t.bookmarkEmpty}</p>`}
<button id="bookmark-add" class="w-full text-left bg-white/10 border border-gold/40 rounded px-2 py-1.5 text-base font-bold hover:bg-white/20 cursor-pointer">+ ${t.bookmarkAdd}</button>
${rows ? `<div class="border-t border-gold/20 my-1"></div>${rows}` : `<p class="text-sm italic opacity-70 px-2 py-1">${t.bookmarkEmpty}</p>`}
`;
document.getElementById('bookmark-add')?.addEventListener('click', () => {
@@ -506,7 +506,7 @@ export class BibleView {
textEl.innerHTML = `
<div class="text-center py-12">
<p class="text-navy-light mb-4">${this.t.bible.notAvailableOffline}</p>
<p class="text-sm text-navy-light">
<p class="text-base text-navy-light">
${this.t.bible.ensureDataConverted}
</p>
</div>
+18 -18
View File
@@ -486,7 +486,7 @@ export class CalendarView {
let col = 0;
gridHtml += this.t.calendar.dayNames.map(d =>
`<div class="text-center text-xs font-bold text-red py-1">${d}</div>`
`<div class="text-center text-sm font-bold text-red py-1">${d}</div>`
).join('');
for (let i = 0; i < firstDow; i++) {
@@ -516,7 +516,7 @@ export class CalendarView {
isToday = date.equals(JDate.today());
}
let classes = 'day-cell p-1 text-center text-sm cursor-pointer rounded transition-colors ';
let classes = 'day-cell p-1 text-center text-base cursor-pointer rounded transition-colors ';
if (isSelected) classes += 'bg-navy text-parchment font-bold ';
else if (isToday) classes += 'bg-gold/20 font-bold ';
else if (dayRank >= 6) classes += 'bg-red/20 text-red font-bold ';
@@ -550,7 +550,7 @@ export class CalendarView {
<button id="next-month" class="p-2 hover:bg-navy/10 rounded">▶</button>
</div>
<div class="grid grid-cols-7 gap-0.5 text-xs">
<div class="grid grid-cols-7 gap-0.5 text-sm">
${gridHtml}
</div>
@@ -560,33 +560,33 @@ export class CalendarView {
<div class="flex-1 p-3 overflow-auto">
<div class="max-w-2xl lg:max-w-3xl xl:max-w-4xl mx-auto">
<h2 class="text-sm font-bold text-red mb-1">
<h2 class="text-base font-bold text-red mb-1">
${julianStr}
</h2>
<div id="day-title" class="mb-2"></div>
<div class="text-sm text-navy-light mb-2">
<div class="text-base text-navy-light mb-2">
${this.t.calendar.gregorianDate[0].toLowerCase() + this.t.calendar.gregorianDate.slice(1)}: ${gregorianStr}
</div>
<div id="astro-panel" class="mb-2 p-2 bg-surface/50 border border-gold/20 rounded-lg text-sm text-navy">
<div id="astro-panel" class="mb-2 p-2 bg-surface/50 border border-gold/20 rounded-lg text-base text-navy">
<span class="text-navy-light italic">${this.t.loading}</span>
</div>
<div id="commemorations-panel" class="mb-2 p-2 bg-surface/50 border border-gold/20 rounded-lg ${fnClass}">
<h3 class="font-bold text-red mb-2">${this.t.calendar.commemorations}</h3>
<p class="text-sm text-navy-light italic">${this.t.loading}</p>
<p class="text-base text-navy-light italic">${this.t.loading}</p>
</div>
<div id="fasting-panel" class="mb-2 p-2 bg-surface/50 border border-gold/20 rounded-lg ${fnClass}">
<h3 class="font-bold text-red mb-2">${this.t.calendar.fastingRule}</h3>
<p class="text-sm italic text-navy-light">${this.t.loading}</p>
<p class="text-base italic text-navy-light">${this.t.loading}</p>
</div>
<div id="readings-panel" class="mb-2 p-2 bg-surface/50 border border-gold/20 rounded-lg ${fnClass}">
<h3 class="font-bold text-red mb-2">${this.t.calendar.readings}</h3>
<p class="text-sm text-navy-light italic">${this.t.loading}</p>
<p class="text-base text-navy-light italic">${this.t.loading}</p>
</div>
<div id="services-panel" class="mb-2 border-t border-gold/20 pt-3"></div>
@@ -652,7 +652,7 @@ export class CalendarView {
if (fastingPanel) {
fastingPanel.innerHTML = `
<h3 class="font-bold text-red mb-2">${this.t.calendar.fastingRule}</h3>
<p class="text-sm">${fastingResult.description || this.t.notAvailable}</p>
<p class="text-base">${fastingResult.description || this.t.notAvailable}</p>
`;
}
@@ -742,7 +742,7 @@ export class CalendarView {
const toneSuffix = tone > 0 ? `; ${this.t.calendar.tone}${tone}` : '';
const feastSuffix = feastName && feastCid !== periodCid ? `; ${feastName}` : '';
const titleColor = feastName ? 'text-red-700' : 'text-navy';
titleEl.innerHTML = `<span class="text-sm font-semibold ${titleColor} italic">${periodName}${toneSuffix}${feastSuffix}</span>`;
titleEl.innerHTML = `<span class="text-base font-semibold ${titleColor} italic">${periodName}${toneSuffix}${feastSuffix}</span>`;
}
if (feastCid) this.skipCids.add(feastCid);
@@ -754,7 +754,7 @@ export class CalendarView {
if (cids.length === 0) {
commPanel.innerHTML = `
<h3 class="font-bold text-red mb-2">${this.t.calendar.commemorations}</h3>
<p class="text-sm text-navy-light italic">${this.t.calendar.noCommemorations}</p>
<p class="text-base text-navy-light italic">${this.t.calendar.noCommemorations}</p>
`;
} else {
const commHtmlParts: string[] = [];
@@ -783,11 +783,11 @@ export class CalendarView {
const icon = this.t.calendar.rankIcons[rank] || '';
const iconHtml = icon ? `<span class="font-slavonic text-lg mr-1">${icon}</span>` : '';
const lifeHtml = lifeText
? `<div class="life-text hidden mt-2 p-3 bg-surface border border-gold/20 rounded text-sm leading-relaxed max-h-60 overflow-y-auto">${lifeText}</div>`
? `<div class="life-text hidden mt-2 p-3 bg-surface border border-gold/20 rounded text-base leading-relaxed max-h-60 overflow-y-auto">${lifeText}</div>`
: '';
commHtmlParts.push(
`<div class="comm-item">
<div class="text-sm font-medium ${lifeText ? 'text-blue-600 underline cursor-pointer hover:text-blue-800' : 'text-navy'}">${iconHtml}${name}${lifeText ? '<span class="comm-chevron text-xs align-middle ml-1 inline-block">▶</span>' : ''}</div>
<div class="text-base font-medium ${lifeText ? 'text-blue-600 underline cursor-pointer hover:text-blue-800' : 'text-navy'}">${iconHtml}${name}${lifeText ? '<span class="comm-chevron text-sm align-middle ml-1 inline-block">▶</span>' : ''}</div>
${lifeHtml}
</div>`
);
@@ -813,7 +813,7 @@ commPanel.addEventListener('click', (e) => {
} else {
commPanel.innerHTML = `
<h3 class="font-bold text-red mb-2">${this.t.calendar.commemorations}</h3>
<p class="text-sm text-navy-light italic">${this.t.calendar.noCommemorations}</p>
<p class="text-base text-navy-light italic">${this.t.calendar.noCommemorations}</p>
`;
}
}
@@ -897,7 +897,7 @@ commPanel.addEventListener('click', (e) => {
if (readingsGroups.length === 0) {
readingsPanel.innerHTML = `
<h3 class="font-bold text-red mb-2">${this.t.calendar.readings}</h3>
<p class="text-sm text-navy-light italic">${this.t.calendar.noReadings}</p>
<p class="text-base text-navy-light italic">${this.t.calendar.noReadings}</p>
`;
} else {
const sorted = [...readingsGroups].sort((a, b) => {
@@ -917,7 +917,7 @@ commPanel.addEventListener('click', (e) => {
refs += item.purpose;
}
}
return `<div class="text-sm text-navy"><strong>${g.service}</strong>${this.t.calendar.colon}${refs}</div>`;
return `<div class="text-base text-navy"><strong>${g.service}</strong>${this.t.calendar.colon}${refs}</div>`;
}).join('');
readingsPanel.innerHTML = `
<h3 class="font-bold text-red mb-2">${this.t.calendar.readings}</h3>
@@ -963,7 +963,7 @@ commPanel.addEventListener('click', (e) => {
const svcView = new ServiceView(svcPanel, this.currentDate, this.language);
await svcView.render(false);
} catch {
svcPanel.innerHTML = `<p class="text-sm text-navy-light italic">${this.t.loading}</p>`;
svcPanel.innerHTML = `<p class="text-base text-navy-light italic">${this.t.loading}</p>`;
}
}
+4 -4
View File
@@ -70,7 +70,7 @@ export class MenaionView {
{ id: 'triodion', view: 'triodion', label: this.t.nav.triodion },
].map(item => `
<button
class="sub-nav-chip text-sm transition-colors rounded-lg px-3 py-1
class="sub-nav-chip text-base transition-colors rounded-lg px-3 py-1
${item.id === 'menaion'
? 'border border-gold bg-gold/10 text-navy font-bold'
: 'border border-gold/20 bg-surface/50 text-navy hover:border-gold/50'}"
@@ -119,7 +119,7 @@ export class MenaionView {
const key = `${mm}-${dd}`;
const n = this.index[key];
daysHtml += `
<button class="px-1.5 py-0.5 text-xs rounded border transition-colors
<button class="px-1.5 py-0.5 text-sm rounded border transition-colors
${n ? 'border-gold/40 bg-gold/10 text-navy hover:border-gold cursor-pointer' : 'border-transparent text-navy-light opacity-30 cursor-default'}"
data-date="${key}" ${n ? '' : 'disabled'}>
${d}
@@ -129,9 +129,9 @@ export class MenaionView {
calEl.innerHTML = `
<div class="flex items-center justify-between mb-3">
<button id="menaion-prev" class="text-sm text-navy-light hover:text-navy px-2 py-1">◀</button>
<button id="menaion-prev" class="text-base text-navy-light hover:text-navy px-2 py-1">◀</button>
<span class="text-base font-bold text-navy">${months[month - 1]}</span>
<button id="menaion-next" class="text-sm text-navy-light hover:text-navy px-2 py-1">▶</button>
<button id="menaion-next" class="text-base text-navy-light hover:text-navy px-2 py-1">▶</button>
</div>
<div class="grid gap-1 grid-cols-7 mb-2">
${daysHtml}
+8 -3
View File
@@ -191,6 +191,9 @@ export class PrayerView {
async render() {
const sections = this.getSections();
if (!this.activeSection) {
this.activeSection = sections[0]?.id ?? null;
}
const isTriodion = this.collection === 'triodion';
const subtitle = isTriodion ? this.t.triodion.subtitle
: this.collection === 'akathists' ? this.t.akathists.subtitle
@@ -218,7 +221,7 @@ export class PrayerView {
<div class="flex flex-wrap gap-2 mb-2">
${subNavItems.map(item => `
<button
class="sub-nav-chip text-sm transition-colors rounded-lg px-3 py-1
class="sub-nav-chip text-base transition-colors rounded-lg px-3 py-1
${this.collection === item.id
? 'border border-gold bg-gold/10 text-navy font-bold'
: 'border border-gold/20 bg-surface/50 text-navy hover:border-gold/50'}"
@@ -226,8 +229,8 @@ export class PrayerView {
>${item.label}</button>
`).join('')}
</div>
<p class="text-navy-light text-sm mb-2">${subtitle}</p>
<select id="prayer-select" class="w-full appearance-none bg-surface text-navy border border-gold/20 rounded-lg px-3 py-2 text-sm mb-3">
<p class="text-navy-light text-base mb-2">${subtitle}</p>
<select id="prayer-select" class="w-full appearance-none bg-surface text-navy border border-gold/20 rounded-lg px-3 py-2 text-base mb-3">
${sections.map(s => `
<option value="${s.id}" ${this.activeSection === s.id ? 'selected' : ''}>${s.name}</option>
`).join('')}
@@ -250,6 +253,8 @@ export class PrayerView {
this.activeSection = id;
this.loadSection(id);
});
if (this.activeSection) void this.loadSection(this.activeSection);
}
private async loadSection(id: string) {
+10 -11
View File
@@ -93,7 +93,7 @@ export class ServiceView {
<div class="flex flex-wrap gap-2 mb-3">
${availableServices.map(s => `
<button
class="service-tab text-sm transition-colors
class="service-tab text-base transition-colors
${this.activeService === s.id
? 'border border-gold bg-gold/10 text-navy font-bold rounded-lg px-3 py-1'
: 'border border-gold/20 bg-surface/50 text-navy hover:border-gold/50 rounded-lg px-3 py-1'}"
@@ -132,9 +132,9 @@ export class ServiceView {
this.container.querySelectorAll('.service-tab').forEach(t => {
const id = t.getAttribute('data-service');
if (id === serviceId) {
t.className = 'service-tab text-sm transition-colors border border-gold bg-gold/10 text-navy font-bold rounded-lg px-3 py-1';
t.className = 'service-tab text-base transition-colors border border-gold bg-gold/10 text-navy font-bold rounded-lg px-3 py-1';
} else {
t.className = 'service-tab text-sm transition-colors border border-gold/20 bg-surface/50 text-navy hover:border-gold/50 rounded-lg px-3 py-1';
t.className = 'service-tab text-base transition-colors border border-gold/20 bg-surface/50 text-navy hover:border-gold/50 rounded-lg px-3 py-1';
}
});
this.loadService(service);
@@ -921,7 +921,7 @@ export class ServiceView {
contentEl.innerHTML = `
<div class="text-center py-8">
<p class="text-navy-light mb-2">${this.t.services.serviceProducedNoOutput}</p>
<p class="text-xs text-navy-light">${nodes.length} ${this.t.services.nodesAssembled}</p>
<p class="text-sm text-navy-light">${nodes.length} ${this.t.services.nodesAssembled}</p>
</div>
`;
}
@@ -929,21 +929,21 @@ export class ServiceView {
contentEl.innerHTML = `
<div class="text-center py-8">
<p class="text-navy-light mb-2">${this.t.services.errorLoading}</p>
<p class="text-xs text-navy-light">${err instanceof Error ? err.message : this.t.error}</p>
<p class="text-sm text-navy-light">${err instanceof Error ? err.message : this.t.error}</p>
</div>
`;
}
}
/**
* Bible translation used for scripture readings inside services.
* Only 'en' and 'cu' can occur here: `serviceLang` maps ru→cu, so RU
* users read the Slavonic text, matching the rest of the service body.
*/
private bibleVersion(lang: string): string {
const map: Record<string, string> = {
en: 'kjv',
cu: 'elis',
fr: 'ls',
el: 'spt',
la: 'vulgate',
zh: 'cuv',
ar: 'svd',
};
return map[lang] || 'kjv';
}
@@ -964,7 +964,6 @@ export class ServiceView {
let text = await tryFetch(this.serviceLang);
if (!text && this.serviceLang !== 'cu') text = await tryFetch('cu');
if (!text) text = await tryFetch('shared');
if (!text) return '';
const lines = text.split('\n');
+49 -51
View File
@@ -3,11 +3,26 @@
*/
import { getTranslations, type LanguageCode } from '../core/i18n';
import { OfflineManager } from '../core/offline-manager';
import { OfflineManager, type PreloadProgress } from '../core/offline-manager';
import { DataCache } from '../core/data-cache';
import { diagTail } from '../core/diag-log';
import { isPwaInstalled } from '../core/pwa-install';
/** Human message for a finished preload run (shared by manual + auto preload). */
function preloadResultText(
prog: PreloadProgress,
s: { offlineAborted: string; offlineDone: string; offlineFailed: string; offlineDegraded: string; offlineMissing: string },
): string {
if (prog.aborted) return s.offlineAborted;
if (prog.manifestMissing) return s.offlineDegraded;
if (prog.failed > 0) {
const failList = prog.failedFiles?.length ? `: ${prog.failedFiles.join(', ')}` : '';
return `${s.offlineDone} ${prog.failed} ${s.offlineFailed}${failList}`;
}
if ((prog.missing ?? 0) > 0) return s.offlineMissing.replace('{0}', String(prog.missing));
return s.offlineDone;
}
const LANGUAGES = [
{ code: 'en', name: 'English', local: 'English' },
{ code: 'ru', name: 'Russian', local: 'Русский' },
@@ -44,7 +59,7 @@ interface Settings {
const DEFAULT_SETTINGS: Settings = {
language: 'en',
cuFont: 'Ponomar',
fontSize: 16,
fontSize: 18,
theme: 'default',
defaultBibleVersion: 'kjv',
showVerseNumbers: true,
@@ -134,7 +149,7 @@ export class SettingsView {
<!-- Language -->
<div class="mb-6">
<label class="block text-sm font-bold text-navy mb-2">${t.settings.language}</label>
<label class="block text-base font-bold text-navy mb-2">${t.settings.language}</label>
<select id="language" class="w-full border border-gold/30 rounded p-2 bg-surface text-navy">
${LANGUAGES.map(l => `
<option value="${l.code}" ${l.code === this.settings.language ? 'selected' : ''}>
@@ -172,7 +187,7 @@ export class SettingsView {
</div>
<div class="p-3 bg-parchment-dark rounded border border-gold/20">
<p class="text-sm text-navy mb-2">${t.settings.fontPreview}</p>
<p class="text-base text-navy mb-2">${t.settings.fontPreview}</p>
<div id="font-preview" class="text-lg">
Сохрани́ мѧ, гдⷭ҇и, ꙗ҆́кѡ на тѧ̀ ᲂу҆пова́хъ
</div>
@@ -181,7 +196,7 @@ export class SettingsView {
<!-- Font Size -->
<div class="mb-6">
<label class="block text-sm font-bold text-navy mb-2">
<label class="block text-base font-bold text-navy mb-2">
${t.settings.fontSize} <span id="font-size-value">${this.settings.fontSize}px</span>
</label>
<input type="range" id="font-size" min="12" max="32" value="${this.settings.fontSize}"
@@ -195,12 +210,12 @@ export class SettingsView {
<label class="flex items-center gap-2 cursor-pointer">
<input type="radio" name="calendar-type" value="julian" ${this.settings.calendarType === 'julian' ? 'checked' : ''}
class="accent-gold">
<span class="text-sm text-navy">${t.settings.julian}</span>
<span class="text-base text-navy">${t.settings.julian}</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="radio" name="calendar-type" value="gregorian" ${this.settings.calendarType === 'gregorian' ? 'checked' : ''}
class="accent-gold">
<span class="text-sm text-navy">${t.settings.gregorian}</span>
<span class="text-base text-navy">${t.settings.gregorian}</span>
</label>
</div>
</div>
@@ -210,21 +225,21 @@ export class SettingsView {
<h3 class="text-lg font-bold text-red mb-3">${t.settings.locationTitle}</h3>
<div class="flex gap-2 mb-2">
<div class="flex-1">
<label class="block text-xs font-bold text-navy mb-1">${t.settings.latitude}</label>
<label class="block text-sm font-bold text-navy mb-1">${t.settings.latitude}</label>
<input type="number" id="latitude" step="0.0001" value="${this.settings.latitude}"
class="w-full border border-gold/30 rounded p-2 bg-surface text-navy text-sm">
class="w-full border border-gold/30 rounded p-2 bg-surface text-navy text-base">
</div>
<div class="flex-1">
<label class="block text-xs font-bold text-navy mb-1">${t.settings.longitude}</label>
<label class="block text-sm font-bold text-navy mb-1">${t.settings.longitude}</label>
<input type="number" id="longitude" step="0.0001" value="${this.settings.longitude}"
class="w-full border border-gold/30 rounded p-2 bg-surface text-navy text-sm">
class="w-full border border-gold/30 rounded p-2 bg-surface text-navy text-base">
</div>
</div>
<div class="flex items-center gap-2">
<button id="request-location" class="bg-navy text-parchment rounded px-3 py-1 text-sm hover:bg-navy-light transition-colors">
<button id="request-location" class="bg-navy text-parchment rounded px-3 py-1 text-base hover:bg-navy-light transition-colors">
${t.settings.requestLocation}
</button>
<span class="text-xs text-navy-light">${t.settings.locationNote}</span>
<span class="text-sm text-navy-light">${t.settings.locationNote}</span>
</div>
</div>
@@ -233,7 +248,7 @@ export class SettingsView {
<h3 class="text-lg font-bold text-red mb-3">${t.settings.bibleSettings}</h3>
<div class="mb-3">
<label class="block text-sm font-bold text-navy mb-1">${t.settings.defaultTranslation}</label>
<label class="block text-base font-bold text-navy mb-1">${t.settings.defaultTranslation}</label>
<select id="default-bible" class="w-full border border-gold/30 rounded p-2 bg-surface text-navy">
<option value="">${t.settings.offlineCalculating}</option>
</select>
@@ -242,13 +257,13 @@ export class SettingsView {
<div class="flex items-center gap-3">
<input type="checkbox" id="show-verses" ${this.settings.showVerseNumbers ? 'checked' : ''}
class="w-4 h-4 accent-gold">
<label for="show-verses" class="text-sm text-navy">${t.settings.showVerseNumbers}</label>
<label for="show-verses" class="text-base text-navy">${t.settings.showVerseNumbers}</label>
</div>
<div class="flex items-center gap-3">
<input type="checkbox" id="verse-newline" ${this.settings.verseNewLine ? 'checked' : ''}
class="w-4 h-4 accent-gold">
<label for="verse-newline" class="text-sm text-navy">${t.settings.verseNewLine}</label>
<label for="verse-newline" class="text-base text-navy">${t.settings.verseNewLine}</label>
</div>
</div>
@@ -257,7 +272,7 @@ export class SettingsView {
<button id="install-pwa" class="hidden w-full bg-navy text-parchment rounded p-3 font-bold hover:bg-navy-light transition-colors">
${t.settings.installPwa}
</button>
<p id="install-hint" class="hidden text-sm text-navy-light mt-2"></p>
<p id="install-hint" class="hidden text-base text-navy-light mt-2"></p>
</div>
<!-- Offline Content -->
@@ -266,11 +281,11 @@ export class SettingsView {
<!-- Language selection for offline -->
<div class="mb-3">
<label class="block text-sm font-bold text-navy mb-2">${t.settings.offlineLangs}</label>
<label class="block text-base font-bold text-navy mb-2">${t.settings.offlineLangs}</label>
<div id="offline-langs" class="flex flex-wrap gap-3">
${LANGUAGES.map(l => `
<label class="flex items-center gap-1 cursor-pointer">
<input type="checkbox" value="${l.code}" class="offline-lang accent-gold"> <span class="text-sm">${l.local}</span>
<input type="checkbox" value="${l.code}" class="offline-lang accent-gold"> <span class="text-base">${l.local}</span>
</label>
`).join('')}
</div>
@@ -278,14 +293,14 @@ export class SettingsView {
<!-- Data type selection -->
<div class="mb-3">
<label class="block text-sm font-bold text-navy mb-2">${t.settings.offlineDataTypes}</label>
<label class="block text-base font-bold text-navy mb-2">${t.settings.offlineDataTypes}</label>
<div id="offline-types" class="flex flex-wrap gap-3">
<label class="flex items-center gap-1 cursor-pointer">
<input type="checkbox" value="calendar" checked class="offline-type accent-gold"> <span class="text-sm">${t.settings.offlineCalendar}</span>
<input type="checkbox" value="calendar" checked class="offline-type accent-gold"> <span class="text-base">${t.settings.offlineCalendar}</span>
</label>
<div id="bible-row" class="flex items-center gap-1 cursor-pointer">
<span class="text-sm">${t.settings.offlineBible} <span id="bible-count" class="text-xs text-navy-light">(0/0)</span></span>
<span id="bible-chevron" class="text-xs text-navy-light hover:text-navy select-none">▶</span>
<span class="text-base">${t.settings.offlineBible} <span id="bible-count" class="text-sm text-navy-light">(0/0)</span></span>
<span id="bible-chevron" class="text-sm text-navy-light hover:text-navy select-none">▶</span>
</div>
</div>
<div id="bible-translations" class="hidden ml-4 mt-1 space-y-1 border-l border-gold/20 pl-3"></div>
@@ -293,17 +308,17 @@ export class SettingsView {
<!-- Storage info and controls -->
<div class="flex items-center justify-between gap-3 mb-2">
<span id="offline-stats" class="text-sm text-navy-light">${t.settings.offlineCalculating}</span>
<span id="offline-stats" class="text-base text-navy-light">${t.settings.offlineCalculating}</span>
<div class="flex gap-2">
<button id="offline-preload" class="bg-gold text-navy rounded px-3 py-1 text-sm font-bold hover:bg-gold-dark transition-colors">${t.settings.offlinePreload}</button>
<button id="offline-clear" class="bg-red text-parchment rounded px-3 py-1 text-sm hover:bg-red-dark transition-colors">${t.settings.offlineClearCache}</button>
<button id="offline-preload" class="bg-gold text-navy rounded px-3 py-1 text-base font-bold hover:bg-gold-dark transition-colors">${t.settings.offlinePreload}</button>
<button id="offline-clear" class="bg-red text-parchment rounded px-3 py-1 text-base hover:bg-red-dark transition-colors">${t.settings.offlineClearCache}</button>
</div>
</div>
<div id="offline-progress" class="hidden">
<div class="w-full bg-parchment-dark rounded-full h-2 mb-1">
<div id="offline-progress-bar" class="bg-gold h-2 rounded-full" style="width: 0%"></div>
</div>
<p id="offline-progress-text" class="text-xs text-navy-light">0 / 0 ${t.settings.offlineDataTypes}</p>
<p id="offline-progress-text" class="text-sm text-navy-light">0 / 0 ${t.settings.offlineDataTypes}</p>
</div>
<pre id="diag-log" class="text-[10px] font-mono text-navy-light whitespace-pre-wrap max-h-32 overflow-auto mt-1"></pre>
</div>
@@ -311,14 +326,15 @@ export class SettingsView {
<!-- About -->
<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">
<p class="text-base text-navy-light">
<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">
<p class="text-sm text-navy-light mt-2">
<a href="https://github.com/typiconman/ponomar" target="_blank" rel="noopener" class="underline hover:text-gold">
${t.settings.aboutLicense}
</a>
</p>
<p class="text-[10px] text-navy-light mt-2 opacity-70">build ${__BUILD_DATE__}</p>
</div>
</div>
`;
@@ -496,14 +512,14 @@ export class SettingsView {
return `
<label class="flex items-center gap-1 cursor-pointer">
<input type="checkbox" value="${v.id}" class="bible-trans accent-gold" ${isCached ? 'checked' : ''}>
<span class="text-xs text-navy">${v.name}</span>
<span class="text-sm text-navy">${v.name}</span>
<span class="text-[10px] text-navy-light">(${OfflineManager.getBibleSize(v.id) || '?'})</span>
</label>
`;
}));
container.innerHTML = `
<div class="mb-1">
<button id="select-all-bible" class="text-xs text-blue-600 underline hover:text-blue-800 cursor-pointer">${t.settings.offlineSelectAll}</button>
<button id="select-all-bible" class="text-sm text-blue-600 underline hover:text-blue-800 cursor-pointer">${t.settings.offlineSelectAll}</button>
</div>
` + html.join('');
document.getElementById('select-all-bible')?.addEventListener('click', () => {
@@ -562,16 +578,7 @@ export class SettingsView {
bibleTranslations: bibleTrans,
}).then((prog) => {
if (progressBar) progressBar.style.width = '100%';
if (progressText) {
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;
}
}
if (progressText) progressText.textContent = preloadResultText(prog, t.settings);
const diagEl = document.getElementById('diag-log');
if (diagEl) diagEl.textContent = diagTail();
OfflineManager.getStats().then(stats => {
@@ -695,16 +702,7 @@ export class SettingsView {
bibleTranslations: 'all',
}).then((prog) => {
if (progressBar) progressBar.style.width = '100%';
if (progressText) {
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;
}
}
if (progressText) progressText.textContent = preloadResultText(prog, t.settings);
const diagEl = document.getElementById('diag-log');
if (diagEl) diagEl.textContent = diagTail();
OfflineManager.getStats().then(stats => {
+3
View File
@@ -1 +1,4 @@
/// <reference types="vite/client" />
/// <reference types="vite-plugin-pwa/client" />
declare const __BUILD_DATE__: string;
File diff suppressed because it is too large. Load diff
+16 -4
View File
@@ -6,15 +6,24 @@ import { VitePWA } from 'vite-plugin-pwa';
export default defineConfig({
plugins: [
tailwindcss(),
VitePWA({
registerType: 'autoUpdate',
VitePWA({ registerType: 'prompt',
workbox: {
skipWaiting: true,
clientsClaim: true,
skipWaiting: false,
clientsClaim: false,
globPatterns: ['**/*.{js,css,html,woff,png}'],
globIgnores: ['**/data/**'],
maximumFileSizeToCacheInBytes: 20 * 1024 * 1024,
runtimeCaching: [
{
// The preload file list must be fresh when online; a stale copy
// silently loses coverage. Offline falls back to the SW copy.
urlPattern: /\/data\/shared\/preload-manifest\.json$/,
handler: 'NetworkFirst',
options: {
cacheName: 'ponomar-manifest',
networkTimeoutSeconds: 3,
},
},
{
urlPattern: /\/data\/.*\.json$/,
handler: 'StaleWhileRevalidate',
@@ -66,5 +75,8 @@ export default defineConfig({
outDir: 'dist',
sourcemap: true,
},
define: {
__BUILD_DATE__: JSON.stringify(new Date().toISOString().slice(0, 10)),
},
publicDir: 'static',
});