Public: anyone can read this; the crew can change it.
The /changelog page speaks the home page's language #9697
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/9688-the-changelog-page-speaks-the-home-page → main 5 commits fork 4 threads · 4 unresolved
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 | ||
| 8 | 8 | |
| 9 | 9 | The preview seed merges one pull with a long title and an unbroken 120-character word (`PREVIEW_LANDING_TITLE`) into |
| 10 | 10 | 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"; | ||
| 21 | 21 | import { shippedAnswer } from "../../release/answer.ts"; |
| 22 | 22 | import { html, type Html } from "../html.ts"; |
| 23 | 23 | 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"; | |
| 25 | 25 | |
| 26 | 26 | /** Where the live client looks: a merge lands, or production serves a commit. */ |
| 27 | 27 | const LIVE = "pull.merged,app.serving"; |
| @@ -37,16 +37,12 @@ function landingRow(l: Landing, own: string | undefined, showRepo: boolean): Htm | ||
| 37 | 37 | }); |
| 38 | 38 | } |
| 39 | 39 | |
| 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. */ | |
| 41 | 41 | function byDay(list: readonly Landing[], own: string | undefined, showRepo: boolean, zone: string, today: string): Html[] { |
| 42 | 42 | const days = new Map<string, Landing[]>(); |
| 43 | 43 | for (const l of list) days.set(dayOf(l.at, zone), [...(days.get(dayOf(l.at, zone)) ?? []), l]); |
| 44 | 44 | 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>`, | |
| 50 | 46 | ); |
| 51 | 47 | } |
| 52 | 48 | |
| @@ -136,10 +132,10 @@ export function shippedRegion( | ||
| 136 | 132 | const way = |
| 137 | 133 | onItsWay.length === 0 |
| 138 | 134 | ? 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>`; | |
| 140 | 136 | const gone = |
| 141 | 137 | 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. |
||
| 143 | 139 | : html`${byDay(live, read.own, choice === "all", zone, todayIs)}`; |
| 144 | 140 | return html`${top}${way}${gone}`; |
| 145 | 141 | } |
test/changelog.test.ts +88 −1
| @@ -9,7 +9,8 @@ import { createUser, type User } from "../src/core/users.ts"; | ||
| 9 | 9 | import { serve, type Servers } from "../src/http/server.ts"; |
| 10 | 10 | import { runGit } from "../src/git/env.ts"; |
| 11 | 11 | 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"; | |
| 13 | 14 | |
| 14 | 15 | const must = <T>(r: { ok: true; value: T } | { ok: false; error: unknown }): T => { |
| 15 | 16 | if (!r.ok) throw new Error(JSON.stringify(r.error)); |
| @@ -234,6 +235,25 @@ describe("changelog page", () => { | ||
| 234 | 235 | expect(body).toContain('href="/changelog"'); |
| 235 | 236 | }); |
| 236 | 237 | |
| 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 | ||
| 237 | 257 | test("empty only when nothing has shipped", async () => { |
| 238 | 258 | app.db.run("UPDATE pull SET merged_at = NULL"); |
| 239 | 259 | const body = await (await fetch(`${servers.url}/changelog`)).text(); |
| @@ -406,3 +426,70 @@ describe("the top line names what is running", () => { | ||
| 406 | 426 | expect(String(shippedRegion({ ...base, own: undefined }, "all"))).not.toContain("/commit/"); |
| 407 | 427 | }); |
| 408 | 428 | }); |
| 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 | }); |
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.