clone

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

Front matter for people and repos, bauhaus avatars, and a grid of days #28

1 commit: 1 rewritten.

rewritten 0ef36d0 → a283b86 Front matter for people and repos, bauhaus avatars, and a grid of days behavior change
@@ Commit message
default branches, following the picked tab) and a repo's insights (its
pushes, landed reviews, releases, site deploys and CI results). Picking a
day narrows the list under it; the address keeps it as #<day> or
- exports each owner's and each repo's activity.json, which the build puts
- in the page and beside it, and which the page fetches for fresher counts.
- A private repo counts on no public page.
+ #<shelf>/<day>. Cells are 13px at most, left in the panel; narrower, they
+ shrink to 10px and then the oldest weeks go. forge lake now keeps the
+ default branches' commits and exports each owner's commit times and each
+ repo's events as activity.json, which the build puts in the page and
+ beside it, and which the page fetches for fresher counts. With JS the
+ days are the reader's, without they are UTC. A private repo counts on no
+ public page.
A shelf's list under its tabs no longer has a head strip saying the tab
again; the tabs' counts follow the globe. DESIGN.md says so, and how
@@ DESIGN.md: it starts a group: the style guide titles groups by those comments).
- Type: `--text-xs` 12, `--text-code` 12.5, `--text-sm` 13.5, `--text-md`
15 (body), `--text-prose` 16, `--text-lg` 18, `--text-xl` 22,
`--text-2xl` 28. Weights `--fw-regular` 400 (body), `--fw-medium` 500
-@@ DESIGN.md: minute, with the reader's full local date and time on hover.
- - The page has the day before the script runs, no wider than what replaces
- it, so nothing moves on load.
+@@ DESIGN.md: dates like Oct. 8, 2026. Interface chrome follows the lowercase rules
+ above. Code, CLI output and the interface's compact times keep their own
+ forms.
+## People and activity
+
@@ DESIGN.md: minute, with the reader's full local date and time on hover.
+- The activity grid (`ui.activity`, data from `lib/activity.ts`, behavior
+ in `site/assets/ui.ts`) is the one way the forge shows a year of days.
+ A column a week, Sunday at the top, no month row, no weekday column, no
-+ range picker; square cells with `--space-half` gaps that stretch to the
-+ panel, the last 26 weeks on phones. Each day is a button named
++ range picker; square cells of 13px at most with `--space-half` gaps,
++ left in the panel. Narrower, the cells shrink to 10px and then the oldest
++ weeks go, so the latest always show (27 weeks at 390px). Days are the
++ reader's with JS (the page has each commit's or event's time), UTC
++ without. Each day is a button named
+ "2026-10-08: 11 commits"; clicking it (or Enter, Space) picks it with an
+ ink outline and narrows the list directly under it, clicking it again or
+ "show all" lets it go; arrow keys move by a day or a week. Without JS
@@ lib/activity.ts (new)
+import path from 'node:path';
+
+export const WEEKS = 53;
-+// On phones the grid is the last 26 weeks: the cells before are "old".
-+export const PHONE_WEEKS = 26;
+
+export interface Cell {
+ day: string;
+ n: number;
+ level: number;
-+ old: boolean;
++ // Weeks before the last column (0 for this week's): where the grid is too
++ // narrow for every week at its smallest, the oldest go (style.css).
++ age: number;
+}
+
+export interface Grid {
@@ lib/activity.ts (new)
+ const weeks: Cell[][] = [];
+ days.forEach((d, i) => {
+ const w = Math.floor(i / 7);
-+ (weeks[w] ??= []).push({ ...d, level: level(d.n, max), old: w < WEEKS - PHONE_WEEKS });
++ (weeks[w] ??= []).push({ ...d, level: level(d.n, max), age: WEEKS - 1 - w });
+ });
+ return { weeks, total: days.reduce((a, d) => a + d.n, 0), noun, today: iso(end) };
+}
@@ lib/activity.ts (new)
+}
+
+export interface PersonExport {
-+ // Day: repo name: commits.
-+ days: Record<string, Record<string, number>>;
++ // Repo name: its commits' times in minutes, delta-encoded (encodeMinutes
++ // in lib/insights.ts).
++ repos: Record<string, number[]>;
+ joined: number | null;
+}
+
++export const decodeMinutes = (enc: number[]): number[] => {
++ let m = 0;
++ return enc.map((d, i) => (m = i ? m + d : d));
++};
++
+export interface RepoActivityExport {
+ days: Record<string, number>;
+ // [time (ms), kind, text, link from the repo's page], newest first.
@@ lib/forge.ts: import { HighlightCache } from './highlight.ts';
import { motd } from './motd.ts';
+import { avatar } from './avatar.ts';
+import { readProfile, linkText } from './profile.ts';
-+import { grid, readExport, type Grid, type PersonExport, type RepoActivityExport } from './activity.ts';
++import { decodeMinutes, grid, readExport, type Grid, type PersonExport, type RepoActivityExport } from './activity.ts';
+import { extraTopics, languages, license } from './front.ts';
+import { catFileBatch } from './git.ts';
import type { CiStatus, Commit, Config, DiffFile, Repo, RepoCounts, RepoSummary, RepoUrls, Review, ReviewComment, SiteLink } from './types.ts';
@@ lib/forge.ts: export interface Profile {
+ owner: string;
+ repos: RepoSummary[];
+ person: Person;
-+ activity: { grid: Grid; data: { days: Record<string, Record<string, number>> } } | null;
++ // data: each repo's commit times, as forge lake exports them (ui.ts puts
++ // them on the reader's days); the grid without JS has UTC days.
++ activity: { grid: Grid; data: { repos: Record<string, number[]> } } | null;
+}
+
+export interface Person {
@@ lib/forge.ts: export function loadForge(env: NodeJS.ProcessEnv = process.env): F
+ const exp = readExport<PersonExport>(cfg.state, 'people', `${owner}.json`);
+ const joinedAt = [exp?.joined, prof.joined ? prof.joined * 1000 : null].filter((t): t is number => !!t);
+ // Only the repos on the page count: one gone private since the export isn't.
-+ const days: Record<string, Record<string, number>> = {};
++ const kept: Record<string, number[]> = {};
+ const totals: Record<string, number> = {};
-+ for (const [d, per] of Object.entries(exp?.days ?? {})) {
-+ for (const [name, n] of Object.entries(per)) {
-+ if (!byName.has(name)) continue;
-+ (days[d] ??= {})[name] = n;
-+ totals[d] = (totals[d] ?? 0) + n;
++ for (const [name, enc] of Object.entries(exp?.repos ?? {})) {
++ if (!byName.has(name) || !Array.isArray(enc)) continue;
++ kept[name] = enc;
++ for (const m of decodeMinutes(enc)) {
++ const d = new Date(m * 60000).toISOString().slice(0, 10);
++ totals[d] = (totals[d] ?? 0) + 1;
+ }
+ }
+ return {
@@ lib/forge.ts: export function loadForge(env: NodeJS.ProcessEnv = process.env): F
+ joined: joinedAt.length ? month(Math.min(...joinedAt)) : '',
+ pins, pinned: pinned.length > 0,
+ },
-+ activity: repos.length ? { grid: grid(totals, 'commit'), data: { days } } : null,
++ activity: repos.length ? { grid: grid(totals, 'commit'), data: { repos: kept } } : null,
+ };
+}
+
@@ lib/insights.ts: async function exportScope(lake: Lake, s: Scope, publicRepos: s
+ return { days, events };
+}
+
-+// Each owner's activity, for the grid on their page: commits a day on the
-+// default branches of their public repos, by repo (author's time, UTC),
-+// and when they joined: their first public event, or the first push to a
-+// public repo of theirs. A private repo's commits are in no export.
++// Each owner's activity, for the grid on their page: when each commit on
++// the default branches of their public repos was made (its author's time),
++// by repo, as minutes since 1970, oldest first, each after the first as
++// the minutes since the one before (lib/activity.ts reads them), so the page
++// can put them on the reader's days; and when they joined: their first
++// public event, or the first push to a public repo of theirs. A private
++// repo's commits are in no export.
+async function exportPeople(lake: Lake, pub: string[]): Promise<number> {
+ const dir = path.join(lake.cfg.exportDir, 'people');
+ const tmp = `${dir}.tmp`;
+ fs.rmSync(tmp, { recursive: true, force: true });
+ fs.mkdirSync(tmp, { recursive: true });
-+ const people = new Map<string, { days: Record<string, Record<string, number>>; joined: number | null }>();
++ const people = new Map<string, { repos: Record<string, number[]>; joined: number | null }>();
+ const of = (o: string) => {
+ let p = people.get(o);
-+ if (!p) people.set(o, (p = { days: {}, joined: null }));
++ if (!p) people.set(o, (p = { repos: {}, joined: null }));
+ return p;
+ };
+ for (const slug of pub) of(slug.split('/')[0]);
-+ for (const [repo, d, n] of await lake.rows(`SELECT repo, strftime(t, '%Y-%m-%d'), count(*) FROM lake.commits WHERE ${inList('repo', pub)} AND t >= ${since()} GROUP BY ALL`)) {
++ for (const [repo, mins] of await lake.rows(`SELECT repo, list(floor(epoch(t) / 60)::BIGINT ORDER BY t) FROM lake.commits WHERE ${inList('repo', pub)} AND t >= ${since()} GROUP BY repo ORDER BY repo`)) {
+ const [o, name] = String(repo).split('/');
-+ ((of(o).days[String(d)] ??= {})[name] = Number(n));
++ of(o).repos[name] = encodeMinutes((mins as unknown[]).map(Number));
+ }
+ for (const [who, t] of await lake.rows(`
+ SELECT who, epoch_ms(min(t)) FROM (
@@ lib/insights.ts: async function exportScope(lake: Lake, s: Scope, publicRepos: s
+ fs.rmSync(old, { recursive: true, force: true });
+ return people.size;
+}
++
++// Ascending minutes as the first, then each one's distance from the one
++// before (decodeMinutes in lib/activity.ts undoes it).
++export const encodeMinutes = (mins: number[]): number[] => mins.map((m, i) => (i ? m - mins[i - 1] : m));
+
const exportFingerprint = (repos: string[]): string => repos.join('\n');
@@ site/_includes/ui.njk
+ picture; with it (ui.ts) clicking a day, or Enter or Space on it, picks
+ it (an ink outline) and narrows the list under it, the arrow keys move
+ between days, and the address keeps the day (#2026-10-08, or
-+ #<shelf>/2026-10-08 on an owner's page with a shelf picked). On phones
-+ it is the last 26 weeks. attrs: data-src="activity.json", what ui.ts
++ #<shelf>/2026-10-08 on an owner's page with a shelf picked). Cells are
++ 13px at most, left-aligned; narrower, they shrink to 10px, then the
++ oldest weeks go (style.css). attrs: data-src="activity.json", what ui.ts
+ fetches for fresher counts (and a repo's events). #}
+{% macro activity(grid, label, attrs = '') -%}
-+<div class="activity" role="group" aria-label="{{ label }}" data-activity data-noun="{{ grid.noun }}"{% if attrs %} {{ attrs | safe }}{% endif %}>
-+{%- 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 }}{% if c.old %} old{% endif %}" 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 -%}
-+</div>
++<div class="activity-fit"><div class="activity" role="group" aria-label="{{ label }}" data-activity data-noun="{{ grid.noun }}"{% if attrs %} {{ attrs | safe }}{% endif %}>
++{%- 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 -%}
++</div></div>
+{%- endmacro %}
## site/assets/style.css ##
@@ site/assets/style.css: details.picker #branch-menu { position: absolute; z-index
+svg.avatar .av-y { fill: var(--avatar-yellow); }
+
+/* the activity grid (ui.activity): a column a week, a square button a
-+ day, coloured by how busy it was; it stretches to its panel, and on
-+ phones keeps the last 26 weeks. A picked day has an ink outline. */
-+.activity { display: grid; grid-template-rows: repeat(7, auto); grid-template-columns: repeat(53, minmax(0, 1fr)); grid-auto-flow: column; gap: var(--space-half); }
-+.activity > button { display: block; min-height: 0; aspect-ratio: 1; width: 100%; padding: 0; border: 0; background: var(--heat-0); cursor: pointer; }
++ day, coloured by how busy it was. Cells are 13px with --space-half gaps,
++ left in the panel; in a narrower box they shrink to fit all 53 weeks,
++ down to 10px, and below that the oldest weeks go (a<n>: n weeks before
++ this one), a week each 12px, so the latest always show. A picked day has
++ an ink outline. */
++.activity-fit { container: activity / inline-size; }
++.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; }
++@container activity (width < 793px) { .activity { --cell: max(10px, calc((100cqw + 2px) / 53 - 2px)); } }
++.activity > button { display: block; min-height: 0; width: 100%; height: 100%; padding: 0; border: 0; background: var(--heat-0); cursor: pointer; }
+.activity > button.l1 { background: var(--heat-1); }
+.activity > button.l2 { background: var(--heat-2); }
+.activity > button.l3 { background: var(--heat-3); }
@@ site/assets/style.css: details.picker #branch-menu { position: absolute; z-index
+.activity > button:hover { outline: var(--hairline) solid var(--muted); outline-offset: 0; }
+.activity > button[aria-pressed="true"] { outline: 2px solid var(--fg); outline-offset: 0; position: relative; z-index: 1; }
+.activity > button:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; position: relative; z-index: 2; }
-+@media (max-width: 720px) {
-+ .activity { grid-template-columns: repeat(26, minmax(0, 1fr)); }
-+ .activity > button.old { display: none; }
-+}
++@container activity (width < 634px) { .activity > .a52 { display: none; } }
++@container activity (width < 622px) { .activity > .a51 { display: none; } }
++@container activity (width < 610px) { .activity > .a50 { display: none; } }
++@container activity (width < 598px) { .activity > .a49 { display: none; } }
++@container activity (width < 586px) { .activity > .a48 { display: none; } }
++@container activity (width < 574px) { .activity > .a47 { display: none; } }
++@container activity (width < 562px) { .activity > .a46 { display: none; } }
++@container activity (width < 550px) { .activity > .a45 { display: none; } }
++@container activity (width < 538px) { .activity > .a44 { display: none; } }
++@container activity (width < 526px) { .activity > .a43 { display: none; } }
++@container activity (width < 514px) { .activity > .a42 { display: none; } }
++@container activity (width < 502px) { .activity > .a41 { display: none; } }
++@container activity (width < 490px) { .activity > .a40 { display: none; } }
++@container activity (width < 478px) { .activity > .a39 { display: none; } }
++@container activity (width < 466px) { .activity > .a38 { display: none; } }
++@container activity (width < 454px) { .activity > .a37 { display: none; } }
++@container activity (width < 442px) { .activity > .a36 { display: none; } }
++@container activity (width < 430px) { .activity > .a35 { display: none; } }
++@container activity (width < 418px) { .activity > .a34 { display: none; } }
++@container activity (width < 406px) { .activity > .a33 { display: none; } }
++@container activity (width < 394px) { .activity > .a32 { display: none; } }
++@container activity (width < 382px) { .activity > .a31 { display: none; } }
++@container activity (width < 370px) { .activity > .a30 { display: none; } }
++@container activity (width < 358px) { .activity > .a29 { display: none; } }
++@container activity (width < 346px) { .activity > .a28 { display: none; } }
++@container activity (width < 334px) { .activity > .a27 { display: none; } }
++@container activity (width < 322px) { .activity > .a26 { display: none; } }
++@container activity (width < 310px) { .activity > .a25 { display: none; } }
++@container activity (width < 298px) { .activity > .a24 { display: none; } }
++@container activity (width < 286px) { .activity > .a23 { display: none; } }
++@container activity (width < 274px) { .activity > .a22 { display: none; } }
++@container activity (width < 262px) { .activity > .a21 { display: none; } }
++@container activity (width < 250px) { .activity > .a20 { display: none; } }
++@container activity (width < 238px) { .activity > .a19 { display: none; } }
++@container activity (width < 226px) { .activity > .a18 { display: none; } }
++@container activity (width < 214px) { .activity > .a17 { display: none; } }
++@container activity (width < 202px) { .activity > .a16 { display: none; } }
++@container activity (width < 190px) { .activity > .a15 { display: none; } }
++@container activity (width < 178px) { .activity > .a14 { display: none; } }
++@container activity (width < 166px) { .activity > .a13 { display: none; } }
++@container activity (width < 154px) { .activity > .a12 { display: none; } }
++@container activity (width < 142px) { .activity > .a11 { display: none; } }
++@container activity (width < 130px) { .activity > .a10 { display: none; } }
++@container activity (width < 118px) { .activity > .a9 { display: none; } }
++@container activity (width < 106px) { .activity > .a8 { display: none; } }
++@container activity (width < 94px) { .activity > .a7 { display: none; } }
++@container activity (width < 82px) { .activity > .a6 { display: none; } }
++@container activity (width < 70px) { .activity > .a5 { display: none; } }
++@container activity (width < 58px) { .activity > .a4 { display: none; } }
++@container activity (width < 46px) { .activity > .a3 { display: none; } }
++@container activity (width < 34px) { .activity > .a2 { display: none; } }
++@container activity (width < 22px) { .activity > .a1 { display: none; } }
+
+/* event rows: what happened, a row each: its kind (as wide as
+ "release"), what, and when. The rows share the list's columns (subgrid),
@@ site/assets/ui.ts
+ const esc = (v: unknown): string => String(v).replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
+ type Days = Record<string, Record<string, number>>;
+ type Ev = [number, string, string, string];
-+ let fresh: { days?: Days | Record<string, number>; events?: Ev[] } | null = null;
++ let fresh: { repos?: Record<string, number[]>; events?: Ev[] } | null = null;
+ let asked = false;
+ let lost = false;
++ // An owner's commits by the reader's day, then repo: each repo's commit
++ // times come as minutes, the first since 1970 and each after it since the
++ // one before (lib/activity.ts).
++ let decoded: { from: unknown; days: Days } | null = null;
+ const ownerData = (): { days: Days } => {
-+ if (fresh?.days) return fresh as { days: Days };
++ const from = fresh?.repos ? fresh : document.querySelector('.activity-data')?.textContent ?? '';
++ if (decoded?.from === from) return decoded;
++ let src: { repos?: Record<string, number[]> } | null = null;
+ try {
-+ return JSON.parse(document.querySelector('.activity-data')?.textContent ?? '') as { days: Days };
-+ } catch {
-+ return { days: {} };
++ src = typeof from === 'string' ? JSON.parse(from) : from;
++ } catch {}
++ const days: Days = {};
++ for (const [repo, enc] of Object.entries(src?.repos ?? {})) {
++ let m = 0;
++ enc.forEach((d, i) => {
++ m = i ? m + d : d;
++ const day = localDay(m * 60000);
++ (days[day] ??= {})[repo] = (days[day][repo] ?? 0) + 1;
++ });
+ }
++ decoded = { from, days };
++ return decoded;
+ };
++
+ // Paint a grid from counts by day (or its own, without).
+ const paint = (g: HTMLElement, counts: Record<string, number> | null): void => {
+ const cells = [...g.querySelectorAll<HTMLElement>('button[data-day]')];
@@ site/assets/ui.ts
+ total += ns[i];
+ const what = `${c.dataset.day}: ${plural(ns[i], noun)}`;
+ c.setAttribute('data-n', String(ns[i]));
-+ c.setAttribute('class', `l${level(ns[i], max)}${c.classList.contains('old') ? ' old' : ''}`);
++ c.setAttribute('class', (c.getAttribute('class') ?? '').replace(/\bl\d\b/, `l${level(ns[i], max)}`));
+ c.setAttribute('aria-label', what);
+ c.title = what;
+ c.setAttribute('aria-pressed', String(c.dataset.day === pick.day));
@@ site/design.njk: table.dg-tokens td.v { font: var(--fw-regular) var(--text-xs) v
+{% set head %}<b>avatars</b><span class="grow">bauhaus tiles drawn from the name (lib/avatar.ts), 72px on a person's page, 36px in lists</span>{% endset %}
+{% call ui.panel(head) %}
+<div class="dg-avatars">
-+{% for n in ['cat', 'dami', 'ana', 'bob', 'kim', 'lee', 'mo', 'jun', 'ola', 'sam', 'tess', 'yuki'] %}<figure>{{ n | avatar(72) }}{{ n | avatar(36) }}<figcaption class="mono">{{ n }}</figcaption></figure>{% endfor %}
++{% for n in ['cat', 'fox', 'bob', 'kim', 'lee', 'mo', 'jun', 'ola', 'sam', 'tess', 'yuki', 'ren'] %}<figure>{{ n | avatar(72) }}{{ n | avatar(36) }}<figcaption class="mono">{{ n }}</figcaption></figure>{% endfor %}
+</div>
+{% endcall %}
+{% set grid = 'commit' | demoActivity %}
@@ test/scenarios/activity-counts.sh (new)
+p=$FORGE_WWW/dami/index.html
+has "$p" '<section class="panel activity-panel"><header class="panel-head"><b class="act-total">8 commits</b><span>in the last year</span></header>'
+has "$p" 'aria-label="2026-09-01: 3 commits" title="2026-09-01: 3 commits"'
-+has "$p" 'class="l3" data-day="2026-09-01" data-n="3"'
-+has "$p" 'class="l4" data-day="2026-09-03" data-n="5"'
++has "$p" 'class="l3 a0" data-day="2026-09-01" data-n="3"'
++has "$p" 'class="l4 a0" data-day="2026-09-03" data-n="5"'
+has "$p" 'aria-label="2026-09-02: 0 commits"'
-+# 53 weeks to today, Sunday first; the last 26 for phones.
-+eq "$(grep -o 'data-day="[0-9-]*"' "$p" | head -n 1)" 'data-day="2025-08-31"'
++# 53 weeks to today, Sunday first, each day marked with its week's age
++# (so a narrow grid can leave out the oldest).
++has "$p" 'class="l0 a52" data-day="2025-08-31"'
+eq "$(grep -o 'data-day="[0-9-]*"' "$p" | tail -n 1)" 'data-day="2026-09-03"'
-+eq "$(grep -o '<button type="button" class="l[0-4] old"' "$p" | wc -l | tr -d ' ')" 189
++eq "$(grep -o 'class="l[0-4] a52"' "$p" | wc -l | tr -d ' ')" 7
+# The same with JS, from the page's data and the activity.json beside it.
+eq "$(node --no-warnings "$T_HOME/test/page.ts" activity "$p")" "8 commits in the last year
+ 2026-09-01: 3 commits l3
@@ test/scenarios/activity-insights.sh (new)
+address: #2026-09-01"
+eq "$(page '#2026-09-01' click '[data-day-reset]' | sed -n 2p)" "latest:"
+ ## test/scenarios/activity-local-day.sh (new) ##
+@@
++# An owner's grid puts commits on the reader's days: the page carries each
++# commit's time, and ui.ts counts them by the reader's zone. Without JS the
++# days are UTC. The fixture's commits are just after midnight UTC on Sep 1
++# and Sep 3, which is the afternoon before in Honolulu.
++. "$(dirname "$0")/../lib.sh"
++. "$(dirname "$0")/../activity-fixture.sh"
++p=$FORGE_WWW/dami/index.html
++has "$p" 'aria-label="2026-09-01: 3 commits"'
++eq "$(TZ=Pacific/Honolulu node --no-warnings "$T_HOME/test/page.ts" activity "$p")" "8 commits in the last year
++ 2026-08-31: 3 commits l3
++ 2026-09-02: 5 commits l4
++all 3: notes go chess
++address: "
++eq "$(TZ=Pacific/Honolulu node --no-warnings "$T_HOME/test/page.ts" activity "$p" '#2026-08-31' | sed -n 4p)" "aug 31, 2026 2 repos, 3 commits show all: go (1 commit) chess (2 commits)"
+
## test/scenarios/activity-private.sh (new) ##
@@
+# A private repo's commits count on no public page: not in its owner's
@@ test/scenarios/activity-private.sh (new)
+eq "$(lq "SELECT count(*) FROM commits WHERE repo = 'dami/secret'")" 4
+has "$p" 'aria-label="2026-09-01: 3 commits"'
+for f in "$p" "$FORGE_WWW/dami/activity.json" "$FORGE_ROOT/.forge/lake/export/people/dami.json"; do lacks "$f" 'secret'; done
-+has "$FORGE_WWW/dami/activity.json" '"2026-09-01":\{("chess":2,"go":1|"go":1,"chess":2)\}'
++# Each repo's commit times: minutes, then minutes since the one before.
++has "$FORGE_WWW/dami/activity.json" '"chess":\[[0-9]+,1\]'
++has "$FORGE_WWW/dami/activity.json" '"notes":\[[0-9]+,1\]'
## test/scenarios/activity-tab.sh (new) ##
@@