Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

147 linesCodeBlameRaw
1/**
2 * Times in the staff member's own zone, on every page (components/ui.tsx
3 * `When`) and in the forms that take a time. Pages are drawn on the
4 * server first, so the zone comes from the request: the `sudo_tz` cookie when someone chose one
5 * (/timezone), else Cloudflare's guess (`request.cf.timezone`), else UTC.
6 * Every time shown says its zone's abbreviation, and `<time datetime>`
7 * plus the hover title keep the UTC instant. No Workers or React imports,
8 * so it is tested under Node.
9 */
10
11export const ZONE_COOKIE = "sudo_tz";
12
13/** Whether `tz` is a time zone this runtime knows. */
14export function validZone(tz: unknown): tz is string {
15 if (typeof tz !== "string" || !tz || tz.length > 64) return false;
16 try {
17 new Intl.DateTimeFormat("en-US", { timeZone: tz });
18 return true;
19 } catch {
20 return false;
21 }
22}
23
24/** A cookie's value from a Cookie header. */
25export function readCookie(header: string | null | undefined, name: string): string | null {
26 for (const part of (header ?? "").split(";")) {
27 const [k, ...v] = part.trim().split("=");
28 if (k === name) {
29 try {
30 return decodeURIComponent(v.join("="));
31 } catch {
32 return null;
33 }
34 }
35 }
36 return null;
37}
38
39/** The zone a request's pages use, and where it came from. */
40export function readZone(cookieHeader: string | null | undefined, cfTimezone: unknown): { zone: string; chosen: boolean } {
41 const chosen = readCookie(cookieHeader, ZONE_COOKIE);
42 if (validZone(chosen)) return { zone: chosen, chosen: true };
43 return { zone: validZone(cfTimezone) ? cfTimezone : "UTC", chosen: false };
44}
45
46/** The zone's short name at that moment: "PDT", "CEST", "BST", or "UTC+9" where there is no common one. */
47export function zoneAbbr(date: Date, tz: string): string {
48 if (tz === "UTC" || tz === "Etc/UTC" || tz === "Etc/GMT") return "UTC";
49 let offset = "";
50 for (const locale of ["en-US", "en-GB"]) {
51 const name =
52 new Intl.DateTimeFormat(locale, { timeZone: tz, timeZoneName: "short" }).formatToParts(date).find((p) => p.type === "timeZoneName")?.value ?? "";
53 if (name && !/^(GMT|UTC)[+-−]/.test(name)) return name;
54 offset ||= name;
55 }
56 return offset.replace(/^GMT/, "UTC") || "UTC";
57}
58
59const formats = new Map<string, Intl.DateTimeFormat>();
60function format(tz: string, time: boolean): Intl.DateTimeFormat {
61 const key = `${tz}|${time}`;
62 let f = formats.get(key);
63 if (!f) {
64 f = new Intl.DateTimeFormat("en-US", time ? { dateStyle: "medium", timeStyle: "short", timeZone: tz } : { dateStyle: "medium", timeZone: tz });
65 formats.set(key, f);
66 }
67 return f;
68}
69
70/**
71 * A moment for a page: "Oct 6, 2026, 12:27 AM" and "PDT" (shown apart, the
72 * zone quieter), or just the day. `utc` is the same moment in UTC, for the
73 * hover title.
74 */
75export function when(at: Date, tz: string, time: boolean): { text: string; zone: string | null; utc: string } {
76 const zone = validZone(tz) ? tz : "UTC";
77 return {
78 text: format(zone, time).format(at),
79 zone: time ? zoneAbbr(at, zone) : null,
80 utc: `${format("UTC", true).format(at)} UTC`,
81 };
82}
83
84/** Minutes the zone is ahead of UTC at that moment. */
85function offsetMinutes(ms: number, tz: string): number {
86 const p = Object.fromEntries(
87 new Intl.DateTimeFormat("en-US", {
88 timeZone: tz,
89 year: "numeric",
90 month: "numeric",
91 day: "numeric",
92 hour: "numeric",
93 minute: "numeric",
94 second: "numeric",
95 hourCycle: "h23",
96 })
97 .formatToParts(new Date(ms))
98 .map((x) => [x.type, Number(x.value)]),
99 );
100 const wall = Date.UTC(p.year!, p.month! - 1, p.day!, p.hour!, p.minute!, p.second!);
101 return Math.round((wall - Math.floor(ms / 1000) * 1000) / 60_000);
102}
103
104/**
105 * A `datetime-local` value (`2026-10-05T14:00`), read as a wall-clock time
106 * in `tz`; an ISO time with its own offset is taken as it is. UTC ISO, or
107 * null when it is not a time.
108 */
109export function fromLocalInput(raw: string, tz = "UTC"): string | null {
110 const value = raw.trim();
111 if (!value) return null;
112 if (/Z$|[+-]\d\d:\d\d$/.test(value)) return Number.isNaN(Date.parse(value)) ? null : new Date(value).toISOString();
113 const m = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2}))?$/.exec(value);
114 if (!m) return null;
115 const wall = Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3]), Number(m[4]), Number(m[5]), Number(m[6] ?? 0));
116 if (Number.isNaN(wall)) return null;
117 const zone = validZone(tz) ? tz : "UTC";
118 // The offset at the guess, then again at the answer: right across a change of clocks.
119 let at = wall - offsetMinutes(wall, zone) * 60_000;
120 at = wall - offsetMinutes(at, zone) * 60_000;
121 return new Date(at).toISOString();
122}
123
124/** A moment as a `datetime-local` value in `tz`: `2026-10-05T07:00`. */
125export function toLocalInput(at: Date | string, tz = "UTC"): string {
126 const ms = new Date(at).getTime();
127 const zone = validZone(tz) ? tz : "UTC";
128 return new Date(ms + offsetMinutes(ms, zone) * 60_000).toISOString().slice(0, 16);
129}
130
131/**
132 * How long before `now` something happened, briefly: "just now", "12 min
133 * ago", "3 h ago", "2 days ago"; past a month, the day. `now` comes from
134 * the loader, so the server and the browser say the same.
135 */
136export function ago(at: string, now: number): string {
137 const then = new Date(at).getTime();
138 if (Number.isNaN(then)) return at;
139 const minutes = Math.max(0, Math.floor((now - then) / 60_000));
140 if (minutes < 1) return "just now";
141 if (minutes < 60) return `${minutes} min ago`;
142 const hours = Math.floor(minutes / 60);
143 if (hours < 24) return `${hours} h ago`;
144 const days = Math.floor(hours / 24);
145 if (days < 31) return `${days} day${days === 1 ? "" : "s"} ago`;
146 return new Date(then).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric", timeZone: "UTC" });
147}