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

The changelog's days and "shipped today" follow the reader's clock #7902

Merged opened this in answer to

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

Merged

mason/forge:mason/7898-the-changelog-s-days-and-shipped-today-f → main 3 commits fork 3 threads · 3 unresolved

  • Merged. mason/7898-the-changelog-s-days-and-shipped-today-f was merged into main as 55ca28f.

Review

Sign in to review this change.
6 files changed +215 −12
README.md +2
@@ -499,6 +499,8 @@ How it works and how to turn it on: [`docs/features/release-keeper.md`](docs/fea
499499
500500 `/changelog` lists the last 30 releases of `FORGE_REPO` (public only) with their pulls, for anyone; the terminal's palette has `Changelog` for the same list. In the terminal, `/` takes a word and keeps the releases whose version or pull titles contain it (`esc` clears it); Enter on a release lists its pulls, and Enter on a pull opens it (`docs/features/changelog.md`).
501501
502 The web changelog's days and its "shipped today" count follow the reader's clock: a small script regroups the server-rendered cards into the browser's own days (Today, Yesterday, then a date) at load and after each live swap. With no script the days are UTC and the page says UTC. The script runs at `DOMContentLoaded`, before the first paint of this small page, and rebuilds the cards from the first one, so only the UTC mark leaves; that shift is accepted. `happy-dom` is a dev dependency, used only by the test that runs this script against a DOM.
503
502504 A repository admin cuts a wave from two doors, both through `cutWaveFor` (`src/actions/cut_wave.ts`): Settings → *Cut a wave*, and `c` on the terminal's waves screen, which says how many pulls would go in, asks `cut wave N? y/n` (`y` and `n` choose outright) and shows the draft pull's number (Enter opens it). Each opens a draft pull listing the pulls merged since the last wave; nothing merged, an earlier wave that does not parse, or a viewer who is not an admin is refused in the action's words, writing nothing.
503505
504506 Cmd-K finds a repository by its name first (exact, then prefix; your own ahead); a repository with an app has an "Open the app of …" option under it, and Cmd/Ctrl+Enter on the repository opens the app (`docs/features/cmd-k-repositories.md`).
bun.lock +15
@@ -8,6 +8,7 @@
88 "@anthropic-ai/sandbox-runtime": "^0.0.79",
99 "@types/bun": "^1.2.0",
1010 "axe-core": "^4.14.0",
11 "happy-dom": "^21.0.4",
1112 "typescript": "^7.0.2",
1213 "typescript-api": "npm:typescript@^5.9.0",
1314 },
@@ -22,6 +23,10 @@
2223
2324 "@types/node": ["@types/[email protected]", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-Vu4a5UFA9rIIFJ7rB/Vaafh9lrCQszopTCx6KjFboXTGQbPNasehVR5TEiithSDGyd1DEiUByggTZsg8jukeIg=="],
2425
26 "@types/whatwg-mimetype": ["@types/[email protected]", "", {}, "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA=="],
27
28 "@types/ws": ["@types/[email protected]", "", { "dependencies": { "@types/node": "*" } }, "sha512-67MQl+fpWKVTT1NYdnmo3U4sc/xPo/zQBncVnI74qmQa0z/b+1g6iYqNmGCPbxO+zz2aklb08a0oHfegiVd0/w=="],
29
2530 "@typescript/typescript-aix-ppc64": ["@typescript/[email protected]", "", { "os": "aix", "cpu": "ppc64" }, "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ=="],
2631
2732 "@typescript/typescript-darwin-arm64": ["@typescript/[email protected]", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA=="],
@@ -64,10 +69,16 @@
6469
6570 "axe-core": ["[email protected]", "", {}, "sha512-9WTZxEjsZ7b13TH8JPmbV2z8CHbl80/2hm3XPEG4JgNdQLK81IBRXmSxHfMAOkSqQeRxT/0dwNDz2GOm3zzpcQ=="],
6671
72 "buffer-image-size": ["[email protected]", "", { "dependencies": { "@types/node": "*" } }, "sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ=="],
73
6774 "bun-types": ["[email protected]", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="],
6875
6976 "commander": ["[email protected]", "", {}, "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA=="],
7077
78 "entities": ["[email protected]", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
79
80 "happy-dom": ["[email protected]", "", { "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", "@types/ws": "^8.18.1", "buffer-image-size": "^0.6.4", "entities": "^7.0.1", "whatwg-mimetype": "^3.0.0", "ws": "^8.21.0" } }, "sha512-tFm+GaLBgo6o0awHnvHaNoraVo27qJ+2tZEtZ+cIa08hXbeitrxAJajLI05hsUy/2/YTKcUjQYzHe5mCcraLBw=="],
81
7182 "node-forge": ["[email protected]", "", {}, "sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ=="],
7283
7384 "typescript": ["[email protected]", "", { "optionalDependencies": { "@typescript/typescript-aix-ppc64": "7.0.2", "@typescript/typescript-darwin-arm64": "7.0.2", "@typescript/typescript-darwin-x64": "7.0.2", "@typescript/typescript-freebsd-arm64": "7.0.2", "@typescript/typescript-freebsd-x64": "7.0.2", "@typescript/typescript-linux-arm": "7.0.2", "@typescript/typescript-linux-arm64": "7.0.2", "@typescript/typescript-linux-loong64": "7.0.2", "@typescript/typescript-linux-mips64el": "7.0.2", "@typescript/typescript-linux-ppc64": "7.0.2", "@typescript/typescript-linux-riscv64": "7.0.2", "@typescript/typescript-linux-s390x": "7.0.2", "@typescript/typescript-linux-x64": "7.0.2", "@typescript/typescript-netbsd-arm64": "7.0.2", "@typescript/typescript-netbsd-x64": "7.0.2", "@typescript/typescript-openbsd-arm64": "7.0.2", "@typescript/typescript-openbsd-x64": "7.0.2", "@typescript/typescript-sunos-x64": "7.0.2", "@typescript/typescript-win32-arm64": "7.0.2", "@typescript/typescript-win32-x64": "7.0.2" }, "bin": { "tsc": "bin/tsc" } }, "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA=="],
@@ -76,6 +87,10 @@
7687
7788 "undici-types": ["[email protected]", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="],
7889
90 "whatwg-mimetype": ["[email protected]", "", {}, "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q=="],
91
92 "ws": ["[email protected]", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-oyL6OPYme/CmiFvNuGzeoxCJ4nOrZE2O1FwM9MWM1AsGri6nLXB2pg+lNA8aLeXMSav0J6eIVS1j6vUPzUtK9g=="],
93
7994 "zod": ["[email protected]", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
8095 }
8196 }
package.json +1
@@ -15,6 +15,7 @@
1515 "@anthropic-ai/sandbox-runtime": "^0.0.79",
1616 "@types/bun": "^1.2.0",
1717 "axe-core": "^4.14.0",
18 "happy-dom": "^21.0.4",
1819 "typescript": "^7.0.2",
1920 "typescript-api": "npm:typescript@^5.9.0"
2021 }
src/release/days.ts added +35
@@ -0,0 +1,35 @@
1 /**
2 * Calendar days as a reader says them. `dayOf` names the day an instant falls
3 * on in a time zone (the server's UTC, or the browser's own), and `dayHeading`
4 * turns a day into Today, Yesterday or a date. Both are written so their
5 * source can be shipped to the browser (`daysJs`) and give the same answers
6 * there: no imports, no types that matter at run time, no closures.
7 */
8
9 /** The day (`YYYY-MM-DD`) an ISO instant falls on in `zone`; the runtime's own zone when none is named. */
10 export function dayOf(at: string, zone?: string): string {
11 const d = new Date(at);
12 if (isNaN(d.getTime())) return at.slice(0, 10);
13 const parts = new Intl.DateTimeFormat("en-CA", { timeZone: zone, year: "numeric", month: "2-digit", day: "2-digit" }).format(d);
14 return parts;
15 }
16
17 /** How a day reads: "Today", "Yesterday", else "Sun 1 Nov", with the year when it is not `today`'s. */
18 export function dayHeading(day: string, today: string): string {
19 const stamp = (s: string) => Date.UTC(+s.slice(0, 4), +s.slice(5, 7) - 1, +s.slice(8, 10));
20 const gap = Math.round((stamp(today) - stamp(day)) / 86400000);
21 if (gap === 0) return "Today";
22 if (gap === 1) return "Yesterday";
23 const d = new Date(stamp(day));
24 const weekday = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"][d.getUTCDay()];
25 const month = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"][d.getUTCMonth()];
26 return `${weekday} ${d.getUTCDate()} ${month}${day.slice(0, 4) === today.slice(0, 4) ? "" : ` ${day.slice(0, 4)}`}`;
27 }
28
29 /** The top line's sentence: how many shipped today, marked UTC when the days are the server's. */
30 export function shippedToday(count: number, utc: boolean): string {
31 return `${count === 0 ? "Nothing shipped today" : `${count} shipped today`}${utc ? " (UTC)" : ""}.`;
32 }
33
34 /** The functions above as browser source. */
35 export const daysJs = `${dayOf.toString()}\n${dayHeading.toString()}\n${shippedToday.toString()}`;
src/web/pages/changelog.ts +70 −11
@@ -8,6 +8,7 @@
88
99 import { notePhase, takeSessionTime, timePhaseAsync } from "../../core/route_timing.ts";
1010 import type { Ctx } from "../../http/context.ts";
11 import { dayHeading, dayOf, daysJs, shippedToday } from "../../release/days.ts";
1112 import { readLandings, type Landing, type Landings } from "../../release/landings.ts";
1213 import { liveRegionsJs } from "../client.ts";
1314 import { pageHead } from "../chrome.ts";
@@ -20,9 +21,6 @@ const LIVE = "pull.merged,app.serving";
2021 /** Other repositories offered as filters: more than this is a search, not a chip. */
2122 const FILTERS = 6;
2223
23 /** The day a landing belongs to, in UTC. */
24 const dayOf = (at: string): string => at.slice(0, 10);
25
2624 /** Which repository the reader asked for: "" for this forge's own, "all", or one named in the list; anything else is the default. */
2725 export function chosenRepo(asked: string | null, all: readonly Landing[], own: string | undefined): string {
2826 if (asked === "all") return "all";
@@ -39,26 +37,87 @@ function landingRow(l: Landing, own: string | undefined, showRepo: boolean): Htm
3937 });
4038 }
4139
42 function byDay(list: readonly Landing[], own: string | undefined, showRepo: boolean): Html[] {
40 /** Day cards in `zone`; the browser regroups them in its own (see `localDaysJs`), so the zone is named while it has not. */
41 function byDay(list: readonly Landing[], own: string | undefined, showRepo: boolean, zone: string, today: string): Html[] {
4342 const days = new Map<string, Landing[]>();
44 for (const l of list) days.set(dayOf(l.at), [...(days.get(dayOf(l.at)) ?? []), l]);
43 for (const l of list) days.set(dayOf(l.at, zone), [...(days.get(dayOf(l.at, zone)) ?? []), l]);
4544 return [...days].map(([day, items]) =>
4645 card(html`<ul class="list">${items.map((l) => landingRow(l, own, showRepo))}</ul>`, {
47 head: html`<h2><time datetime="${day}">${day}</time></h2>${badge(String(items.length))}`,
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}"`,
4848 pad: false,
4949 }),
5050 );
5151 }
5252
53 /**

Empty span when not UTC and a leading-space string: render the span only when zone is UTC.

54 * Moves the changelog's rows into the reader's own days: headings, the
55 * "shipped today" count and the UTC mark follow the browser's time zone.
56 * Without script the server's UTC grouping stands, and says so.
57 */
58 export const localDaysJs = /* js */ `
59 (function () {
60 ${daysJs}
61 function regroup() {
62 var root = document.getElementById('shipped');
63 if (!root) return;
64 var cards = root.querySelectorAll('[data-day]');
65 var top = document.getElementById('shipped-today');
66 if (!cards.length && !top) return;
67 var today = dayOf(new Date().toISOString());
68 var rows = [];
69 cards.forEach(function (c) { c.querySelectorAll('li').forEach(function (li) { rows.push(li); }); });
70 var seen = {}, order = [];
71 rows.forEach(function (li) {

Untested browser path: cloning the first card and moving <li>s is the whole feature. Add a DOM test; today nothing fails if this block is deleted.

72 var t = li.querySelector('time[datetime]');
73 var d = t ? dayOf(t.getAttribute('datetime')) : '';
74 li.setAttribute('data-local-day', d);
75 if (!seen[d]) { seen[d] = []; order.push(d); }
76 seen[d].push(li);
77 });
78 if (cards.length) {
79 var proto = cards[0];
80 var after = proto.previousSibling;
81 var parent = proto.parentNode;
82 var made = order.map(function (d) {
83 var c = proto.cloneNode(true);
84 c.setAttribute('data-day', d);
85 var tm = c.querySelector('h2 time');
86 tm.setAttribute('datetime', d);
87 tm.textContent = dayHeading(d, today);
88 var z = c.querySelector('[data-zone]');
89 if (z) z.parentNode.removeChild(z);
90 var ul = c.querySelector('ul');
91 while (ul.firstChild) ul.removeChild(ul.firstChild);
92 seen[d].forEach(function (li) { ul.appendChild(li); });
93 var n = c.querySelector('.badge');
94 if (n) n.textContent = String(seen[d].length);
95 return c;
96 });
97 cards.forEach(function (c) { parent.removeChild(c); });
98 var ref = after ? after.nextSibling : parent.firstChild;

Client sentence ('N shipped today.') duplicates the server's wording by hand; build both from one function so they cannot drift.

99 made.forEach(function (c) { parent.insertBefore(c, ref); });
100 }
101 if (top) {
102 var n = (seen[today] || []).length;
103 top.textContent = shippedToday(n, false);
104 }
105 }
106 regroup();
107 document.addEventListener('forge:swapped', regroup);
108 })();
109 `;
110
53111 /** The region: the top line, what is on its way, and what went live by day. */
54 export function shippedRegion(read: Landings, choice: string, now: Date = new Date()): Html {
112 export function shippedRegion(read: Landings, choice: string, now: Date = new Date(), zone = "UTC"): Html {
55113 const keep = (l: Landing) => choice === "all" || l.repo === choice;
56114 const live = read.live.filter(keep);
57115 const onItsWay = read.onItsWay.filter(keep);
58 const today = live.filter((l) => dayOf(l.at) === now.toISOString().slice(0, 10)).length;
116 const todayIs = dayOf(now.toISOString(), zone);
117 const today = live.filter((l) => dayOf(l.at, zone) === todayIs).length;
59118 const version = read.commit === null ? read.version : html`${read.version} · ${sha(read.commit)}`;
60119 const top = html`<p class="small muted" id="shipped-top">
61 Live: ${version}. ${today === 0 ? "Nothing shipped today." : `${today} shipped today.`}
120 Live: ${version}. <span id="shipped-today">${shippedToday(today, zone === "UTC")}</span>
62121 </p>`;
63122 const way =
64123 onItsWay.length === 0
@@ -67,7 +126,7 @@ export function shippedRegion(read: Landings, choice: string, now: Date = new Da
67126 const gone =
68127 live.length === 0
69128 ? empty({ title: "Nothing has shipped yet", note: "Each pull appears here once the running commit contains it." })
70 : html`${byDay(live, read.own, choice === "all")}`;
129 : html`${byDay(live, read.own, choice === "all", zone, todayIs)}`;
71130 return html`${top}${way}${gone}`;
72131 }
73132
@@ -86,7 +145,7 @@ export async function changelogPage(ctx: Ctx): Promise<Response> {
86145 ]);
87146 const body = html`${pageHead("Changelog", { sub: "What went live, as it happens." })}${filters}
88147 <div id="shipped" data-live="${LIVE}" aria-live="polite">${shippedRegion(read, choice)}</div>`;
89 const response = page({ ctx, title: "Changelog", body, scriptJs: liveRegionsJs });
148 const response = page({ ctx, title: "Changelog", body, scriptJs: `${liveRegionsJs}\n${localDaysJs}` });
90149 notePhase(ctx.request, "render", performance.now() - rendering);
91150 return response;
92151 }
test/changelog.test.ts +92 −1
@@ -1,4 +1,4 @@
1 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
1 import { afterAll, afterEach, beforeAll, describe, expect, setSystemTime, test } from "bun:test";
22 import { mkdtemp, rm } from "node:fs/promises";
33 import { tmpdir } from "node:os";
44 import { join } from "node:path";
@@ -240,3 +240,94 @@ describe("changelog page", () => {
240240 expect(body).toContain("Nothing has shipped yet");
241241 });
242242 });
243
244 describe("changelog days follow a time zone", () => {
245 const landing = (at: string) => ({ repo: "a/forge", number: 1, title: "Late merge", at, commit: "" }) as any;
246 const read = (at: string) => ({ live: [landing(at)], onItsWay: [], version: "1", commit: null, own: "a/forge" }) as any;
247 // 6:30 pm on 1 Nov in Chicago (UTC-5 after the clocks went back) is already 2 Nov in UTC.
248 const at = "2026-11-01T23:30:00.000Z";
249 const now = new Date("2026-11-02T00:10:00.000Z");
250
251 test("a merge late in the day west of UTC is today there, and the next day in UTC", async () => {
252 const { shippedRegion } = await import("../src/web/pages/changelog.ts");
253 const west = String(shippedRegion(read(at), "all", now, "America/Chicago"));
254 expect(west).toContain(">Today<");
255 expect(west).toContain('data-day="2026-11-01"');
256 expect(west).toContain("1 shipped today.");
257 expect(west).not.toContain("(UTC)");
258 expect(west).not.toContain("· UTC");
259 const utc = String(shippedRegion(read(at), "all", new Date("2026-11-02T10:00:00.000Z")));
260 expect(utc).toContain(">Yesterday<");
261 expect(utc).toContain("(UTC)");
262 expect(utc).toContain("data-zone");
263 });
264
265 test("headings read Today, Yesterday, then a date", async () => {
266 const { dayHeading, dayOf, daysJs } = await import("../src/release/days.ts");
267 expect(dayHeading("2026-11-02", "2026-11-02")).toBe("Today");
268 expect(dayHeading("2026-11-01", "2026-11-02")).toBe("Yesterday");
269 expect(dayHeading("2026-10-30", "2026-11-02")).toBe("Fri 30 Oct");
270 expect(dayHeading("2025-12-31", "2026-01-01")).toBe("Yesterday");
271 expect(dayHeading("2025-12-30", "2026-01-01")).toBe("Tue 30 Dec 2025");
272 expect(dayOf(at, "America/Chicago")).toBe("2026-11-01");
273 expect(dayOf(at, "UTC")).toBe("2026-11-01");
274 expect(dayOf("2026-11-02T01:30:00Z", "America/Chicago")).toBe("2026-11-01");
275 expect(dayOf("2026-11-02T01:30:00Z", "UTC")).toBe("2026-11-02");
276 // The source sent to the browser answers the same.
277 const shipped = new Function(`${daysJs}; return { dayOf, dayHeading };`)();
278 expect(shipped.dayHeading("2026-10-30", "2026-11-02")).toBe("Fri 30 Oct");
279 expect(shipped.dayOf("2026-11-02T01:30:00Z")).toMatch(/^2026-11-0[12]$/);
280 });
281 });
282
283 describe("the browser regroups the changelog into the reader's days", () => {
284 const rows = [
285 { repo: "a/forge", number: 3, title: "Three", at: "2026-11-03T02:00:00.000Z", commit: "" },
286 { repo: "a/forge", number: 2, title: "Two", at: "2026-11-02T20:00:00.000Z", commit: "" },
287 { repo: "a/forge", number: 1, title: "One", at: "2026-11-02T01:30:00.000Z", commit: "" },
288 ] as any[];
289 const read = { live: rows, onItsWay: [], version: "1", commit: null, own: "a/forge" } as any;
290 const server = async () => {
291 const { shippedRegion } = await import("../src/web/pages/changelog.ts");
292 return `<div id="shipped">${String(shippedRegion(read, "all", new Date("2026-11-03T03:00:00.000Z")))}</div>`;
293 };
294 const titles = (card: any) => [...(card.querySelectorAll(".row-title") as any[])].map((e: any) => e.textContent);
295 let zone: string | undefined;
296 afterEach(() => {
297 setSystemTime();
298 if (zone === undefined) delete process.env["TZ"];
299 else process.env["TZ"] = zone;
300 });
301
302 test("west of UTC rows move between cards, headings read Today and Yesterday, counts and the UTC marks follow", async () => {
303 const { Window } = await import("happy-dom");
304 const { localDaysJs } = await import("../src/web/pages/changelog.ts");
305 zone = process.env["TZ"];
306 process.env["TZ"] = "America/Chicago";
307 setSystemTime(new Date("2026-11-03T03:00:00.000Z")); // 9 pm on 2 Nov in Chicago
308 const window = new Window();
309 const doc = window.document as any;
310 doc.body.innerHTML = await server();
311 // What the server drew: UTC days, one of them emptied below, and the UTC marks.
312 expect(doc.querySelectorAll("[data-day]").length).toBe(2);
313 expect(doc.getElementById("shipped-today")!.textContent).toBe("1 shipped today (UTC).");
314 expect(doc.querySelectorAll("[data-zone]").length).toBe(2);
315 new Function("document", localDaysJs)(doc);
316 const cards = [...doc.querySelectorAll("[data-day]")];
317 expect(cards.map((c) => c.getAttribute("data-day"))).toEqual(["2026-11-02", "2026-11-01"]);
318 expect(cards.map((c) => c.querySelector("h2 time")!.textContent)).toEqual(["Today", "Yesterday"]);
319 expect(cards.map(titles)).toEqual([["Three", "Two"], ["One"]]);
320 expect(cards.map((c) => c.querySelector(".badge")!.textContent)).toEqual(["2", "1"]);
321 expect(doc.getElementById("shipped-today")!.textContent).toBe("2 shipped today.");
322 expect(doc.querySelectorAll("[data-zone]").length).toBe(0);
323 expect(doc.body.textContent).not.toContain("UTC");
324
325 // A swap puts the server's UTC cards back; the client regroups them again.
326 doc.getElementById("shipped")!.innerHTML = (await server()).replace(/^<div id="shipped">|<\/div>$/g, "");
327 expect(doc.querySelectorAll("[data-day]").length).toBe(2);
328 doc.dispatchEvent(new window.Event("forge:swapped") as unknown as Event);
329 expect([...doc.querySelectorAll("[data-day]")].map(titles)).toEqual([["Three", "Two"], ["One"]]);
330 expect(doc.getElementById("shipped-today")!.textContent).toBe("2 shipped today.");
331 await window.happyDOM.close();
332 });
333 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs