| M | .forge/ | 6 | ++- |
| M | DESIGN.md | 7 | +++ |
| M | demo/ | 4 | +- |
| M | lib/ | 4 | +- |
| M | lib/ | 5 | +-- |
| M | lib/ | 44 | +++++++++------------- |
| M | site/ | 18 | +++------ |
| M | site/ | 8 | ++- |
| M | site/ | 14 | ++++--- |
| M | site/ | 62 | +++++++++--------------------- |
| M | site/ | 3 | + |
| M | test/ | 17 | ++------ |
| M | test/ | 35 | ++++++++++------- |
@@ -38,9 +38,11 @@ for f in events builds reviews ci; do test "$(head -c 4 $www/insights/$f.parquet
3838 # Templates render the same without 11ty (lib/render-page.ts), with the3939 # demo build's settings; every asset a page links exists under its hashed4040 # name; link previews. The demo's clock (make-demo.sh), so a date says the41-# same year it did when the demo was built.41+# same year it did when the demo was built, and an activity grid ends on42+# the day its last build did: 2026-09-02, from midnight, as the demo's43+# clock starts at midnight the day before.4244 tmp=$(mktemp -d)43-demo_now=$(date -u -j -f %Y-%m-%d 2026-09-02 +%s 2>/dev/null || date -u -d 2026-09-02 +%s)45+demo_now=$(date -u -j -f '%Y-%m-%d %H:%M:%S' '2026-09-02 00:00:00' +%s 2>/dev/null || date -u -d '2026-09-02 00:00:00' +%s)4446 FORGE_ROOT=$PWD/demo/root FORGE_SSH=git@forge.local FORGE_CACHE=$tmp FORGE_STRICT=1 FORGE_NOW=$demo_now node lib/render-page.ts check $www4547 rm -rf "$tmp"4648 for a in $(grep -rhoE '(href|src)="/assets/[^"]+"' $www --include=index.html | sed -E 's/^[a-z-]+="//; s/"$//' | sort -u); do@@ -158,6 +158,13 @@ forms.
158158 default branches of their public repos, following the picked tab) and a159159 repo's insights (its pushes, landed reviews, releases, site deploys and160160 CI results); not on the commits tab.161+- On a repo's insights the grid is a chart like the others there, in a162+ chart panel with its total and what it counts in the head strip. It is163+ still (`ui.activity` with `still`): each day is a span, named and164+ titled as a button would be ("2026-10-08: 3 events"), the same square165+ with no hover, no pointer and nothing to pick, and no list goes under166+ it. With JS it only recounts its days in the reader's time from167+ `activity.json`; the address never names a day.161168 - The address keeps a picked day so a narrowed view can be shared:162169 `#<day>` (`#2026-10-08`), or `#<shelf>/<day>` on an owner's page with a163170 shelf picked, beside the shelves' own `#<shelf>`. The day is always last@@ -27,7 +27,9 @@ cat >"$GIT_CONFIG_GLOBAL" <<'EOF'
2727 detachedHead = false2828 EOF2929 30-t=$(date -u -j -f %Y-%m-%d 2026-09-01 +%s 2>/dev/null || date -u -d 2026-09-01 +%s)30+# From midnight: BSD date -j fills the hour it isn't given from the clock,31+# and the demo's last day would move with the time it was made.32+t=$(date -u -j -f '%Y-%m-%d %H:%M:%S' '2026-09-01 00:00:00' +%s 2>/dev/null || date -u -d '2026-09-01 00:00:00' +%s)3133 # The hooks stamp reviews with FORGE_NOW, so the demo's clock moves with its commits.3234 tick() { t=$((t + ${1:-5400})); export GIT_AUTHOR_DATE="@$t +0000" GIT_COMMITTER_DATE="@$t +0000" FORGE_NOW=$t; }3335 commit() {@@ -73,6 +73,6 @@ export const decodeMinutes = (enc: number[]): number[] => {
7373 7474 export interface RepoActivityExport {7575 days: Record<string, number>;76- // [time (ms), kind, text, link from the repo's page], newest first.77- events: [number, string, string, string][];76+ // Each event's time (ms), newest first.77+ events: number[];7878 }@@ -433,10 +433,7 @@ export function loadForge(env: NodeJS.ProcessEnv = process.env): Forge {
433433 // The activity grid comes from the lake's last export (lib/insights.ts);434434 // with JS the page fetches the export forge lake keeps fresh.435435 const act = readExport<RepoActivityExport>(cfg.state, repo.owner, repo.name, 'insights', 'activity.json');436- const activity = act ? {437- grid: grid(act.days, 'event'),438- recent: act.events.slice(0, 20).map(([t, kind, text, rel]) => ({ at: new Date(t).toISOString(), kind, text, href: rel ? u.home.href + rel : null })),439- } : null;436+ const activity = act ? { grid: grid(act.days, 'event') } : null;440437 add(pages.insights, page(u.insights.path, `Insights - ${repo.slug}`, 'insights', { activity }), `insights ${fingerprint(activity)}`);441438 raw.push(...insightFiles(cfg, path.join(repo.owner, repo.name, 'insights'), u.insights.path));442439 for (const w of repo.wiki) {@@ -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 @@ -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>@@ -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 %}@@ -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; }@@ -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 @@ -106,6 +106,9 @@ table.dg-tokens td.v { font: var(--fw-regular) var(--text-xs) var(--mono); color
106106 {% set grid = 'commit' | demoActivity %}107107 {% set head %}<b class="act-total">{{ grid.total | thousands }} commits</b><span>in the last year: ui.activity, a day a button; pick one</span>{% endset %}108108 {% call ui.panel(head, 'activity-panel') %}<div class="panel-body">{{ ui.activity(grid, 'commits a day') }}</div>{% endcall %}109+{% set grid = 'event' | demoActivity %}110+{% set head %}<b class="act-total">{{ grid.total | thousands }} events</b><span>in the last year: ui.activity, still, a chart with nothing to pick, as on a repo's insights</span>{% endset %}111+{% call ui.panel(head, 'activity-panel') %}<div class="panel-body">{{ ui.activity(grid, 'events a day', '', true) }}</div>{% endcall %}109112 110113 {{ ui.heading('section heading', 4, 'a count, then a quieter note') }}111114 {% call ui.panel() %}@@ -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}`);@@ -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"