flagon-io/g1t

public

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

g1t/apps/sudo/app/lib/time.ts

119 lines4,929 bytesCodeBlame
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
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 = 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
58const formats = new Map<string, Intl.DateTimeFormat>();
59function 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 */
74export 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. */
84function 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 */
99export 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`. */
115export 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}