Public: anyone can read this; the crew can change it.
The changelog's days and "shipped today" follow the reader's clock #7902
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/7898-the-changelog-s-days-and-shipped-today-f → main 3 commits fork 3 threads · 3 unresolved
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 | ||
| 499 | 499 | |
| 500 | 500 | `/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`). |
| 501 | 501 | |
| 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 | ||
| 502 | 504 | 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. |
| 503 | 505 | |
| 504 | 506 | 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 @@ | ||
| 8 | 8 | "@anthropic-ai/sandbox-runtime": "^0.0.79", |
| 9 | 9 | "@types/bun": "^1.2.0", |
| 10 | 10 | "axe-core": "^4.14.0", |
| 11 | "happy-dom": "^21.0.4", | |
| 11 | 12 | "typescript": "^7.0.2", |
| 12 | 13 | "typescript-api": "npm:typescript@^5.9.0", |
| 13 | 14 | }, |
| @@ -22,6 +23,10 @@ | ||
| 22 | 23 | |
| 23 | 24 | "@types/node": ["@types/[email protected]", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-Vu4a5UFA9rIIFJ7rB/Vaafh9lrCQszopTCx6KjFboXTGQbPNasehVR5TEiithSDGyd1DEiUByggTZsg8jukeIg=="], |
| 24 | 25 | |
| 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 | ||
| 25 | 30 | "@typescript/typescript-aix-ppc64": ["@typescript/[email protected]", "", { "os": "aix", "cpu": "ppc64" }, "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ=="], |
| 26 | 31 | |
| 27 | 32 | "@typescript/typescript-darwin-arm64": ["@typescript/[email protected]", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA=="], |
| @@ -64,10 +69,16 @@ | ||
| 64 | 69 | |
| 65 | 70 | "axe-core": ["[email protected]", "", {}, "sha512-9WTZxEjsZ7b13TH8JPmbV2z8CHbl80/2hm3XPEG4JgNdQLK81IBRXmSxHfMAOkSqQeRxT/0dwNDz2GOm3zzpcQ=="], |
| 66 | 71 | |
| 72 | "buffer-image-size": ["[email protected]", "", { "dependencies": { "@types/node": "*" } }, "sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ=="], | |
| 73 | ||
| 67 | 74 | "bun-types": ["[email protected]", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], |
| 68 | 75 | |
| 69 | 76 | "commander": ["[email protected]", "", {}, "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA=="], |
| 70 | 77 | |
| 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 | ||
| 71 | 82 | "node-forge": ["[email protected]", "", {}, "sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ=="], |
| 72 | 83 | |
| 73 | 84 | "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 @@ | ||
| 76 | 87 | |
| 77 | 88 | "undici-types": ["[email protected]", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="], |
| 78 | 89 | |
| 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 | ||
| 79 | 94 | "zod": ["[email protected]", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], |
| 80 | 95 | } |
| 81 | 96 | } |
package.json +1
| @@ -15,6 +15,7 @@ | ||
| 15 | 15 | "@anthropic-ai/sandbox-runtime": "^0.0.79", |
| 16 | 16 | "@types/bun": "^1.2.0", |
| 17 | 17 | "axe-core": "^4.14.0", |
| 18 | "happy-dom": "^21.0.4", | |
| 18 | 19 | "typescript": "^7.0.2", |
| 19 | 20 | "typescript-api": "npm:typescript@^5.9.0" |
| 20 | 21 | } |
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 @@ | ||
| 8 | 8 | |
| 9 | 9 | import { notePhase, takeSessionTime, timePhaseAsync } from "../../core/route_timing.ts"; |
| 10 | 10 | import type { Ctx } from "../../http/context.ts"; |
| 11 | import { dayHeading, dayOf, daysJs, shippedToday } from "../../release/days.ts"; | |
| 11 | 12 | import { readLandings, type Landing, type Landings } from "../../release/landings.ts"; |
| 12 | 13 | import { liveRegionsJs } from "../client.ts"; |
| 13 | 14 | import { pageHead } from "../chrome.ts"; |
| @@ -20,9 +21,6 @@ const LIVE = "pull.merged,app.serving"; | ||
| 20 | 21 | /** Other repositories offered as filters: more than this is a search, not a chip. */ |
| 21 | 22 | const FILTERS = 6; |
| 22 | 23 | |
| 23 | /** The day a landing belongs to, in UTC. */ | |
| 24 | const dayOf = (at: string): string => at.slice(0, 10); | |
| 25 | ||
| 26 | 24 | /** Which repository the reader asked for: "" for this forge's own, "all", or one named in the list; anything else is the default. */ |
| 27 | 25 | export function chosenRepo(asked: string | null, all: readonly Landing[], own: string | undefined): string { |
| 28 | 26 | if (asked === "all") return "all"; |
| @@ -39,26 +37,87 @@ function landingRow(l: Landing, own: string | undefined, showRepo: boolean): Htm | ||
| 39 | 37 | }); |
| 40 | 38 | } |
| 41 | 39 | |
| 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[] { | |
| 43 | 42 | 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]); | |
| 45 | 44 | return [...days].map(([day, items]) => |
| 46 | 45 | 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}"`, | |
| 48 | 48 | pad: false, |
| 49 | 49 | }), |
| 50 | 50 | ); |
| 51 | 51 | } |
| 52 | 52 | |
| 53 | /** | |
| 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 | ||
| 53 | 111 | /** 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 { | |
| 55 | 113 | const keep = (l: Landing) => choice === "all" || l.repo === choice; |
| 56 | 114 | const live = read.live.filter(keep); |
| 57 | 115 | 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; | |
| 59 | 118 | const version = read.commit === null ? read.version : html`${read.version} · ${sha(read.commit)}`; |
| 60 | 119 | 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> | |
| 62 | 121 | </p>`; |
| 63 | 122 | const way = |
| 64 | 123 | onItsWay.length === 0 |
| @@ -67,7 +126,7 @@ export function shippedRegion(read: Landings, choice: string, now: Date = new Da | ||
| 67 | 126 | const gone = |
| 68 | 127 | live.length === 0 |
| 69 | 128 | ? 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)}`; | |
| 71 | 130 | return html`${top}${way}${gone}`; |
| 72 | 131 | } |
| 73 | 132 | |
| @@ -86,7 +145,7 @@ export async function changelogPage(ctx: Ctx): Promise<Response> { | ||
| 86 | 145 | ]); |
| 87 | 146 | const body = html`${pageHead("Changelog", { sub: "What went live, as it happens." })}${filters} |
| 88 | 147 | <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}` }); | |
| 90 | 149 | notePhase(ctx.request, "render", performance.now() - rendering); |
| 91 | 150 | return response; |
| 92 | 151 | } |
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"; | |
| 2 | 2 | import { mkdtemp, rm } from "node:fs/promises"; |
| 3 | 3 | import { tmpdir } from "node:os"; |
| 4 | 4 | import { join } from "node:path"; |
| @@ -240,3 +240,94 @@ describe("changelog page", () => { | ||
| 240 | 240 | expect(body).toContain("Nothing has shipped yet"); |
| 241 | 241 | }); |
| 242 | 242 | }); |
| 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 | }); |
new above
offline
Empty span when not UTC and a leading-space string: render the span only when zone is UTC.