clone

a personal forge on plain git: static pages, stacked-diff reviews, signed local ci, repo sites and releases

A repo's insights shows its activity grid as a chart, without the list #31

commits2oldest firstcombined diff 13 files, +102 -125
discussion0 comments

No comments yet: forge comment 31 adds one.

1/2 The demo's clock starts at midnight, so CI's render check holds all day

BSD date -j -f %Y-%m-%d fills the hour it isn't given from the current time, so the demo started on 2026-09-01 at whatever time it was made and, about 35 clock hours later, finished on 09-02 or, made after about 12:40 UTC, on 09-03. The render check in .forge/ci renders at 2026-09-02, so after midday the insights pages' activity grids ended a day early there and differed from 11ty's, on main too. make-demo.sh starts at 2026-09-01 00:00:00 UTC and the check renders at 2026-09-02 00:00:00, both spelled out for BSD and GNU date; the demo now ends on 09-02 around 11:00, whenever it is made.
2 files changed, +7 -3
M.forge/ci6++++--
Mdemo/make-demo.sh4+++-
M .forge/ci +4 -2
M demo/make-demo.sh +3 -1

2/2 A repo's insights shows its activity grid as a chart, without the list

The insights page had the activity grid and, under it, the latest events, with a picked day listing that day's. Now the grid is a chart like the others on the page: a figure panel whose head strip has its total and what it counts, and no list. ui.activity takes still, which makes each day a span named and titled with its count, with no tabindex, no aria-pressed and no hover or pointer in style.css; ui.ts repaints still grids from activity.json in the reader's days, and a click or a day's address picks nothing. The owner page's grid is as it was. activity.json now carries only each event's time, newest first, so repoActivity drops the kinds, texts, links and the title and subject subqueries, and lib/forge.ts drops recent. DESIGN.md and the style guide show the still grid. Scenario: the insights page has spans and no list, activity.json is days and times, and clicking a day or opening a day's address leaves the grid unpicked.
11 files changed, +95 -122
MDESIGN.md7+++
Mlib/activity.ts4+-
Mlib/forge.ts5+--
Mlib/insights.ts44+++++++++-------------
Msite/_includes/insights-body.njk18+++------
Msite/_includes/ui.njk8++-
Msite/assets/style.css14++++---
Msite/assets/ui.ts62+++++++++---------------------
Msite/design.njk3+
Mtest/page.ts17++------
Mtest/scenarios/activity-insights.sh35++++++++++-------
M DESIGN.md +7 -0
M lib/activity.ts +2 -2
M lib/forge.ts +1 -4
M lib/insights.ts +18 -26
@@ -20,6 +20,7 @@ import fs from 'node:fs';
2020 import path from 'node:path';2121 import { listRepos } from './lake-sources.ts';2222 import { lit, type Lake, type LakeConfig } from './lake.ts';23+import type { RepoActivityExport } from './activity.ts';2324 2425 export const FILES = ['insights.json', 'activity.json', 'events.parquet', 'builds.parquet', 'reviews.parquet', 'ci.parquet'];2526 // How far back the activity exports go: a year's grid, and some.
@@ -125,44 +126,35 @@ const since = (): string => {
125126 };126127 const day = (ms: number): string => new Date(ms).toISOString().slice(0, 10);127128 128-// A repo's activity, for the grid on its insights page (ui.activity): what129-// happened each day (pushes, landed reviews, releases, site deploys, CI130-// results) and the events themselves, newest first, each [time (ms), kind,131-// text, link from the repo's page]. Days are UTC.132-export async function repoActivity(lake: Lake, slug: string): Promise<{ days: Record<string, number>; events: [number, string, string, string][] }> {129+// A repo's activity, for the grid on its insights page (ui.activity): how130+// many things happened each day (pushes, landed reviews, releases, site131+// deploys, CI results), and each one's time (ms), newest first, so the132+// page can count them on the reader's days. Days are UTC.133+export async function repoActivity(lake: Lake, slug: string): Promise<RepoActivityExport> {133134 const where = `repo = ${lit(slug)}`;134- const events: [number, string, string, string][] = [];135- for (const [t, user, refs] of await lake.rows(`SELECT epoch_ms(t), "user", refs FROM lake.events WHERE type = 'push' AND ${where} AND t >= ${since()} AND refs IS NOT NULL`)) {136- const at = Number(t);137- const heads: string[] = [];135+ const events: number[] = [];136+ for (const [t, refs] of await lake.rows(`SELECT epoch_ms(t), refs FROM lake.events WHERE type = 'push' AND ${where} AND t >= ${since()} AND refs IS NOT NULL`)) {137+ let heads = false;138138 const named = new Set<string>();139139 for (const ref of String(refs).split(/\s+/).filter(Boolean)) {140140 const m = /^refs\/(heads|tags|releases|sites)\/(.+)$/.exec(ref);141141 if (!m) continue;142- if (m[1] === 'heads') heads.push(m[2]);143- else if (m[1] === 'sites') events.push([at, 'site', `deployed ${m[2]}`, 'sites/']);142+ if (m[1] === 'heads') heads = true;143+ else if (m[1] === 'sites') events.push(Number(t));144144 else if (!named.has(m[2])) {145145 // A tag and its release in one push are one release.146146 named.add(m[2]);147- events.push([at, 'release', m[2], `releases/#${encodeURIComponent(m[2])}`]);147+ events.push(Number(t));148148 }149149 }150- if (heads.length) events.push([at, 'pushed', `${user ?? 'someone'} pushed ${heads.join(', ')}`, '']);150+ // Its branches are one push.151+ if (heads) events.push(Number(t));151152 }152- for (const [t, id, title] of await lake.rows(`153- SELECT epoch_ms(e.ts), e.review, (SELECT any_value(title) FROM lake.reviews r WHERE r.repo = e.repo AND r.review = e.review AND kind = 'created')154- FROM lake.review_ends e WHERE e.kind = 'landed' AND e.${where} AND e.ts >= ${since()}`)) {155- events.push([Number(t), 'landed', `#${id}${title ? ' ' + title : ''}`, `reviews/${id}/`]);156- }157- for (const [t, result, sha, subject] of await lake.rows(`158- SELECT epoch_ms(c.started), c.result, c."commit", (SELECT any_value(subject) FROM lake.commits m WHERE m.repo = c.repo AND m.sha = c."commit")159- FROM lake.ci c WHERE c.${where} AND c.started >= ${since()} AND c."commit" IS NOT NULL`)) {160- const s = String(sha);161- events.push([Number(t), result === 'pass' ? 'passed' : 'failed', `${s.slice(0, 7)}${subject ? ' ' + subject : ''}`, `commit/${s}/`]);162- }163- events.sort((a, b) => b[0] - a[0]);153+ for (const [t] of await lake.rows(`SELECT epoch_ms(ts) FROM lake.review_ends WHERE kind = 'landed' AND ${where} AND ts >= ${since()}`)) events.push(Number(t));154+ for (const [t] of await lake.rows(`SELECT epoch_ms(started) FROM lake.ci WHERE ${where} AND started >= ${since()} AND "commit" IS NOT NULL`)) events.push(Number(t));155+ events.sort((a, b) => b - a);164156 const days: Record<string, number> = {};165- for (const e of events) days[day(e[0])] = (days[day(e[0])] ?? 0) + 1;157+ for (const t of events) days[day(t)] = (days[day(t)] ?? 0) + 1;166158 return { days, events };167159 }168160
M site/_includes/insights-body.njk +6 -12
@@ -20,6 +20,8 @@
2020 the plot, then its table folded. */2121 .insights .charts { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 440px), 1fr)); gap: var(--space-4); margin: 0 0 var(--space-4); }2222 .insights figure.panel { margin: 0; position: relative; }23+/* A repo's activity grid: a chart panel as wide as the page, over them. */24+.insights figure.activity-panel { margin-bottom: var(--space-4); }2325 .insights .plot { position: relative; margin: var(--space-3) var(--space-3) 0; }2426 .insights .plot:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }2527 .insights svg { display: block; width: 100%; overflow: visible; font: var(--fw-regular) var(--text-xs) var(--mono); font-variant-numeric: tabular-nums; }
@@ -54,19 +56,11 @@
5456 <p class="desc ins-meta" id="ins-meta">From the forge's lake (<code>forge lake</code>).<noscript> The charts need JavaScript; the numbers are in the files below.</noscript></p>5557 <div class="panel kpis" id="ins-kpis"></div>5658 {% if not forgeWide and p.activity %}57-{# The repo's activity grid (ui.activity): what happened each day; a day58- picked lists its events under it, in place of the latest ones. #}59+{# The repo's activity grid (ui.activity, still), a chart like the others:60+ how much happened each day, nothing to pick; its total and what it61+ counts in the head strip. #}5962 {% set a = p.activity %}60-{% set head %}<b class="act-total">{{ a.grid.total | thousands }} {{ 'event' if a.grid.total == 1 else 'events' }}</b><span>in the last year: pushes, landed reviews, releases, site deploys and ci results</span>{% endset %}61-{% call ui.panel(head, 'activity-panel') %}<div class="panel-body">{{ ui.activity(a.grid, 'events a day', 'data-src="activity.json" data-base="' + p.repo.urls.home.href + '"') }}</div>{% endcall %}62-<section class="panel act-list">63-<header class="panel-head list-head"><h3>latest</h3></header>64-<header class="panel-head day-head" hidden><h3 class="day-title"></h3><span class="day-note"></span><span class="end"><a href="#" data-day-reset>show all</a></span></header>65-<ul class="rows ev-rows">66-{% for e in a.recent %}<li class="ev"><span class="ev-kind">{{ e.kind }}</span>{% if e.href %}<a class="ev-what" href="{{ e.href }}">{{ e.text }}</a>{% else %}<span class="ev-what">{{ e.text }}</span>{% endif %}{{ e.at | date }}</li>67-{% endfor %}</ul>68-<p class="empty day-empty"{% if a.recent.length %} hidden{% endif %}>Nothing happened {{ 'that day' if a.recent.length else 'in the last year' }}.</p>69-</section>63+<figure class="panel activity-panel"><figcaption class="panel-head"><h3 class="act-total">{{ a.grid.total | thousands }} {{ 'event' if a.grid.total == 1 else 'events' }}</h3><span class="grow">in the last year: pushes, landed reviews, releases, site deploys and ci results</span></figcaption><div class="panel-body">{{ ui.activity(a.grid, 'events a day', 'data-src="activity.json"', true) }}</div></figure>7064 {% endif %}7165 <div class="charts">7266 <figure class="panel" data-chart="builds"><figcaption class="panel-head"><h3>build time</h3><span class="grow">seconds per build of {{ 'the pages' if forgeWide else "this repo's pages" }}</span></figcaption><div class="plot"></div><details class="table"><summary>table</summary></details></figure>
M site/_includes/ui.njk +5 -3
@@ -119,9 +119,11 @@
119119 #<shelf>/2026-10-08 on an owner's page with a shelf picked). Cells are120120 13px at most, left-aligned; narrower, they shrink to 10px, then the121121 oldest weeks go (style.css). attrs: data-src="activity.json", what ui.ts122- fetches for fresher counts (and a repo's events). #}123-{% macro activity(grid, label, attrs = '') -%}122+ fetches for fresher counts. still: a chart, with nothing to pick (a123+ repo's insights): each day a span, named and titled the same, and a124+ picture with JS too. #}125+{% macro activity(grid, label, attrs = '', still = false) -%}124126 <div class="activity-fit"><div class="activity" role="group" aria-label="{{ label }}" data-activity data-noun="{{ grid.noun }}"{% if attrs %} {{ attrs | safe }}{% endif %}>125-{%- for w in grid.weeks %}{% for c in w %}{% set what = c.day + ': ' + (c.n | plural(grid.noun)) %}<button type="button" class="l{{ c.level }} a{{ c.age }}" data-day="{{ c.day }}" data-n="{{ c.n }}" aria-pressed="false" tabindex="{{ '0' if c.day == grid.today else '-1' }}" aria-label="{{ what }}" title="{{ what }}"></button>{% endfor %}{% endfor -%}127+{%- for w in grid.weeks %}{% for c in w %}{% set what = c.day + ': ' + (c.n | plural(grid.noun)) %}{% if still %}<span class="l{{ c.level }} a{{ c.age }}" data-day="{{ c.day }}" data-n="{{ c.n }}" role="img" aria-label="{{ what }}" title="{{ what }}"></span>{% else %}<button type="button" class="l{{ c.level }} a{{ c.age }}" data-day="{{ c.day }}" data-n="{{ c.n }}" aria-pressed="false" tabindex="{{ '0' if c.day == grid.today else '-1' }}" aria-label="{{ what }}" title="{{ what }}"></button>{% endif %}{% endfor %}{% endfor -%}126128 </div></div>127129 {%- endmacro %}
M site/assets/style.css +8 -6
@@ -327,15 +327,17 @@ svg.avatar .av-y { fill: var(--avatar-yellow); }
327327 left in the panel; in a narrower box they shrink to fit all 53 weeks,328328 down to 10px, and below that the oldest weeks go (a<n>: n weeks before329329 this one), a week each 12px, so the latest always show. A picked day has330- an ink outline. */330+ an ink outline. A still grid's days (a repo's insights) are spans, the331+ same squares with nothing to hover or pick. */331332 .activity-fit { container: activity / inline-size; }332333 .activity { --cell: 13px; display: grid; grid-template-rows: repeat(7, var(--cell)); grid-auto-columns: var(--cell); grid-auto-flow: column; gap: var(--space-half); justify-content: start; }333334 @container activity (width < 793px) { .activity { --cell: max(10px, calc((100cqw + 2px) / 53 - 2px)); } }334-.activity > button { display: block; min-height: 0; width: 100%; height: 100%; padding: 0; border: 0; background: var(--heat-0); cursor: pointer; }335-.activity > button.l1 { background: var(--heat-1); }336-.activity > button.l2 { background: var(--heat-2); }337-.activity > button.l3 { background: var(--heat-3); }338-.activity > button.l4 { background: var(--heat-4); }335+.activity > :is(button, span) { display: block; min-height: 0; width: 100%; height: 100%; padding: 0; border: 0; background: var(--heat-0); }336+.activity > button { cursor: pointer; }337+.activity > .l1 { background: var(--heat-1); }338+.activity > .l2 { background: var(--heat-2); }339+.activity > .l3 { background: var(--heat-3); }340+.activity > .l4 { background: var(--heat-4); }339341 .activity > button:hover { outline: var(--hairline) solid var(--muted); outline-offset: 0; }340342 .activity > button[aria-pressed="true"] { outline: 2px solid var(--fg); outline-offset: 0; position: relative; z-index: 1; }341343 .activity > button:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; position: relative; z-index: 2; }
M site/assets/ui.ts +19 -43
@@ -174,8 +174,8 @@
174174 // #<shelf>/<day> with a shelf picked on an owner's page, beside the175175 // shelves' own #<shelf>. On an owner's page the counts are commits by176176 // repo (the page's JSON, then the fresher activity.json forge lake177- // writes beside it); on a repo's insights, events, whose list for a day178- // comes from its activity.json.177+ // writes beside it). A repo's insights has a still grid, a span a day178+ // and nothing to pick: its counts are events, from its activity.json.179179 const MONTHS = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec'];180180 const words = (d: string): string => `${MONTHS[Number(d.slice(5, 7)) - 1]} ${Number(d.slice(8))}, ${d.slice(0, 4)}`;181181 const plural = (n: number, w: string): string => `${n.toLocaleString('en-US')} ${w}${n === 1 ? '' : 's'}`;
@@ -186,12 +186,9 @@
186186 const t = new Date(ms);187187 return `${t.getFullYear()}-${two(t.getMonth() + 1)}-${two(t.getDate())}`;188188 };189- const esc = (v: unknown): string => String(v).replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);190189 type Days = Record<string, Record<string, number>>;191- type Ev = [number, string, string, string];192- let fresh: { repos?: Record<string, number[]>; events?: Ev[] } | null = null;190+ let fresh: { repos?: Record<string, number[]>; events?: number[] } | null = null;193191 let asked = false;194- let lost = false;195192 // An owner's commits by the reader's day, then repo: each repo's commit196193 // times come as minutes, the first since 1970 and each after it since the197194 // one before (lib/activity.ts).
@@ -216,9 +213,10 @@
216213 return decoded;217214 };218215 219- // Paint a grid from counts by day (or its own, without).216+ // Paint a grid from counts by day (or its own, without); a still grid's217+ // days have nothing to press.220218 const paint = (g: HTMLElement, counts: Record<string, number> | null): void => {221- const cells = [...g.querySelectorAll<HTMLElement>('button[data-day]')];219+ const cells = [...g.querySelectorAll<HTMLElement>('[data-day]')];222220 const ns = cells.map((c) => (counts ? counts[c.dataset.day!] ?? 0 : Number(c.dataset.n)));223221 const max = Math.max(0, ...ns);224222 const noun = g.dataset.noun ?? 'commit';
@@ -230,42 +228,24 @@
230228 c.setAttribute('class', (c.getAttribute('class') ?? '').replace(/\bl\d\b/, `l${level(ns[i], max)}`));231229 c.setAttribute('aria-label', what);232230 c.title = what;233- c.setAttribute('aria-pressed', String(c.dataset.day === pick.day));231+ if (c.hasAttribute('aria-pressed')) c.setAttribute('aria-pressed', String(c.dataset.day === pick.day));234232 });235233 const t = g.closest('.panel')?.querySelector('.act-total');236234 if (t) t.textContent = plural(total, noun);237235 };238- // A repo's insights: the latest events, or a picked day's.239- const evRow = (base: string, [t, kind, text, rel]: Ev): string => {240- const iso = new Date(t).toISOString().replace(/\.\d+Z$/, 'Z');241- const what = rel ? `<a class="ev-what" href="${esc(base + rel)}">${esc(text)}</a>` : `<span class="ev-what">${esc(text)}</span>`;242- return `<li class="ev"><span class="ev-kind">${esc(kind)}</span>${what}<time datetime="${iso}">${iso.slice(0, 10)}</time></li>`;243- };236+ // A repo's insights: its events' times, counted on the reader's days.237+ // Until they come (or with an export that has no times), the page's own.244238 const insights = (): void => {245239 const g = document.querySelector<HTMLElement>('.insights [data-activity]');246- const list = document.querySelector<HTMLElement>('.act-list');247- if (!g || !list) return;240+ if (!g) return;248241 const events = fresh?.events;249- if (events) {250- const counts: Record<string, number> = {};251- for (const e of events) {252- const d = localDay(e[0]);253- counts[d] = (counts[d] ?? 0) + 1;254- }255- paint(g, counts);256- } else paint(g, null);257- const head = list.querySelector<HTMLElement>('.day-head')!;258- const none = list.querySelector<HTMLElement>('.day-empty')!;259- list.querySelector<HTMLElement>('.list-head')!.hidden = !!pick.day;260- head.hidden = !pick.day;261- if (!pick.day && !events) return;262- const day = pick.day ? (events ?? []).filter((e) => localDay(e[0]) === pick.day) : events!.slice(0, 20);263- head.querySelector('.day-title')!.textContent = pick.day ? words(pick.day) : '';264- head.querySelector('.day-note')!.textContent = pick.day ? (events ? plural(day.length, 'event') : lost ? "couldn't load the day's events" : 'loading') : '';265- list.querySelector('ul')!.innerHTML = day.map((e) => evRow(g.dataset.base ?? '', e)).join('');266- none.hidden = !!day.length || (!!pick.day && !events);267- none.textContent = pick.day ? 'Nothing happened that day.' : 'Nothing happened in the last year.';268- times();242+ if (!Array.isArray(events) || !events.every(Number.isFinite)) return paint(g, null);243+ const counts: Record<string, number> = {};244+ for (const t of events) {245+ const d = localDay(t);246+ counts[d] = (counts[d] ?? 0) + 1;247+ }248+ paint(g, counts);269249 };270250 // The fresher export, once a page (and again after live.ts swaps one in).271251 const refresh = (): void => {
@@ -276,13 +256,9 @@
276256 .then((r) => (r.ok ? r.json() : null))277257 .then((d) => {278258 if (d && typeof d === 'object') fresh = d;279- else lost = true;280259 activity();281260 })282- .catch(() => {283- lost = true;284- activity();285- });261+ .catch(() => activity());286262 };287263 const activity = (): void => {288264 shelves();
@@ -356,7 +332,7 @@
356332 setInterval(times, 60000);357333 // live.ts swaps in rebuilt content.358334 document.addEventListener('forge:content', () => {359- [fresh, asked, lost] = [null, false, false];335+ [fresh, asked] = [null, false];360336 content();361337 });362338
M site/design.njk +3 -0
M test/page.ts +5 -12
@@ -12,10 +12,10 @@
1212 // the shelf panels that show, a line each: `<head strip>: <repo>1313 // <repo>...`, the repos as their rows show them (a panel without a head1414 // strip is named by the picked tab). activity prints the activity grid's15-// head strip, its days with something on them (`*` marks the picked one),16-// then the list under it: the shelves as above, with each repo's count for17-// a picked day, or a repo's events; and the address's fragment. fetch()18-// reads files beside the page.15+// head strip, its days with something on them (`*` marks the picked one;16+// a still grid's days, a repo's insights, are never picked), then the17+// shelves under it as above, with each repo's count for a picked day; and18+// the address's fragment. fetch() reads files beside the page.1919 import fs from 'node:fs';2020 import vm from 'node:vm';2121 import path from 'node:path';
@@ -281,7 +281,7 @@ if (mode === 'activity') {
281281 const g = doc.querySelector('[data-activity]');282282 if (!g) throw new Error('test/page.ts: no activity grid on the page');283283 console.log(headOf(g.closest('.panel')!.querySelector('.panel-head')!));284- for (const c of g.querySelectorAll('button[data-day]')) {284+ for (const c of g.querySelectorAll('[data-day]')) {285285 if (c.dataset.n !== '0' || c.getAttribute('aria-pressed') === 'true') console.log(`${c.getAttribute('aria-pressed') === 'true' ? '*' : ' '} ${c.getAttribute('aria-label')} l${(c.getAttribute('class') ?? '').match(/l(\d)/)?.[1]}`);286286 }287287 }
@@ -298,11 +298,4 @@ for (const sec of doc.querySelectorAll('section.shelf').filter(shown)) {
298298 console.log(`${name}: ${names.join(' ')}${none && shown(none) ? words(none.textContent) : ''}`);299299 }300300 if (doc.querySelector('.shelves-empty') && shown(doc.querySelector('.shelves-empty')!)) console.log('empty: ' + words(doc.querySelector('.shelves-empty')!.textContent));301-const list = doc.querySelector('.act-list');302-if (list) {303- console.log(headOf(list.querySelectorAll('.panel-head').filter(shown)[0]) + ':');304- for (const li of list.querySelectorAll('.ev')) console.log(` ${words(li.querySelector('.ev-kind')!.textContent)} ${words(li.querySelector('.ev-what')!.textContent)}`);305- const none = list.querySelector('.day-empty');306- if (none && shown(none)) console.log(' ' + words(none.textContent));307-}308301 if (mode === 'activity') console.log(`address: ${location.hash}`);
M test/scenarios/activity-insights.sh +21 -14
@@ -1,8 +1,11 @@
11 # A repo's insights page has the same grid of its activity from the lake:2-# pushes, landed reviews, releases, site deploys and CI results a day.3-# Under it the latest of them; picking a day lists that day's instead,4-# kind, what and when, with "show all" back. (forge attest stamps CI with5-# the real clock, after this forge's fixed one: listed, but in no day.)2+# pushes, landed reviews, releases, site deploys and CI results a day. It3+# is a chart like the others there, still: a span a day, named and titled4+# with its count, and no list under it. Clicking a day picks nothing and5+# the address stays empty; a day's address picks nothing. With JS it6+# recounts from activity.json, which has only each event's time. (forge7+# attest stamps CI with the real clock, after this forge's fixed one:8+# exported, but in no day.)69 . "$(dirname "$0")/../lib.sh"710 user dami811 repo dami/greet
@@ -24,13 +27,17 @@ ok forge lake
2427 export FORGE_STRICT=12528 build2629 p=$FORGE_WWW/dami/greet/insights/index.html27-has "$p" 'aria-label="2026-09-01: 1 event"'28-exists "$FORGE_WWW/dami/greet/insights/activity.json"29-page() { node --no-warnings "$T_HOME/test/page.ts" activity "$p" "$@" | grep -v '^[ *] 20'; }30-sha=$(git -C "$T/w/d" rev-parse --short=7 HEAD)31-eq "$(page)" "$(printf '%s\n' "3 events in the last year: pushes, landed reviews, releases, site deploys and ci results" "latest:" " passed $sha second" " release v1" " pushed dami pushed main" " pushed dami pushed main" "address: ")"32-eq "$(page click '[data-activity] button[data-day="2026-09-01"]')" "3 events in the last year: pushes, landed reviews, releases, site deploys and ci results33-sep 1, 2026 1 event show all:34- pushed dami pushed main35-address: #2026-09-01"36-eq "$(page '#2026-09-01' click '[data-day-reset]' | sed -n 2p)" "latest:"30+has "$p" '<figure class="panel activity-panel"><figcaption class="panel-head"><h3 class="act-total">3 events</h3><span class="grow">in the last year: pushes, landed reviews, releases, site deploys and ci results</span></figcaption>'31+has "$p" '<span class="l2 a[0-9]+" data-day="2026-09-01" data-n="1" role="img" aria-label="2026-09-01: 1 event" title="2026-09-01: 1 event"></span>'32+lacks "$p" '<button[^>]*data-day|aria-pressed|act-list|data-day-reset|ev-rows'33+a=$FORGE_WWW/dami/greet/insights/activity.json34+has "$a" '^\{"days":\{[^}]*\},"events":\[[0-9]+,[0-9]+,[0-9]+,[0-9]+\]\}$'35+page() { node --no-warnings "$T_HOME/test/page.ts" activity "$p" "$@"; }36+grid="3 events in the last year: pushes, landed reviews, releases, site deploys and ci results37+ 2026-09-01: 1 event l238+ 2026-09-03: 2 events l439+address: "40+eq "$(page)" "$grid"41+eq "$(page click '[data-activity] [data-day="2026-09-01"]')" "$grid"42+# Opened at a day's address, it picks nothing either, and leaves it be.43+eq "$(page '#2026-09-01')" "${grid}#2026-09-01"