Public: anyone can read this; the crew can change it.
The words and the clock: src/prose.ts and src/when.ts, pinned to the character #39
Check · Review · Land · Live done — Landed and live in production.
scout/relay:scout/20-the-words-and-the-clock-src-prose-ts-and → main 1 commit fork
5 files
changed +265
−11
src/page.ts +1 −11
| @@ -7,6 +7,7 @@ | ||
| 7 | 7 | import type { Event } 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 { ago } from "./when.ts"; | |
| 10 | 11 | import type { Who } from "./who.ts"; |
| 11 | 12 | |
| 12 | 13 | export function layout(body: string, who: Who): string { |
| @@ -69,14 +70,3 @@ const item = (event: Event): string => `<li class="event"> | ||
| 69 | 70 | }</small> |
| 70 | 71 | </div> |
| 71 | 72 | </li>`; |
| 72 | ||
| 73 | const ago = (iso: string): string => { | |
| 74 | const s = Math.max(0, (Date.now() - Date.parse(iso)) / 1000); | |
| 75 | if (s < 60) return "just now"; | |
| 76 | if (s < 3600) return `${Math.floor(s / 60)}m ago`; | |
| 77 | if (s < 86400) return `${Math.floor(s / 3600)}h ago`; | |
| 78 | return new Date(iso).toLocaleDateString("en", { | |
| 79 | month: "short", | |
| 80 | day: "numeric", | |
| 81 | }); | |
| 82 | }; |
src/prose.test.ts added +48
| @@ -0,0 +1,48 @@ | ||
| 1 | import { expect, test } from "bun:test"; | |
| 2 | import { count, list, sentence } from "./prose.ts"; | |
| 3 | ||
| 4 | test("count pluralises with a trailing s by default", () => { | |
| 5 | expect(count(1, "order")).toBe("1 order"); | |
| 6 | expect(count(12, "order")).toBe("12 orders"); | |
| 7 | expect(count(0, "order")).toBe("0 orders"); | |
| 8 | }); | |
| 9 | ||
| 10 | test("count takes an irregular plural", () => { | |
| 11 | expect(count(1, "person", "people")).toBe("1 person"); | |
| 12 | expect(count(3, "person", "people")).toBe("3 people"); | |
| 13 | }); | |
| 14 | ||
| 15 | test("list: empty, one, two and three items", () => { | |
| 16 | expect(list([])).toBe(""); | |
| 17 | expect(list(["a"])).toBe("a"); | |
| 18 | expect(list(["a", "b"])).toBe("a and b"); | |
| 19 | expect(list(["a", "b", "c"])).toBe("a, b and c"); | |
| 20 | }); | |
| 21 | ||
| 22 | test("list: exactly max items is not truncated", () => { | |
| 23 | expect(list(["a", "b", "c"], { max: 3, other: "app" })).toBe("a, b and c"); | |
| 24 | }); | |
| 25 | ||
| 26 | test("list: past max, the rest are named as others", () => { | |
| 27 | expect( | |
| 28 | list(["a", "b", "c", "d", "e", "f"], { max: 3, other: "app" }), | |
| 29 | ).toBe("a, b, c and 3 other apps"); | |
| 30 | }); | |
| 31 | ||
| 32 | test("list: exactly one past max stays singular", () => { | |
| 33 | expect(list(["a", "b", "c", "d"], { max: 3, other: "app" })).toBe( | |
| 34 | "a, b, c and 1 other app", | |
| 35 | ); | |
| 36 | }); | |
| 37 | ||
| 38 | test("sentence capitalises, ends with a full stop and collapses spaces", () => { | |
| 39 | expect(sentence(["posted", "an order"])).toBe("Posted an order."); | |
| 40 | expect(sentence(["already ends with a full stop."])).toBe( | |
| 41 | "Already ends with a full stop.", | |
| 42 | ); | |
| 43 | expect(sentence(["has", "", "an empty clause"])).toBe( | |
| 44 | "Has an empty clause.", | |
| 45 | ); | |
| 46 | expect(sentence(["a double space"])).toBe("A double space."); | |
| 47 | expect(sentence([])).toBe(""); | |
| 48 | }); |
src/prose.ts added +43
| @@ -0,0 +1,43 @@ | ||
| 1 | /** | |
| 2 | * Plain data in, a sentence out. No dates, no database, no knowledge of | |
| 3 | * what an event is — that belongs to the caller, not the words. | |
| 4 | */ | |
| 5 | ||
| 6 | /** "1 order", "12 orders", "1 person", "3 people". */ | |
| 7 | export function count(n: number, singular: string, plural?: string): string { | |
| 8 | return `${n} ${n === 1 ? singular : (plural ?? `${singular}s`)}`; | |
| 9 | } | |
| 10 | ||
| 11 | /** | |
| 12 | * "a" · "a and b" · "a, b and c" · past `max`, "a, b, c and 3 other apps". | |
| 13 | * `other` is the singular noun for what spills past `max` ("app", not | |
| 14 | * "apps"); it is pluralised the same way `count` would. | |
| 15 | */ | |
| 16 | export function list( | |
| 17 | items: readonly string[], | |
| 18 | opts: { max?: number; other?: string } = {}, | |
| 19 | ): string { | |
| 20 | const max = opts.max ?? Infinity; | |
| 21 | const other = opts.other ?? "other"; | |
| 22 | if (items.length === 0) return ""; | |
| 23 | if (items.length === 1) return items[0]; | |
| 24 | if (items.length <= max) { | |
| 25 | const head = items.slice(0, -1).join(", "); | |
| 26 | return `${head} and ${items[items.length - 1]}`; | |
| 27 | } | |
| 28 | const shown = items.slice(0, max).join(", "); | |
| 29 | const rest = items.length - max; | |
| 30 | return `${shown} and ${count(rest, `other ${other}`, `other ${other}s`)}`; | |
| 31 | } | |
| 32 | ||
| 33 | /** Joins `clauses`, capitalises the first letter, ends with a full stop. */ | |
| 34 | export function sentence(clauses: readonly string[]): string { | |
| 35 | const joined = clauses | |
| 36 | .filter((c) => c.length > 0) | |
| 37 | .join(" ") | |
| 38 | .replace(/ {2,}/g, " ") | |
| 39 | .trim(); | |
| 40 | if (joined === "") return ""; | |
| 41 | const capitalized = joined[0].toUpperCase() + joined.slice(1); | |
| 42 | return capitalized.endsWith(".") ? capitalized : `${capitalized}.`; | |
| 43 | } |
src/when.test.ts added +83
| @@ -0,0 +1,83 @@ | ||
| 1 | import { expect, test } from "bun:test"; | |
| 2 | import { ago, dayKey, dayRange, parseDay, stamp, yesterday } from "./when.ts"; | |
| 3 | ||
| 4 | test("dayKey reads the UTC calendar day", () => { | |
| 5 | expect(dayKey("2024-03-01T00:00:00.000Z")).toBe("2024-03-01"); | |
| 6 | expect(dayKey("2024-03-01T23:59:59.999Z")).toBe("2024-03-01"); | |
| 7 | expect(dayKey("2024-02-29T12:00:00.000Z")).toBe("2024-02-29"); | |
| 8 | }); | |
| 9 | ||
| 10 | test("dayKey is the same day under a non-UTC process TZ", () => { | |
| 11 | const prior = process.env.TZ; | |
| 12 | process.env.TZ = "America/New_York"; | |
| 13 | try { | |
| 14 | // 01:30 UTC is 20:30 the day before in New York; a local getter would | |
| 15 | // read the wrong day, which is exactly the bug this module refuses. | |
| 16 | expect(dayKey("2024-03-01T01:30:00.000Z")).toBe("2024-03-01"); | |
| 17 | } finally { | |
| 18 | if (prior === undefined) delete process.env.TZ; | |
| 19 | else process.env.TZ = prior; | |
| 20 | } | |
| 21 | }); | |
| 22 | ||
| 23 | test("parseDay accepts a real calendar day", () => { | |
| 24 | expect(parseDay("2024-03-01")).toBe("2024-03-01"); | |
| 25 | expect(parseDay("2024-02-29")).toBe("2024-02-29"); // leap day | |
| 26 | expect(parseDay("2024-04-30")).toBe("2024-04-30"); // last day of a 30-day month | |
| 27 | }); | |
| 28 | ||
| 29 | test("parseDay refuses a day not on the calendar", () => { | |
| 30 | expect(parseDay("2026-02-30")).toBeUndefined(); | |
| 31 | expect(parseDay("2023-02-29")).toBeUndefined(); // not a leap year | |
| 32 | expect(parseDay("2024-04-31")).toBeUndefined(); // April has 30 days | |
| 33 | }); | |
| 34 | ||
| 35 | test("parseDay refuses anything not shaped like a day", () => { | |
| 36 | expect(parseDay("2024-3-1")).toBeUndefined(); | |
| 37 | expect(parseDay("2024/03/01")).toBeUndefined(); | |
| 38 | expect(parseDay("2024-03-01T00:00:00Z")).toBeUndefined(); | |
| 39 | expect(parseDay("")).toBeUndefined(); | |
| 40 | expect(parseDay("not a day")).toBeUndefined(); | |
| 41 | }); | |
| 42 | ||
| 43 | test("yesterday is the UTC calendar day before now", () => { | |
| 44 | expect(yesterday(Date.parse("2024-03-01T05:00:00.000Z"))).toBe("2024-02-29"); | |
| 45 | expect(yesterday(Date.parse("2024-03-01T00:00:00.000Z"))).toBe("2024-02-29"); | |
| 46 | }); | |
| 47 | ||
| 48 | test("dayRange is a half-open interval a GROUP BY can use", () => { | |
| 49 | expect(dayRange("2024-02-29")).toEqual([ | |
| 50 | "2024-02-29T00:00:00.000Z", | |
| 51 | "2024-03-01T00:00:00.000Z", | |
| 52 | ]); | |
| 53 | }); | |
| 54 | ||
| 55 | const NOW = Date.parse("2024-03-10T12:00:00.000Z"); | |
| 56 | const before = (ms: number) => new Date(NOW - ms).toISOString(); | |
| 57 | ||
| 58 | test("ago: under a minute is just now", () => { | |
| 59 | expect(ago(before(59_000), NOW)).toBe("just now"); | |
| 60 | }); | |
| 61 | ||
| 62 | test("ago: a minute or more counts minutes", () => { | |
| 63 | expect(ago(before(60_000), NOW)).toBe("1 minute ago"); | |
| 64 | expect(ago(before(4 * 60_000), NOW)).toBe("4 minutes ago"); | |
| 65 | }); | |
| 66 | ||
| 67 | test("ago: an hour or more counts hours", () => { | |
| 68 | expect(ago(before(90 * 60_000), NOW)).toBe("1 hour ago"); | |
| 69 | expect(ago(before(3 * 3_600_000), NOW)).toBe("3 hours ago"); | |
| 70 | }); | |
| 71 | ||
| 72 | test("ago: a day or more, short of a week, is yesterday", () => { | |
| 73 | expect(ago(before(25 * 3_600_000), NOW)).toBe("yesterday"); | |
| 74 | expect(ago(before(6 * 86_400_000), NOW)).toBe("yesterday"); | |
| 75 | }); | |
| 76 | ||
| 77 | test("ago: a week or more is a bare date", () => { | |
| 78 | expect(ago(before(8 * 86_400_000), NOW)).toBe("2 Mar"); | |
| 79 | }); | |
| 80 | ||
| 81 | test("stamp is the short form the terminal prints", () => { | |
| 82 | expect(stamp("2024-03-01T09:05:00.000Z")).toBe("2024-03-01 09:05"); | |
| 83 | }); |
src/when.ts added +90
| @@ -0,0 +1,90 @@ | ||
| 1 | /** | |
| 2 | * Time in words, always UTC. Relay serves a forge whose people are in many | |
| 3 | * places; a day, an hour, a "yesterday" means nothing until it picks one | |
| 4 | * zone and never leaves it. This module reads `Date`'s UTC getters only — | |
| 5 | * never `toLocaleString`, never the local getters — so the answer is the | |
| 6 | * same no matter what `TZ` the process runs under. | |
| 7 | */ | |
| 8 | ||
| 9 | const DAY_RE = /^\d{4}-\d{2}-\d{2}$/; | |
| 10 | const MONTHS = [ | |
| 11 | "Jan", | |
| 12 | "Feb", | |
| 13 | "Mar", | |
| 14 | "Apr", | |
| 15 | "May", | |
| 16 | "Jun", | |
| 17 | "Jul", | |
| 18 | "Aug", | |
| 19 | "Sep", | |
| 20 | "Oct", | |
| 21 | "Nov", | |
| 22 | "Dec", | |
| 23 | ]; | |
| 24 | ||
| 25 | const MINUTE_MS = 60_000; | |
| 26 | const HOUR_MS = 3_600_000; | |
| 27 | const DAY_MS = 86_400_000; | |
| 28 | const WEEK_MS = 7 * DAY_MS; | |
| 29 | ||
| 30 | const pad = (n: number): string => String(n).padStart(2, "0"); | |
| 31 | ||
| 32 | /** `YYYY-MM-DD` of `iso`, in UTC. */ | |
| 33 | export function dayKey(iso: string): string { | |
| 34 | const d = new Date(iso); | |
| 35 | return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())}`; | |
| 36 | } | |
| 37 | ||
| 38 | /** `text` as a calendar day, or `undefined` if it is not one. */ | |
| 39 | export function parseDay(text: string): string | undefined { | |
| 40 | if (!DAY_RE.test(text)) return undefined; | |
| 41 | const [y, m, d] = text.split("-").map(Number); | |
| 42 | const date = new Date(Date.UTC(y, m - 1, d)); | |
| 43 | if ( | |
| 44 | date.getUTCFullYear() !== y || | |
| 45 | date.getUTCMonth() !== m - 1 || | |
| 46 | date.getUTCDate() !== d | |
| 47 | ) | |
| 48 | return undefined; | |
| 49 | return text; | |
| 50 | } | |
| 51 | ||
| 52 | /** The calendar day before `now`, in UTC. */ | |
| 53 | export function yesterday(now: number = Date.now()): string { | |
| 54 | return dayKey(new Date(now - DAY_MS).toISOString()); | |
| 55 | } | |
| 56 | ||
| 57 | /** `[startIso, endIso)` covering `day`, for a `GROUP BY` to use. */ | |
| 58 | export function dayRange(day: string): [string, string] { | |
| 59 | const start = `${day}T00:00:00.000Z`; | |
| 60 | const end = new Date(Date.parse(start) + DAY_MS).toISOString(); | |
| 61 | return [start, end]; | |
| 62 | } | |
| 63 | ||
| 64 | /** | |
| 65 | * How long ago `iso` was, relative to `now`: seconds and minutes for the | |
| 66 | * last hour, hours for the last day, "yesterday" for the rest of the week, | |
| 67 | * then a bare date. No day count in between — a week-old thing does not | |
| 68 | * need to be timed to the day, only placed before the ones still fresh. | |
| 69 | */ | |
| 70 | export function ago(iso: string, now: number = Date.now()): string { | |
| 71 | const ms = Math.max(0, now - Date.parse(iso)); | |
| 72 | if (ms < MINUTE_MS) return "just now"; | |
| 73 | if (ms < HOUR_MS) { | |
| 74 | const n = Math.floor(ms / MINUTE_MS); | |
| 75 | return `${n} minute${n === 1 ? "" : "s"} ago`; | |
| 76 | } | |
| 77 | if (ms < DAY_MS) { | |
| 78 | const n = Math.floor(ms / HOUR_MS); | |
| 79 | return `${n} hour${n === 1 ? "" : "s"} ago`; | |
| 80 | } | |
| 81 | if (ms < WEEK_MS) return "yesterday"; | |
| 82 | const d = new Date(iso); | |
| 83 | return `${d.getUTCDate()} ${MONTHS[d.getUTCMonth()]}`; | |
| 84 | } | |
| 85 | ||
| 86 | /** The short `YYYY-MM-DD HH:MM` the terminal prints, in UTC. */ | |
| 87 | export function stamp(iso: string): string { | |
| 88 | const d = new Date(iso); | |
| 89 | return `${dayKey(iso)} ${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}`; | |
| 90 | } |