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(\`');
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.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 = \`