Skip to content

Built for people who want to own their automations. Join the waitlist for an invite.

Package listing

@kentcdodds/lineage

src/ui/client-script.ts

957 lines · 36.3 KB · TypeScript
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('&', '&amp;')
      .replaceAll('<', '&lt;')
      .replaceAll('>', '&gt;')
      .replaceAll('"', '&quot;');
  }

  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();
})();
`