Public: anyone can read this; the crew can change it.
Time in words: src/when.ts, and a countdown that does not move the page #39
Check · Review · Land · Live done — Landed and live in production.
mason/parley:mason/20-time-in-words-src-when-ts-and-a-countdow → main 4 commits fork
10 files
changed +536
−9
AGENTS.md +11
| @@ -33,6 +33,17 @@ door reads its wire format and shapes the answer; it decides nothing. A | ||
| 33 | 33 | rule in an action holds at every door at once; a rule anywhere else is a |
| 34 | 34 | bug waiting for the door you forgot. |
| 35 | 35 | |
| 36 | A time is said once, in one vocabulary. `src/when.ts` is every phrase the | |
| 37 | app has for a moment — "4h 12m left", "20m ago", "14 Sep 2026, 09:41 UTC" — | |
| 38 | and `timeTag` is the markup that carries one. Nothing formats a time for | |
| 39 | itself. The words themselves are in `static/when.js`, which the browser | |
| 40 | loads as well, because the server renders a countdown at paint and | |
| 41 | `static/live.js` re-renders it a second later: two runtimes, one file, so | |
| 42 | they cannot drift. That is the one import that crosses from `src/` into | |
| 43 | `static/`, and it goes that way round — `static/when.js` stays plain, | |
| 44 | pure JavaScript with no import of its own, no DOM and no `Intl`. The server | |
| 45 | renders UTC and guesses no locale; only the browser knows the reader's zone. | |
| 46 | ||
| 36 | 47 | A rule of the game is not an action. `src/board.ts` is the deal, the score |
| 37 | 48 | and the sworn arithmetic from `RULES.md` — a seed and a seat count in, a |
| 38 | 49 | charter and charges out; a charge and an accord in, what it pays and what a |
README.md +12
| @@ -29,6 +29,7 @@ decide nothing themselves. `AGENTS.md` says how to add to it. | ||
| 29 | 29 | | ---------------------------------- | ------------------------------------------------------------ | |
| 30 | 30 | | `src/actions.ts` | open, sit, leave, read a table — and who may see what | |
| 31 | 31 | | `src/board.ts` | the deal, the score and the sworn rule: by RULES.md, no db | |
| 32 | | `src/when.ts` | time in words: "4h 12m left", "20m ago", a UTC moment | | |
| 32 | 33 | | `src/result.ts` | data or a named failure — the answer shape every door reads | |
| 33 | 34 | | `src/who.ts` | who is asking, from the forge's signed headers | |
| 34 | 35 | | `src/db.ts` | SQLite under `FORGE_APP_DATA`, WAL, forward-only migrations | |
| @@ -41,6 +42,7 @@ decide nothing themselves. `AGENTS.md` says how to add to it. | ||
| 41 | 42 | | `src/tty.ts` | the terminal, over the same actions | |
| 42 | 43 | | `src/env.ts` | what the forge told this process | |
| 43 | 44 | | `static/app.css`, `static/live.js` | the look, and the list that stays current | |
| 45 | | `static/when.js` | the words for a time, run by the server and the browser both | | |
| 44 | 46 | |
| 45 | 47 | ## The table |
| 46 | 48 | |
| @@ -127,6 +129,16 @@ The page stays current over `/events`: a table someone else opens, sits at | ||
| 127 | 129 | or leaves appears without a reload. Without JavaScript it is the same page, |
| 128 | 130 | one reload behind. |
| 129 | 131 | |
| 132 | Times are said the same way everywhere — "4h 12m left", "20m ago", "14 Sep | |
| 133 | 2026, 09:41 UTC" — because the server and the browser format them with the | |
| 134 | same file (`static/when.js`, typed and wrapped in markup by `src/when.ts`). | |
| 135 | The server writes the instant into the `<time datetime>` in UTC and never | |
| 136 | guesses a zone; the browser re-renders the words from that same attribute | |
| 137 | once a second — one interval for the whole page, stopped while the tab is | |
| 138 | hidden, and not a single request — and puts the reader's local time in the | |
| 139 | hover text. Without JavaScript the words are right at paint and then stand | |
| 140 | still. | |
| 141 | ||
| 130 | 142 | The terminal opens with the exact command in `FORGE_APP_SSH` when the app |
| 131 | 143 | runs (`ssh -p 2222 <name>@<forge>` — the app is the login name, the forge |
| 132 | 144 | the host; `<name>--<pull>@<forge>` for a preview's). It is `src/tty.ts` on |
src/page.ts +4 −8
| @@ -7,6 +7,7 @@ | ||
| 7 | 7 | import { canJoin, canLeave, SEATS, TITLE_MAX, type Table } from "./actions.ts"; |
| 8 | 8 | import { commit, description, env, ssh, title, where } from "./env.ts"; |
| 9 | 9 | import { asset, escape } from "./http.ts"; |
| 10 | import { duration, timeTag } from "./when.ts"; | |
| 10 | 11 | import type { Who } from "./who.ts"; |
| 11 | 12 | |
| 12 | 13 | export function layout(body: string, who: Who): string { |
| @@ -84,7 +85,7 @@ const item = (table: Table, who: Who): string => `<li class="item"> | ||
| 84 | 85 | <div> |
| 85 | 86 | <p><b>${escape(table.title)}</b> <span class="pill">${escape(table.status)}</span></p> |
| 86 | 87 | <p class="muted">${table.seated}/${table.seats_max} seated · ${escape(names(table))}</p> |
| 87 | <small class="muted">Opened by ${escape(table.host_name)} · ${duration(table.turn_seconds)} a turn · ${duration(table.deadline_seconds)} in all · <a href="/api/tables/${table.id}">state</a></small> | |
| 88 | <small class="muted">Opened by ${escape(table.host_name)} ${timeTag(table.created_at)} · ${span(table.turn_seconds)} a turn · ${span(table.deadline_seconds)} in all · <a href="/api/tables/${table.id}">state</a></small> | |
| 88 | 89 | </div> |
| 89 | 90 | ${seatButton(table, who)} |
| 90 | 91 | </li>`; |
| @@ -104,10 +105,5 @@ const seatButton = (table: Table, who: Who): string => { | ||
| 104 | 105 | return ""; |
| 105 | 106 | }; |
| 106 | 107 | |
| 107 | /** Seconds as a person would say them: 6h, 3d, 45m. */ | |
| 108 | const duration = (seconds: number): string => { | |
| 109 | if (seconds >= 86_400) return `${Math.round(seconds / 86_400)}d`; | |
| 110 | if (seconds >= 3_600) return `${Math.round(seconds / 3_600)}h`; | |
| 111 | if (seconds >= 60) return `${Math.round(seconds / 60)}m`; | |
| 112 | return `${seconds}s`; | |
| 113 | }; | |
| 108 | /** A window in seconds, in the app's one vocabulary (`src/when.ts`): 6h, 3d, 45m. */ | |
| 109 | const span = (seconds: number): string => duration(seconds * 1000); |
src/server.test.ts +20
| @@ -114,6 +114,26 @@ test("a form opens a table and takes a seat; a guest is sent to sign in", async | ||
| 114 | 114 | expect(await (await fetch(base + "/")).text()).toContain("2/5 seated"); |
| 115 | 115 | }); |
| 116 | 116 | |
| 117 | test("a time on the page is the instant, in words, in a slot that ticks", async () => { | |
| 118 | await openTable(alice, "The Hour Glass", { turn_seconds: 5400 }); | |
| 119 | const page = await (await api("/", {}, alice)).text(); | |
| 120 | const tag = /<time class="when"[^>]*>[^<]*<\/time>/.exec(page)?.[0]; | |
| 121 | expect(tag).toBeDefined(); | |
| 122 | // The kind the browser re-renders with, and the instant it renders from. | |
| 123 | expect(tag).toContain('data-when="relative"'); | |
| 124 | expect(tag).toMatch(/datetime="\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z"/); | |
| 125 | expect(tag).toContain("UTC"); // The title is UTC: the server guesses no zone. | |
| 126 | expect(tag).toContain(">just now<"); | |
| 127 | // A turn window is the same vocabulary, and says what it really is: an | |
| 128 | // hour and a half is "1h 30m a turn", never a rounded "2h". | |
| 129 | expect(page).toContain("1h 30m a turn"); | |
| 130 | // The browser gets the same words from the same file the server used. | |
| 131 | const when = await fetch(base + "/static/when.js"); | |
| 132 | expect(when.headers.get("content-type")).toContain("javascript"); | |
| 133 | expect(when.headers.get("x-content-type-options")).toBe("nosniff"); | |
| 134 | expect(await when.text()).toContain("export function remaining"); | |
| 135 | }); | |
| 136 | ||
| 117 | 137 | test("the JSON API is the same actions with the same identity", async () => { |
| 118 | 138 | const id = await openTable(alice, "The Salvage Hearing"); |
| 119 | 139 | const state = (await (await fetch(`${base}/api/tables/${id}`)).json()) as { |
src/tty.ts +2 −1
| @@ -20,6 +20,7 @@ import { | ||
| 20 | 20 | } from "./actions.ts"; |
| 21 | 21 | import { openDb } from "./db.ts"; |
| 22 | 22 | import { ssh, title, where } from "./env.ts"; |
| 23 | import { relative } from "./when.ts"; | |
| 23 | 24 | import { whoAtTty } from "./who.ts"; |
| 24 | 25 | |
| 25 | 26 | const db = openDb(); |
| @@ -60,7 +61,7 @@ function draw(): void { | ||
| 60 | 61 | const here = i === cursor; |
| 61 | 62 | const name = here ? bold(table.title) : table.title; |
| 62 | 63 | lines.push( |
| 63 | `${here ? "▸" : " "} ${name} ${dim(`${table.status} · ${table.seated}/${table.seats_max} seated · host ${table.host_name}${table.your_seat === null ? "" : " · you are seated"}`)}`, | |
| 64 | `${here ? "▸" : " "} ${name} ${dim(`${table.status} · ${table.seated}/${table.seats_max} seated · host ${table.host_name} · opened ${relative(table.created_at, Date.now())}${table.your_seat === null ? "" : " · you are seated"}`)}`, | |
| 64 | 65 | ); |
| 65 | 66 | }); |
| 66 | 67 | const here = rows[cursor]; |
src/when.test.ts added +187
| @@ -0,0 +1,187 @@ | ||
| 1 | import { expect, test } from "bun:test"; | |
| 2 | import { | |
| 3 | absolute, | |
| 4 | duration, | |
| 5 | instant, | |
| 6 | phrase, | |
| 7 | relative, | |
| 8 | remaining, | |
| 9 | timeTag, | |
| 10 | UNKNOWN, | |
| 11 | } from "./when.ts"; | |
| 12 | ||
| 13 | /** The clock every case is handed: nothing here reads the real one. */ | |
| 14 | const NOW = "2026-09-14T09:41:03.000Z"; | |
| 15 | const now = Date.parse(NOW); | |
| 16 | ||
| 17 | const s = 1000; | |
| 18 | const m = 60 * s; | |
| 19 | const h = 60 * m; | |
| 20 | const d = 24 * h; | |
| 21 | ||
| 22 | test("a span is at most two units, largest first, zeroes dropped", () => { | |
| 23 | const table: [number, string][] = [ | |
| 24 | [0, "0s"], | |
| 25 | [1, "0s"], | |
| 26 | [999, "0s"], | |
| 27 | [1 * s, "1s"], | |
| 28 | [59 * s, "59s"], | |
| 29 | [60 * s, "1m"], | |
| 30 | [61 * s, "1m 1s"], | |
| 31 | [90 * s, "1m 30s"], | |
| 32 | [59 * m + 59 * s, "59m 59s"], | |
| 33 | [60 * m, "1h"], | |
| 34 | [90 * m, "1h 30m"], | |
| 35 | [4 * h + 12 * m + 59 * s, "4h 12m"], | |
| 36 | [23 * h + 59 * m, "23h 59m"], | |
| 37 | [24 * h, "1d"], | |
| 38 | [24 * h + 30 * m, "1d"], | |
| 39 | [25 * h, "1d 1h"], | |
| 40 | [8 * d, "8d"], | |
| 41 | [8 * d + 3 * h + 20 * m, "8d 3h"], | |
| 42 | [365 * d, "365d"], | |
| 43 | // A span cannot be negative: there is no "-1m" in this vocabulary. | |
| 44 | [-1, "0s"], | |
| 45 | [-90 * s, "0s"], | |
| 46 | ]; | |
| 47 | for (const [ms, expected] of table) expect(duration(ms)).toBe(expected); | |
| 48 | }); | |
| 49 | ||
| 50 | test("a deadline counts down and then says it has lapsed, never both", () => { | |
| 51 | const at = (ms: number) => new Date(now + ms).toISOString(); | |
| 52 | const table: [string, string][] = [ | |
| 53 | [at(4 * h + 12 * m), "4h 12m left"], | |
| 54 | [at(90 * m), "1h 30m left"], | |
| 55 | [at(24 * h), "1d left"], | |
| 56 | [at(8 * d), "8d left"], | |
| 57 | [at(60 * s), "1m left"], | |
| 58 | [at(59 * s), "59s left"], | |
| 59 | [at(1), "0s left"], | |
| 60 | // On the second it arrives, and every second after, it has lapsed. | |
| 61 | [at(0), "lapsed"], | |
| 62 | [at(-1), "lapsed"], | |
| 63 | [at(-20 * m), "lapsed"], | |
| 64 | [at(-8 * d), "lapsed"], | |
| 65 | ]; | |
| 66 | for (const [until, expected] of table) | |
| 67 | expect(remaining(until, now)).toBe(expected); | |
| 68 | }); | |
| 69 | ||
| 70 | test("a past time reads as past, a future one as future, and the minute around now is 'just now'", () => { | |
| 71 | const at = (ms: number) => new Date(now + ms).toISOString(); | |
| 72 | const table: [string, string][] = [ | |
| 73 | [at(0), "just now"], | |
| 74 | [at(-1 * s), "just now"], | |
| 75 | [at(-59 * s), "just now"], | |
| 76 | [at(59 * s), "just now"], | |
| 77 | [at(-60 * s), "1m ago"], | |
| 78 | [at(-20 * m), "20m ago"], | |
| 79 | [at(-90 * m), "1h 30m ago"], | |
| 80 | [at(-(23 * h + 59 * m)), "23h 59m ago"], | |
| 81 | [at(-24 * h), "1d ago"], | |
| 82 | [at(-8 * d), "8d ago"], | |
| 83 | [at(60 * s), "in 1m"], | |
| 84 | [at(4 * h + 12 * m), "in 4h 12m"], | |
| 85 | [at(8 * d), "in 8d"], | |
| 86 | ]; | |
| 87 | for (const [when, expected] of table) | |
| 88 | expect(relative(when, now)).toBe(expected); | |
| 89 | }); | |
| 90 | ||
| 91 | test("an absolute time is UTC, readable, and carries its ISO string", () => { | |
| 92 | expect(absolute(NOW)).toEqual({ | |
| 93 | text: "14 Sep 2026, 09:41 UTC", | |
| 94 | iso: "2026-09-14T09:41:03.000Z", | |
| 95 | }); | |
| 96 | expect(absolute(0)).toEqual({ | |
| 97 | text: "1 Jan 1970, 00:00 UTC", | |
| 98 | iso: "1970-01-01T00:00:00.000Z", | |
| 99 | }); | |
| 100 | expect(absolute(new Date("2026-12-31T23:05:00Z")).text).toBe( | |
| 101 | "31 Dec 2026, 23:05 UTC", | |
| 102 | ); | |
| 103 | // The same instant however it is held: string, milliseconds, Date. | |
| 104 | for (const at of [NOW, now, new Date(now)]) | |
| 105 | expect(absolute(at).iso).toBe(NOW); | |
| 106 | }); | |
| 107 | ||
| 108 | test("a value that is not a time is unknown, not a crash and not a guess", () => { | |
| 109 | for (const bad of [ | |
| 110 | "", | |
| 111 | " ", | |
| 112 | "yesterday", | |
| 113 | "2026-13-45T99:99:99Z", | |
| 114 | Number.NaN, | |
| 115 | Number.POSITIVE_INFINITY, | |
| 116 | new Date("nonsense"), | |
| 117 | null, | |
| 118 | undefined, | |
| 119 | {}, | |
| 120 | ] as unknown as string[]) { | |
| 121 | expect(instant(bad)).toBeNull(); | |
| 122 | expect(remaining(bad, now)).toBe(UNKNOWN); | |
| 123 | expect(relative(bad, now)).toBe(UNKNOWN); | |
| 124 | expect(absolute(bad)).toEqual({ text: UNKNOWN, iso: "" }); | |
| 125 | expect(timeTag(bad, now, "remaining")).toBe( | |
| 126 | '<span class="when">unknown</span>', | |
| 127 | ); | |
| 128 | } | |
| 129 | expect(phrase("nothing" as "relative", NOW, now)).toBe(UNKNOWN); | |
| 130 | }); | |
| 131 | ||
| 132 | test("the tag carries the instant a machine reads and the words a person reads", () => { | |
| 133 | const until = new Date(now + 4 * h + 12 * m).toISOString(); | |
| 134 | expect(timeTag(until, now, "remaining")).toBe( | |
| 135 | `<time class="when" data-when="remaining" datetime="2026-09-14T13:53:03.000Z" title="14 Sep 2026, 13:53 UTC">4h 12m left</time>`, | |
| 136 | ); | |
| 137 | expect(timeTag(new Date(now - 20 * m), now)).toBe( | |
| 138 | `<time class="when" data-when="relative" datetime="2026-09-14T09:21:03.000Z" title="14 Sep 2026, 09:21 UTC">20m ago</time>`, | |
| 139 | ); | |
| 140 | expect(timeTag(NOW, now, "absolute")).toContain(">14 Sep 2026, 09:41 UTC<"); | |
| 141 | }); | |
| 142 | ||
| 143 | test("nothing a caller passes is echoed into the markup", () => { | |
| 144 | // `datetime` is the canonical ISO form of the parsed instant, never the | |
| 145 | // string that was handed in, so there is no attribute to break out of. | |
| 146 | const sneaky = `2026-09-14T09:41:03.000Z" onmouseover="alert(1)`; | |
| 147 | const tag = timeTag(sneaky, now, "relative"); | |
| 148 | expect(tag).not.toContain("onmouseover"); | |
| 149 | expect(tag).not.toContain("alert"); | |
| 150 | // Whether that string parses or not, only ISO 8601 reaches the attribute. | |
| 151 | const datetime = /datetime="([^"]*)"/.exec(tag)?.[1]; | |
| 152 | if (datetime !== undefined) | |
| 153 | expect(datetime).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/); | |
| 154 | }); | |
| 155 | ||
| 156 | test("the server renders UTC and asks no locale", async () => { | |
| 157 | const code = ( | |
| 158 | await Bun.file(new URL("../static/when.js", import.meta.url)).text() | |
| 159 | ) | |
| 160 | .replace(/\/\*[\s\S]*?\*\//g, "") | |
| 161 | .replace(/\/\/.*/g, ""); | |
| 162 | for (const guess of ["Intl", "toLocale", "getTimezoneOffset"]) | |
| 163 | expect(code).not.toContain(guess); | |
| 164 | ||
| 165 | // Every Date field it reads is a UTC one (`getTime` is epoch, so it counts), | |
| 166 | // which is why a server in any zone renders the same words. | |
| 167 | expect(code).not.toMatch(/\.get(?!UTC|Time\b)[A-Z]\w*\(/); | |
| 168 | }); | |
| 169 | ||
| 170 | test("the browser ticks the same words, once for the whole page, for free", async () => { | |
| 171 | const code = ( | |
| 172 | await Bun.file(new URL("../static/live.js", import.meta.url)).text() | |
| 173 | ) | |
| 174 | .replace(/\/\*[\s\S]*?\*\//g, "") | |
| 175 | .replace(/\/\/.*/g, ""); | |
| 176 | // The same vocabulary as the server, not a second copy of it. | |
| 177 | expect(code).toContain(`from "./when.js"`); | |
| 178 | // One interval for the page however many clocks are on it, stopped while | |
| 179 | // the tab is hidden: this is the app's only per-second work. | |
| 180 | expect(code.match(/setInterval\(/g)).toHaveLength(1); | |
| 181 | expect(code).toContain("clearInterval"); | |
| 182 | expect(code).toContain("visibilitychange"); | |
| 183 | // A countdown asks the network for nothing: it has the instant already. | |
| 184 | const clocks = code.slice(code.indexOf("function tick(")); | |
| 185 | for (const request of ["fetch(", "EventSource", "XMLHttpRequest"]) | |
| 186 | expect(clocks).not.toContain(request); | |
| 187 | }); |
src/when.ts added +83
| @@ -0,0 +1,83 @@ | ||
| 1 | /** | |
| 2 | * How this app says a time, for every door at once. | |
| 3 | * | |
| 4 | * Pure: an instant in, a string out, `now` handed in by the caller, no | |
| 5 | * database, no clock of its own, no dependency — so a page that counts down | |
| 6 | * can be tested against a clock that never moves. | |
| 7 | * | |
| 8 | * The words themselves live in `static/when.js`, which the browser loads | |
| 9 | * too: the server renders the text at paint and `static/live.js` re-renders | |
| 10 | * it every second from the same file, so the two cannot drift apart. This | |
| 11 | * module is that file with types on it, plus the one thing only a server | |
| 12 | * does — `timeTag`, the escaped markup every page uses. | |
| 13 | */ | |
| 14 | ||
| 15 | import { | |
| 16 | absolute as absoluteOf, | |
| 17 | duration as durationOf, | |
| 18 | instant as instantOf, | |
| 19 | phrase as phraseOf, | |
| 20 | relative as relativeOf, | |
| 21 | remaining as remainingOf, | |
| 22 | UNKNOWN as UNKNOWN_, | |
| 23 | } from "../static/when.js"; | |
| 24 | import { escape } from "./http.ts"; | |
| 25 | ||
| 26 | /** An instant, however the caller holds it: ISO string, epoch milliseconds, `Date`. */ | |
| 27 | export type TimeIn = string | number | Date; | |
| 28 | ||
| 29 | /** The three ways a time is said. A `<time>` carries its kind in `data-when`. */ | |
| 30 | export type Kind = "remaining" | "relative" | "absolute"; | |
| 31 | ||
| 32 | /** What an unreadable time says, at every door. */ | |
| 33 | export const UNKNOWN: string = UNKNOWN_; | |
| 34 | ||
| 35 | /** Epoch milliseconds, or `null` when the value is not a time at all. */ | |
| 36 | export const instant = instantOf as (at: TimeIn) => number | null; | |
| 37 | ||
| 38 | /** A span in words: "0s", "1m", "1h 30m", "8d 3h" — at most two units. */ | |
| 39 | export const duration = durationOf as (ms: number) => string; | |
| 40 | ||
| 41 | /** "4h 12m left", or "lapsed" once the deadline is here. */ | |
| 42 | export const remaining = remainingOf as (until: TimeIn, now: TimeIn) => string; | |
| 43 | ||
| 44 | /** "20m ago", "just now", "in 4h 12m". */ | |
| 45 | export const relative = relativeOf as (at: TimeIn, now: TimeIn) => string; | |
| 46 | ||
| 47 | /** A readable UTC moment and the ISO string it came from. */ | |
| 48 | export const absolute = absoluteOf as (at: TimeIn) => { | |
| 49 | text: string; | |
| 50 | iso: string; | |
| 51 | }; | |
| 52 | ||
| 53 | /** One of the three, by name. */ | |
| 54 | export const phrase = phraseOf as ( | |
| 55 | kind: Kind, | |
| 56 | at: TimeIn, | |
| 57 | now: TimeIn, | |
| 58 | ) => string; | |
| 59 | ||
| 60 | /** | |
| 61 | * The markup every page uses for a time: | |
| 62 | * | |
| 63 | * ```html | |
| 64 | * <time class="when" data-when="remaining" datetime="2026-09-14T09:41:03.000Z" | |
| 65 | * title="14 Sep 2026, 09:41 UTC">4h 12m left</time> | |
| 66 | * ``` | |
| 67 | * | |
| 68 | * The `datetime` attribute is the canonical ISO form of the instant, not | |
| 69 | * the caller's string — nothing a caller passes is ever echoed into the | |
| 70 | * markup, so there is no attribute to break out of — and the text is | |
| 71 | * derived from that attribute, here and again in the browser. A time that | |
| 72 | * cannot be read is a plain span: a `<time>` without a machine-readable | |
| 73 | * datetime is worse than no `<time>` at all. | |
| 74 | */ | |
| 75 | export function timeTag( | |
| 76 | at: TimeIn, | |
| 77 | now: TimeIn = Date.now(), | |
| 78 | kind: Kind = "relative", | |
| 79 | ): string { | |
| 80 | const { text, iso } = absolute(at); | |
| 81 | if (iso === "") return `<span class="when">${escape(UNKNOWN)}</span>`; | |
| 82 | return `<time class="when" data-when="${escape(kind)}" datetime="${escape(iso)}" title="${escape(text)}">${escape(phrase(kind, iso, now))}</time>`; | |
| 83 | } |
static/app.css +22
| @@ -234,6 +234,28 @@ footer code { | ||
| 234 | 234 | border-color: currentColor; |
| 235 | 235 | } |
| 236 | 236 | |
| 237 | /* Time in words (`src/when.ts`). A clock that ticks once a second must not | |
| 238 | move anything: the numbers are tabular, so every digit is the same width, | |
| 239 | and a slot that is re-rendered holds a width of its own in `ch` — wide | |
| 240 | enough for the longest thing its vocabulary can say ("23h 59m left", | |
| 241 | "in 8d 12h") — so "4h 12m left" becoming "4h 11m left" reflows nothing. */ | |
| 242 | .when { | |
| 243 | font-variant-numeric: tabular-nums; | |
| 244 | font-feature-settings: "tnum" 1; | |
| 245 | white-space: nowrap; | |
| 246 | } | |
| 247 | .when[data-when="remaining"], | |
| 248 | .when[data-when="relative"] { | |
| 249 | display: inline-block; | |
| 250 | text-align: start; | |
| 251 | } | |
| 252 | .when[data-when="remaining"] { | |
| 253 | min-width: 12ch; | |
| 254 | } | |
| 255 | .when[data-when="relative"] { | |
| 256 | min-width: 10ch; | |
| 257 | } | |
| 258 | ||
| 237 | 259 | /* A word from the last request, shown once, gone in a moment. */ |
| 238 | 260 | .toast { |
| 239 | 261 | position: fixed; |
static/live.js +47
| @@ -2,8 +2,12 @@ | ||
| 2 | 2 | // changed; each event becomes `live` and `live:<type>` on `document`, and |
| 3 | 3 | // any element with data-live="<types>" is refetched and swapped when one of |
| 4 | 4 | // its types arrives — server-rendered, so the page never renders twice. |
| 5 | // The clocks tick here too, from the instant the server already wrote into | |
| 6 | // each <time>, so a countdown costs no request at all. | |
| 5 | 7 | // Without this file the page is the same page, one reload behind. |
| 6 | 8 | |
| 9 | import { phrase } from "./when.js"; | |
| 10 | ||
| 7 | 11 | let since = ""; |
| 8 | 12 | let tries = 0; |
| 9 | 13 | let source; |
| @@ -66,3 +70,46 @@ async function refresh(types) { | ||
| 66 | 70 | |
| 67 | 71 | document.addEventListener("live", (event) => refresh([event.detail.type])); |
| 68 | 72 | connect(); |
| 73 | ||
| 74 | // The clocks. Every <time class="when"> says which vocabulary it speaks | |
| 75 | // (data-when) and carries the instant itself (datetime, UTC, written by the | |
| 76 | // server); the words are derived from that attribute here by the same | |
| 77 | // module the server used, so the two can only ever say the same thing. One | |
| 78 | // interval for the whole page however many clocks it has, nothing sent or | |
| 79 | // fetched, and no work at all while the tab is hidden — a phone in a pocket | |
| 80 | // during a six-hour table does nothing. The <time> holds its own width | |
| 81 | // (app.css), so a tick never moves the row around it. With this file | |
| 82 | // missing the server's words simply stand still. | |
| 83 | const WHEN = "time.when[data-when]"; | |
| 84 | let ticking = null; | |
| 85 | ||
| 86 | function tick() { | |
| 87 | const now = Date.now(); | |
| 88 | for (const el of document.querySelectorAll(WHEN)) { | |
| 89 | const at = el.getAttribute("datetime"); | |
| 90 | if (at === null) continue; | |
| 91 | // The server says UTC and cannot know the reader's zone; the browser can, | |
| 92 | // so the hover text becomes local. Once per element: it never changes. | |
| 93 | if (el.dataset.local === undefined) { | |
| 94 | el.dataset.local = "1"; | |
| 95 | const local = new Date(at); | |
| 96 | if (!Number.isNaN(local.getTime())) el.title = local.toLocaleString(); | |
| 97 | } | |
| 98 | if (el.dataset.when === "absolute") continue; // A fixed moment stays put. | |
| 99 | const next = phrase(el.dataset.when, at, now); | |
| 100 | if (next !== el.textContent) el.textContent = next; | |
| 101 | } | |
| 102 | } | |
| 103 | ||
| 104 | function clocks() { | |
| 105 | if (document.hidden) { | |
| 106 | if (ticking !== null) clearInterval(ticking); | |
| 107 | ticking = null; | |
| 108 | return; | |
| 109 | } | |
| 110 | tick(); // Whatever went stale while the tab slept is current again now. | |
| 111 | ticking ??= setInterval(tick, 1000); | |
| 112 | } | |
| 113 | ||
| 114 | document.addEventListener("visibilitychange", clocks); | |
| 115 | clocks(); |
static/when.js added +148
| @@ -0,0 +1,148 @@ | ||
| 1 | /** | |
| 2 | * Time in words, in one vocabulary, for both runtimes. | |
| 3 | * | |
| 4 | * The server renders "4h 12m left" at paint and the browser re-renders the | |
| 5 | * same words a second later, so the two have to agree exactly: they agree | |
| 6 | * by running this file. It is plain JavaScript with no import of its own — | |
| 7 | * `src/when.ts` types it and wraps it in markup, `static/live.js` ticks it | |
| 8 | * — and it is pure: an instant in, a string out, `now` always handed in, | |
| 9 | * no clock of its own, no DOM, no locale, no `Intl`. UTC or nothing. | |
| 10 | * | |
| 11 | * The vocabulary, fixed: units are `d h m s`, at most two of them, largest | |
| 12 | * first, the smaller one dropped when it is zero. Under a minute either | |
| 13 | * way is "just now", so nothing ever reads "in 0 minutes", and a time that | |
| 14 | * has passed never renders as one still to come. | |
| 15 | */ | |
| 16 | ||
| 17 | const SECOND = 1000; | |
| 18 | const MINUTE = 60 * SECOND; | |
| 19 | const HOUR = 60 * MINUTE; | |
| 20 | const DAY = 24 * HOUR; | |
| 21 | ||
| 22 | /** What a time that cannot be read says. Never an empty slot, never a throw. */ | |
| 23 | export const UNKNOWN = "unknown"; | |
| 24 | ||
| 25 | const MONTHS = [ | |
| 26 | "Jan", | |
| 27 | "Feb", | |
| 28 | "Mar", | |
| 29 | "Apr", | |
| 30 | "May", | |
| 31 | "Jun", | |
| 32 | "Jul", | |
| 33 | "Aug", | |
| 34 | "Sep", | |
| 35 | "Oct", | |
| 36 | "Nov", | |
| 37 | "Dec", | |
| 38 | ]; | |
| 39 | ||
| 40 | const pad = (n) => String(n).padStart(2, "0"); | |
| 41 | ||
| 42 | /** | |
| 43 | * The epoch milliseconds of an ISO string, a number of milliseconds or a | |
| 44 | * `Date` — and `null` for anything else, which is every caller's one check. | |
| 45 | * @param {string | number | Date} at | |
| 46 | * @returns {number | null} | |
| 47 | */ | |
| 48 | export function instant(at) { | |
| 49 | if (at instanceof Date) | |
| 50 | return Number.isNaN(at.getTime()) ? null : at.getTime(); | |
| 51 | if (typeof at === "number") return Number.isFinite(at) ? at : null; | |
| 52 | if (typeof at === "string") { | |
| 53 | const ms = Date.parse(at); | |
| 54 | return Number.isNaN(ms) ? null : ms; | |
| 55 | } | |
| 56 | return null; | |
| 57 | } | |
| 58 | ||
| 59 | /** | |
| 60 | * A span of milliseconds in words: "0s", "59s", "1m", "1h 30m", "8d 3h". | |
| 61 | * Whole seconds, rounded towards zero, and a negative span is no span. | |
| 62 | * @param {number} ms | |
| 63 | * @returns {string} | |
| 64 | */ | |
| 65 | export function duration(ms) { | |
| 66 | const total = Math.max(0, Math.floor(ms / SECOND)); | |
| 67 | const units = [ | |
| 68 | [Math.floor(total / (DAY / SECOND)), "d"], | |
| 69 | [Math.floor(total / (HOUR / SECOND)) % 24, "h"], | |
| 70 | [Math.floor(total / 60) % 60, "m"], | |
| 71 | [total % 60, "s"], | |
| 72 | ]; | |
| 73 | const first = units.findIndex(([n]) => n > 0); | |
| 74 | if (first === -1) return "0s"; | |
| 75 | const [n, unit] = units[first]; | |
| 76 | const next = units[first + 1]; | |
| 77 | return next !== undefined && next[0] > 0 | |
| 78 | ? `${n}${unit} ${next[0]}${next[1]}` | |
| 79 | : `${n}${unit}`; | |
| 80 | } | |
| 81 | ||
| 82 | /** | |
| 83 | * How long until a deadline: "4h 12m left", or "lapsed" once it is here. | |
| 84 | * A deadline exactly now has passed. | |
| 85 | * @param {string | number | Date} until | |
| 86 | * @param {string | number | Date} now | |
| 87 | * @returns {string} | |
| 88 | */ | |
| 89 | export function remaining(until, now) { | |
| 90 | const at = instant(until); | |
| 91 | const from = instant(now); | |
| 92 | if (at === null || from === null) return UNKNOWN; | |
| 93 | return at <= from ? "lapsed" : `${duration(at - from)} left`; | |
| 94 | } | |
| 95 | ||
| 96 | /** | |
| 97 | * How long ago something was: "20m ago", "just now", "1d 4h ago" — and | |
| 98 | * "in 20m" for a time still to come, so a future instant is never dressed | |
| 99 | * as a past one. | |
| 100 | * @param {string | number | Date} at | |
| 101 | * @param {string | number | Date} now | |
| 102 | * @returns {string} | |
| 103 | */ | |
| 104 | export function relative(at, now) { | |
| 105 | const when = instant(at); | |
| 106 | const from = instant(now); | |
| 107 | if (when === null || from === null) return UNKNOWN; | |
| 108 | const delta = from - when; | |
| 109 | if (Math.abs(delta) < MINUTE) return "just now"; | |
| 110 | return delta > 0 ? `${duration(delta)} ago` : `in ${duration(-delta)}`; | |
| 111 | } | |
| 112 | ||
| 113 | /** | |
| 114 | * A fixed moment a person can read, in UTC, with the ISO string it came | |
| 115 | * from: `{ text: "14 Sep 2026, 09:41 UTC", iso: "2026-09-14T09:41:03.000Z" }`. | |
| 116 | * The text is built by hand from the UTC fields — no locale is guessed here; | |
| 117 | * the browser is welcome to put the local time in a `title`. | |
| 118 | * @param {string | number | Date} at | |
| 119 | * @returns {{ text: string, iso: string }} | |
| 120 | */ | |
| 121 | export function absolute(at) { | |
| 122 | const ms = instant(at); | |
| 123 | if (ms === null) return { text: UNKNOWN, iso: "" }; | |
| 124 | const d = new Date(ms); | |
| 125 | return { | |
| 126 | text: `${d.getUTCDate()} ${MONTHS[d.getUTCMonth()]} ${d.getUTCFullYear()}, ${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())} UTC`, | |
| 127 | iso: d.toISOString(), | |
| 128 | }; | |
| 129 | } | |
| 130 | ||
| 131 | /** The three ways a time is said, by name. */ | |
| 132 | export const KINDS = ["remaining", "relative", "absolute"]; | |
| 133 | ||
| 134 | /** | |
| 135 | * One of the three, chosen by name: what the server writes into a `<time>` | |
| 136 | * and what the browser recomputes from its `datetime` a second later. An | |
| 137 | * unknown name is as unreadable as an unreadable time. | |
| 138 | * @param {string} kind | |
| 139 | * @param {string | number | Date} at | |
| 140 | * @param {string | number | Date} now | |
| 141 | * @returns {string} | |
| 142 | */ | |
| 143 | export function phrase(kind, at, now) { | |
| 144 | if (kind === "remaining") return remaining(at, now); | |
| 145 | if (kind === "relative") return relative(at, now); | |
| 146 | if (kind === "absolute") return absolute(at).text; | |
| 147 | return UNKNOWN; | |
| 148 | } |
new above
offline