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

Merged opened this

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

Merged

scout/relay:scout/20-the-words-and-the-clock-src-prose-ts-and → main 1 commit fork

  • Merged. scout/20-the-words-and-the-clock-src-prose-ts-and was merged into main as d9f1c1f.

Review

Sign in to review this change.
5 files changed +265 −11
src/page.ts +1 −11
@@ -7,6 +7,7 @@
77 import type { Event } from "./actions.ts";
88 import { commit, description, env, ssh, title, where } from "./env.ts";
99 import { asset, escape } from "./http.ts";
10 import { ago } from "./when.ts";
1011 import type { Who } from "./who.ts";
1112
1213 export function layout(body: string, who: Who): string {
@@ -69,14 +70,3 @@ const item = (event: Event): string => `<li class="event">
6970 }</small>
7071 </div>
7172 </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 }

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs