import { serviceWorkerScope, shouldCanonicalizeClientPath } from './app-url.ts' import { LANGUAGE_PALETTE, clampRange, closestIndex, colorForLanguage, decoratePoints, filterPointsByRange, languageStats, } from './chart-model.ts' import { addAligned, chooseTimeAxisStep, formatTick, pickStep, startOfUnit, timeAxisTicks, } from './time-axis.ts' import { isIosBrowser, isSafariFamily, isStandaloneDisplay, shouldShowInstallButton, shouldShowIosInstallHint, } from './install-state.ts' import { LOCAL_SERIES_KEY, parseStoredSeries } from './local-series.ts' import { afterRefresh, whileRefreshing } from './refresh-state.ts' import { UPDATE_CHECK_MIN_INTERVAL_MS, describeAvailableUpdate, shouldRunUpdateCheck, } from './update-check.ts' export const clientScript = `(() => { let deferredInstall = null; const installListeners = new Set(); window.addEventListener('beforeinstallprompt', (event) => { event.preventDefault(); deferredInstall = event; installListeners.forEach((listener) => listener()); }); window.addEventListener('appinstalled', () => { deferredInstall = null; installListeners.forEach((listener) => listener()); }); const LANGUAGE_PALETTE = ${JSON.stringify(LANGUAGE_PALETTE)}; const decoratePoints = ${decoratePoints.toString()}; const filterPointsByRange = ${filterPointsByRange.toString()}; const languageStats = ${languageStats.toString()}; const colorForLanguage = ${colorForLanguage.toString()}; const closestIndex = ${closestIndex.toString()}; const clampRange = ${clampRange.toString()}; const HOUR = ${60 * 60 * 1000}; const DAY = ${24 * 60 * 60 * 1000}; const pickStep = ${pickStep.toString()}; const chooseTimeAxisStep = ${chooseTimeAxisStep.toString()}; const startOfUnit = ${startOfUnit.toString()}; const addAligned = ${addAligned.toString()}; const formatTick = ${formatTick.toString()}; const timeAxisTicks = ${timeAxisTicks.toString()}; const serviceWorkerScope = ${serviceWorkerScope.toString()}; const shouldCanonicalizeClientPath = ${shouldCanonicalizeClientPath.toString()}; const whileRefreshing = ${whileRefreshing.toString()}; const afterRefresh = ${afterRefresh.toString()}; const parseStoredSeries = ${parseStoredSeries.toString()}; const LOCAL_SERIES_KEY = ${JSON.stringify(LOCAL_SERIES_KEY)}; const isStandaloneDisplay = ${isStandaloneDisplay.toString()}; const isIosBrowser = ${isIosBrowser.toString()}; const isSafariFamily = ${isSafariFamily.toString()}; const shouldShowInstallButton = ${shouldShowInstallButton.toString()}; const shouldShowIosInstallHint = ${shouldShowIosInstallHint.toString()}; const shouldRunUpdateCheck = ${shouldRunUpdateCheck.toString()}; const describeAvailableUpdate = ${describeAvailableUpdate.toString()}; const UPDATE_CHECK_MIN_INTERVAL_MS = ${JSON.stringify(UPDATE_CHECK_MIN_INTERVAL_MS)}; if (shouldCanonicalizeClientPath(location.pathname)) { location.replace(location.pathname + '/' + location.search + location.hash); return; } const root = document.getElementById('app'); if (!root) return; const base = document.documentElement.dataset.base || ''; const dbName = 'lineage-cache'; const storeName = 'payloads'; const kinds = ['source', 'tests', 'docs']; const state = { payload: null, decorated: [], mode: localStorage.getItem('lineage-mode') || 'cumulative', from: '', to: '', rangeFrom: 0, rangeTo: 0, enabledLanguages: new Set(), enabledKinds: new Set(kinds), offline: !navigator.onLine, registration: null, waitingWorker: null, }; const els = { modes: root.querySelector('[data-modes]'), presets: root.querySelector('[data-presets]'), from: root.querySelector('#from'), to: root.querySelector('#to'), rangeFrom: root.querySelector('[data-range-from]'), rangeTo: root.querySelector('[data-range-to]'), rangeTrack: root.querySelector('[data-range-track]'), languages: root.querySelector('[data-languages]'), kinds: root.querySelector('[data-kinds]'), chart: root.querySelector('[data-chart]'), tooltip: root.querySelector('[data-tooltip]'), commits: root.querySelector('[data-commits]'), story: root.querySelector('[data-story]'), status: root.querySelector('[data-status]'), statusText: root.querySelector('[data-status-text]'), installOpen: root.querySelector('[data-install-open]'), installWrap: root.querySelector('[data-install-wrap]'), installPopover: root.querySelector('[data-install-popover]'), sheet: root.querySelector('[data-sheet]'), install: root.querySelector('[data-install]'), about: root.querySelector('[data-about]'), aboutSheet: root.querySelector('[data-about-sheet]'), toast: root.querySelector('[data-toast]'), toastTitle: root.querySelector('[data-toast-title]'), toastText: root.querySelector('[data-toast-text]'), toastAction: root.querySelector('[data-toast-action]'), toastDismiss: root.querySelector('[data-toast-dismiss]'), installHint: root.querySelector('[data-install-hint]'), installHintDismiss: root.querySelector('[data-install-hint-dismiss]'), panel: root.querySelector('[data-panel]'), }; function api(path) { const urlRoot = new URL(base || '/', location.origin); if (!urlRoot.pathname.endsWith('/')) urlRoot.pathname += '/'; return new URL(String(path).replace(/^\\//, ''), urlRoot).toString(); } function withTimeout(promise, ms, label) { return new Promise((resolve, reject) => { const timer = setTimeout(() => reject(new Error(label || 'timeout')), ms); promise.then( (value) => { clearTimeout(timer); resolve(value); }, (error) => { clearTimeout(timer); reject(error); }, ); }); } function openDb() { return withTimeout(new Promise((resolve, reject) => { const req = indexedDB.open(dbName, 1); req.onupgradeneeded = () => req.result.createObjectStore(storeName); req.onsuccess = () => resolve(req.result); req.onerror = () => reject(req.error); }), 1500, 'cache open timeout'); } async function cacheGet(key) { try { const db = await openDb(); return await withTimeout(new Promise((resolve, reject) => { const tx = db.transaction(storeName, 'readonly'); const req = tx.objectStore(storeName).get(key); req.onsuccess = () => resolve(req.result || null); req.onerror = () => reject(req.error); }), 1500, 'cache read timeout'); } catch { return null; } } async function cacheSet(key, value) { try { localStorage.setItem(LOCAL_SERIES_KEY, JSON.stringify(value)); } catch { try { localStorage.removeItem(LOCAL_SERIES_KEY); } catch {} } try { const db = await openDb(); await withTimeout(new Promise((resolve, reject) => { const tx = db.transaction(storeName, 'readwrite'); tx.objectStore(storeName).put(value, key); tx.oncomplete = () => resolve(); tx.onerror = () => reject(tx.error); }), 1500, 'cache write timeout'); } catch { // IndexedDB is optional; never block the chart on a cache write. } } function readLocalSeries() { try { return parseStoredSeries(localStorage.getItem(LOCAL_SERIES_KEY)); } catch { return null; } } function toLocalInput(iso) { if (!iso) return ''; const date = new Date(iso); const pad = (n) => String(n).padStart(2, '0'); return \`\${date.getFullYear()}-\${pad(date.getMonth() + 1)}-\${pad(date.getDate())}T\${pad(date.getHours())}:\${pad(date.getMinutes())}\`; } function fromLocalInput(value) { return value ? new Date(value).toISOString() : ''; } function setStatusText(text) { if (els.statusText) els.statusText.textContent = text; } function applyPresentation(presentation) { if (els.panel) els.panel.setAttribute('aria-busy', 'false'); if (els.status) { els.status.classList.remove('is-busy'); els.status.classList.toggle('is-updating', presentation.statusUpdating); els.status.classList.toggle('is-offline', presentation.treatAsOffline || !navigator.onLine); } state.offline = presentation.treatAsOffline || !navigator.onLine; } async function fetchSeries() { const url = api('api/series') + '?mode=' + encodeURIComponent(state.mode); const response = await fetch(url, { cache: 'no-store' }); if (!response.ok) throw new Error('series ' + response.status); const payload = await response.json(); await cacheSet('latest', payload); state.offline = false; return payload; } function applyPayload(payload) { if (!payload || !Array.isArray(payload.points)) return; state.payload = payload; if (state.enabledLanguages.size === 0 && Array.isArray(payload.languages)) { payload.languages.forEach((language) => state.enabledLanguages.add(language)); } redecorate(); if (!state.from && payload.points[0]) { state.rangeFrom = 0; state.rangeTo = payload.points.length - 1; } else if (payload.points.length) { state.rangeFrom = closestIndex(payload.points, state.from || payload.points[0].at); state.rangeTo = closestIndex(payload.points, state.to || payload.points[payload.points.length - 1].at); } syncRangeUi(); draw(); } function redecorate() { const payload = state.payload; if (!payload) { state.decorated = []; return; } state.decorated = decoratePoints(payload.points, state.enabledLanguages, state.enabledKinds); } function visiblePoints() { const points = state.decorated; if (points.length === 0) return []; const start = points[state.rangeFrom] || points[0]; const end = points[state.rangeTo] || points[points.length - 1]; return filterPointsByRange(points, start.at, end.at); } function formatNumber(value) { return new Intl.NumberFormat(undefined, { maximumFractionDigits: 0 }).format(value); } function formatWhen(iso) { if (!iso) return 'Unknown'; return new Intl.DateTimeFormat(undefined, { year: 'numeric', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit', }).format(new Date(iso)); } function syncRangeUi() { const points = state.payload?.points || []; const last = Math.max(0, points.length - 1); const clamped = clampRange(state.rangeFrom, state.rangeTo, last); state.rangeFrom = clamped.from; state.rangeTo = clamped.to; if (els.rangeFrom) { els.rangeFrom.max = String(last); els.rangeFrom.value = String(state.rangeFrom); } if (els.rangeTo) { els.rangeTo.max = String(last); els.rangeTo.value = String(state.rangeTo); } const start = points[state.rangeFrom]; const end = points[state.rangeTo]; state.from = start?.at || ''; state.to = end?.at || ''; if (els.from) els.from.value = state.from ? toLocalInput(state.from) : ''; if (els.to) els.to.value = state.to ? toLocalInput(state.to) : ''; if (els.rangeTrack && last > 0) { const left = (state.rangeFrom / last) * 100; const right = (state.rangeTo / last) * 100; els.rangeTrack.style.background = \`linear-gradient(to right, var(--line) \${left}%, var(--red) \${left}%, var(--red) \${right}%, var(--line) \${right}%)\`; } } function renderChips(payload) { const points = visiblePoints(); const ranked = languageStats(points, payload.languages); els.languages.innerHTML = ''; for (const item of ranked) { const button = document.createElement('button'); button.className = 'chip'; button.type = 'button'; button.style.setProperty('--swatch', colorForLanguage(item.language, payload.languages)); button.setAttribute('aria-pressed', String(state.enabledLanguages.has(item.language))); button.innerHTML = \`\${escapeHtml(item.language)}\${formatNumber(item.lines)}\`; button.addEventListener('click', () => { if (state.enabledLanguages.has(item.language) && state.enabledLanguages.size === 1) return; if (state.enabledLanguages.has(item.language)) state.enabledLanguages.delete(item.language); else state.enabledLanguages.add(item.language); redecorate(); draw(); }); els.languages.append(button); } els.kinds.innerHTML = ''; for (const kind of kinds) { const button = document.createElement('button'); button.className = 'chip'; button.type = 'button'; button.textContent = kind; button.setAttribute('aria-pressed', String(state.enabledKinds.has(kind))); button.addEventListener('click', () => { if (state.enabledKinds.has(kind) && state.enabledKinds.size === 1) return; if (state.enabledKinds.has(kind)) state.enabledKinds.delete(kind); else state.enabledKinds.add(kind); redecorate(); draw(); }); els.kinds.append(button); } } function areaFrom(upper, lower, x, y) { if (upper.length === 0) return ''; let d = upper.map((value, index) => \`\${index === 0 ? 'M' : 'L'}\${x(index)} \${y(value)}\`).join(' '); for (let i = lower.length - 1; i >= 0; i -= 1) { d += \` L\${x(i)} \${y(lower[i])}\`; } return d + ' Z'; } function niceMax(value) { if (value <= 0) return 1; const pow = Math.pow(10, Math.floor(Math.log10(value))); return Math.ceil(value / pow) * pow; } function stackedLanguages(payload, points) { return languageStats(points, payload.languages) .map((item) => item.language) .filter((language) => state.enabledLanguages.has(language)); } function draw() { const payload = state.payload; if (!payload) return; const points = visiblePoints(); const languages = stackedLanguages(payload, points); const width = 1000; const height = 570; const pad = { l: 56, r: 16, t: 28, b: 42 }; const innerW = width - pad.l - pad.r; const innerH = height - pad.t - pad.b; const x = (index) => pad.l + (points.length <= 1 ? innerW / 2 : (index / (points.length - 1)) * innerW); let max = 1; let min = 0; if (state.mode === 'cumulative') { max = niceMax(Math.max(1, ...points.map((point) => point.cumulative))); } else if (state.mode === 'delta') { max = niceMax(Math.max(1, ...points.map((point) => point.additions))); min = -niceMax(Math.max(1, ...points.map((point) => point.deletions))); } else { const pos = Math.max(1, ...points.map((point) => languages.reduce((sum, language) => sum + Math.max(0, point.byLanguage.get(language)?.net || 0), 0), )); const neg = Math.max(1, ...points.map((point) => languages.reduce((sum, language) => sum + Math.max(0, -(point.byLanguage.get(language)?.net || 0)), 0), )); max = niceMax(pos); min = -niceMax(neg); } const y = (value) => pad.t + ((max - value) / (max - min)) * innerH; const svg = []; svg.push(\`\`); for (const tick of [0, 0.25, 0.5, 0.75, 1]) { const value = min + (max - min) * (1 - tick); const yy = pad.t + innerH * tick; svg.push(\`\`); svg.push(\`\${formatNumber(value)}\`); } const xTicks = timeAxisTicks(points); for (const tick of xTicks) { const xx = x(tick.index); const first = tick.index === xTicks[0]?.index; const last = tick.index === xTicks[xTicks.length - 1]?.index; const anchor = first ? 'start' : last ? 'end' : 'middle'; svg.push(\`\`); svg.push(\`\${escapeHtml(tick.label)}\`); } if (state.mode === 'cumulative') { let baseline = points.map(() => 0); languages.forEach((language) => { const upper = points.map((item, index) => baseline[index] + (item.byLanguage.get(language)?.cumulative || 0)); const color = colorForLanguage(language, payload.languages); svg.push(\`\`); baseline = upper; }); } else { const bar = Math.max(1.4, innerW / Math.max(points.length, 1) * 0.7); points.forEach((point, index) => { const cx = x(index); if (state.mode === 'delta') { let addTop = 0; let delTop = 0; languages.forEach((language) => { const bucket = point.byLanguage.get(language); const color = colorForLanguage(language, payload.languages); const adds = bucket?.additions || 0; const dels = bucket?.deletions || 0; if (adds) { svg.push(\`\`); addTop += adds; } if (dels) { svg.push(\`\`); delTop += dels; } }); } else { let pos = 0; let neg = 0; languages.forEach((language) => { const net = point.byLanguage.get(language)?.net || 0; if (!net) return; const color = colorForLanguage(language, payload.languages); if (net > 0) { svg.push(\`\`); pos += net; } else { svg.push(\`\`); neg += net; } }); } }); } const commentaries = payload.commentaries.filter((item) => points.some((point) => point.sha === item.sha)); commentaries.forEach((note, noteOrder) => { const index = points.findIndex((point) => point.sha === note.sha); if (index < 0) return; const point = points[index]; const value = state.mode === 'cumulative' ? point.cumulative : state.mode === 'delta' ? point.additions : point.net; // Stagger labels so dense markers do not stack on the same baseline. const labelY = y(value) - 8 - (noteOrder % 3) * 12; svg.push(\` \${escapeHtml(note.label)} \`); }); svg.push(''); els.chart.innerHTML = svg.join(''); els.chart.querySelectorAll('.marker').forEach((marker) => { marker.addEventListener('click', () => { const note = payload.commentaries.find((item) => item.id === marker.getAttribute('data-id')); openSheet(note, points.find((point) => point.sha === note?.sha)); }); }); const svgEl = els.chart.querySelector('svg'); svgEl.addEventListener('pointermove', (event) => { const bounds = svgEl.getBoundingClientRect(); const ratio = (event.clientX - bounds.left) / bounds.width; const index = Math.round(ratio * Math.max(points.length - 1, 0)); const point = points[index]; if (!point) return; const parts = languages .map((language) => { const bucket = point.byLanguage.get(language); const value = state.mode === 'cumulative' ? bucket?.cumulative || 0 : state.mode === 'delta' ? (bucket?.additions || 0) - (bucket?.deletions || 0) : bucket?.net || 0; return value ? \`\${escapeHtml(language)} \${formatNumber(value)}\` : ''; }) .filter(Boolean) .slice(0, 6); els.tooltip.hidden = false; els.tooltip.style.left = Math.min(event.clientX - els.chart.getBoundingClientRect().left + 12, bounds.width - 200) + 'px'; els.tooltip.style.top = '12px'; els.tooltip.innerHTML = \`\${escapeHtml(point.message)}
\${formatWhen(point.at)}
\${ state.mode === 'cumulative' ? 'Cumulative ' + formatNumber(point.cumulative) : state.mode === 'delta' ? '+' + formatNumber(point.additions) + ' / −' + formatNumber(point.deletions) : 'Net ' + formatNumber(point.net) }\${parts.length ? '
' + parts.join(' · ') : ''}\`; }); svgEl.addEventListener('pointerleave', () => { els.tooltip.hidden = true; }); renderChips(payload); renderStory(payload, points); renderCommits(points); const latest = points[points.length - 1]; setStatusText( latest ? \`\${formatNumber(points.length)} commits · \${formatNumber(latest.cumulative)}\` : 'No commits in this range yet.', ); if (els.status) els.status.classList.toggle('is-offline', state.offline); } function renderStory(payload, points) { const notes = payload.commentaries .filter((item) => points.some((point) => point.sha === item.sha)) .slice() .sort((left, right) => { const leftAt = points.find((point) => point.sha === left.sha)?.at || ''; const rightAt = points.find((point) => point.sha === right.sha)?.at || ''; return leftAt.localeCompare(rightAt); }); els.story.innerHTML = notes.map((note) => { const point = points.find((item) => item.sha === note.sha); return \`\`; }).join('') || '

No labeled moments in this range. Widen the dates or tap All.

'; els.story.querySelectorAll('.story-card').forEach((card) => { card.addEventListener('click', () => { const note = payload.commentaries.find((item) => item.id === card.getAttribute('data-id')); openSheet(note, points.find((point) => point.sha === note?.sha)); }); }); } function renderCommits(points) { const visible = points.slice(-40).reverse(); els.commits.innerHTML = visible.map((point) => \` \${point.sha.slice(0, 7)} \${escapeHtml(point.message)} \${formatWhen(point.at)} · \${point.net >= 0 ? '+' : ''}\${formatNumber(point.net)} \`).join(''); } function openSheet(note, point) { if (!note) return; els.sheet.hidden = false; els.sheet.innerHTML = \`

\${escapeHtml(note.label)}

\${escapeHtml(note.narrative)}

\${point ? \`

\${point.sha.slice(0, 7)} · \${escapeHtml(point.message)}

\` : ''} \`; els.sheet.querySelector('[data-close]').addEventListener('click', () => { els.sheet.hidden = true; }); } function escapeHtml(value) { return String(value) .replaceAll('&', '&') .replaceAll('<', '<') .replaceAll('>', '>') .replaceAll('"', '"'); } function applyPreset(name) { const points = state.payload?.points || []; if (points.length === 0) return; const last = points.length - 1; if (name === 'all') { state.rangeFrom = 0; state.rangeTo = last; } else { const now = Date.parse(points[last].at); const start = new Date(now); if (name === '7d') start.setDate(start.getDate() - 7); if (name === '30d') start.setDate(start.getDate() - 30); if (name === '90d') start.setDate(start.getDate() - 90); if (name === '1y') start.setFullYear(start.getFullYear() - 1); state.rangeFrom = closestIndex(points, start.toISOString()); state.rangeTo = last; } syncRangeUi(); draw(); } async function refresh() { if (!state.payload) { const cached = await cacheGet('latest'); if (cached) applyPayload(cached); } applyPresentation(whileRefreshing(Boolean(state.payload))); try { const payload = await fetchSeries(); applyPayload(payload); applyPresentation(afterRefresh({ hasCachedSeries: true, fetchSucceeded: true, networkOnline: navigator.onLine, })); } catch (error) { const message = error instanceof Error ? error.message : String(error); applyPresentation(afterRefresh({ hasCachedSeries: Boolean(state.payload), fetchSucceeded: false, networkOnline: navigator.onLine, })); if (!state.payload) setStatusText(message); } } els.modes.querySelectorAll('button').forEach((button) => { button.setAttribute('aria-pressed', String(button.dataset.mode === state.mode)); button.addEventListener('click', () => { state.mode = button.dataset.mode; localStorage.setItem('lineage-mode', state.mode); els.modes.querySelectorAll('button').forEach((item) => { item.setAttribute('aria-pressed', String(item === button)); }); draw(); }); }); els.presets.querySelectorAll('button').forEach((button) => { button.addEventListener('click', () => applyPreset(button.dataset.preset)); }); els.from.addEventListener('change', () => { const points = state.payload?.points || []; state.rangeFrom = closestIndex(points, fromLocalInput(els.from.value) || points[0]?.at || ''); syncRangeUi(); draw(); }); els.to.addEventListener('change', () => { const points = state.payload?.points || []; state.rangeTo = closestIndex(points, fromLocalInput(els.to.value) || points[points.length - 1]?.at || ''); syncRangeUi(); draw(); }); function onSlider(which, value) { if (which === 'from') state.rangeFrom = Number(value); else state.rangeTo = Number(value); syncRangeUi(); draw(); } els.rangeFrom?.addEventListener('input', (event) => onSlider('from', event.target.value)); els.rangeTo?.addEventListener('input', (event) => onSlider('to', event.target.value)); function showToast(title, text, actionLabel, onAction) { if (!els.toast) return; els.toast.hidden = false; if (els.toastTitle) els.toastTitle.textContent = title; if (els.toastText) els.toastText.textContent = text; if (els.toastAction) { els.toastAction.textContent = actionLabel; els.toastAction.onclick = () => { els.toast.hidden = true; onAction(); }; } } function hideToast() { if (els.toast) els.toast.hidden = true; } function standaloneNow() { return isStandaloneDisplay({ displayModeStandalone: window.matchMedia('(display-mode: standalone)').matches, navigatorStandalone: navigator.standalone, }); } function iosNow() { return isIosBrowser({ userAgent: navigator.userAgent, platform: navigator.platform, maxTouchPoints: navigator.maxTouchPoints, }); } function closeInstallPopover() { if (!els.installPopover) return; els.installPopover.classList.remove('is-open'); if (typeof els.installPopover.hidePopover === 'function') { try { els.installPopover.hidePopover(); } catch {} } } function openInstallPopover() { if (!els.installPopover) return; if (typeof els.installPopover.showPopover === 'function') { els.installPopover.showPopover(); return; } els.installPopover.classList.add('is-open'); } function syncInstallUi() { const ios = iosNow(); const standalone = standaloneNow(); document.documentElement.classList.toggle('is-ios', ios); document.documentElement.classList.toggle('is-standalone', standalone); if (els.installOpen) { els.installOpen.hidden = !shouldShowInstallButton({ standalone, ios }); } if (els.installHint) { let dismissed = false; try { dismissed = localStorage.getItem('lineage-install-hint-dismissed') !== null; } catch {} els.installHint.hidden = !shouldShowIosInstallHint({ standalone, ios, safariFamily: isSafariFamily(navigator.userAgent), dismissed, }); } } async function ensureServiceWorker() { if (!('serviceWorker' in navigator)) return null; if (state.registration) return state.registration; try { state.registration = await navigator.serviceWorker.register(api('sw.js'), { scope: serviceWorkerScope(base || '/', location.origin), updateViaCache: 'none', }); watchServiceWorker(state.registration); return state.registration; } catch { return null; } } function watchServiceWorker(registration) { const track = (worker) => { if (!worker) return; worker.addEventListener('statechange', () => { if (worker.state === 'installed' && navigator.serviceWorker.controller) { state.waitingWorker = worker; showToast( 'Update available', 'A newer Lineage is ready. Reload to download it onto this device.', 'Reload now', applyUpdate, ); } }); }; track(registration.installing); registration.addEventListener('updatefound', () => track(registration.installing)); navigator.serviceWorker.addEventListener('controllerchange', () => { if (state.waitingWorker) location.reload(); }); } async function applyUpdate() { const worker = state.waitingWorker || state.registration?.waiting; if (worker) worker.postMessage('SKIP_WAITING'); else location.reload(); } async function promptInstall() { const promptEvent = deferredInstall; if (promptEvent?.prompt) { deferredInstall = null; try { await promptEvent.prompt(); await promptEvent.userChoice; syncInstallUi(); return true; } catch { syncInstallUi(); return false; } } if (typeof navigator.install === 'function') { try { await navigator.install(); syncInstallUi(); return true; } catch { syncInstallUi(); return false; } } return false; } async function loadVersion() { try { const response = await fetch(api('api/version'), { cache: 'no-store' }); if (!response.ok) throw new Error('version ' + response.status); return await response.json(); } catch { return { sha: document.documentElement.dataset.runningSha || '', shortSha: (document.documentElement.dataset.runningSha || '').slice(0, 7), message: '', committedAt: '', publishedAt: '', }; } } function renderAbout(version, checking) { const runningSha = document.documentElement.dataset.runningSha || version.sha || ''; const latestSha = version.sha || runningSha; const stale = runningSha && latestSha && runningSha !== latestSha; els.aboutSheet.hidden = false; els.aboutSheet.innerHTML = \`

About Lineage

Running version
\${escapeHtml(runningSha ? runningSha.slice(0, 7) : 'Not recorded yet')}
Latest published
\${escapeHtml(latestSha ? latestSha.slice(0, 7) : 'Not recorded yet')}
Commit message
\${escapeHtml(version.message || 'Unknown')}
Committed
\${escapeHtml(version.committedAt ? formatWhen(version.committedAt) : 'Unknown')}
Published
\${escapeHtml(version.publishedAt ? formatWhen(version.publishedAt) : 'Unknown')}
\`; els.aboutSheet.querySelector('[data-close]').addEventListener('click', () => { els.aboutSheet.hidden = true; }); els.aboutSheet.querySelector('[data-check-updates]')?.addEventListener('click', () => checkForUpdates(true)); if (stale) { showToast( 'Update available', 'This tab is still on ' + runningSha.slice(0, 7) + '. Latest published is ' + latestSha.slice(0, 7) + '.', 'Reload now', () => location.reload(), ); } } const updateCheck = { inFlight: false, lastStartedAt: null }; async function checkForUpdates(fromAbout) { if (!fromAbout && !shouldRunUpdateCheck({ visible: document.visibilityState === 'visible', inFlight: updateCheck.inFlight, lastStartedAt: updateCheck.lastStartedAt, now: Date.now(), minIntervalMs: UPDATE_CHECK_MIN_INTERVAL_MS, })) return; updateCheck.inFlight = true; updateCheck.lastStartedAt = Date.now(); try { if (fromAbout) renderAbout(await loadVersion(), true); const registration = await ensureServiceWorker(); const [version] = await Promise.all([ loadVersion(), registration?.update?.() || Promise.resolve(), ]); const waiting = state.waitingWorker || registration?.waiting || null; if (waiting) state.waitingWorker = waiting; const runningSha = document.documentElement.dataset.runningSha || ''; if (fromAbout) renderAbout(version, false); const available = describeAvailableUpdate({ runningSha, latestSha: version.sha || '', hasWaitingWorker: Boolean(waiting), }); if (available === 'worker') { showToast('Update available', 'A newer Lineage is ready. Reload to download it onto this device.', 'Reload now', applyUpdate); return; } if (available === 'published') { showToast( 'Update available', 'Latest published commit is ' + (version.shortSha || version.sha.slice(0, 7)) + '. Reload to download it.', 'Reload now', () => location.reload(), ); return; } if (fromAbout) setStatusText('You are on the latest published Lineage.'); } finally { updateCheck.inFlight = false; } } function onAppOpened() { checkForUpdates(false); } installListeners.add(syncInstallUi); els.installOpen?.addEventListener('click', async () => { const prompted = await promptInstall(); if (!prompted) openInstallPopover(); }); els.install?.addEventListener('click', async () => { closeInstallPopover(); await promptInstall(); }); els.installHintDismiss?.addEventListener('click', () => { try { localStorage.setItem('lineage-install-hint-dismissed', String(Date.now())); } catch {} if (els.installHint) els.installHint.hidden = true; }); els.about?.addEventListener('click', async () => { renderAbout(await loadVersion(), false); }); els.toastDismiss?.addEventListener('click', hideToast); syncInstallUi(); window.addEventListener('online', () => { state.offline = false; refresh(); }); window.addEventListener('offline', () => { state.offline = true; if (els.status) els.status.classList.add('is-offline'); }); document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') onAppOpened(); }); window.addEventListener('pageshow', onAppOpened); const bootSeries = readLocalSeries(); if (bootSeries) applyPayload(bootSeries); ensureServiceWorker().then(() => onAppOpened()); refresh(); })(); `