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 { 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 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]'),
loading: root.querySelector('[data-loading]'),
loadingTitle: root.querySelector('[data-loading-title]'),
loadingDetail: root.querySelector('[data-loading-detail]'),
};
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 {
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 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 setLoading(on, title, detail) {
if (els.panel) {
els.panel.classList.toggle('is-loading', on);
els.panel.setAttribute('aria-busy', String(on));
}
if (els.loading) els.loading.hidden = !on;
if (els.status) els.status.classList.toggle('is-busy', on);
if (title && els.loadingTitle) els.loadingTitle.textContent = title;
if (detail && els.loadingDetail) els.loadingDetail.textContent = detail;
if (on && title) setStatusText(title);
}
function setStatusText(text) {
if (els.statusText) els.statusText.textContent = text;
}
function applyPresentation(presentation) {
if (presentation.showFullPageLoading) {
setLoading(
true,
'Loading countable history',
'Reading the latest countable commits and drawing the graph.',
);
} else {
setLoading(false);
if (els.status) els.status.classList.remove('is-busy');
}
if (els.status) {
els.status.classList.toggle('is-updating', presentation.statusUpdating);
els.status.classList.toggle('is-offline', presentation.treatAsOffline || !navigator.onLine);
}
state.offline = presentation.treatAsOffline || !navigator.onLine;
}
function waitForPaint() {
return new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
}
async function fetchSeries() {
const url = api('api/series') + '?mode=' + encodeURIComponent(state.mode);
const response = await fetch(url);
if (!response.ok) throw new Error('series ' + response.status);
const payload = await response.json();
await cacheSet('latest', payload);
state.offline = false;
return payload;
}
async function applyPayload(payload, { drawing = true } = {}) {
state.payload = payload;
if (state.enabledLanguages.size === 0) {
payload.languages.forEach((language) => state.enabledLanguages.add(language));
}
if (drawing) {
setLoading(true, 'Drawing the graph', 'Laying out stacked language totals for the commits in view.');
await waitForPaint();
}
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();
setLoading(false);
}
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 = \`<span class="swatch" aria-hidden="true"></span>\${escapeHtml(item.language)}<span class="count">\${formatNumber(item.lines)}</span>\`;
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(\`<svg class="chart" viewBox="0 0 \${width} \${height}" role="img" aria-label="Lines of code chart">\`);
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(\`<line class="grid" x1="\${pad.l}" x2="\${width - pad.r}" y1="\${yy}" y2="\${yy}" />\`);
svg.push(\`<text class="axis" x="\${pad.l - 8}" y="\${yy + 4}" text-anchor="end">\${formatNumber(value)}</text>\`);
}
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(\`<line class="grid grid-x" x1="\${xx}" x2="\${xx}" y1="\${pad.t}" y2="\${pad.t + innerH}" />\`);
svg.push(\`<text class="axis axis-x" x="\${xx}" y="\${height - 12}" text-anchor="\${anchor}">\${escapeHtml(tick.label)}</text>\`);
}
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(\`<path d="\${areaFrom(upper, baseline, x, y)}" fill="\${color}" fill-opacity="0.42" stroke="\${color}" stroke-width="1.5"></path>\`);
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(\`<rect x="\${cx - bar / 2}" y="\${y(addTop + adds)}" width="\${bar}" height="\${Math.max(0, y(addTop) - y(addTop + adds))}" fill="\${color}"></rect>\`);
addTop += adds;
}
if (dels) {
svg.push(\`<rect x="\${cx - bar / 2}" y="\${y(-delTop)}" width="\${bar}" height="\${Math.max(0, y(-delTop - dels) - y(-delTop))}" fill="\${color}" fill-opacity="0.55"></rect>\`);
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(\`<rect x="\${cx - bar / 2}" y="\${y(pos + net)}" width="\${bar}" height="\${Math.max(0, y(pos) - y(pos + net))}" fill="\${color}"></rect>\`);
pos += net;
} else {
svg.push(\`<rect x="\${cx - bar / 2}" y="\${y(neg)}" width="\${bar}" height="\${Math.max(0, y(neg + net) - y(neg))}" fill="\${color}" fill-opacity="0.7"></rect>\`);
neg += net;
}
});
}
});
}
const commentaries = payload.commentaries.filter((item) => points.some((point) => point.sha === item.sha));
for (const note of commentaries) {
const index = points.findIndex((point) => point.sha === note.sha);
if (index < 0) continue;
const point = points[index];
const value = state.mode === 'cumulative' ? point.cumulative : state.mode === 'delta' ? point.additions : point.net;
svg.push(\`<g class="marker" data-sha="\${note.sha}" data-id="\${note.id}">
<circle cx="\${x(index)}" cy="\${y(value)}" r="6" fill="var(--yellow)" stroke="var(--red)" stroke-width="2"></circle>
<text x="\${x(index) + 8}" y="\${y(value) - 8}">\${escapeHtml(note.label)}</text>
</g>\`);
}
svg.push('</svg>');
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 ? \`<span style="color:\${colorForLanguage(language, payload.languages)}">\${escapeHtml(language)} \${formatNumber(value)}</span>\` : '';
})
.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 = \`<strong>\${escapeHtml(point.message)}</strong><br>\${formatWhen(point.at)}<br>\${
state.mode === 'cumulative'
? 'Cumulative ' + formatNumber(point.cumulative)
: state.mode === 'delta'
? '+' + formatNumber(point.additions) + ' / −' + formatNumber(point.deletions)
: 'Net ' + formatNumber(point.net)
}\${parts.length ? '<br>' + 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 \`<button class="story-card" type="button" data-id="\${escapeHtml(note.id)}">
<b>\${escapeHtml(note.label)}</b>
<span>\${point ? formatWhen(point.at) + ' · ' + point.sha.slice(0, 7) : note.sha.slice(0, 7)}</span>
<span>\${escapeHtml(note.narrative)}</span>
</button>\`;
}).join('') || '<p class="story-lede">No labeled moments in this range. Widen the dates or tap All.</p>';
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) => \`
<a class="commit" href="\${point.htmlUrl}" target="_blank" rel="noreferrer">
<span>\${point.sha.slice(0, 7)}</span>
<b>\${escapeHtml(point.message)}</b>
<span>\${formatWhen(point.at)} · \${point.net >= 0 ? '+' : ''}\${formatNumber(point.net)}</span>
</a>
\`).join('');
}
function openSheet(note, point) {
if (!note) return;
els.sheet.hidden = false;
els.sheet.innerHTML = \`
<button class="ghost" type="button" data-close>Close</button>
<h2>\${escapeHtml(note.label)}</h2>
<p>\${escapeHtml(note.narrative)}</p>
\${point ? \`<p><a href="\${point.htmlUrl}" target="_blank" rel="noreferrer">\${point.sha.slice(0, 7)} · \${escapeHtml(point.message)}</a></p>\` : ''}
\`;
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) {
applyPresentation(whileRefreshing(true));
await applyPayload(cached, { drawing: false });
applyPresentation(whileRefreshing(true));
} else {
applyPresentation(whileRefreshing(false));
}
} else {
applyPresentation(whileRefreshing(true));
}
try {
const payload = await fetchSeries();
await applyPayload(payload, { drawing: !state.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) {
setLoading(true, 'Could not load history', message + '. Check your Kody session and try again.');
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 = \`
<button class="ghost" type="button" data-close>Close</button>
<h2>About Lineage</h2>
<dl class="about-dl">
<div><dt>Running version</dt><dd>\${escapeHtml(runningSha ? runningSha.slice(0, 7) : 'Not recorded yet')}</dd></div>
<div><dt>Latest published</dt><dd>\${escapeHtml(latestSha ? latestSha.slice(0, 7) : 'Not recorded yet')}</dd></div>
<div><dt>Commit message</dt><dd>\${escapeHtml(version.message || 'Unknown')}</dd></div>
<div><dt>Committed</dt><dd>\${escapeHtml(version.committedAt ? formatWhen(version.committedAt) : 'Unknown')}</dd></div>
<div><dt>Published</dt><dd>\${escapeHtml(version.publishedAt ? formatWhen(version.publishedAt) : 'Unknown')}</dd></div>
</dl>
<div class="about-actions">
<button class="install" type="button" data-check-updates \${checking ? 'disabled' : ''}>\${checking ? 'Checking…' : 'Check for updates'}</button>
</div>
\`;
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);
ensureServiceWorker().then(() => onAppOpened());
refresh();
})();
`