/);
+ assert.match(providerSource, /wrapper: \(props\) =>
/);
+});
+
+test('does not schedule height work when swipeable views has no updater', () => {
+ const heightUpdaterSource = readAppFile('components/HeightUpdate/HeightUpdater.tsx');
+ const guardPosition = heightUpdaterSource.indexOf('if (!updateHeight)');
+ const timerPosition = heightUpdaterSource.indexOf('window.setTimeout');
+
+ assert.notEqual(guardPosition, -1);
+ assert.equal(guardPosition < timerPosition, true);
+ assert.match(heightUpdaterSource, /window\.clearTimeout\(timer\)/);
+});
+
+test('keeps editor-only UI and storage work out of the inactive heading path', () => {
+ const inputSource = readAppFile('components/ScriptEditor/ScriptEditorInput.tsx');
+ const activeInputSource = readAppFile('components/ScriptEditor/ActiveScriptEditorInput.tsx');
+ const loaderSource = readFileSync(path.join(serviceRoot, 'Texts/MdxLoader.tsx'), 'utf8');
+ const loaderRuntimeSource = readFileSync(path.join(serviceRoot, 'Texts/MdxLoaderRuntime.tsx'), 'utf8');
+
+ assert.match(inputSource, /if \(!scriptEditorIsActive\) \{\s*return null;/);
+ assert.match(inputSource, /React\.lazy\(/);
+ assert.doesNotMatch(inputSource, /localStorage|useTheme|useState/);
+ assert.match(activeInputSource, /localStorage/);
+ assert.doesNotMatch(loaderSource, /useRecoil/);
+ assert.match(loaderRuntimeSource, /MdxLoaderRuntimeProvider/);
+});
diff --git a/app/data/calendarQueryPolicy.test.mjs b/app/data/calendarQueryPolicy.test.mjs
new file mode 100644
index 0000000000..540c37efd4
--- /dev/null
+++ b/app/data/calendarQueryPolicy.test.mjs
@@ -0,0 +1,32 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+
+import { getCalendarQueryPolicy } from './calendarQueryPolicy.ts';
+
+const now = new Date(2026, 6, 28, 12);
+
+test('keeps historical calendar responses fresh indefinitely', () => {
+ assert.deepEqual(getCalendarQueryPolicy('2026-07-27', now), {
+ staleTime: Infinity,
+ refetchOnMount: false,
+ refetchOnReconnect: false,
+ refetchOnWindowFocus: false,
+ });
+});
+
+test('refreshes current and future calendar responses after five minutes', () => {
+ for (const date of ['2026-07-28', '2026-07-29']) {
+ assert.deepEqual(getCalendarQueryPolicy(date, now), {
+ staleTime: 5 * 60 * 1000,
+ refetchOnMount: true,
+ refetchOnReconnect: true,
+ refetchOnWindowFocus: true,
+ });
+ }
+});
+
+test('treats malformed dates as mutable instead of accidentally caching them forever', () => {
+ assert.equal(getCalendarQueryPolicy('', now).staleTime, 5 * 60 * 1000);
+ assert.equal(getCalendarQueryPolicy('27-07-2026', now).staleTime, 5 * 60 * 1000);
+ assert.equal(getCalendarQueryPolicy('2026-02-31', now).staleTime, 5 * 60 * 1000);
+});
diff --git a/app/data/calendarQueryPolicy.ts b/app/data/calendarQueryPolicy.ts
new file mode 100644
index 0000000000..7cff6884f5
--- /dev/null
+++ b/app/data/calendarQueryPolicy.ts
@@ -0,0 +1,50 @@
+const MUTABLE_DATE_STALE_TIME_MS = 5 * 60 * 1000;
+const ISO_CALENDAR_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
+
+export interface CalendarQueryPolicy {
+ staleTime: number;
+ refetchOnMount: boolean;
+ refetchOnReconnect: boolean;
+ refetchOnWindowFocus: boolean;
+}
+
+/**
+ * Past calendar data is immutable for normal navigation. Today's and future
+ * data can still change as the calendar API is updated, so it is refreshed
+ * after a short freshness window when the app remounts, reconnects, or
+ * returns to the foreground. Manual pull-to-refresh remains available for
+ * every date regardless of this policy.
+ */
+export const getCalendarQueryPolicy = (date: string, now: Date = new Date()): CalendarQueryPolicy => {
+ const currentDate = [
+ now.getFullYear(),
+ String(now.getMonth() + 1).padStart(2, '0'),
+ String(now.getDate()).padStart(2, '0'),
+ ].join('-');
+ const dateParts = ISO_CALENDAR_DATE_PATTERN.exec(date);
+ const parsedDate = dateParts
+ ? new Date(Date.UTC(Number(date.slice(0, 4)), Number(date.slice(5, 7)) - 1, Number(date.slice(8, 10))))
+ : null;
+ const isValidCalendarDate =
+ parsedDate !== null &&
+ parsedDate.getUTCFullYear() === Number(date.slice(0, 4)) &&
+ parsedDate.getUTCMonth() === Number(date.slice(5, 7)) - 1 &&
+ parsedDate.getUTCDate() === Number(date.slice(8, 10));
+ const isHistoricalDate = isValidCalendarDate && date < currentDate;
+
+ if (isHistoricalDate) {
+ return {
+ staleTime: Infinity,
+ refetchOnMount: false,
+ refetchOnReconnect: false,
+ refetchOnWindowFocus: false,
+ };
+ }
+
+ return {
+ staleTime: MUTABLE_DATE_STALE_TIME_MS,
+ refetchOnMount: true,
+ refetchOnReconnect: true,
+ refetchOnWindowFocus: true,
+ };
+};
diff --git a/app/data/readingQueryPolicy.test.mjs b/app/data/readingQueryPolicy.test.mjs
new file mode 100644
index 0000000000..855bb809fc
--- /dev/null
+++ b/app/data/readingQueryPolicy.test.mjs
@@ -0,0 +1,38 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+
+import { getReadingQueryPlan } from './readingQueryPolicy.ts';
+
+test('does not fan out individual default-reading requests while the bulk response is pending', () => {
+ assert.deepEqual(getReadingQueryPlan('default', 'pending', false), {
+ fetchBulkReadings: true,
+ fetchIndividualReading: false,
+ useBulkReading: false,
+ });
+});
+
+test('uses a default reading supplied by the bulk response without an individual request', () => {
+ assert.deepEqual(getReadingQueryPlan('default', 'success', true), {
+ fetchBulkReadings: true,
+ fetchIndividualReading: false,
+ useBulkReading: true,
+ });
+});
+
+test('falls back to an individual request when bulk default readings are unavailable', () => {
+ for (const status of ['success', 'error']) {
+ assert.deepEqual(getReadingQueryPlan('default', status, false), {
+ fetchBulkReadings: true,
+ fetchIndividualReading: true,
+ useBulkReading: false,
+ });
+ }
+});
+
+test('loads an explicitly selected translation directly without observing bulk readings', () => {
+ assert.deepEqual(getReadingQueryPlan('91Slavic', 'pending', false), {
+ fetchBulkReadings: false,
+ fetchIndividualReading: true,
+ useBulkReading: false,
+ });
+});
diff --git a/app/data/readingQueryPolicy.ts b/app/data/readingQueryPolicy.ts
new file mode 100644
index 0000000000..54003ae526
--- /dev/null
+++ b/app/data/readingQueryPolicy.ts
@@ -0,0 +1,28 @@
+export type BulkReadingQueryStatus = 'pending' | 'error' | 'success';
+
+export interface ReadingQueryPlan {
+ fetchBulkReadings: boolean;
+ fetchIndividualReading: boolean;
+ useBulkReading: boolean;
+}
+
+/**
+ * The date-level readings response already contains the default translation.
+ * An individual request is only a fallback when that response has settled
+ * without the requested reading. Non-default translations are never supplied
+ * by the bulk endpoint and can start immediately.
+ */
+export const getReadingQueryPlan = (
+ translation: string,
+ bulkQueryStatus: BulkReadingQueryStatus,
+ hasBulkReading: boolean
+): ReadingQueryPlan => {
+ const fetchBulkReadings = translation === 'default';
+ const useBulkReading = fetchBulkReadings && hasBulkReading;
+
+ return {
+ fetchBulkReadings,
+ fetchIndividualReading: !fetchBulkReadings || (bulkQueryStatus !== 'pending' && !useBulkReading),
+ useBulkReading,
+ };
+};
diff --git a/app/hooks/useAudio.ts b/app/hooks/useAudio.ts
index 2622679076..e0afe53dcd 100644
--- a/app/hooks/useAudio.ts
+++ b/app/hooks/useAudio.ts
@@ -1,5 +1,6 @@
import { useTheme } from '@emotion/react';
-import { useEffect, useRef } from 'react';
+import { useEffect } from 'react';
+import type { RefObject } from 'react';
import { css } from '@emotion/css';
const formatTime = (seconds) => new Date(1000 * parseInt(seconds || 0)).toISOString().substr(14, 5);
@@ -239,38 +240,68 @@ const augmentAudio = (audioElement, theme) => {
};
};
-const useAudio = (ref) => {
+const useAudio = (ref: RefObject
) => {
const theme = useTheme();
- const cleanups = useRef(new Map void>());
useEffect(() => {
- if (!ref?.current) {
- return;
+ const root = ref?.current;
+ if (!root) {
+ return undefined;
}
- const audioElements = new Set(ref.current.querySelectorAll('audio'));
- cleanups.current.forEach((cleanup, audioElement) => {
- if (!audioElements.has(audioElement)) {
- cleanup();
- cleanups.current.delete(audioElement);
+ const cleanups = new Map void>();
+ const enhanceAudioElement = (audioElement: HTMLAudioElement) => {
+ if (audioElement.closest('[data-audio-root]') !== root || cleanups.has(audioElement)) {
+ return;
}
- });
- audioElements.forEach((audioElement) => {
- if (!cleanups.current.has(audioElement)) {
- const cleanup = augmentAudio(audioElement, theme);
- if (cleanup) {
- cleanups.current.set(audioElement, cleanup);
+
+ const cleanup = augmentAudio(audioElement, theme);
+ if (cleanup) {
+ cleanups.set(audioElement, cleanup);
+ }
+ };
+ const enhanceAudioInNode = (node: Node) => {
+ if (!(node instanceof Element)) {
+ return;
+ }
+ if (node instanceof HTMLAudioElement) {
+ enhanceAudioElement(node);
+ }
+ node.querySelectorAll('audio').forEach(enhanceAudioElement);
+ };
+ const removeDetachedAudio = () => {
+ cleanups.forEach((cleanup, audioElement) => {
+ if (!root.contains(audioElement)) {
+ cleanup();
+ cleanups.delete(audioElement);
+ }
+ });
+ };
+
+ enhanceAudioInNode(root);
+
+ const observer = new MutationObserver((mutations) => {
+ let mayHaveRemovedAudio = false;
+ mutations.forEach((mutation) => {
+ mutation.addedNodes.forEach(enhanceAudioInNode);
+ if (mutation.removedNodes.length > 0) {
+ mayHaveRemovedAudio = true;
}
+ });
+
+ if (mayHaveRemovedAudio) {
+ removeDetachedAudio();
}
});
- });
+ observer.observe(root, { childList: true, subtree: true });
- useEffect(
- () => () => {
- cleanups.current.forEach((cleanup) => cleanup());
- cleanups.current.clear();
- },
- []
- );
+ return () => {
+ observer.disconnect();
+ cleanups.forEach((cleanup) => {
+ cleanup();
+ });
+ cleanups.clear();
+ };
+ }, [ref, theme]);
};
export default useAudio;
diff --git a/app/hooks/useDay.ts b/app/hooks/useDay.ts
index de650164b2..2a5e9493eb 100644
--- a/app/hooks/useDay.ts
+++ b/app/hooks/useDay.ts
@@ -4,6 +4,7 @@ import type { UseQueryResult } from '@tanstack/react-query';
import { getFeastInfo, getLentInfo } from 'domain/getDayInfo';
import type { Day, DayApiResponse } from 'data/contracts';
import { queryKeys } from 'data/queryKeys';
+import { getCalendarQueryPolicy } from 'data/calendarQueryPolicy';
import cachedFetch from 'utils/cachedFetch';
export async function fetchDay(date: string): Promise {
@@ -43,6 +44,7 @@ const useDay = (date: string): UseQueryResult =>
queryKey: queryKeys.day(date),
queryFn: async () => fetchDay(date),
retry: false,
+ ...getCalendarQueryPolicy(date),
});
export default useDay;
diff --git a/app/hooks/useExternalDay.ts b/app/hooks/useExternalDay.ts
index fdd982e57e..87cc141776 100644
--- a/app/hooks/useExternalDay.ts
+++ b/app/hooks/useExternalDay.ts
@@ -5,6 +5,7 @@ import cachedFetch from 'utils/cachedFetch';
import useDay from 'hooks/useDay';
import type { ExternalDay, ReadingVersesByType } from 'data/contracts';
import { queryKeys } from 'data/queryKeys';
+import { getCalendarQueryPolicy } from 'data/calendarQueryPolicy';
const fetchExternalDay = async (date: string, verses?: ReadingVersesByType): Promise =>
cachedFetch(
@@ -21,6 +22,7 @@ const useExternalDay = (date: string): UseQueryResult => {
queryFn: async () => fetchExternalDay(date, readings),
retry: false,
enabled: dayQuery.isFetched,
+ ...getCalendarQueryPolicy(date),
});
};
diff --git a/app/hooks/useParts.ts b/app/hooks/useParts.ts
index 6d0a28f75c..3737495ebb 100644
--- a/app/hooks/useParts.ts
+++ b/app/hooks/useParts.ts
@@ -4,6 +4,7 @@ import type { UseQueryResult } from '@tanstack/react-query';
import cachedFetch from 'utils/cachedFetch';
import type { PartsResponse } from 'data/contracts';
import { queryKeys } from 'data/queryKeys';
+import { getCalendarQueryPolicy } from 'data/calendarQueryPolicy';
export async function fetchParts(date: string, lang: string): Promise {
return cachedFetch(`${process.env.API_HOST}/parts/${date}/${lang}`);
@@ -14,6 +15,7 @@ const useParts = (date: string, lang: string): UseQueryResult fetchParts(date, lang),
retry: false,
+ ...getCalendarQueryPolicy(date),
});
export default useParts;
diff --git a/app/hooks/useReading.ts b/app/hooks/useReading.ts
index 7f6c34a334..38a1ec81ac 100644
--- a/app/hooks/useReading.ts
+++ b/app/hooks/useReading.ts
@@ -6,6 +6,7 @@ import useReadings from './useReadings';
import cachedFetch from 'utils/cachedFetch';
import type { ReadingResponse } from 'data/contracts';
import { queryKeys } from 'data/queryKeys';
+import { getReadingQueryPlan } from 'data/readingQueryPolicy';
export async function fetchReading(
link: string,
@@ -44,15 +45,19 @@ const useReading = (
date: string,
translationPriority: string[] = []
): ReadingQueryResult => {
- const { data: readings } = useReadings(date);
+ const initialQueryPlan = getReadingQueryPlan(translation, 'pending', false);
+ const readingsQuery = useReadings(date, initialQueryPlan.fetchBulkReadings);
+ const bulkReading = readingsQuery.data?.[link];
+ const queryPlan = getReadingQueryPlan(translation, readingsQuery.status, Boolean(bulkReading));
const readingQuery = useQuery({
queryKey: queryKeys.reading(link, translation),
queryFn: async () => fetchReading(link, translation, translationPriority),
retry: false,
+ enabled: queryPlan.fetchIndividualReading,
});
- if (readings?.[link] && translation === 'default') {
+ if (queryPlan.useBulkReading) {
return {
- data: readings[link],
+ data: bulkReading,
status: 'success',
};
}
diff --git a/app/hooks/useReadings.ts b/app/hooks/useReadings.ts
index 2c67343224..64a8c6d817 100644
--- a/app/hooks/useReadings.ts
+++ b/app/hooks/useReadings.ts
@@ -4,16 +4,19 @@ import type { UseQueryResult } from '@tanstack/react-query';
import cachedFetch from 'utils/cachedFetch';
import type { ReadingsResponse } from 'data/contracts';
import { queryKeys } from 'data/queryKeys';
+import { getCalendarQueryPolicy } from 'data/calendarQueryPolicy';
export async function fetchReadings(date: string): Promise {
return cachedFetch(`${process.env.API_HOST}/readings/${date}`);
}
-const useReadings = (date: string): UseQueryResult =>
+const useReadings = (date: string, enabled = true): UseQueryResult =>
useQuery({
queryKey: queryKeys.readings(date),
queryFn: async () => fetchReadings(date),
retry: false,
+ ...getCalendarQueryPolicy(date),
+ enabled,
});
export default useReadings;
diff --git a/app/hooks/useUpdateTOC.ts b/app/hooks/useUpdateTOC.ts
deleted file mode 100644
index ef094f4f13..0000000000
--- a/app/hooks/useUpdateTOC.ts
+++ /dev/null
@@ -1,54 +0,0 @@
-import { useEffect } from 'react';
-import { useSetRecoilState } from 'recoil';
-
-import TOCState from 'state/TOCState';
-
-const POLL_INTERVAL_MS = 500;
-const MAX_FOLLOW_UPDATES = 10;
-
-export const useUpdateTOC = (): void => {
- const setTOC = useSetRecoilState(TOCState);
-
- useEffect(() => {
- let cancelled = false;
- let timeoutId: number | undefined;
- let tocLength = 0;
- let updatesCount = 0;
- let pageHeight = 0;
-
- const updateTOC = () => {
- if (cancelled) {
- return;
- }
-
- const values = Object.values(window.TOC || {});
- if (pageHeight < document.body.scrollHeight || values.length !== tocLength) {
- pageHeight = document.body.scrollHeight;
- const sortedTOC = values
- .map((item) => ({
- item,
- offsetTop: document.getElementById(item.value)?.offsetTop ?? Number.POSITIVE_INFINITY,
- }))
- .sort((a, b) => a.offsetTop - b.offsetTop);
-
- setTOC(sortedTOC.map(({ item }) => item));
- tocLength = values.length;
- }
-
- // Keep on re-running 10 times every half a second, to make sure no updates are skipped.
- if (updatesCount < MAX_FOLLOW_UPDATES) {
- timeoutId = window.setTimeout(updateTOC, POLL_INTERVAL_MS);
- updatesCount += 1;
- }
- };
-
- timeoutId = window.setTimeout(updateTOC, POLL_INTERVAL_MS);
-
- return () => {
- cancelled = true;
- if (timeoutId !== undefined) {
- window.clearTimeout(timeoutId);
- }
- };
- }, [setTOC]);
-};
diff --git a/app/routeLoaders.ts b/app/routeLoaders.ts
new file mode 100644
index 0000000000..a80d00708f
--- /dev/null
+++ b/app/routeLoaders.ts
@@ -0,0 +1,6 @@
+export const loadServiceRoute = async () =>
+ await import(/* webpackChunkName: "route-service" */ 'containers/Service/Service');
+
+export const preloadServiceRoute = () => {
+ void loadServiceRoute();
+};
diff --git a/app/state/TOCState.ts b/app/state/TOCState.ts
deleted file mode 100644
index fb8801f124..0000000000
--- a/app/state/TOCState.ts
+++ /dev/null
@@ -1,23 +0,0 @@
-import { atom } from 'recoil';
-
-export interface TOCItem {
- value: string;
- label: string;
- shortLabel: string;
- level?: number;
-}
-
-export type TOCRegistry = Record;
-
-declare global {
- interface Window {
- TOC?: TOCRegistry;
- }
-}
-
-const TOCState = atom({
- key: 'TOC',
- default: [],
-});
-
-export default TOCState;
diff --git a/app/utils/performanceTelemetry.ts b/app/utils/performanceTelemetry.ts
new file mode 100644
index 0000000000..b0f0a1b3be
--- /dev/null
+++ b/app/utils/performanceTelemetry.ts
@@ -0,0 +1,123 @@
+import type { Metric } from 'web-vitals';
+
+interface PerformanceConnection {
+ effectiveType?: string;
+ saveData?: boolean;
+}
+
+interface PerformanceNavigator extends Navigator {
+ connection?: PerformanceConnection;
+ deviceMemory?: number;
+}
+
+interface ServiceMilestones {
+ complete?: number;
+ renderKey?: string;
+ tocReady?: number;
+}
+
+const pushPerformanceEvent = (event: Record) => {
+ const telemetryWindow = window as unknown as {
+ dataLayer?: Array>;
+ };
+ const standalone =
+ window.matchMedia('(display-mode: standalone)').matches ||
+ Boolean((navigator as Navigator & { standalone?: boolean }).standalone);
+ telemetryWindow.dataLayer = telemetryWindow.dataLayer || [];
+ telemetryWindow.dataLayer.push({
+ appMode: window.origin.includes('capacitor://') ? 'capacitor' : standalone ? 'standalone' : 'browser',
+ release: VERSION,
+ deviceMemory: (navigator as PerformanceNavigator).deviceMemory,
+ effectiveConnectionType: (navigator as PerformanceNavigator).connection?.effectiveType,
+ hardwareConcurrency: navigator.hardwareConcurrency,
+ saveData: (navigator as PerformanceNavigator).connection?.saveData,
+ serviceWorkerControlled: Boolean(navigator.serviceWorker?.controller),
+ standalone,
+ ...event,
+ });
+};
+
+const observeServiceMilestones = () => {
+ if (typeof PerformanceObserver === 'undefined') {
+ return;
+ }
+
+ const milestones: ServiceMilestones = {};
+ let settledTimer: number | undefined;
+
+ const scheduleSettledReport = () => {
+ if (milestones.complete === undefined || milestones.tocReady === undefined) {
+ return;
+ }
+
+ window.clearTimeout(settledTimer);
+ settledTimer = window.setTimeout(() => {
+ const settled = performance.now();
+ performance.clearMarks?.('service_settled');
+ performance.mark?.('service_settled');
+ pushPerformanceEvent({
+ event: 'service_performance',
+ completeCommit: milestones.complete,
+ renderKey: milestones.renderKey,
+ settled,
+ tocReady: milestones.tocReady,
+ });
+ milestones.complete = undefined;
+ milestones.renderKey = undefined;
+ milestones.tocReady = undefined;
+ }, 750);
+ };
+
+ const observer = new PerformanceObserver((list) => {
+ for (const entry of list.getEntries()) {
+ if (entry.name === 'service_complete_commit') {
+ milestones.complete = entry.startTime;
+ const detail = (entry as PerformanceMark).detail as { renderKey?: unknown } | null;
+ milestones.renderKey = typeof detail?.renderKey === 'string' ? detail.renderKey : undefined;
+ scheduleSettledReport();
+ } else if (entry.name === 'service_toc_ready') {
+ milestones.tocReady = entry.startTime;
+ scheduleSettledReport();
+ }
+ }
+ });
+
+ try {
+ observer.observe({ entryTypes: ['mark'] });
+ } catch {
+ observer.disconnect();
+ }
+};
+
+export const startPerformanceTelemetry = () => {
+ observeServiceMilestones();
+
+ const loadWebVitals = () => {
+ void import(/* webpackChunkName: "performance-telemetry" */ 'web-vitals')
+ .then(({ onCLS, onINP, onLCP, onTTFB }) => {
+ const report = ({ delta, id, name, rating, value }: Metric) => {
+ pushPerformanceEvent({
+ event: 'web_vital',
+ metricDelta: delta,
+ metricId: id,
+ metricName: name,
+ metricRating: rating,
+ metricValue: value,
+ });
+ };
+
+ onCLS(report);
+ onINP(report);
+ onLCP(report);
+ onTTFB(report);
+ })
+ .catch(() => undefined);
+ };
+
+ const scheduleWebVitals = () => window.setTimeout(loadWebVitals, 4000);
+ if (document.readyState === 'complete') {
+ scheduleWebVitals();
+ } else {
+ window.addEventListener('load', scheduleWebVitals, { once: true });
+ }
+};
diff --git a/app/utils/recoverableError.ts b/app/utils/recoverableError.ts
new file mode 100644
index 0000000000..a9fa6f1684
--- /dev/null
+++ b/app/utils/recoverableError.ts
@@ -0,0 +1,22 @@
+type RecoveryAction = () => void;
+
+const recoveryActions = new WeakMap>();
+
+export const registerErrorRecovery = (error: Error, recoveryAction: RecoveryAction): void => {
+ const actions = recoveryActions.get(error);
+ if (actions) {
+ actions.add(recoveryAction);
+ return;
+ }
+ recoveryActions.set(error, new Set([recoveryAction]));
+};
+
+export const recoverFromError = (error: Error): void => {
+ const actions = recoveryActions.get(error);
+ if (!actions) {
+ return;
+ }
+
+ recoveryActions.delete(error);
+ actions.forEach((recoveryAction) => recoveryAction());
+};
diff --git a/app/utils/staticDelivery.mjs b/app/utils/staticDelivery.mjs
new file mode 100644
index 0000000000..413c9b9d0b
--- /dev/null
+++ b/app/utils/staticDelivery.mjs
@@ -0,0 +1,32 @@
+export const IMMUTABLE_CACHE_CONTROL = 'public, max-age=31536000, immutable';
+export const REVALIDATE_CACHE_CONTROL = 'no-cache';
+
+const CONTENT_HASH = /(?:^|\.)([a-f0-9]{16,})(?:\.|$)/iu;
+const COMPRESSIBLE_EXTENSIONS = new Set([
+ '.css',
+ '.html',
+ '.js',
+ '.json',
+ '.map',
+ '.svg',
+ '.txt',
+ '.webmanifest',
+ '.xml',
+]);
+
+export const normalizeStaticPath = (filePath) => filePath.replaceAll('\\', '/').replace(/^\/+/u, '');
+
+export const isImmutableBuiltAsset = (filePath) => {
+ const normalizedPath = normalizeStaticPath(filePath);
+ const fileName = normalizedPath.slice(normalizedPath.lastIndexOf('/') + 1);
+ return normalizedPath.startsWith('built/') && CONTENT_HASH.test(fileName);
+};
+
+export const isCompressibleStaticAsset = (filePath) => {
+ const normalizedPath = normalizeStaticPath(filePath);
+ const extensionStart = normalizedPath.lastIndexOf('.');
+ return extensionStart >= 0 && COMPRESSIBLE_EXTENSIONS.has(normalizedPath.slice(extensionStart).toLowerCase());
+};
+
+export const cacheControlForStaticPath = (filePath) =>
+ isImmutableBuiltAsset(filePath) ? IMMUTABLE_CACHE_CONTROL : REVALIDATE_CACHE_CONTROL;
diff --git a/app/utils/staticDelivery.test.mjs b/app/utils/staticDelivery.test.mjs
new file mode 100644
index 0000000000..33f442a2c8
--- /dev/null
+++ b/app/utils/staticDelivery.test.mjs
@@ -0,0 +1,85 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+
+import {
+ IMMUTABLE_CACHE_CONTROL,
+ REVALIDATE_CACHE_CONTROL,
+ cacheControlForStaticPath,
+ isCompressibleStaticAsset,
+ isImmutableBuiltAsset,
+ normalizeStaticPath,
+} from './staticDelivery.mjs';
+import { createDeploymentPlan } from '../../scripts/deploy-static.mjs';
+
+const createPlan = (versionFiles = ['version', 'version.json']) =>
+ createDeploymentPlan({
+ deploymentBucket: 's3://example-bucket',
+ immutableRoot: '/tmp/deploy/immutable',
+ revalidateRoot: '/tmp/deploy/revalidate',
+ sourceRoot: '/repo/www',
+ versionFiles,
+ });
+
+test('recognises content-hashed build assets without treating mutable build metadata as immutable', () => {
+ assert.equal(isImmutableBuiltAsset('built/vendor.807c1869148f94d91589.js'), true);
+ assert.equal(isImmutableBuiltAsset('built/605d5b6a7bdf8b7ddc75.woff2'), true);
+ assert.equal(isImmutableBuiltAsset('built/7521.0740774f0703f0663272.js.map'), true);
+ assert.equal(isImmutableBuiltAsset('built/version.json'), false);
+ assert.equal(isImmutableBuiltAsset('service-worker.js'), false);
+ assert.equal(isImmutableBuiltAsset('assets/icons/ascension.svg'), false);
+});
+
+test('assigns immutable caching only to content-addressed build output', () => {
+ assert.equal(cacheControlForStaticPath('built/main.a5c5bec4e24084239c5d.js'), IMMUTABLE_CACHE_CONTROL);
+ assert.equal(cacheControlForStaticPath('built/version.json'), REVALIDATE_CACHE_CONTROL);
+ assert.equal(cacheControlForStaticPath('index.html'), REVALIDATE_CACHE_CONTROL);
+ assert.equal(cacheControlForStaticPath('service-worker.js'), REVALIDATE_CACHE_CONTROL);
+});
+
+test('selects text-based assets for transport compression and normalizes platform paths', () => {
+ assert.equal(isCompressibleStaticAsset('built/main.js'), true);
+ assert.equal(isCompressibleStaticAsset('index.html'), true);
+ assert.equal(isCompressibleStaticAsset('image.png'), false);
+ assert.equal(isCompressibleStaticAsset('font.woff2'), false);
+ assert.equal(normalizeStaticPath('built\\nested\\main.js'), 'built/nested/main.js');
+ assert.equal(normalizeStaticPath('/built/main.js'), 'built/main.js');
+});
+
+test('uploads every immutable representation before mutable rollout references', () => {
+ const plan = createPlan();
+
+ assert.deepEqual(
+ plan.map(({ phase }) => phase),
+ [
+ 'immutable-uncompressed',
+ 'immutable-gzip',
+ 'mutable-shell',
+ 'mutable-version:version',
+ 'mutable-version:version.json',
+ 'mutable-gzip',
+ ]
+ );
+
+ assert.deepEqual(plan[0].commandArguments.slice(0, 3), ['sync', '/repo/www/built/', 's3://example-bucket/built/']);
+ assert.ok(plan[0].commandArguments.includes('--exclude=version'));
+ assert.ok(plan[0].commandArguments.includes('--exclude=version.json'));
+ assert.deepEqual(plan[1].commandArguments.slice(0, 3), ['sync', '/tmp/deploy/immutable/', 's3://example-bucket/']);
+ assert.ok(plan[1].commandArguments.includes('--add-header=Content-Encoding:gzip'));
+
+ const firstMutableStep = plan.findIndex(({ phase }) => phase.startsWith('mutable-'));
+ const lastImmutableStep = plan.findLastIndex(({ phase }) => phase.startsWith('immutable-'));
+ assert.ok(lastImmutableStep < firstMutableStep);
+});
+
+test('retains old content hashes and includes only version files present in the build', () => {
+ const plan = createPlan(['version.json']);
+
+ assert.deepEqual(
+ plan.filter(({ phase }) => phase.startsWith('mutable-version:')).map(({ phase }) => phase),
+ ['mutable-version:version.json']
+ );
+ assert.equal(
+ plan.some(({ commandArguments }) => commandArguments.some((argument) => argument.startsWith('--delete'))),
+ false
+ );
+});
diff --git a/docs/modernization/02-performance.md b/docs/modernization/02-performance.md
index 4dc138f25e..8cd6e329b1 100644
--- a/docs/modernization/02-performance.md
+++ b/docs/modernization/02-performance.md
@@ -1,8 +1,8 @@
# Plan 02 — Frontend Performance
-Status: In progress
+Status: Implemented; production deployment validation pending
-Scope: Browser-side performance without backend or offline implementation changes
+Scope: Browser runtime, static delivery, and offline-safe chunking. Backend, Capacitor, TWA, and service-worker behavior remain unchanged.
## Objective
@@ -10,13 +10,24 @@ Improve startup time, interaction responsiveness, rerender cost, and long-term c
## Tracker
-| ID | Unit | Status | Offline-sensitive |
-| --- | --- | --- | --- |
-| PERF-001 | Create the performance baseline | `in-progress` | No |
-| PERF-002 | Lazy-load non-core routes | `done` | Yes |
-| PERF-003 | Fix measured render and lifecycle waste | `done` | No |
-| PERF-004 | Reduce startup and compatibility cost | `done` | Yes |
-| PERF-005 | Optimize static assets | `done` | No |
+| ID | Unit | Status | Offline-sensitive |
+| -------- | ----------------------------------------------------------- | ----------- | ----------------- |
+| PERF-001 | Create the performance baseline | `done` | No |
+| PERF-002 | Lazy-load non-core routes | `done` | Yes |
+| PERF-003 | Fix measured render and lifecycle waste | `done` | No |
+| PERF-004 | Reduce startup and compatibility cost | `done` | Yes |
+| PERF-005 | Optimize static assets | `done` | No |
+| PERF-006 | Finish the reproducible service benchmark and budgets | `done` | No |
+| PERF-007 | Enable compressed immutable delivery for hashed assets | `done` | No |
+| PERF-008 | Remove avoidable service-entry and inactive-feature work | `done` | No |
+| PERF-009 | Stabilize MDX import caching and failure handling | `done` | No |
+| PERF-010 | Remove repeated per-fragment effects and subscriptions | `done` | No |
+| PERF-011 | Replace TOC polling with batched incremental updates | `done` | No |
+| PERF-012 | Reduce data-query fan-out with explicit freshness rules | `done` | No |
+| PERF-013 | Split optional service features and inspect vendor grouping | `done` | Yes |
+| PERF-014 | Generate measured coherent MDX chunk groups | `done` | Yes |
+| PERF-015 | Evaluate progressive parallel/below-fold rendering | `cancelled` | Yes |
+| PERF-016 | Add field performance telemetry | `done` | No |
## PERF-001 Create the performance baseline
@@ -44,6 +55,10 @@ Acceptance criteria:
- Baseline artifacts identify the largest entrypoint contributors.
- CI displays a before/after size delta.
+The long-service measurement methodology, results, budgets, bottleneck map, and
+follow-up units are recorded in
+[the full-service performance study](./service-performance-study.md).
+
## PERF-002 Lazy-load non-core routes
Objective: keep the calendar and prayer-reading path in the initial shell while deferring features that are not needed at startup.
@@ -141,8 +156,46 @@ Acceptance criteria:
## Completion notes
-| Date | ID | Baseline | Result | Pull request / commit |
-| --- | --- | --- | --- | --- |
-| 2026-07-28 | PERF-001 | 543,191 B initial JS gzip | Reproducible report added; runtime/mobile traces remain | This branch |
-| 2026-07-28 | PERF-002–004 | 1,999,966 B raw / 543,191 B gzip | 1,673,473 B raw / 483,303 B gzip; offline smoke passes | This branch |
-| 2026-07-28 | PERF-005 | 17 audited assets | 88,026 raw asset bytes removed with visual validation | This branch |
+| Date | ID | Baseline | Result | Pull request / commit |
+| ---------- | ------------ | ----------------------------------------------------------------- | ---------------------------------------------------------------------------------- | --------------------- |
+| 2026-07-28 | PERF-001 | 543,191 B initial JS gzip | Reproducible report added; runtime/mobile traces remain | This branch |
+| 2026-07-28 | PERF-002–004 | 1,999,966 B raw / 543,191 B gzip | 1,673,473 B raw / 483,303 B gzip; offline smoke passes | This branch |
+| 2026-07-28 | PERF-005 | 17 audited assets | 88,026 raw asset bytes removed with visual validation | This branch |
+| 2026-07-28 | PERF-006 | `origin/master`: 14.09 s complete liturgy | Current: 12.31 s; full protocol and interaction cases remain | This branch |
+| 2026-07-28 | PERF-006–016 | Pre-implementation current: 12.31 s uncompressed complete liturgy | Production-like gzip: 4.16 s complete / 5.72 s settled; 67-heading shape preserved | This branch |
+
+## Implementation result
+
+The completed work is detailed in
+[the full-service performance study](./service-performance-study.md) and its
+[compact implementation summary](./service-performance-implementation-summary.json).
+
+The primary three-run older-phone comparison, with the same 4× CPU and
+1.6 Mbps profile but production-like gzip delivery, measured:
+
+- 6.02 s → 4.16 s complete service commit after coherent liturgy chunking;
+- 7.58 s → 5.72 s settled;
+- 605,386 B → 543,712 B JavaScript transferred;
+- 123 ms → 110 ms Total Blocking Time;
+- identical 67-entry TOC order, IDs, labels, document height, and paragraph count.
+
+The new initial entrypoint is 1,512,607 B raw / 437,057 B gzip. Workbox
+precaches 1,555 URLs totalling 7.66 MB, down from 1,874 URLs / 7.77 MB before
+the coherent liturgy grouping.
+
+Installed/offline browser QA covers an unvisited lazy route, optional search
+controls, the full Zlatoust liturgy, Church Slavonic, and parallel mode with no
+failed content-hashed chunk requests.
+
+Progressive below-fold or parallel rendering was not retained. The measured
+parallel transition already reaches its complete commit in 122 ms and TOC
+readiness in 848 ms on the primary older-phone profile. Deferring part of the
+DOM would move work beyond the completion marker and create avoidable
+find-in-page, print, anchor, and layout risks without a demonstrated need.
+
+Remaining deployment checks:
+
+- verify gzip or Brotli plus immutable headers on the real hosting path;
+- verify field `web_vital` and `service_performance` events after release;
+- compare production HTTP/2 field data with the deliberately conservative
+ HTTP/1.1 lab server.
diff --git a/docs/modernization/04-code-quality-and-architecture.md b/docs/modernization/04-code-quality-and-architecture.md
index 63929e05c0..b853dee68c 100644
--- a/docs/modernization/04-code-quality-and-architecture.md
+++ b/docs/modernization/04-code-quality-and-architecture.md
@@ -10,13 +10,13 @@ Make feature work safer by clarifying boundaries and reducing oversized, weakly
## Tracker
-| ID | Unit | Status | Expected risk |
-| --- | --- | --- | --- |
-| QUAL-001 | Type the boundaries first | `done` | Medium |
-| QUAL-002 | Stabilize the application shell | `done` | Medium |
-| QUAL-003 | Split large modules by responsibility | `in-progress` | Medium |
-| QUAL-004 | Remove accidental global state | `in-progress` | Low |
-| QUAL-005 | Clarify state ownership | `done` | Medium |
+| ID | Unit | Status | Expected risk |
+| -------- | ------------------------------------- | ------------- | ------------- |
+| QUAL-001 | Type the boundaries first | `done` | Medium |
+| QUAL-002 | Stabilize the application shell | `done` | Medium |
+| QUAL-003 | Split large modules by responsibility | `in-progress` | Medium |
+| QUAL-004 | Remove accidental global state | `in-progress` | Low |
+| QUAL-005 | Clarify state ownership | `done` | Medium |
## Target boundaries
@@ -95,7 +95,7 @@ Objective: reduce hidden coupling and browser lifecycle leaks.
Initial candidates:
-- `window.TOC`
+- `window.TOC` (`done` through PERF-011)
- `window.pullDownDisabled`
- custom history events and scroll globals
- direct `window.matchMedia` listeners
@@ -143,8 +143,8 @@ Acceptance criteria:
## Completion notes
-| Date | ID | Extracted/changed boundary | Result | Pull request / commit |
-| --- | --- | --- | --- | --- |
-| 2026-07-28 | QUAL-001/002 | Typed API/query boundaries and named provider/error shell | Tests, quality, and browser smoke pass | This branch |
-| 2026-07-28 | QUAL-003 | `getDayInfo.ts` split into calendar, fasting, feast/liturgy, and daily-selection modules | 16 calendar characterizations pass | This branch |
-| 2026-07-28 | QUAL-004/005 | TOC lifecycle/global contract stabilized and state ownership documented | Remaining globals stay tracked | This branch |
+| Date | ID | Extracted/changed boundary | Result | Pull request / commit |
+| ---------- | ------------ | ---------------------------------------------------------------------------------------- | -------------------------------------- | --------------------- |
+| 2026-07-28 | QUAL-001/002 | Typed API/query boundaries and named provider/error shell | Tests, quality, and browser smoke pass | This branch |
+| 2026-07-28 | QUAL-003 | `getDayInfo.ts` split into calendar, fasting, feast/liturgy, and daily-selection modules | 16 calendar characterizations pass | This branch |
+| 2026-07-28 | QUAL-004/005 | TOC lifecycle/global contract stabilized and state ownership documented | Remaining globals stay tracked | This branch |
diff --git a/docs/modernization/06-older-device-and-perceived-performance.md b/docs/modernization/06-older-device-and-perceived-performance.md
new file mode 100644
index 0000000000..700e1a4d8a
--- /dev/null
+++ b/docs/modernization/06-older-device-and-perceived-performance.md
@@ -0,0 +1,685 @@
+# Plan 06 — Older-device and perceived performance
+
+Last updated: 2026-08-05
+
+Overall status: `ready` for measurement; implementation has not started
+
+Recommended first unit: `PERF-017`
+
+Scope: returning-user startup, long-service rendering, touch responsiveness,
+route and language transitions, scrolling, memory retention, and perceived
+readiness. PHP/backend work, service-worker strategy changes, Capacitor/TWA
+upgrades, and product redesign are excluded.
+
+Related evidence:
+
+- [Frontend performance plan](./02-performance.md)
+- [Full-service performance study](./service-performance-study.md)
+- [Post-implementation measurements](./service-performance-implementation-summary.json)
+
+## Objective
+
+Make the application feel immediate and stable on older phones, especially for
+returning and installed users, without trading away offline availability,
+complete service content, search, print, anchors, or accessibility.
+
+Developer experience is also a constraint. Performance work must not make
+ordinary component authoring, debugging, testing, or maintenance materially
+harder merely to reduce bundle size or satisfy a synthetic metric. A change that
+degrades DX requires trace-level proof that the affected code is a critical
+user-visible bottleneck, a conclusive repeated-run improvement, and evidence
+that a lower-cost alternative cannot deliver the same result.
+
+This phase optimizes four kinds of user experience:
+
+1. **Readiness:** how quickly useful calendar or service content appears.
+2. **Response:** how quickly a tap produces visible feedback.
+3. **Continuity:** whether scrolling and transitions maintain consistent frame pacing.
+4. **Durability:** whether repeated navigation remains responsive without memory,
+ listener, observer, or cache growth.
+
+## Current measured baseline
+
+These values are comparison anchors, not permanent budgets. Each experiment
+must reproduce its own before/after baseline from the same revision, machine,
+browser, profile, and deterministic content fixture.
+
+| Area | Current observation |
+| --------------------------------- | ----------------------------------------------: |
+| Initial JavaScript | 1,512,607 B raw / 437,057 B gzip |
+| Initial vendor JavaScript | approximately 1,195,000 B raw / 339,000 B gzip |
+| Older-phone cold complete Liturgy | 4.16 s |
+| Older-phone cold Liturgy settled | 5.72 s |
+| Warm SPA service entry at 6× CPU | 486.9 ms |
+| Hot service revisit at 6× CPU | 202.5 ms |
+| Throttled first SPA service entry | approximately 1.20 s |
+| Throttled hot service revisit | approximately 155 ms |
+| Russian service shape | 67 TOC entries, 568 paragraphs, 2,274 DOM nodes |
+| Parallel service shape | approximately 4,050 DOM nodes |
+| Hot parallel commit | approximately 109–122 ms |
+| Parallel longest task | approximately 185 ms |
+| TOC selection | approximately 147–175 ms |
+| First search-panel open | approximately 469 ms |
+| Search result | approximately 244 ms, including 180 ms debounce |
+| Workbox precache | 1,555 URLs / 7.66 MB |
+
+The current `service_toc_ready` and `service_settled` values include deliberate
+500 ms TOC and stability/quiet windows. Agents must not treat those windows as
+CPU cost or move the existing marks earlier to claim an improvement.
+
+## Status and coordination protocol
+
+The tracker in this file is the source of truth for this workstream. Update it
+before editing implementation files and again before handing work to another
+agent.
+
+Status values use the definitions from the
+[master roadmap](./README.md#status-values).
+
+### Claiming a unit
+
+1. Confirm all dependencies are `done` or explicitly waived in the notes.
+2. Change the unit to `in-progress` in both trackers.
+3. Record the owner/agent, date, branch or worktree, baseline artifact, and files
+ likely to change in the coordination ledger.
+4. Check the conflict group. Two agents must not edit the same conflict group
+ concurrently in a shared worktree.
+5. If agents share a worktree, parallelize only read-only experiments or units
+ with disjoint file ownership.
+
+### Completing or handing off a unit
+
+Record:
+
+- exact benchmark command and environment;
+- raw result artifact path;
+- median and p75 before/after values;
+- content, offline, interaction, memory, and quality checks run;
+- keep/revert decision and its evidence;
+- remaining risk, blocker, or next action;
+- commit or pull-request reference when one exists.
+
+An experiment that fails its retention gate should normally be reverted and
+marked `cancelled`, with the useful evidence preserved in the notes.
+
+## Global measurement profiles
+
+### Installed returning-user startup
+
+Measure each of these separately:
+
+- process-cold, cache-warm, online launch;
+- process-cold, installed and fully offline launch;
+- warm-process launch or reload;
+- anonymous user;
+- authenticated user with realistic settings and habit data;
+- normal, 100 KB, and 1 MB persisted-state fixtures.
+
+The installed profile must first complete service-worker installation and API
+data precaching, close the browser process, and relaunch from the manifest start
+URL. A normal page reload is not a process-cold startup measurement.
+
+### Long-service profiles
+
+- Cold direct Russian service.
+- Warm calendar-to-service navigation.
+- Hot revisit.
+- Russian to Church Slavonic with the target language cold.
+- Russian to parallel with Church Slavonic cold.
+- Hot Russian, Church Slavonic, and parallel switches.
+- Direct persisted-parallel launch.
+
+### Real interaction profiles
+
+- Real CDP touch swipes, not only `scrollBy`.
+- Rapid vertical reading scroll and slow precision scroll.
+- Date swipe in both directions.
+- Menu, calendar, TOC, language, zoom, search, and back navigation.
+- Twenty-to-thirty date/service/language cycles with forced-GC diagnostics when
+ the browser supports them.
+- At least one physical low-end Android or WebView validation before release.
+
+## Metrics
+
+### Readiness marks
+
+- `bundle_evaluated`
+- `react_render_requested`
+- `inline_loader_hidden`
+- `app_header_ready`
+- `date_primary_content_ready`
+- `above_fold_stable`
+- `navigation_intent`
+- `route_chunk_loaded`
+- `service_shell_ready`
+- `service_complete_commit`
+- `service_toc_ready`
+- `service_settled`
+- `background_precache_started`
+
+Every transition mark must include a navigation/render key so SPA timestamps can
+be converted to click-to-result durations. Absolute page-lifetime timestamps are
+not transition durations.
+
+### Main-thread and interaction metrics
+
+- Event Timing/INP for the initiating control.
+- Tap or key event to next visible paint.
+- Total Blocking Time and longest task before useful content.
+- Long Animation Frames when available, with long-task fallback.
+- Frame intervals, frames over 33 ms and 50 ms, and worst frame.
+- Script, style, layout, paint, and raster duration.
+- Count and duration of non-passive touch handlers.
+- React commits and component render time in a production profiling build.
+
+### Resource and runtime metrics
+
+- Initial raw, gzip, decoded, and executed JavaScript.
+- JavaScript coverage at primary-content and full-service commit.
+- Initial script and Workbox manifest entry counts.
+- Foreground and background request counts before useful content.
+- IndexedDB operations and cache writes before useful content.
+- Mounted calendar slides and DOM nodes.
+- Peak and retained heap, listeners, observers, and detached nodes.
+- Third-party script and worker task time.
+
+## Global retention gates
+
+A candidate is retained only if it improves its intended older-device metric by
+at least **10% or 100 ms**, unless the unit defines a stronger gate.
+
+It must also satisfy all applicable safeguards:
+
+- no more than 5% p75 regression in unrelated readiness or interaction metrics;
+- no more than 10% Total Blocking Time regression;
+- immediate visible control feedback below 100 ms ideally and below 200 ms as a
+ hard lab ceiling;
+- stress-profile longest task at or below 200 ms;
+- fewer than 5% of stress scroll frames over 50 ms;
+- CLS at or below 0.1 with no visible scroll jump;
+- no more than 10% peak or retained-heap regression;
+- no linear listener, observer, detached-node, or heap growth over repeated use;
+- initial JavaScript does not exceed 437,057 B gzip without explicit review;
+- exact normalized service text, heading order, IDs, labels, and search results;
+- complete print, copy/select-all, native-find, TOC, anchor, zoom, theme, editor,
+ audio, and scroll-restoration behavior;
+- fresh install followed by fully offline direct loading of an unvisited Russian,
+ Church Slavonic, and parallel service;
+- zero failed content-hashed offline requests;
+- complete Workbox coverage and atomic update behavior;
+- no material regression in component authoring, debugging, testability, or
+ maintainability without the stronger critical-bottleneck evidence described
+ above.
+
+## Work-unit tracker
+
+| ID | Priority | Work unit | Status | Risk | Depends on | Conflict group |
+| -------- | -------- | ------------------------------------------------------------ | ---------- | ------------------------- | ---------------------------- | ------------------ |
+| PERF-017 | P0 | Installed-startup and real-touch measurement foundation | `ready` | Low | PERF-016 | measurement |
+| PERF-018 | P0 | Defer future-date precaching and cache refresh contention | `proposed` | Medium, offline-sensitive | PERF-017 | startup-client |
+| PERF-019 | P0 | Defer analytics, tracing, Webvisor, and optional polyfills | `proposed` | Medium | PERF-017 | startup-client |
+| PERF-020 | P0 | Replace the global pull-to-refresh touch path | `proposed` | Medium | PERF-017 | interaction-shell |
+| PERF-021 | P0 | Render one calendar slide during startup | `proposed` | Medium | PERF-017 | calendar-runtime |
+| PERF-022 | P1 | Split below-fold calendar and optional home features | `proposed` | Medium, offline-sensitive | PERF-021 | calendar-runtime |
+| PERF-023 | P1 | Remove avoidable UI libraries from the initial shell | `proposed` | Medium | PERF-017 | startup-shell |
+| PERF-024 | P1 | Defer optional auth, Convex, and native-platform code | `proposed` | High | PERF-017, PERF-023 | startup-providers |
+| PERF-025 | P1 | Cache persisted state parsing and delay data revalidation | `proposed` | Medium, offline-sensitive | PERF-017 | state-and-data |
+| PERF-026 | P1 | Add responsive route/language transitions and intent loading | `proposed` | Medium, offline-sensitive | PERF-017 | service-navigation |
+| PERF-027 | P1 | Remove remaining per-fragment MDX reader overhead | `proposed` | Medium | PERF-017 | service-runtime |
+| PERF-028 | P2 | Pilot coarse service `content-visibility` | `proposed` | High | PERF-027 | service-runtime |
+| PERF-029 | P2 | Refine service-resolved MDX loading packs | `proposed` | High, offline-sensitive | PERF-017, PERF-027 | mdx-build |
+| PERF-030 | P3 | Prototype a compact static-reader content representation | `deferred` | High, offline-sensitive | PERF-029 | mdx-build |
+| PERF-031 | P1 | Reduce first-open menu and search latency | `proposed` | Low, offline-sensitive | PERF-017 | service-navigation |
+| PERF-032 | P2 | Evaluate replacing swipeable-views gesture handling | `deferred` | High | PERF-017, PERF-020, PERF-021 | calendar-runtime |
+| PERF-033 | P1 | Physical-device and long-session retention validation | `proposed` | Low | PERF-017 | validation |
+
+## Execution waves
+
+### Wave A — Establish the missing evidence
+
+1. PERF-017
+
+Exit: process-cold installed startup, real-touch scrolling, first-versus-warm
+interactions, cold language/parallel transitions, and longer retention runs all
+produce machine-readable artifacts.
+
+### Wave B — Remove critical-window contention
+
+1. PERF-018
+2. PERF-019
+3. PERF-020
+4. PERF-021
+
+PERF-018 and PERF-019 share `client.tsx` and must not be implemented concurrently
+in the same worktree. Their A/B measurements may be prepared independently.
+
+Exit: useful calendar content is no longer competing with optional background
+work; real finger scrolling does not depend on a permanent global non-passive
+listener; only the active date view mounts during startup.
+
+### Wave C — Reduce startup code and render work
+
+1. PERF-022
+2. PERF-023
+3. PERF-024
+4. PERF-025
+
+Exit: initial gzip and executed JavaScript are materially lower, anonymous and
+authenticated startup both pass, and cached data remains immediately available
+online and offline.
+
+### Wave D — Improve perceived transitions
+
+1. PERF-026
+2. PERF-031
+
+Exit: every route, menu, search, and language action produces visible feedback
+within 100 ms where possible, without showing stale content as current.
+
+### Wave E — Reduce long-service CPU, layout, and paint
+
+1. PERF-027
+2. PERF-028
+3. PERF-029
+
+PERF-028 and PERF-029 are separate experiments. Do not combine content
+containment with another MDX chunking change; each needs an attributable result.
+
+Exit: the retained approach materially improves full-service or parallel-mode
+work while preserving the complete-document contract.
+
+### Wave F — Structural decisions and release proof
+
+1. PERF-030 only if the preceding results leave a demonstrated parse/evaluate or
+ React-fiber bottleneck.
+2. PERF-032 only if real-touch measurements still show swipe-handler cost.
+3. PERF-033 after each retained wave and before release.
+
+## Detailed work units
+
+### PERF-017 — Installed-startup and real-touch measurement foundation
+
+Objective: measure the experiences that the existing service benchmark does not
+cover.
+
+Work:
+
+- Add all installed-startup, service-transition, touch, cold-language, and
+ retention profiles described above.
+- Add keyed application marks without moving existing completion semantics.
+- Capture production-parity runs with analytics and background precaching enabled.
+- Add isolated controls that disable one subsystem at a time.
+- Store raw artifacts plus a compact comparison summary.
+
+Acceptance:
+
+- Three-run smoke and twenty-run comparison modes are reproducible.
+- Process-cold and warm-process launches are demonstrably different scenarios.
+- Real touch input is visible in Event Timing and trace output.
+- A failing content, offline, or interaction gate exits non-zero.
+
+Coordination: claim `scripts/performance/`, telemetry files, and relevant E2E
+fixtures. Avoid product changes in this unit.
+
+Execution notes: not started.
+
+### PERF-018 — Defer future-date precaching and cache refresh contention
+
+Objective: prevent offline-maintenance work from competing with useful content.
+
+Hypothesis: starting the precache worker after route readiness and limiting its
+concurrency will improve process-cold and cached startup without reducing the
+future-day offline horizon.
+
+Work:
+
+- Compare immediate start, route-ready start, and idle start with a bounded
+ fallback timeout.
+- Persist the last successful refresh time.
+- Cap future-date work to a measured concurrency.
+- Deduplicate foreground and background requests.
+- Schedule cache-hit revalidation after useful content where freshness permits.
+
+Retain when: startup or service readiness improves by the global threshold and
+the complete future-date offline corpus is available after the bounded refresh.
+
+Coordination: likely touches `client.tsx`, `precache.ts`, the worker, and cached
+fetch scheduling. Do not overlap PERF-019 in a shared worktree.
+
+Execution notes: not started.
+
+### PERF-019 — Defer analytics, tracing, Webvisor, and optional polyfills
+
+Objective: keep non-product JavaScript out of the first useful render.
+
+Work:
+
+- A/B Yandex, Webvisor, GTM, Sentry tracing, and the share polyfill independently.
+- Preserve early errors with a small `error`/`unhandledrejection` queue.
+- Initialize retained systems after useful content or during idle.
+- Emit explicit initial page-view events after delayed initialization.
+- Consider sampling Webvisor by device capability or session.
+
+Retain when: the production-parity startup profile improves materially and
+monitoring/page-view correctness is demonstrated.
+
+Coordination: owns startup analytics in `client.tsx` and `index.html` while
+active.
+
+Execution notes: not started.
+
+### PERF-020 — Replace the global pull-to-refresh touch path
+
+Objective: allow normal vertical reading scroll to remain compositor-driven.
+
+Work:
+
+- Establish an A/B with pull-to-refresh disabled on service pages.
+- Replace the permanent global `passive: false` listener if the A/B improves
+ real-touch input or frame pacing.
+- Recognize only a confirmed top-edge downward pull.
+- Batch visual updates to animation frames and prefer transforms over height.
+- Preserve refresh, cancellation, accessibility, and reduced-motion behavior.
+
+Retain when: real-touch Event Timing or frame pacing improves by the global
+threshold with no gesture regression on browser, installed PWA, and Capacitor
+smoke profiles.
+
+Coordination: owns the app-shell pull gesture. It must not change date-swipe
+handling in this unit.
+
+Execution notes: not started.
+
+### PERF-021 — Render one calendar slide during startup
+
+Objective: avoid mounting previous, current, and next full day trees before the
+user can interact.
+
+Work:
+
+- Compare initial swipe overscan `1/1` with an active-only first commit.
+- Prefetch adjacent-day data without mounting adjacent DOM.
+- Enable adjacent rendering during idle or on swipe intent.
+- Record slide count, DOM nodes, queries, MDX imports, commit time, and first
+ swipe latency.
+
+Retain when: primary calendar readiness improves materially and first-swipe p75
+does not regress by more than 100 ms after intent preloading.
+
+Coordination: owns `Main.tsx` and calendar swipe orchestration while active.
+
+Execution notes: not started.
+
+### PERF-022 — Split below-fold calendar and optional home features
+
+Objective: render the useful date heading and primary day content without
+evaluating below-fold MDX and secondary features.
+
+Candidates:
+
+- calendar picker;
+- homepage troparions, kondaks, and generic MDX context;
+- favourites, sermons, banners, and optional prompts;
+- share implementation when native share is available.
+
+Retain when: initial executed/gzip JavaScript and primary-content readiness
+improve materially, with offline first-open coverage for every new chunk.
+
+Coordination: begins only after PERF-021 settles the active-slide boundary.
+
+Execution notes: not started.
+
+### PERF-023 — Remove avoidable UI libraries from the initial shell
+
+Objective: remove libraries whose startup cost is disproportionate to their
+initial-shell responsibility.
+
+Experiments:
+
+- Replace MUI `createTheme` on the core path with the application colour theme.
+- Replace or defer the MUI update prompt.
+- Remove `styled-components` with the pullable replacement.
+- Lazy-load `react-nice-dates` with the calendar picker.
+- Keep the existing select-control architecture by default. Do not replace it
+ with native `