collective

Browse / github.com/storytold/craft-repo-status-app / patch

Update table rows in place and highlight cells that changed

Update table rows in place and highlight cells that changed ## Why Since #10 the app emits a snapshot at the start and the end of each repo fetch, about every 20 s while repos are due, and the clock renders the table every 15 s. `renderTable` rebuilt every row on each render, so a tooltip, a link being pressed and the refresh button that #11 added to the Fetched cell were replaced under the pointer. The table also did not show which values a fetch had changed. ## What - The table keeps one row per repo and updates its cells in place. A cell whose content did not change keeps its nodes. A cell whose content differs only in its outer tooltip keeps its

Status
open
Score
0.00
Adopters
0 other operators
Author
dotCooCoo (imported from pull request #13, open)
Base
9eaf2b22d3630c5ca0731009a2a3e835f49ee6d0
Size
+743 −104 in 6 files
Review
pending
Shared
2026-10-28
Id
83bbcbd7010911a71751689a

How it went

No other operator has reported using it yet.

Diff

raw · README.md ui/src/app.test.ts ui/src/app.ts ui/src/model.test.ts ui/src/model.ts ui/style.css

diff --git a/README.md b/README.md--- a/README.md+++ b/README.md@@ -67,6 +67,12 @@ sort by a column again.   fetch. A stale row is dimmed and gets an amber dot; a failed row gets a red   dot, and its error appears in the banner. The tray tooltip also says   whether the data is stale.+- **Live updates**: the repo being fetched has a pulsing blue dot. When its+  fetch lands, its row updates in place, and each cell whose value changed,+  other than Fetched, is highlighted for 2.5 s. While the pointer is over the+  rows, they keep their places; they move to their sorted order when the+  pointer leaves or the window loses focus. Sorting, changing the window and+  dragging a row reorder the table at once.  ### How issues are scored diff --git a/ui/src/app.test.ts b/ui/src/app.test.ts--- a/ui/src/app.test.ts+++ b/ui/src/app.test.ts@@ -281,6 +281,332 @@ test('an empty config says so', async () => {   assert.match(h.doc.getElementById('tbody')!.textContent ?? '', /No repositories configured/); }); +// ───────────────────────────── live updates ─────────────────────────────++/** The default snapshot with some repos' stats, entry fields or snapshot fields changed. */+function changed(stats: Record<string, Partial<RepoStats>>, entries: Record<string, Partial<RepoEntry>> = {}, extra: Partial<Snapshot> = {}): Snapshot {+  const s = snapshot();+  s.repos = s.repos.map((e) => {+    const name = e.repo.slice(2);+    return { ...e, ...entries[name], stats: e.stats && { ...e.stats, ...stats[name] } };+  });+  return { ...s, ...extra };+}++const hover = (h: Harness, type: 'pointerenter' | 'pointerleave') =>+  h.doc.getElementById('tbody')!.dispatchEvent(new h.win.PointerEvent(type) as unknown as Event);++const column = (key: string): number => COLUMNS.findIndex((c) => c.key === key);+const FETCHED = column('fetched');+const URGENCY = column('urgency');+const PRS = column('open_prs');++test('a snapshot updates rows in place', async () => {+  const h = await setup();+  const alpha = h.row('alpha');+  const prs = alpha.cells[PRS]!;+  h.emit('craft:snapshot', changed({ alpha: { open_prs: 9 } }));+  // assert.ok rather than assert.equal: a failing equal prints both elements, which can take minutes.+  assert.ok(h.row('alpha') === alpha, 'the same row');+  assert.ok(alpha.cells[PRS] === prs, 'the same cell');+  assert.equal(prs.textContent, '9');+});++test('a snapshot keeps the nodes of cells whose content did not change', async () => {+  const h = await setup();+  const prs = () => h.row('alpha').cells[PRS]!.firstChild as HTMLElement;+  const link = prs();+  const name = h.row('alpha').cells[0]!.firstChild;+  h.emit('craft:snapshot', changed({ beta: { open_prs: 9 } }, {}, { fetching: 'o/alpha' }));+  assert.ok(prs() === link, 'the same link element');+  assert.ok(h.row('alpha').cells[0]!.firstChild === name, 'the same repo name');+  link.click();+  assert.equal(h.calls.at(-1)?.args?.url, 'https://github.com/o/alpha/pulls');+  h.emit('craft:snapshot', changed({ alpha: { open_prs: 9 } }));+  assert.ok(prs() !== link, 'a new value gets a new element');+  assert.equal(prs().textContent, '9');+});++test('a fetch keeps the repo name and its link, and updates the tooltip', async () => {+  const h = await setup();+  const name = () => h.row('alpha').cells[0]!.firstChild as HTMLElement;+  const before = name();+  const link = before.querySelector('.link');+  const tip = before.title;+  h.emit('craft:snapshot', changed({}, { alpha: { fetched_at: '2026-01-02T03:04:05Z' } }));+  assert.ok(name() === before && name().querySelector('.link') === link, 'the same name and link');+  assert.notEqual(before.title, tip);+  assert.match(before.title, /^Fetched /);+});++test('a clock render keeps an activity cell whose tooltip has aged', async (t) => {+  t.mock.timers.enable({ apis: ['setInterval', 'Date'], now: Date.now() });+  const built = new Date(Date.now() - 3600e3 + 5e3).toISOString();+  const act = { commits: 3, prs_opened: 0, prs_merged: 0, issues_opened: 0, people: 1 };+  const h = await setup({ snap: changed({ alpha: { latest_build_at: built, recent: { release: act } } }), storage: { window: 'release' } });+  const commits = () => h.row('alpha').cells[column('recent_commits')]!.firstChild as HTMLElement;+  const before = commits();+  assert.match(before.title, /\(59m ago\)$/);+  t.mock.timers.tick(15_000);+  assert.ok(commits() === before, 'the same element');+  assert.match(before.title, /\(1h ago\)$/);+});++test('a link whose address changes is replaced even when its text is the same', async () => {+  const lag = (from_sha: string) => ({ since_build: { commits: 5, basis: 'by_date' as const, from_sha } });+  const h = await setup({ snap: changed({ alpha: lag('aaaaaaa1') }) });+  const unreleased = () => h.row('alpha').cells[column('commits_since_build')]!.querySelector<HTMLElement>('.link')!;+  const before = unreleased();+  h.emit('craft:snapshot', changed({ alpha: lag('bbbbbbb2') }));+  assert.ok(unreleased() !== before, 'a new link');+  unreleased().click();+  assert.equal(h.calls.at(-1)?.args?.url, 'https://github.com/o/alpha/compare/bbbbbbb2...HEAD');+});++test('cells carry their tooltips, links and marks, and the footer its totals', async () => {+  const old = new Date(Date.now() - 20 * 86400e3).toISOString();+  const act = { commits: 3, prs_opened: 0, prs_merged: 0, issues_opened: 0, people: 1 };+  const h = await setup({ snap: snapshot([+    repo('alpha', {+      since_build: { commits: 4, basis: 'exact', from_sha: 'aaaaaaa1bbbb' }, latest_build_prerelease: true,+      oldest_open_pr_at: old, recent: { release: act },+    }),+    repo('beta', { since_build: { commits: 0, basis: 'by_date', from_sha: 'ccccccc3dddd' } }, { error: 'boom' }),+    repo('gamma', { since_build: { commits: 2, basis: 'by_date', from_sha: null } }, { fetched_at: null }),+    repo('delta', null, { inaccessible: true }),+    repo('eps', { latest_build: null }),+  ]), storage: { window: 'release' } });+  const at = (name: string, key: string) => h.row(name).cells[column(key)]!;+  const tip = (name: string, key: string) => (at(name, key).firstChild as HTMLElement).title;++  assert.match(tip('alpha', 'name'), /^Fetched /);+  assert.equal(tip('beta', 'name'), 'Last attempt failed: boom');+  assert.equal(tip('gamma', 'name'), 'Not fetched yet');+  assert.equal(tip('delta', 'name'), 'Not accessible (private, or not found)');+  assert.equal(at('delta', 'urgency').textContent, '');++  assert.equal(tip('alpha', 'commits_since_build'), '4 commits on main since v1.0.0 (aaaaaaa)');+  at('alpha', 'commits_since_build').querySelector<HTMLElement>('.link')!.click();+  assert.equal(h.calls.at(-1)?.args?.url, 'https://github.com/o/alpha/compare/v1.0.0...HEAD');+  assert.equal(tip('beta', 'commits_since_build'),+    'v1.0.0’s commit shares no history with main: ~0 commits on main are newer than it, counted from ccccccc');+  assert.ok(at('beta', 'commits_since_build').querySelector('.link.zero'), 'a zero count is a dimmed link');+  assert.equal(tip('gamma', 'commits_since_build'), 'v1.0.0’s commit shares no history with main: ~2 commits on main are newer than it');+  assert.ok(!at('gamma', 'commits_since_build').querySelector('.link'), 'no commit to compare from');+  assert.equal(at('gamma', 'commits_since_build').textContent, '~2');+  assert.ok(at('eps', 'commits_since_build').querySelector('.muted'));++  assert.equal(at('alpha', 'latest_build').textContent, 'v1.0.0PRE');+  assert.ok(at('alpha', 'latest_build').querySelector('.link + .pre'));+  assert.equal(at('beta', 'latest_build').querySelector('.pre'), null);+  assert.equal(at('eps', 'latest_build').querySelector('.muted')!.textContent, '—');++  assert.ok(at('alpha', 'oldest_open_pr_at').querySelector('.old'), 'an open PR older than 14 days');+  assert.equal(at('beta', 'oldest_open_pr_at').querySelector('.old'), null);+  assert.ok(tip('beta', 'oldest_open_pr_at'));++  assert.match(tip('alpha', 'recent_commits'), /^Since v1\.0\.0, published .+ \(now ago\)$/);+  assert.ok(at('alpha', 'recent_commits').querySelector('.active'));+  assert.equal(tip('beta', 'recent_commits'), 'Not fetched yet for this window');+  assert.equal(tip('eps', 'recent_commits'), 'No release yet');++  const foot = [...h.doc.querySelectorAll('tfoot td')];+  assert.equal(foot[0]!.textContent, '5 repos');+  assert.equal(foot[PRS]!.textContent, '4');+  assert.equal(foot[column('oldest_open_pr_at')]!.textContent, '');+});++test('rows move to their sorted place without being rebuilt', async () => {+  const h = await setup();+  const before = ['alpha', 'beta', 'gamma'].map((n) => h.row(n));+  h.emit('craft:snapshot', changed({ gamma: { urgency: 400 } }));+  assert.deepEqual(h.rows(), ['gamma', 'beta', 'alpha']);+  assert.ok(['alpha', 'beta', 'gamma'].every((n, i) => h.row(n) === before[i]), 'the same rows');+});++test('the Fetched cell keeps its refresh button while the clock and fetches change its age', async (t) => {+  // The 15 s clock is created by start(), so setInterval is mocked before setup(); setTimeout stays real for tick().+  t.mock.timers.enable({ apis: ['setInterval', 'Date'], now: Date.now() });+  const fresh = () => new Date().toISOString();+  const h = await setup({ snap: changed({}, { gamma: { fetched_at: fresh() } }) });+  const cell = () => h.row('gamma').cells[FETCHED]!;+  const button = cell().querySelector<HTMLButtonElement>('.row-refresh')!;+  assert.equal(cell().textContent, 'now');+  t.mock.timers.tick(15_000);+  assert.equal(cell().textContent, '15s', 'the clock aged the text');+  assert.ok(cell().querySelector('.row-refresh') === button, 'the same button after a clock render');+  h.emit('craft:snapshot', changed({}, { gamma: { fetched_at: fresh() } }, { fetching: 'o/gamma' }));+  assert.equal(cell().textContent, 'now');+  assert.ok(cell().querySelector('.row-refresh') === button, 'the same button after a fetch');+  assert.ok(button.classList.contains('spinning'));+  assert.ok(!cell().classList.contains('changed'), 'a new fetch time does not flash');+  h.emit('craft:snapshot', changed({}, { gamma: { fetched_at: fresh() } }));+  assert.ok(!button.classList.contains('spinning'), 'done');+  assert.equal(button.title, 'Refresh only gamma');+});++test('the row of the repo being fetched is marked', async () => {+  const h = await setup();+  h.emit('craft:snapshot', changed({}, {}, { fetching: 'o/alpha' }));+  assert.ok(h.row('alpha').classList.contains('fetching'));+  assert.ok(!h.row('beta').classList.contains('fetching'));+  h.emit('craft:snapshot', snapshot());+  assert.ok(!h.doc.querySelector('tr.fetching'));+});++test('rows do not move under the pointer', async () => {+  const h = await setup();+  hover(h, 'pointerenter');+  h.emit('craft:snapshot', changed({ gamma: { urgency: 400 } }));+  assert.deepEqual(h.rows(), ['beta', 'gamma', 'alpha'], 'the order waits');+  assert.equal(h.row('gamma').cells[URGENCY]!.textContent, '400', 'the values do not');+  hover(h, 'pointerleave');+  assert.deepEqual(h.rows(), ['gamma', 'beta', 'alpha']);+});++test('a hover ends when the window loses focus, and the held order applies', async () => {+  const h = await setup();+  hover(h, 'pointerenter');+  h.emit('craft:snapshot', changed({ gamma: { urgency: 400 } }));+  assert.deepEqual(h.rows(), ['beta', 'gamma', 'alpha'], 'the order waits');+  h.win.dispatchEvent(new h.win.Event('blur'));+  assert.deepEqual(h.rows(), ['gamma', 'beta', 'alpha']);+  h.emit('craft:snapshot', changed({ alpha: { urgency: 500 } }));+  assert.deepEqual(h.rows(), ['alpha', 'beta', 'gamma'], 'the next snapshot reorders at once');+  hover(h, 'pointerenter');+  h.emit('craft:snapshot', changed({ gamma: { urgency: 600 } }));+  assert.deepEqual(h.rows(), ['alpha', 'beta', 'gamma']);+  h.emit('craft:focus', true);+  assert.deepEqual(h.rows(), ['alpha', 'beta', 'gamma'], 'gaining focus changes nothing');+  h.emit('craft:focus', false);+  assert.deepEqual(h.rows(), ['gamma', 'beta', 'alpha'], 'the host’s focus event too');+  hover(h, 'pointerenter');+  h.emit('craft:snapshot', changed({ beta: { urgency: 700 } }));+  assert.deepEqual(h.rows(), ['gamma', 'beta', 'alpha']);+  Object.defineProperty(h.doc, 'hidden', { value: true, configurable: true });+  h.doc.dispatchEvent(new h.win.Event('visibilitychange') as unknown as Event);+  assert.deepEqual(h.rows(), ['beta', 'gamma', 'alpha'], 'hiding the window');+});++test('a header click reorders at once even while hovering', async () => {+  const h = await setup();+  hover(h, 'pointerenter');+  header(h, 'Repo').click();+  assert.deepEqual(h.rows(), ['alpha', 'beta', 'gamma']);+  dragRow(h, 'alpha', 2.5 * ROW_HEIGHT);+  assert.deepEqual(h.rows(), ['beta', 'gamma', 'alpha'], 'a drop too');+  assert.ok(!h.doc.querySelector('tr.dragging'), 'the dropped row is no longer marked as dragged');+  assert.deepEqual(stored(h, 'order'), ['o/beta', 'o/gamma', 'o/alpha']);+  h.emit('craft:snapshot', changed({ alpha: { urgency: 500 } }));+  assert.deepEqual(h.rows(), ['beta', 'gamma', 'alpha'], 'a snapshot keeps the manual order');+});++test('repos that leave the config lose their rows', async () => {+  const h = await setup();+  const beta = h.row('beta');+  h.emit('craft:snapshot', snapshot([repo('beta', {}), repo('delta', {})]));+  assert.deepEqual(h.rows().sort(), ['beta', 'delta']);+  assert.ok(h.row('beta') === beta, 'the same row');+  h.emit('craft:snapshot', snapshot([]));+  assert.match(h.doc.getElementById('tbody')!.textContent ?? '', /No repositories configured/);+  h.emit('craft:snapshot', snapshot());+  assert.deepEqual(h.rows(), ['beta', 'gamma', 'alpha']);+  assert.doesNotMatch(h.doc.getElementById('tbody')!.textContent ?? '', /No repositories configured/);+});++// setTimeout is mocked only after setup(), whose tick() needs the real one;+// setInterval (the 15 s clock) stays real.+const lit = (h: Harness) => [...h.doc.querySelectorAll('td.changed')];++test('a changed value flashes its cell, then the class goes', async (t) => {+  const h = await setup();+  t.mock.timers.enable({ apis: ['setTimeout'] });+  h.emit('craft:snapshot', changed({ alpha: { open_prs: 9, urgency: 100.2 } }));+  const prs = h.row('alpha').cells[PRS]!;+  assert.ok(lit(h).length === 1 && lit(h)[0] === prs, 'only the PRs cell: urgency still shows 100');+  t.mock.timers.tick(2499);+  assert.ok(prs.classList.contains('changed'));+  t.mock.timers.tick(1);+  assert.equal(lit(h).length, 0);+});++test('no flash on first render, sort click, window change, or a snapshot with identical data', async (t) => {+  const h = await setup();+  t.mock.timers.enable({ apis: ['setTimeout'] });+  assert.equal(lit(h).length, 0, 'first render');+  header(h, 'Repo').click();+  assert.equal(lit(h).length, 0, 'sort click');+  const picker = h.doc.querySelector<HTMLSelectElement>('.window-picker')!;+  picker.value = '7d';+  picker.dispatchEvent(new h.win.Event('change') as unknown as Event);+  assert.equal(lit(h).length, 0, 'window change');+  h.emit('craft:snapshot', snapshot());+  assert.equal(lit(h).length, 0, 'identical snapshot');+  h.doc.querySelector<HTMLElement>('[data-view="urgent"]')!.click();+  h.emit('craft:snapshot', changed({ alpha: { open_prs: 9 } }));+  h.doc.querySelector<HTMLElement>('[data-view="repos"]')!.click();+  assert.equal(lit(h).length, 0, 'a snapshot while the table is hidden');+  dragRow(h, 'beta', 2.5 * ROW_HEIGHT, { drop: false });+  h.emit('craft:snapshot', changed({ alpha: { open_prs: 10 } }));+  pointer(h, h.win as unknown as EventTarget, 'pointerup', 2.5 * ROW_HEIGHT);+  assert.equal(h.row('alpha').cells[PRS]!.textContent, '10');+  assert.equal(lit(h).length, 0, 'a snapshot mid-drag, then the drop');+});++test('the first data for a repo does not flash', async (t) => {+  const h = await setup({ snap: snapshot([repo('alpha', null, { fetched_at: null }), repo('beta', {})]) });+  t.mock.timers.enable({ apis: ['setTimeout'] });+  h.emit('craft:snapshot', snapshot([repo('alpha', { open_prs: 4 }), repo('beta', {}), repo('delta', { open_prs: 5 })]));+  assert.equal(h.row('alpha').cells[PRS]!.textContent, '4');+  assert.equal(lit(h).length, 0, 'a repo fetched for the first time, and a repo new to the config');+});++test('a second change restarts the flash', async (t) => {+  const h = await setup();+  t.mock.timers.enable({ apis: ['setTimeout'] });+  h.emit('craft:snapshot', changed({ alpha: { open_prs: 9 } }));+  const prs = h.row('alpha').cells[PRS]!;+  t.mock.timers.tick(2000);+  h.emit('craft:snapshot', changed({ alpha: { open_prs: 10 } }));+  t.mock.timers.tick(2000);+  assert.ok(prs.classList.contains('changed'), 'the first flash’s timer was cleared');+  t.mock.timers.tick(500);+  assert.ok(!prs.classList.contains('changed'));+});++test('a highlight continues from its timer when its row moves or the table is shown again', async (t) => {+  const h = await setup();+  t.mock.timers.enable({ apis: ['setTimeout', 'Date'], now: Date.now() });+  h.emit('craft:snapshot', changed({ alpha: { open_prs: 9 }, beta: { open_prs: 9 } }));+  const alphaPrs = h.row('alpha').cells[PRS]!;+  const betaPrs = h.row('beta').cells[PRS]!;+  t.mock.timers.tick(1000);+  h.emit('craft:snapshot', changed({ alpha: { open_prs: 9, urgency: 400 }, beta: { open_prs: 9 } }));+  assert.deepEqual(h.rows(), ['alpha', 'beta', 'gamma'], 'alpha moved; beta and gamma did not');+  assert.equal(alphaPrs.style.animationDelay, '-1000ms');+  assert.equal(betaPrs.style.animationDelay, '', 'a row that did not move keeps its running animation');+  assert.equal(h.row('alpha').cells[URGENCY]!.style.animationDelay, '', 'the urgency cell started its highlight now');+  h.doc.querySelector<HTMLElement>('[data-view="urgent"]')!.click();+  t.mock.timers.tick(500);+  h.doc.querySelector<HTMLElement>('[data-view="repos"]')!.click();+  assert.equal(betaPrs.style.animationDelay, '-1500ms');+  assert.equal(h.row('alpha').cells[URGENCY]!.style.animationDelay, '-500ms');+  t.mock.timers.tick(1000);+  assert.ok(!alphaPrs.classList.contains('changed'));+  assert.equal(alphaPrs.style.animationDelay, '', 'the timer clears the delay with the class');+});++test('a snapshot does not rebuild the urgent repo picker', async () => {+  const h = await setup();+  h.doc.querySelector<HTMLElement>('[data-view="urgent"]')!.click();+  const options = () => [...h.doc.querySelectorAll('#urgent-repo option')];+  const before = options();+  h.emit('craft:snapshot', changed({ alpha: { open_prs: 9 } }));+  assert.ok(options().length === 4 && options().every((o, i) => o === before[i]), 'the same option elements');+  h.emit('craft:snapshot', snapshot([repo('alpha', {}), repo('delta', {})]));+  assert.deepEqual(options().map((o) => o.textContent), ['All repositories', 'alpha', 'delta']);+});+ // ───────────────────────────── status ─────────────────────────────  test('status pill, failures banner and budget', async () => {diff --git a/ui/src/app.ts b/ui/src/app.ts--- a/ui/src/app.ts+++ b/ui/src/app.ts@@ -3,9 +3,9 @@  import {   COLUMNS, RECENT, WINDOWS, MANUAL, windowById, value, orderedEntries, nextSort, dropIndex,-  freshness, ago, totals, urgentAcross, tokenStatus, isRefreshing,+  freshness, ago, totals, urgentAcross, tokenStatus, isRefreshing, formatNum, displayValue, changedCells, } from './model.ts';-import type { Column, Meta, RepoEntry, Snapshot, Sort } from './model.ts';+import type { Column, Meta, RepoEntry, RepoStats, Snapshot, Sort, Value } from './model.ts';  /** What the UI needs from the host: Tauri commands and events, window chrome. */ export interface Api {@@ -16,6 +16,9 @@ export interface Api {  type View = 'repos' | 'urgent'; +/** What triggered a render: a snapshot from the host, the clock, or the user. */+type Reason = 'data' | 'clock' | 'user';+ interface State {   snap: Snapshot | null;   meta: Meta;@@ -61,11 +64,51 @@ function el<K extends keyof HTMLElementTagNameMap>(tag: K, props: Props = {}, .. const localTime = (iso: string | null | undefined): string =>   (iso ? new Date(iso).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' }) : ''); const clock = (t: number): string => new Date(t).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });-const num = (n: number | string | null | undefined): string => Number(n ?? 0).toLocaleString('en-US');+const num = formatNum; const gh = (repo: string, path = ''): string => `https://github.com/${repo}${path}`; const shortName = (repo: string): string => repo.split('/').pop() ?? repo; const classes = (...cs: (string | false | null | undefined)[]): string => cs.filter(Boolean).join(' '); const DAY = 86400e3;+const shortSha = (sha: string | null) => sha?.slice(0, 7);+const isElement = (n: Node): n is Element => n.nodeType === n.ELEMENT_NODE;++function setTitle(n: Element, title: string | null): void {+  if (title === null) n.removeAttribute('title'); else n.setAttribute('title', title);+}++/** Puts `node` in `td` unless `td` already holds an equal node. When the two+ * differ only in the outer element's `title`, the node in `td` stays and+ * takes the new title. */+function setCell(td: HTMLTableCellElement, node: Node): void {+  const old = td.childNodes.length === 1 ? td.firstChild! : null;+  if (old && isElement(old) && isElement(node)) {+    const title = node.getAttribute('title');+    setTitle(node, old.getAttribute('title'));+    if (old.isEqualNode(node)) { setTitle(old, title); return; }+    setTitle(node, title);+  } else if (old?.isEqualNode(node)) return;+  td.replaceChildren(node);+}++/** The Repo cell's tooltip: the last attempt's error, no access, or when the data was fetched. */+function repoTip(entry: RepoEntry): string {+  if (entry.error) return `Last attempt failed: ${entry.error}`;+  if (entry.inaccessible) return 'Not accessible (private, or not found)';+  return entry.fetched_at ? `Fetched ${localTime(entry.fetched_at)}` : 'Not fetched yet';+}++/** A date column's cell; `oldest_open_pr_at` is marked `old` past 14 days. */+function dateCell(key: string, v: Value, text: string, now: number): HTMLElement {+  if (!v) return el('span', { class: 'muted' }, text);+  const old = key === 'oldest_open_pr_at' && now - Date.parse(String(v)) > 14 * DAY;+  return el('span', { title: localTime(String(v)), class: old ? 'old' : '' }, text);+}++/** A footer cell's text: the repo count under the first column, totals under count columns. */+function footText(c: Column, i: number, repos: number, t: Record<string, number>): string {+  if (i === 0) return `${repos} repos`;+  return c.kind === 'num' ? num(Math.round(t[c.key] ?? 0)) : '';+}  /** The toolbar's refresh arrow, as an element (SVG needs its namespace). */ function refreshIcon(): SVGSVGElement {@@ -98,7 +141,7 @@ export function start(api: Api): () => void {   }    const link = (text: string, url: string, cls = ''): HTMLElement =>-    el('span', { class: `link ${cls}`, onclick: (e: Event) => openUrl(url, e) }, text);+    el('span', { class: `link ${cls}`, dataset: { url }, onclick: (e: Event) => openUrl(url, e) }, text);    /** The repo's app icon; repos without one (libraries) get a lettered tile, unfetched ones a blank. */   function appIcon(entry: Pick<RepoEntry, 'repo'> & Partial<RepoEntry>): HTMLElement {@@ -107,86 +150,105 @@ export function start(api: Api): () => void {     return el('span', { class: 'app-icon none', 'aria-hidden': 'true' }, shortName(entry.repo).charAt(0).toUpperCase());   } -  /** Cell content for a column. */-  function cell(entry: RepoEntry, col: Column): Node {+  /** Cell content for a column; its text is `displayValue`'s, which the highlight compares. */+  function cell(entry: RepoEntry, col: Column, now: number): Node {     const v = value(entry, col.key, state.window);+    const text = displayValue(entry, col.key, state.window, now);     const s = entry.stats;     if (col.key === 'name') {-      const tip = entry.error-        ? `Last attempt failed: ${entry.error}`-        : entry.inaccessible ? 'Not accessible (private, or not found)'-        : entry.fetched_at ? `Fetched ${localTime(entry.fetched_at)}` : 'Not fetched yet';-      return el('span', { class: 'repo', title: tip }, el('span', { class: 'dot' }), appIcon(entry), link(String(v), gh(entry.repo)));-    }-    if (col.key === 'fetched') {-      const age = v ? el('span', { title: `Fetched ${localTime(String(v))}` }, ago(String(v))) : el('span', { class: 'muted', title: 'Not fetched yet' }, '—');-      return el('span', { class: 'fetched' }, age, rowRefresh(entry));+      return el('span', { class: 'repo', title: repoTip(entry) }, el('span', { class: 'dot' }), appIcon(entry), link(text, gh(entry.repo)));     }-    if (!s) return entry.inaccessible ? document.createTextNode('') : el('span', { class: 'muted' }, '—');+    if (!s) return entry.inaccessible ? document.createTextNode(text) : el('span', { class: 'muted' }, text);     switch (col.key) {       case 'urgency': {         const a = Math.min(1, Number(v) / 350) * 0.45;-        return el('span', { class: 'urgency', style: `background: rgb(var(--hot-rgb) / ${a.toFixed(2)})` }, String(Math.round(Number(v))));+        return el('span', { class: 'urgency', style: `background: rgb(var(--hot-rgb) / ${a.toFixed(2)})` }, text);       }-      case 'critical_issues': return el('span', { class: v ? 'crit' : 'zero' }, num(v));-      case 'open_prs': return link(num(v), gh(entry.repo, '/pulls'));-      case 'open_issues': return link(num(v), gh(entry.repo, '/issues'));+      case 'critical_issues': return el('span', { class: v ? 'crit' : 'zero' }, text);+      case 'open_prs': return link(text, gh(entry.repo, '/pulls'));+      case 'open_issues': return link(text, gh(entry.repo, '/issues'));       case 'recent_commits': case 'recent_prs': case 'recent_merged': case 'recent_issues': case 'recent_people':-        if (v === null) {-          const why = state.window === 'release' && !s.latest_build ? 'No release yet' : 'Not fetched yet for this window';-          return el('span', { class: 'muted', title: why }, '—');-        }-        return el('span', {-          class: v ? 'active' : 'zero',-          title: state.window === 'release' ? `Since ${s.latest_build}, published ${localTime(s.latest_build_at)} (${ago(s.latest_build_at)} ago)` : null,-        }, num(v));-      case 'commits_since_build': {-        const lag = s.since_build;-        if (!lag) return el('span', { class: 'muted' }, '—');-        const short = (sha: string | null) => sha?.slice(0, 7);-        const tip = {-          exact: `${num(v)} commits on main since ${s.latest_build} (${short(lag.from_sha)})`,-          branched: `${s.latest_build}’s commit isn’t on main: ~${num(v)} commits on main since the two split`,-          by_date: `${s.latest_build}’s commit shares no history with main: ~${num(v)} commits on main are newer than it`-            + (lag.from_sha ? `, counted from ${short(lag.from_sha)}` : ''),-        }[lag.basis];-        const from = lag.basis === 'by_date' ? lag.from_sha : s.latest_build;-        const text = `${lag.basis === 'exact' ? '' : '~'}${num(v)}`;-        return el('span', { title: tip }, from ? link(text, gh(entry.repo, `/compare/${encodeURIComponent(from)}...HEAD`), v ? '' : 'zero') : text);-      }-      case 'latest_build':-        if (!v) return el('span', { class: 'muted' }, '—');-        return el('span', {}, link(String(v), gh(entry.repo, `/releases/tag/${encodeURIComponent(v)}`)),-          s.latest_build_prerelease ? el('span', { class: 'pre' }, 'PRE') : null);+        return recentCell(s, v, text, now);+      case 'commits_since_build': return sinceBuildCell(entry.repo, s, v, text);+      case 'latest_build': return latestBuildCell(entry.repo, s, v, text);     }-    if (col.kind === 'date') {-      if (!v) return el('span', { class: 'muted' }, '—');-      const old = col.key === 'oldest_open_pr_at' && Date.now() - Date.parse(String(v)) > 14 * DAY;-      return el('span', { title: localTime(String(v)), class: old ? 'old' : '' }, ago(String(v)));+    if (col.kind === 'date') return dateCell(col.key, v, text, now);+    return document.createTextNode(text);+  }++  /** A recent-activity cell; in the release window its tooltip names the release the count starts at. */+  function recentCell(s: RepoStats, v: Value, text: string, now: number): HTMLElement {+    if (v === null) {+      const why = state.window === 'release' && !s.latest_build ? 'No release yet' : 'Not fetched yet for this window';+      return el('span', { class: 'muted', title: why }, text);     }-    return document.createTextNode(num(v));+    let tip: string | null = null;+    if (state.window === 'release') tip = `Since ${s.latest_build}, published ${localTime(s.latest_build_at)} (${ago(s.latest_build_at, now)} ago)`;+    return el('span', { class: v ? 'active' : 'zero', title: tip }, text);+  }++  /** The commits-since-release cell: the count links to the compare view, and the tooltip says how it was counted. */+  function sinceBuildCell(repo: string, s: RepoStats, v: Value, text: string): HTMLElement {+    const lag = s.since_build;+    if (!lag) return el('span', { class: 'muted' }, text);+    const counted = lag.from_sha ? `, counted from ${shortSha(lag.from_sha)}` : '';+    const tip = {+      exact: `${num(v)} commits on main since ${s.latest_build} (${shortSha(lag.from_sha)})`,+      branched: `${s.latest_build}’s commit isn’t on main: ~${num(v)} commits on main since the two split`,+      by_date: `${s.latest_build}’s commit shares no history with main: ~${num(v)} commits on main are newer than it${counted}`,+    }[lag.basis];+    const from = lag.basis === 'by_date' ? lag.from_sha : s.latest_build;+    if (!from) return el('span', { title: tip }, text);+    return el('span', { title: tip }, link(text, gh(repo, `/compare/${encodeURIComponent(from)}...HEAD`), v ? '' : 'zero'));+  }++  /** The latest release cell: the tag is a link, and a pre-release adds a PRE badge after it, matching `text`. */+  function latestBuildCell(repo: string, s: RepoStats, v: Value, text: string): HTMLElement {+    if (!v) return el('span', { class: 'muted' }, text);+    return el('span', {}, link(String(v), gh(repo, `/releases/tag/${encodeURIComponent(v)}`)),+      s.latest_build_prerelease ? el('span', { class: 'pre' }, 'PRE') : null);   } -  /** The tiny button that refetches just this row. */-  function rowRefresh(entry: RepoEntry): HTMLButtonElement {+  /** The Fetched cell: the data's age and the row's refresh button. The cell+   * is built once per row. Later renders change only the age text, its+   * tooltip and `muted` class, and the button's spin and tooltip. */+  function updateFetched(td: HTMLTableCellElement, entry: RepoEntry, now: number): void {+    let box = td.firstElementChild;+    if (!box?.classList.contains('fetched')) {+      box = el('span', { class: 'fetched' }, el('span'), rowRefresh(entry.repo));+      td.replaceChildren(box);+    }+    const age = box.firstElementChild as HTMLElement;+    const text = displayValue(entry, 'fetched', state.window, now);+    if (age.textContent !== text) age.textContent = text;+    age.title = entry.fetched_at ? `Fetched ${localTime(entry.fetched_at)}` : 'Not fetched yet';+    age.classList.toggle('muted', !entry.fetched_at);+    const button = box.lastElementChild as HTMLButtonElement;     const busy = state.snap ? isRefreshing(state.snap, entry.repo) : false;+    button.classList.toggle('spinning', busy);+    button.title = busy ? 'Refreshing this repo…' : `Refresh only ${shortName(entry.repo)}`;+  }++  /** The tiny button that refetches just this row; `updateFetched` sets its spin and tooltip. */+  function rowRefresh(repo: string): HTMLButtonElement {     return el('button', {-      class: classes('row-refresh', busy && 'spinning'),-      title: busy ? 'Refreshing this repo…' : `Refresh only ${shortName(entry.repo)}`,-      'aria-label': `Refresh ${shortName(entry.repo)}`,+      class: 'row-refresh',+      'aria-label': `Refresh ${shortName(repo)}`,       onclick: (e: Event) => {         e.stopPropagation();         (e.currentTarget as HTMLElement).classList.add('spinning');-        api.invoke('refresh_repo', { repo: entry.repo }).catch((err: unknown) => console.warn(err));+        api.invoke('refresh_repo', { repo }).catch((err: unknown) => console.warn(err));       },     }, refreshIcon());   }    // ───────────────────────────── rendering ───────────────────────────── -  /** The group row, built once: rebuilding it would destroy the window-   * dropdown, closing it mid-choice whenever a poll or the clock re-renders. */+  /** The header rows, built once and updated in place: rebuilding them would+   * destroy the window dropdown, closing it mid-choice whenever a poll or the+   * clock re-renders. */   let groupRow: HTMLTableRowElement | null = null;+  let colsRow: HTMLTableRowElement | null = null;    function renderHead(): void {     if (!groupRow) {@@ -197,26 +259,25 @@ export function start(api: Api): () => void {         prev = c.group;         row.append(el('th', { class: c.group ? 'group-start' : '' }, c.group === RECENT ? windowPicker() : c.group));       }-      $('thead').replaceChildren(row);+      colsRow = el('tr', { class: 'cols' }, COLUMNS.map((c) => el('th', {+        onclick: () => {+          state.sort = nextSort(state.sort, c.key);+          save('sort', state.sort);+          render();+        },+      }, c.label)));+      $('thead').replaceChildren(row, colsRow);       groupRow = row;     }     const picker = groupRow.querySelector<HTMLSelectElement>('.window-picker')!;     if (picker.value !== state.window) picker.value = state.window;-    const cols = el('tr', { class: 'cols' });+    const cols = colsRow!;     COLUMNS.forEach((c, i) => {       const sorted = state.sort.key === c.key;-      cols.append(el('th', {-        title: c.title?.replace('{span}', windowById(state.window).span) ?? `Sort by ${c.label}`,-        class: classes(sorted && 'sorted', sorted && state.sort.dir === 'asc' && 'asc', groupStart(i)),-        onclick: () => {-          state.sort = nextSort(state.sort, c.key);-          save('sort', state.sort);-          render();-        },-      }, c.label));+      const th = cols.cells[i]!;+      th.title = c.title?.replace('{span}', windowById(state.window).span) ?? `Sort by ${c.label}`;+      th.className = classes(sorted && 'sorted', sorted && state.sort.dir === 'asc' && 'asc', groupStart(i));     });-    groupRow.nextSibling?.remove();-    $('thead').append(cols);   }    /** The dropdown that heads the recent-activity columns. */@@ -233,30 +294,129 @@ export function start(api: Api): () => void {     return i > 0 && COLUMNS[i - 1]!.group !== COLUMNS[i]!.group ? 'group-start' : '';   } -  function renderTable(snap: Snapshot): void {+  // Rows are kept per repo, and their cells are updated in place. While the+  // pointer is over the rows, a snapshot or the clock leaves their order+  // alone, and the order is applied when the pointer leaves. Sorting, window+  // changes and drops apply it at once.++  const rows = new Map<string, HTMLTableRowElement>();+  let emptyRow: HTMLTableRowElement | null = null;+  let footRow: HTMLTableRowElement | null = null;+  let hovering = false;+  let pendingOrder = false;++  function newRow(repo: string): HTMLTableRowElement {+    const tr: HTMLTableRowElement = el('tr', {+      dataset: { repo },+      title: 'Click for this repo’s most urgent issues · drag to reorder',+      onpointerdown: startRowDrag,+      onclick: () => {+        if (justDragged) return;+        state.urgentRepo = tr.dataset.repo ?? '';+        setView('urgent');+      },+    }, COLUMNS.map((_, i) => el('td', { class: groupStart(i) })));+    return tr;+  }++  function updateRow(tr: HTMLTableRowElement, entry: RepoEntry, snap: Snapshot, now: number): void {+    const stale = !entry.fetched_at || now - Date.parse(entry.fetched_at) > snap.stale_after_secs * 1000;+    tr.className = classes(stale && 'stale', entry.error && 'error', snap.fetching === entry.repo && 'fetching');+    COLUMNS.forEach((c, i) => {+      if (c.key === 'fetched') updateFetched(tr.cells[i]!, entry, now);+      else setCell(tr.cells[i]!, cell(entry, c, now));+    });+  }++  /** How long a changed cell stays highlighted. It matches `td.changed`'s animation in style.css. */+  const FLASH_MS = 2500;+  const flashTimers = new WeakMap<HTMLTableCellElement, ReturnType<typeof setTimeout>>();+  const flashStarts = new WeakMap<HTMLTableCellElement, number>();++  /** Highlights a cell for FLASH_MS, restarting the highlight if it is already on. */+  function flash(td: HTMLTableCellElement): void {+    clearTimeout(flashTimers.get(td));+    td.classList.remove('changed');+    td.style.animationDelay = '';+    td.getBoundingClientRect(); // Forcing a reflow makes the animation start again.+    td.classList.add('changed');+    flashStarts.set(td, Date.now());+    flashTimers.set(td, setTimeout(() => {+      td.classList.remove('changed');+      td.style.animationDelay = '';+      flashTimers.delete(td);+    }, FLASH_MS));+  }++  /** The browser starts a cell's animation over when its row moves or the+   * table is shown again. A negative delay continues each highlight in+   * `cells` from where its timer is. Call it only for cells whose animation+   * restarted: on a running animation the delay moves it forward. */+  function resumeFlashes(cells: Iterable<HTMLTableCellElement>): void {+    const now = Date.now();+    for (const td of cells) {+      const start = flashStarts.get(td);+      if (start !== undefined && td.classList.contains('changed')) td.style.animationDelay = `${start - now}ms`;+    }+  }++  /** Highlights the cells whose shown text differs between `prev`'s entries and the snapshot just drawn. */+  function flashChanges(prev: ReadonlyMap<string, RepoEntry>, snap: Snapshot): void {+    // A hidden table shows nothing, and mid-drag render() leaves the cells as they were.+    if (state.view !== 'repos' || drag?.moved) return;+    const now = Date.now();+    for (const entry of snap.repos) {+      const tr = rows.get(entry.repo);+      if (!tr) continue;+      for (const key of changedCells(prev.get(entry.repo), entry, state.window, now)) {+        flash(tr.cells[COLUMNS.findIndex((c) => c.key === key)]!);+      }+    }+  }++  /** Puts `want` in order at the top of `tbody`, moving only rows that are out of place, and removes the rest. */+  function placeRows(tbody: HTMLElement, want: readonly HTMLTableRowElement[]): void {+    let at = tbody.firstElementChild;+    for (const tr of want) {+      if (tr === at) { at = at.nextElementSibling; continue; }+      if (at) at.before(tr);+      else tbody.append(tr);+      resumeFlashes(tr.cells);+    }+    while (at) { const next = at.nextElementSibling; at.remove(); at = next; }+  }++  function renderTable(snap: Snapshot, reason: Reason): void {     renderHead();-    const rows = orderedEntries(snap.repos, state.sort, state.order, state.window);+    const tbody = $('tbody');+    const entries = orderedEntries(snap.repos, state.sort, state.order, state.window);     const now = Date.now();-    $('tbody').replaceChildren(...rows.map((e) => {-      const stale = !e.fetched_at || now - Date.parse(e.fetched_at) > snap.stale_after_secs * 1000;-      return el('tr', {-        class: classes(stale && 'stale', e.error && 'error'),-        dataset: { repo: e.repo },-        title: 'Click for this repo’s most urgent issues · drag to reorder',-        onpointerdown: startRowDrag,-        onclick: () => {-          if (justDragged) return;-          state.urgentRepo = e.repo;-          setView('urgent');-        },-      }, COLUMNS.map((c, i) => el('td', { class: groupStart(i) }, cell(e, c))));-    }));-    if (!rows.length) {-      $('tbody').replaceChildren(el('tr', {}, el('td', { colspan: COLUMNS.length, class: 'empty' }, 'No repositories configured — Edit config.')));+    const configured = new Set(entries.map((e) => e.repo));+    for (const [repo, tr] of rows) if (!configured.has(repo)) { tr.remove(); rows.delete(repo); }+    const want = entries.map((e) => {+      let tr = rows.get(e.repo);+      if (!tr) { tr = newRow(e.repo); rows.set(e.repo, tr); }+      updateRow(tr, e, snap, now);+      return tr;+    });+    if (!want.length) {+      emptyRow ??= el('tr', {}, el('td', { colspan: COLUMNS.length, class: 'empty' }, 'No repositories configured — Edit config.'));+      tbody.replaceChildren(emptyRow);+      pendingOrder = false;+    } else if (hovering && reason !== 'user') {+      emptyRow?.remove();+      for (const tr of want) if (tr.parentNode !== tbody) tbody.append(tr);+      pendingOrder = want.some((tr, i) => tbody.children[i] !== tr);+    } else {+      placeRows(tbody, want);+      pendingOrder = false;     }     const t = totals(snap.repos, state.window);-    $('tfoot').replaceChildren(el('tr', {}, COLUMNS.map((c, i) =>-      el('td', { class: groupStart(i) }, i === 0 ? `${snap.repos.length} repos` : c.kind === 'num' ? num(Math.round(t[c.key] ?? 0)) : ''))));+    const foot = footRow ??= el('tr', {}, COLUMNS.map((_, i) => el('td', { class: groupStart(i) })));+    if (foot.parentNode !== $('tfoot')) $('tfoot').replaceChildren(foot);+    COLUMNS.forEach((c, i) => {+      foot.cells[i]!.textContent = footText(c, i, snap.repos.length, t);+    });   }    // ───────────────────────────── row dragging ─────────────────────────────@@ -294,7 +454,7 @@ export function start(api: Api): () => void {     const others = [...tbody.children].filter((r) => r !== drag!.row);     const mids = others.map((r) => { const b = r.getBoundingClientRect(); return b.top + b.height / 2; });     const next = others[dropIndex(mids, e.clientY)] ?? null;-    if (drag.row.nextSibling !== next) tbody.insertBefore(drag.row, next);+    if (drag.row.nextSibling !== next) { tbody.insertBefore(drag.row, next); resumeFlashes(drag.row.cells); }   }    /** Ends a press; true if it had become a drag. */@@ -324,11 +484,18 @@ export function start(api: Api): () => void {     if (finishRowDrag()) render();   } +  /** The repos the urgent view's picker lists. The picker is rebuilt only when they change. */+  let pickerRepos: string | null = null;+   function renderUrgent(snap: Snapshot): void {     const sel = $<HTMLSelectElement>('urgent-repo');-    sel.replaceChildren(el('option', { value: '' }, 'All repositories'),-      ...snap.repos.map((r) => el('option', { value: r.repo }, shortName(r.repo))));-    sel.value = state.urgentRepo;+    const repos = snap.repos.map((r) => r.repo).join('\n');+    if (repos !== pickerRepos) {+      sel.replaceChildren(el('option', { value: '' }, 'All repositories'),+        ...snap.repos.map((r) => el('option', { value: r.repo }, shortName(r.repo))));+      pickerRepos = repos;+    }+    if (sel.value !== state.urgentRepo) sel.value = state.urgentRepo;     $<HTMLInputElement>('critical-only').checked = state.criticalOnly;     const items = urgentAcross(snap.repos, { repo: state.urgentRepo, criticalOnly: state.criticalOnly });     const SEVERE = /crash|panic|freeze|hang|data loss|corruption|won't|blank|security/;@@ -390,20 +557,25 @@ export function start(api: Api): () => void {     $('repo-count').textContent = String(snap.repos.length);   } -  function render(): void {-    // Rebuilding the table mid-drag would drop the row out of the user's hand;+  function render(reason: Reason = 'user'): void {+    // Reordering the table mid-drag would move rows under the user's hand;     // the drop renders anyway.     const snap = state.snap;     if (!snap || drag?.moved) return;     renderStatus(snap);     document.querySelectorAll<HTMLElement>('.view').forEach((b) => b.setAttribute('aria-selected', String(b.dataset.view === state.view)));-    $('repos-view').hidden = state.view !== 'repos';+    const table = $('repos-view');+    const shown = table.hidden && state.view === 'repos';+    table.hidden = state.view !== 'repos';+    if (shown) resumeFlashes(table.querySelectorAll<HTMLTableCellElement>('td.changed'));     $('urgent-view').hidden = state.view !== 'urgent';-    if (state.view === 'repos') renderTable(snap); else renderUrgent(snap);+    if (state.view === 'repos') renderTable(snap, reason); else renderUrgent(snap);   }    function setView(v: View): void {     state.view = v;+    // A hidden table gets no pointerleave.+    if (v !== 'repos') hovering = false;     save('view', v);     render();   }@@ -442,16 +614,37 @@ export function start(api: Api): () => void {     else if (mod && e.key === '2') setView('urgent');   }); -  api.listen<Snapshot>('craft:snapshot', (s) => { state.snap = s; render(); });+  // pointerenter and pointerleave fire once for the whole tbody.+  $('tbody').addEventListener('pointerenter', () => { hovering = true; });+  /** Ends a hover and applies the order a snapshot held back. */+  function endHover(): void {+    hovering = false;+    if (pendingOrder) render();+  }+  $('tbody').addEventListener('pointerleave', endHover);+  // With overlay scrollbars, WebKit tracks the pointer only in the focused+  // window, so a pointer that leaves an unfocused window sends no pointerleave.+  window.addEventListener('blur', endHover);+  api.listen<boolean>('craft:focus', (focused) => { if (!focused) endHover(); });+  document.addEventListener('visibilitychange', () => { if (document.hidden) endHover(); });++  // Only a snapshot event highlights cells. The first render (from+  // get_snapshot), the clock, sorting, a window change and a drop do not.+  api.listen<Snapshot>('craft:snapshot', (s) => {+    const prev = new Map((state.snap?.repos ?? []).map((e) => [e.repo, e] as const));+    state.snap = s;+    render('data');+    flashChanges(prev, s);+  });   api.listen<Meta>('craft:meta', applyMeta);   // Relative times and staleness change with the clock alone.-  const clockTimer = setInterval(render, 15_000);+  const clockTimer = setInterval(() => render('clock'), 15_000);    (async () => {     try {       applyMeta(await api.invoke<Meta>('get_meta'));       state.snap = await api.invoke<Snapshot>('get_snapshot');-      render();+      render('data');     } catch (e) {       console.error(e);     } finally {diff --git a/ui/src/model.test.ts b/ui/src/model.test.ts--- a/ui/src/model.test.ts+++ b/ui/src/model.test.ts@@ -2,7 +2,7 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import {   sortEntries, manualOrder, orderedEntries, nextSort, dropIndex, MANUAL, versionKey, freshness, isStale, ago, compact, tokenStatus,-  totals, urgentAcross, isRefreshing, defaultDir, value, windowById, WINDOWS, COLUMNS, COLUMN,+  totals, urgentAcross, isRefreshing, defaultDir, value, windowById, WINDOWS, COLUMNS, COLUMN, displayValue, changedCells, } from './model.ts'; import type { Activity, RepoEntry, RepoStats, UrgentIssue } from './model.ts'; @@ -199,6 +199,62 @@ test('recent-activity columns follow the selected window', () => {   assert.deepEqual(WINDOWS.map((w) => w.id), ['10m', '30m', '1h', '4h', '12h', '1d', '7d', 'release', 'all']); }); +test('displayValue is the text each column shows', () => {+  const now = Date.parse('2026-10-08T12:00:00Z');+  const act: Activity = { commits: 1234, prs_opened: 0, prs_merged: 2, issues_opened: 0, people: 3 };+  const e = entry('app', {+    urgency: 100.4, critical_issues: 0, open_prs: 1234, open_issues: 2, last_commit_at: '2026-10-08T09:00:00Z', last_issue_at: null,+    recent: { '4h': act }, latest_build: 'v1.2.0', latest_build_prerelease: true, latest_build_downloads: 12345,+    since_build: { commits: 1500, basis: 'branched', from_sha: null },+  }, '2026-10-08T10:00:00Z');+  const shown = (key: string, x: RepoEntry = e, window = '4h') => displayValue(x, key, window, now);+  assert.equal(shown('name'), 'app');+  assert.equal(shown('fetched'), '2h');+  assert.equal(shown('urgency'), '100');+  assert.equal(shown('critical_issues'), '0');+  assert.equal(shown('open_prs'), '1,234');+  assert.equal(shown('last_commit_at'), '3h');+  assert.equal(shown('last_issue_at'), '—', 'a missing date');+  assert.equal(shown('recent_commits'), '1,234');+  assert.equal(shown('recent_commits', e, '7d'), '—', 'a window not fetched yet');+  assert.equal(shown('commits_since_build'), '~1,500', 'an estimated lag');+  assert.equal(shown('latest_build'), 'v1.2.0 PRE');+  assert.equal(shown('latest_build_downloads'), '12,345');+  const plain = entry('app', { latest_build: 'v1.2.0', latest_build_prerelease: false, since_build: { commits: 3, basis: 'exact', from_sha: 'x' } });+  assert.equal(shown('latest_build', plain), 'v1.2.0');+  assert.equal(shown('commits_since_build', plain), '3');+  assert.equal(shown('urgency', plain), '0', 'a stat the cache lacks');+  const bare = entry('app', { latest_build: null, since_build: null });+  assert.equal(shown('latest_build', bare), '—');+  assert.equal(shown('commits_since_build', bare), '—');+  assert.equal(shown('urgency', entry('app', null, null)), '—', 'no stats yet');+  assert.equal(shown('fetched', entry('app', null, null)), '—', 'never fetched');+  const hidden = { ...entry('app', null), inaccessible: true };+  assert.equal(shown('urgency', hidden), '', 'an inaccessible repo');+  assert.equal(shown('fetched', hidden), 'now', 'an inaccessible repo still shows when it was checked');+  assert.equal(shown('name', entry('app', null, null)), 'app');+});++test('changedCells lists the columns whose shown text changed', () => {+  const now = Date.parse('2026-10-08T12:00:00Z');+  const act = (commits: number): Activity => ({ commits, prs_opened: 0, prs_merged: 0, issues_opened: 0, people: 1 });+  const base: Partial<RepoStats> = { urgency: 100.4, open_prs: 1, last_commit_at: '2026-10-08T09:00:00Z', recent: { '4h': act(1), '1d': act(1) } };+  const prev = entry('app', base);+  const next = (s: Partial<RepoStats>) => entry('app', { ...base, ...s });+  assert.deepEqual(changedCells(prev, prev, '4h', now + 60 * 60_000), [], 'an hour later, with the same data');+  assert.deepEqual(changedCells(prev, next({ open_prs: 2 }), '4h', now), ['open_prs']);+  assert.deepEqual(changedCells(prev, next({ last_commit_at: '2026-10-08T11:59:00Z' }), '4h', now), ['last_commit_at']);+  assert.deepEqual(changedCells(prev, next({ urgency: 100.2 }), '4h', now), [], 'urgency rounds to the same 100');+  assert.deepEqual(changedCells(prev, entry('app', base, '2026-10-08T11:00:00Z'), '4h', now), [], 'a new fetch time alone');+  assert.deepEqual(changedCells(undefined, next({ open_prs: 2 }), '4h', now), [], 'a repo new to the table');+  assert.deepEqual(changedCells(entry('app', null, null), next({ open_prs: 2 }), '4h', now), [], 'the first data for a repo');+  const other = next({ recent: { '4h': act(1), '1d': act(5) } });+  assert.deepEqual(changedCells(prev, other, '4h', now), [], 'a change in another window');+  assert.deepEqual(changedCells(prev, other, '1d', now), ['recent_commits'], 'the selected window');+  const filled = next({ open_prs: 2, recent: { '4h': act(1), '1d': act(1), '7d': act(9) } });+  assert.deepEqual(changedCells(prev, filled, '7d', now), ['open_prs'], 'a window read for the first time');+});+ test('a row is refreshing while its repo is being fetched or queued', () => {   assert.equal(isRefreshing({ fetching: 'o/a', requested: ['o/b'] }, 'o/a'), true);   assert.equal(isRefreshing({ fetching: 'o/a', requested: ['o/b'] }, 'o/b'), true);diff --git a/ui/src/model.ts b/ui/src/model.ts--- a/ui/src/model.ts+++ b/ui/src/model.ts@@ -338,6 +338,58 @@ export function compact(n: number | null | undefined): string {   return `${(n / 1e6).toFixed(1)}M`; } +/** A count with thousands separators: 1,234. */+export const formatNum = (n: Value | undefined): string => Number(n ?? 0).toLocaleString('en-US');++/**+ * The text a table cell shows for a column, with times aged to `now`: counts+ * with separators, urgency rounded, `~` before an estimated lag, `PRE` after a+ * pre-release build, and '—' where there is no data.+ */+export function displayValue(entry: RepoEntry, key: string, window: string, now: number): string {+  const v = value(entry, key, window);+  const s = entry.stats;+  const col = COLUMN[key];+  if (key === 'name') return String(v);+  if (col?.kind === 'age') return v ? ago(String(v), now) : '—';+  if (!s) return entry.inaccessible ? '' : '—';+  switch (key) {+    case 'urgency': return String(Math.round(Number(v)));+    case 'commits_since_build': return lagText(s, v);+    case 'latest_build': return buildText(s, v);+  }+  if (col?.recent) return v === null ? '—' : formatNum(v);+  if (col?.kind === 'date') return v ? ago(String(v), now) : '—';+  return formatNum(v);+}++/** The commits-since-release count, with `~` when it is an estimate. */+function lagText(s: RepoStats, v: Value): string {+  if (!s.since_build) return '—';+  const estimate = s.since_build.basis === 'exact' ? '' : '~';+  return `${estimate}${formatNum(v)}`;+}++/** The latest release's tag, with ` PRE` after a pre-release. */+function buildText(s: RepoStats, v: Value): string {+  if (!v) return '—';+  return s.latest_build_prerelease ? `${v} PRE` : String(v);+}++/**+ * The keys of the columns whose shown text differs between two versions of a+ * repo's entry. Both are read at the same `now`, so a relative time that has+ * only aged compares equal. It returns an empty list when either version has+ * no stats. `age` columns (Fetched) are left out, and so are the+ * recent-activity columns when `prev` has no activity for `window`.+ */+export function changedCells(prev: RepoEntry | undefined, next: RepoEntry, window: string, now: number): string[] {+  if (!prev?.stats || !next.stats) return [];+  const unread = !prev.stats.recent?.[window];+  return COLUMNS.filter((c) => c.kind !== 'age' && !(c.recent && unread)+    && displayValue(prev, c.key, window, now) !== displayValue(next, c.key, window, now)).map((c) => c.key);+}+ /** Column totals for the footer row (counts only; dates and versions blank). */ export function totals(entries: readonly RepoEntry[], window: string = DEFAULT_WINDOW): Record<string, number> {   const out: Record<string, number> = {};diff --git a/ui/style.css b/ui/style.css--- a/ui/style.css+++ b/ui/style.css@@ -10,6 +10,7 @@   --warn: #b7741a;   --bad: #d23c3c;   --hot-rgb: 210 60 60;+  --flash-rgb: 47 111 224;   --lib: #7a4fd0;   --row-hover: rgba(47, 111, 224, .06);   --opacity: .96;@@ -26,6 +27,7 @@     --warn: #f4bc70;     --bad: #ff8f8f;     --hot-rgb: 255 110 110;+    --flash-rgb: 91 157 255;     --lib: #b59bff;     --row-hover: rgba(91, 157, 255, .07);   }@@ -104,6 +106,10 @@ body.reordering tbody tr:not(.dragging) td:first-child::before { content: none; .app-icon.pending { background: var(--surface); } tr.stale .repo .dot { background: var(--warn); } tr.error .repo .dot { background: var(--bad); }+tr.fetching .repo .dot { background: var(--accent); animation: pulse 1.2s ease-in-out infinite; }+td.changed { animation: cell-flash 2.5s ease-out; }+@keyframes cell-flash { from { box-shadow: inset 0 0 0 100vmax rgb(var(--flash-rgb) / .3); } to { box-shadow: inset 0 0 0 100vmax rgb(var(--flash-rgb) / 0); } }+@media (prefers-reduced-motion: reduce) { tr.fetching .repo .dot { animation: none; } td.changed { animation: none; box-shadow: inset 0 0 0 100vmax rgb(var(--flash-rgb) / .18); } } tr.stale td:not(:first-child) { opacity: .55; } .muted { color: var(--muted); } .fetched { display: inline-flex; align-items: center; gap: 3px; }