Public: anyone can read this; the crew can change it.
A pure module names a post's day: Today, Yesterday, or `Mon 20 Oct`, in UTC #6081
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/6064-a-pure-module-names-a-post-s-day-today-y → main 1 commit fork 1 thread · 1 unresolved
2 files
changed +107
−0
src/web/studio/day_divider.ts added +56
| @@ -0,0 +1,56 @@ | ||
| 1 | /** | |
| 2 | * The day a post belongs to, named for a reader: `Today`, `Yesterday`, or | |
| 3 | * `Mon 20 Oct` (plus the year when it is not now's). Days are UTC, so the | |
| 4 | * same post carries the same label for everyone. Plain data in, plain text | |
| 5 | * out — no db, no HTML. | |
| 6 | */ | |
| 7 | ||
| 8 | const DAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; | |
| 9 | const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; | |
| 10 | const DAY_MS = 86_400_000; | |
| 11 | ||
| 12 | /** An item is its ISO time, or an object carrying it as `at`. */ | |
| 13 | export type DayItem = string | { at: string }; | |
| 14 | ||
| 15 | /** UTC midnight of the instant's day, or null when it does not parse. */ | |
| 16 | function utcDay(iso: unknown): number | null { | |
| 17 | if (typeof iso !== "string" || iso === "") return null; | |
| 18 | const t = Date.parse(iso); | |
| 19 | return Number.isFinite(t) ? Math.floor(t / DAY_MS) * DAY_MS : null; | |
| 20 | } | |
| 21 | ||
| 22 | function labelOf(day: number, now: Date): string { | |
| 23 | const today = utcDay(now.toISOString()); | |
| 24 | if (today === null) return ""; | |
| 25 | if (day === today) return "Today"; | |
| 26 | if (day === today - DAY_MS) return "Yesterday"; | |
| 27 | const d = new Date(day); | |
| 28 | const base = `${DAYS[d.getUTCDay()]} ${d.getUTCDate()} ${MONTHS[d.getUTCMonth()]}`; | |
| 29 | return d.getUTCFullYear() === now.getUTCFullYear() ? base : `${base} ${d.getUTCFullYear()}`; | |
| 30 | } | |
| 31 | ||
| 32 | /** The label for the UTC day of `iso`; "" when the time does not parse. */ | |
| 33 | export function dayLabel(iso: string, now: Date): string { | |
| 34 | const day = utcDay(iso); | |
| 35 | if (day === null || !Number.isFinite(now.getTime())) return ""; | |
| 36 | return labelOf(day, now); | |
| 37 | } | |
| 38 | ||
| 39 | /** | |
| 40 | * The labels to put before items, in order: one for each item whose UTC day | |
| 41 | * differs from the previous item's. An item whose time does not parse gets | |
| 42 | * none and does not break the run. `lastDayOnPage` (an ISO time) is the day | |
| 43 | * the page before already ended on; that day gets no label again. | |
| 44 | */ | |
| 45 | export function dividers(items: readonly DayItem[], now: Date, lastDayOnPage?: string): string[] { | |
| 46 | const out: string[] = []; | |
| 47 | let prev = lastDayOnPage === undefined ? null : utcDay(lastDayOnPage); | |
| 48 | for (const item of items) { | |
| 49 | const day = utcDay(typeof item === "string" ? item : item?.at); | |
| 50 | if (day === null || day === prev) continue; | |
| 51 | const label = dayLabel(new Date(day).toISOString(), now); | |
| 52 | if (label) out.push(label); | |
| 53 | prev = day; | |
| 54 | } | |
| 55 | return out; | |
| 56 | } |
test/day-divider.test.ts added +51
| @@ -0,0 +1,51 @@ | ||
| 1 | import { describe, expect, test } from "bun:test"; | |
| 2 | import { dayLabel, dividers } from "../src/web/studio/day_divider.ts"; | |
| 3 | ||
| 4 | const now = new Date("2026-10-26T12:00:00Z"); | |
| 5 | ||
| 6 | describe("dayLabel", () => { | |
| 7 | test("names today, yesterday, and older days", () => { | |
| 8 | expect(dayLabel("2026-10-26T01:00:00Z", now)).toBe("Today"); | |
| 9 | expect(dayLabel("2026-10-25T23:00:00Z", now)).toBe("Yesterday"); | |
| 10 | expect(dayLabel("2026-10-20T09:00:00Z", now)).toBe("Tue 20 Oct"); | |
| 11 | }); | |
| 12 | test("adds the year only when it differs", () => { | |
| 13 | expect(dayLabel("2025-10-20T09:00:00Z", now)).toBe("Mon 20 Oct 2025"); | |
| 14 | }); | |
| 15 | test("midnight UTC falls on either side", () => { | |
| 16 | expect(dayLabel("2026-10-26T00:00:00Z", now)).toBe("Today"); | |
| 17 | expect(dayLabel("2026-10-25T23:59:59.999Z", now)).toBe("Yesterday"); | |
| 18 | expect(dayLabel("2026-10-24T23:59:59Z", now)).toBe("Sat 24 Oct"); | |
| 19 | }); | |
| 20 | test("an offset time is read in UTC", () => { | |
| 21 | expect(dayLabel("2026-10-26T01:00:00+05:00", now)).toBe("Yesterday"); | |
| 22 | }); | |
| 23 | test("an unparsable time has no label", () => { | |
| 24 | expect(dayLabel("soon", now)).toBe(""); | |
| 25 | expect(dayLabel("", now)).toBe(""); | |
| 26 | }); | |
| 27 | }); | |
| 28 | ||
| 29 | describe("dividers", () => { | |
| 30 | test("three days in order", () => { | |
| 31 | const items = ["2026-10-26T10:00:00Z", "2026-10-26T08:00:00Z", "2026-10-25T20:00:00Z", "2026-10-20T01:00:00Z"]; | |
| 32 | expect(dividers(items, now)).toEqual(["Today", "Yesterday", "Tue 20 Oct"]); | |
| 33 | }); | |
| 34 | test("midnight UTC starts a new day", () => { | |
| 35 | expect(dividers(["2026-10-26T00:00:00Z", "2026-10-25T23:59:59Z"], now)).toEqual(["Today", "Yesterday"]); | |
| 36 | }); | |
| 37 | test("accepts objects with an at", () => { | |
| 38 | expect(dividers([{ at: "2026-10-26T10:00:00Z" }, { at: "2026-10-25T10:00:00Z" }], now)).toEqual(["Today", "Yesterday"]); | |
| 39 | }); | |
| 40 | test("an unparsable time gets no label and does not split a day", () => { | |
| 41 | expect(dividers(["2026-10-26T10:00:00Z", "nope", "2026-10-26T09:00:00Z"], now)).toEqual(["Today"]); | |
| 42 | }); | |
| 43 | test("lastDayOnPage suppresses a repeat", () => { | |
| 44 | const items = ["2026-10-25T10:00:00Z", "2026-10-20T10:00:00Z"]; | |
| 45 | expect(dividers(items, now, "2026-10-25T23:00:00Z")).toEqual(["Tue 20 Oct"]); | |
| 46 | expect(dividers(items, now)).toEqual(["Yesterday", "Tue 20 Oct"]); | |
| 47 | }); | |
| 48 | test("no items, no labels", () => { | |
| 49 | expect(dividers([], now)).toEqual([]); | |
| 50 | }); | |
| 51 | }); |
new above
offline
Returning bare strings leaves the caller to re-derive which item each label precedes. Fine for this scope; the wiring pull should return {index,label} or add a sibling function.