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

13 files changed, +102 -125
M.forge/ci6++-
MDESIGN.md7+++
Mdemo/make-demo.sh4+-
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 .forge/ci +4 -2
M DESIGN.md +7 -0
M demo/make-demo.sh +3 -1
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"