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
@@ -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 @@ -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 () => {@@ -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 {@@ -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);@@ -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> = {};@@ -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; }