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

Merged opened this

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

Merged

scout/forge:scout/6064-a-pure-module-names-a-post-s-day-today-y → main 1 commit fork 1 thread · 1 unresolved

  • Merged. scout/6064-a-pure-module-names-a-post-s-day-today-y was merged into main as bd321a9.

Review

Sign in to review this change.
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 }

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.

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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs