Public: anyone can read this; the crew can change it.

The /changelog page speaks the home page's language #9697

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

mason/forge:mason/9688-the-changelog-page-speaks-the-home-page → main 5 commits fork 4 threads · 4 unresolved

  • Merged. mason/9688-the-changelog-page-speaks-the-home-page was merged into main as 50ed917.

Review

Sign in to review this change.
3 files changed +97 −10
docs/design/areas/changelog.md +4
@@ -8,3 +8,7 @@ No page-local style, no fixed width; the page does not scroll sideways. No new c
88
99 The preview seed merges one pull with a long title and an unbroken 120-character word (`PREVIEW_LANDING_TITLE`) into
1010 the sample before its forks are made, so the preview's `/changelog` phone look shows a real row, not the empty state.
11
12 ## Rows, not boxes (#9688)
13
14 Each day is a `<section data-day>` with an `<h2>` (the day, the UTC mark when the zone is not the reader's, the count) over a `list setting-list`; "On its way" is an `<h2>` over the same list. The empty state is one muted line, `<p class="mt muted">Nothing has shipped yet.</p>`, the same quiet line the board's Landed region uses (`region()` in `pages/board.ts`); not `empty()`, which is a card. No card, no new class; `test/changelog.test.ts` holds it, and `localDaysJs` still finds `[data-day]`, `h2 time`, `[data-zone]` and `.badge`.
src/web/pages/changelog.ts +5 −9
@@ -21,7 +21,7 @@ import { json } from "../../http/respond.ts";
2121 import { shippedAnswer } from "../../release/answer.ts";
2222 import { html, type Html } from "../html.ts";
2323 import { page } from "../layout.ts";
24 import { badge, card, empty, row, seg, sha, time } from "../ui.ts";
24 import { badge, row, seg, sha, time } from "../ui.ts";
2525
2626 /** Where the live client looks: a merge lands, or production serves a commit. */
2727 const LIVE = "pull.merged,app.serving";
@@ -37,16 +37,12 @@ function landingRow(l: Landing, own: string | undefined, showRepo: boolean): Htm
3737 });
3838 }
3939
40 /** Day cards in `zone`; the browser regroups them in its own (see `localDaysJs`), so the zone is named while it has not. */
40 /** Day sections in `zone`; the browser regroups them in its own (see `localDaysJs`), so the zone is named while it has not. */
4141 function byDay(list: readonly Landing[], own: string | undefined, showRepo: boolean, zone: string, today: string): Html[] {
4242 const days = new Map<string, Landing[]>();
4343 for (const l of list) days.set(dayOf(l.at, zone), [...(days.get(dayOf(l.at, zone)) ?? []), l]);
4444 return [...days].map(([day, items]) =>
45 card(html`<ul class="list setting-list">${items.map((l) => landingRow(l, own, showRepo))}</ul>`, {
46 head: html`<h2><time datetime="${day}">${dayHeading(day, today)}</time>${zone === "UTC" ? html`<span data-zone>${badge("UTC", { variant: "outline" })}</span>` : null}</h2>${badge(String(items.length))}`,
47 attrs: html`data-day="${day}"`,
48 pad: false,
49 }),
45 html`<section class="mt" data-day="${day}"><h2><time datetime="${day}">${dayHeading(day, today)}</time>${zone === "UTC" ? html` <span data-zone>${badge("UTC", { variant: "outline" })}</span>` : null} ${badge(String(items.length))}</h2><ul class="list setting-list">${items.map((l) => landingRow(l, own, showRepo))}</ul></section>`,

Principle 3/7: good — section over rows. Keep the count badge after the zone mark as now, since localDaysJs reads the first .badge after removing data-zone; a comment-free test (see test file) should hold that order.

5046 );
5147 }
5248
@@ -136,10 +132,10 @@ export function shippedRegion(
136132 const way =
137133 onItsWay.length === 0
138134 ? null
139 : html`<h2 class="mt">On its way</h2>${card(html`<ul class="list setting-list">${onItsWay.map((l) => landingRow(l, read.own, choice === "all"))}</ul>`, { pad: false })}`;
135 : html`<h2 class="mt">On its way</h2><ul class="list setting-list">${onItsWay.map((l) => landingRow(l, read.own, choice === "all"))}</ul>`;
140136 const gone =
141137 live.length === 0
142 ? empty({ title: "Nothing has shipped yet", note: "Each pull appears here once the running commit contains it." })
138 ? html`<p class="mt muted">Nothing has shipped yet.</p>`
outdated

Principles 1 and 8: the empty state is hand-rolled markup and says the same thing in a bold line and a muted line. Use one plain sentence, or a named pattern.

143139 : html`${byDay(live, read.own, choice === "all", zone, todayIs)}`;
144140 return html`${top}${way}${gone}`;
145141 }
test/changelog.test.ts +88 −1
@@ -9,7 +9,8 @@ import { createUser, type User } from "../src/core/users.ts";
99 import { serve, type Servers } from "../src/http/server.ts";
1010 import { runGit } from "../src/git/env.ts";
1111 import { repoDir } from "../src/git/repos.ts";
12 import { candidates, forgetLive } from "../src/release/landings.ts";
12 import { candidates, forgetLive, type Landing, type Landings } from "../src/release/landings.ts";
13 import { expectWork, requestStatements } from "./helpers/work.ts";
1314
1415 const must = <T>(r: { ok: true; value: T } | { ok: false; error: unknown }): T => {
1516 if (!r.ok) throw new Error(JSON.stringify(r.error));
@@ -234,6 +235,25 @@ describe("changelog page", () => {
234235 expect(body).toContain('href="/changelog"');
235236 });
236237
238 test("warm, the page answers with its days already drawn, for no more work than cold (#9688)", async () => {
239 const url = `${servers.url}/changelog`;
240 const cold = await requestStatements(app.db, url);
241 const warmStatements = await requestStatements(app.db, url);
242 expectWork(warmStatements, { max: cold });
243 const res = await fetch(url);
244 expect(res.headers.get("server-timing")).toContain("render");
245 const { Window } = await import("happy-dom");
246 const doc = new Window().document;
247 doc.body.innerHTML = await res.text();
248 // No script ran: the days, their headings and their rows are in the markup the server sent.
249 doc.querySelectorAll("script").forEach((s) => s.remove());
250 const days = [...doc.querySelectorAll("#shipped section[data-day]")];
251 expect(days.length).toBe(1);
252 expect(days[0]!.querySelector("h2 time")!.getAttribute("datetime")).toBe(new Date().toISOString().slice(0, 10));
253 expect([...days[0]!.querySelectorAll("li .row-title")].map((t) => t.textContent)).toEqual(["Second thing", "First <b>thing</b>"]);
254 expect(doc.getElementById("shipped-today")!.textContent).toBe("2 shipped today (UTC).");
255 });
256
237257 test("empty only when nothing has shipped", async () => {
238258 app.db.run("UPDATE pull SET merged_at = NULL");
239259 const body = await (await fetch(`${servers.url}/changelog`)).text();
@@ -406,3 +426,70 @@ describe("the top line names what is running", () => {
406426 expect(String(shippedRegion({ ...base, own: undefined }, "all"))).not.toContain("/commit/");
407427 });
408428 });
429
430 describe("changelog rows, not boxes (#9688)", () => {
431 const landing = (number: number, at: string, bet?: string): Landing => ({ repo: "a/forge", number, title: `T${number}`, at, live: true, ...(bet === undefined ? {} : { bet }) });
432 const landings = (live: Landing[], onItsWay: Landing[] = []): Landings => ({ live, onItsWay, version: "1", commit: null, own: "a/forge" });
433 const sample = landings([landing(2, "2026-11-02T01:00:00.000Z"), landing(1, "2026-11-01T01:00:00.000Z", "Speed")], [landing(3, "2026-11-02T02:00:00.000Z")]);
434 const parse = async (markup: string) => {
435 const { Window } = await import("happy-dom");
436 const doc = new Window().document;
437 doc.body.innerHTML = markup;
438 return doc;
439 };
440
441 test("each day is a section with an h2 over a list, and nothing is a card", async () => {
442 const { shippedRegion } = await import("../src/web/pages/changelog.ts");
443 const doc = await parse(String(shippedRegion(sample, "all", new Date("2026-11-02T10:00:00.000Z"))));
444 expect(doc.querySelectorAll(".card, .card-head").length).toBe(0);
445 const days = [...doc.querySelectorAll("section[data-day]")];
446 expect(days.map((d) => d.getAttribute("data-day"))).toEqual(["2026-11-02", "2026-11-01"]);
447 for (const d of days) {
448 // The heading, then the list, and nothing else.
449 expect([...d.children].map((c) => `${c.tagName}.${c.className}`)).toEqual(["H2.", "UL.list setting-list"]);
450 }
451 // On its way is the same shape: a heading over the same list.
452 const way = [...doc.querySelectorAll("h2")].find((h) => h.textContent === "On its way")!;
453 expect(way.nextElementSibling!.className).toBe("list setting-list");
454 });
455
456 test("each heading holds what localDaysJs reads, in its order: the day, the UTC mark, then the one count badge", async () => {
457 const { shippedRegion, localDaysJs } = await import("../src/web/pages/changelog.ts");
458 const doc = await parse(String(shippedRegion(sample, "all", new Date("2026-11-02T10:00:00.000Z"))));
459 const heads = [...doc.querySelectorAll("section[data-day] > h2")];
460 expect(heads.length).toBe(2);
461 const shape = heads.map((h) => [...h.children].map((c) => (c.tagName === "TIME" ? `time ${c.getAttribute("datetime")}` : c.hasAttribute("data-zone") ? `zone ${c.textContent}` : `${c.className} ${c.textContent}`)));
462 expect(shape).toEqual([
463 ["time 2026-11-02", "zone UTC", "badge 1"],
464 ["time 2026-11-01", "zone UTC", "badge 1"],
465 ]);
466 // The script's first .badge in a section, once the zone mark is gone, is the count: a row's bet badge sits under the list.
467 for (const h of heads) {
468 h.querySelector("[data-zone]")!.remove();
469 expect(h.parentElement!.querySelector(".badge")!.textContent).toBe("1");
470 }
471 // In the reader's own zone there is no mark at all.
472 const local = await parse(String(shippedRegion(sample, "all", new Date("2026-11-02T10:00:00.000Z"), "America/Chicago")));
473 expect(local.querySelectorAll("[data-zone]").length).toBe(0);
474 for (const sel of ["[data-day]", "h2 time", "[data-zone]", ".badge"]) expect(localDaysJs).toContain(`'${sel}'`);
475 expect(localDaysJs.indexOf("[data-zone]")).toBeLessThan(localDaysJs.indexOf("c.querySelector('.badge')"));
476 });
477
478 test("nothing shipped is one quiet line, the board's own", async () => {
479 const { shippedRegion } = await import("../src/web/pages/changelog.ts");
480 const doc = await parse(String(shippedRegion(landings([]), "all")));
481 const lines = [...doc.querySelectorAll("p")].filter((p) => p.id !== "shipped-top");
482 expect(lines.map((p) => [p.className, p.innerHTML])).toEqual([["mt muted", "Nothing has shipped yet."]]);
483 expect(doc.querySelectorAll(".card, strong").length).toBe(0);
484 });
485
486 test("the region is bounded work (no clock) and reads without script", async () => {
487 const { shippedRegion } = await import("../src/web/pages/changelog.ts");
488 const many = Array.from({ length: 100 }, (_, i) => landing(i + 1, new Date(Date.UTC(2026, 10, 1) - i * 7_200_000).toISOString()));
489 const out = String(shippedRegion(landings(many), "all"));
490 // Work, not a stopwatch: about 100 bytes of markup per row beyond its title, a day section per two rows.
491 expectWork(out.length, { max: 100 * 700 });
492 const doc = await parse(out);
493 expect(doc.querySelectorAll("section[data-day] li").length).toBe(100);
494 });
495 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs