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

Merged opened this

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

Merged

mason/parley:mason/20-time-in-words-src-when-ts-and-a-countdow → main 4 commits fork

  • Merged. mason/20-time-in-words-src-when-ts-and-a-countdow was merged into main as 80362cf.

Review

Sign in to review this change.
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
3333 rule in an action holds at every door at once; a rule anywhere else is a
3434 bug waiting for the door you forgot.
3535
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
3647 A rule of the game is not an action. `src/board.ts` is the deal, the score
3748 and the sworn arithmetic from `RULES.md` — a seed and a seat count in, a
3849 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.
2929 | ---------------------------------- | ------------------------------------------------------------ |
3030 | `src/actions.ts` | open, sit, leave, read a table — and who may see what |
3131 | `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 |
3233 | `src/result.ts` | data or a named failure — the answer shape every door reads |
3334 | `src/who.ts` | who is asking, from the forge's signed headers |
3435 | `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.
4142 | `src/tty.ts` | the terminal, over the same actions |
4243 | `src/env.ts` | what the forge told this process |
4344 | `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 |
4446
4547 ## The table
4648
@@ -127,6 +129,16 @@ The page stays current over `/events`: a table someone else opens, sits at
127129 or leaves appears without a reload. Without JavaScript it is the same page,
128130 one reload behind.
129131
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
130142 The terminal opens with the exact command in `FORGE_APP_SSH` when the app
131143 runs (`ssh -p 2222 <name>@<forge>` — the app is the login name, the forge
132144 the host; `<name>--<pull>@<forge>` for a preview's). It is `src/tty.ts` on
src/page.ts +4 −8
@@ -7,6 +7,7 @@
77 import { canJoin, canLeave, SEATS, TITLE_MAX, type Table } from "./actions.ts";
88 import { commit, description, env, ssh, title, where } from "./env.ts";
99 import { asset, escape } from "./http.ts";
10 import { duration, timeTag } from "./when.ts";
1011 import type { Who } from "./who.ts";
1112
1213 export function layout(body: string, who: Who): string {
@@ -84,7 +85,7 @@ const item = (table: Table, who: Who): string => `<li class="item">
8485 <div>
8586 <p><b>${escape(table.title)}</b> <span class="pill">${escape(table.status)}</span></p>
8687 <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>
8889 </div>
8990 ${seatButton(table, who)}
9091 </li>`;
@@ -104,10 +105,5 @@ const seatButton = (table: Table, who: Who): string => {
104105 return "";
105106 };
106107
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
114114 expect(await (await fetch(base + "/")).text()).toContain("2/5 seated");
115115 });
116116
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
117137 test("the JSON API is the same actions with the same identity", async () => {
118138 const id = await openTable(alice, "The Salvage Hearing");
119139 const state = (await (await fetch(`${base}/api/tables/${id}`)).json()) as {
src/tty.ts +2 −1
@@ -20,6 +20,7 @@ import {
2020 } from "./actions.ts";
2121 import { openDb } from "./db.ts";
2222 import { ssh, title, where } from "./env.ts";
23 import { relative } from "./when.ts";
2324 import { whoAtTty } from "./who.ts";
2425
2526 const db = openDb();
@@ -60,7 +61,7 @@ function draw(): void {
6061 const here = i === cursor;
6162 const name = here ? bold(table.title) : table.title;
6263 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"}`)}`,
6465 );
6566 });
6667 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 {
234234 border-color: currentColor;
235235 }
236236
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
237259 /* A word from the last request, shown once, gone in a moment. */
238260 .toast {
239261 position: fixed;
static/live.js +47
@@ -2,8 +2,12 @@
22 // changed; each event becomes `live` and `live:<type>` on `document`, and
33 // any element with data-live="<types>" is refetched and swapped when one of
44 // 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.
57 // Without this file the page is the same page, one reload behind.
68
9 import { phrase } from "./when.js";
10
711 let since = "";
812 let tries = 0;
913 let source;
@@ -66,3 +70,46 @@ async function refresh(types) {
6670
6771 document.addEventListener("live", (event) => refresh([event.detail.type]));
6872 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 }

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs