| 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. sudo ships no script, so the |
| 4 | * 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 | |
| 11 | export const ZONE_COOKIE = "sudo_tz"; |
| 12 | |
| 13 | /** Whether `tz` is a time zone this runtime knows. */ |
| 14 | export 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. */ |
| 25 | export 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. */ |
| 40 | export 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. */ |
| 47 | export 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 = new Intl.DateTimeFormat(locale, { timeZone: tz, timeZoneName: "short" }).formatToParts(date).find((p) => p.type === "timeZoneName")?.value ?? ""; |
| 52 | if (name && !/^(GMT|UTC)[+-−]/.test(name)) return name; |
| 53 | offset ||= name; |
| 54 | } |
| 55 | return offset.replace(/^GMT/, "UTC") || "UTC"; |
| 56 | } |
| 57 | |
| 58 | const formats = new Map<string, Intl.DateTimeFormat>(); |
| 59 | function format(tz: string, time: boolean): Intl.DateTimeFormat { |
| 60 | const key = `${tz}|${time}`; |
| 61 | let f = formats.get(key); |
| 62 | if (!f) { |
| 63 | f = new Intl.DateTimeFormat("en-US", time ? { dateStyle: "medium", timeStyle: "short", timeZone: tz } : { dateStyle: "medium", timeZone: tz }); |
| 64 | formats.set(key, f); |
| 65 | } |
| 66 | return f; |
| 67 | } |
| 68 | |
| 69 | /** |
| 70 | * A moment for a page: "Oct 6, 2026, 12:27 AM" and "PDT" (shown apart, the |
| 71 | * zone quieter), or just the day. `utc` is the same moment in UTC, for the |
| 72 | * hover title. |
| 73 | */ |
| 74 | export function when(at: Date, tz: string, time: boolean): { text: string; zone: string | null; utc: string } { |
| 75 | const zone = validZone(tz) ? tz : "UTC"; |
| 76 | return { |
| 77 | text: format(zone, time).format(at), |
| 78 | zone: time ? zoneAbbr(at, zone) : null, |
| 79 | utc: `${format("UTC", true).format(at)} UTC`, |
| 80 | }; |
| 81 | } |
| 82 | |
| 83 | /** Minutes the zone is ahead of UTC at that moment. */ |
| 84 | function offsetMinutes(ms: number, tz: string): number { |
| 85 | const p = Object.fromEntries( |
| 86 | new Intl.DateTimeFormat("en-US", { timeZone: tz, year: "numeric", month: "numeric", day: "numeric", hour: "numeric", minute: "numeric", second: "numeric", hourCycle: "h23" }) |
| 87 | .formatToParts(new Date(ms)) |
| 88 | .map((x) => [x.type, Number(x.value)]), |
| 89 | ); |
| 90 | const wall = Date.UTC(p.year!, p.month! - 1, p.day!, p.hour!, p.minute!, p.second!); |
| 91 | return Math.round((wall - Math.floor(ms / 1000) * 1000) / 60_000); |
| 92 | } |
| 93 | |
| 94 | /** |
| 95 | * A `datetime-local` value (`2026-10-05T14:00`), read as a wall-clock time |
| 96 | * in `tz`; an ISO time with its own offset is taken as it is. UTC ISO, or |
| 97 | * null when it is not a time. |
| 98 | */ |
| 99 | export function fromLocalInput(raw: string, tz = "UTC"): string | null { |
| 100 | const value = raw.trim(); |
| 101 | if (!value) return null; |
| 102 | if (/Z$|[+-]\d\d:\d\d$/.test(value)) return Number.isNaN(Date.parse(value)) ? null : new Date(value).toISOString(); |
| 103 | const m = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2}))?$/.exec(value); |
| 104 | if (!m) return null; |
| 105 | const wall = Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3]), Number(m[4]), Number(m[5]), Number(m[6] ?? 0)); |
| 106 | if (Number.isNaN(wall)) return null; |
| 107 | const zone = validZone(tz) ? tz : "UTC"; |
| 108 | // The offset at the guess, then again at the answer: right across a change of clocks. |
| 109 | let at = wall - offsetMinutes(wall, zone) * 60_000; |
| 110 | at = wall - offsetMinutes(at, zone) * 60_000; |
| 111 | return new Date(at).toISOString(); |
| 112 | } |
| 113 | |
| 114 | /** A moment as a `datetime-local` value in `tz`: `2026-10-05T07:00`. */ |
| 115 | export function toLocalInput(at: Date | string, tz = "UTC"): string { |
| 116 | const ms = new Date(at).getTime(); |
| 117 | const zone = validZone(tz) ? tz : "UTC"; |
| 118 | return new Date(ms + offsetMinutes(ms, zone) * 60_000).toISOString().slice(0, 16); |
| 119 | } |