Files
clawdbot/src/agents/date-time.ts
2026-01-15 22:27:06 +00:00

165 lines
4.8 KiB
TypeScript

import { execSync } from "node:child_process";
export type TimeFormatPreference = "auto" | "12" | "24";
export type ResolvedTimeFormat = "12" | "24";
let cachedTimeFormat: ResolvedTimeFormat | undefined;
export function resolveUserTimezone(configured?: string): string {
const trimmed = configured?.trim();
if (trimmed) {
try {
new Intl.DateTimeFormat("en-US", { timeZone: trimmed }).format(new Date());
return trimmed;
} catch {
// ignore invalid timezone
}
}
const host = Intl.DateTimeFormat().resolvedOptions().timeZone;
return host?.trim() || "UTC";
}
export function resolveUserTimeFormat(preference?: TimeFormatPreference): ResolvedTimeFormat {
if (preference === "12" || preference === "24") return preference;
if (cachedTimeFormat) return cachedTimeFormat;
cachedTimeFormat = detectSystemTimeFormat() ? "24" : "12";
return cachedTimeFormat;
}
export function normalizeTimestamp(
raw: unknown,
): { timestampMs: number; timestampUtc: string } | undefined {
if (raw == null) return undefined;
let timestampMs: number | undefined;
if (raw instanceof Date) {
timestampMs = raw.getTime();
} else if (typeof raw === "number" && Number.isFinite(raw)) {
timestampMs = raw < 1_000_000_000_000 ? Math.round(raw * 1000) : Math.round(raw);
} else if (typeof raw === "string") {
const trimmed = raw.trim();
if (!trimmed) return undefined;
if (/^\d+(\.\d+)?$/.test(trimmed)) {
const num = Number(trimmed);
if (Number.isFinite(num)) {
if (trimmed.includes(".")) {
timestampMs = Math.round(num * 1000);
} else if (trimmed.length >= 13) {
timestampMs = Math.round(num);
} else {
timestampMs = Math.round(num * 1000);
}
}
} else {
const parsed = Date.parse(trimmed);
if (!Number.isNaN(parsed)) timestampMs = parsed;
}
}
if (timestampMs === undefined || !Number.isFinite(timestampMs)) return undefined;
return { timestampMs, timestampUtc: new Date(timestampMs).toISOString() };
}
export function withNormalizedTimestamp<T extends Record<string, unknown>>(
value: T,
rawTimestamp: unknown,
): T & { timestampMs?: number; timestampUtc?: string } {
const normalized = normalizeTimestamp(rawTimestamp);
if (!normalized) return value;
return {
...value,
timestampMs:
typeof value.timestampMs === "number" && Number.isFinite(value.timestampMs)
? value.timestampMs
: normalized.timestampMs,
timestampUtc:
typeof value.timestampUtc === "string" && value.timestampUtc.trim()
? value.timestampUtc
: normalized.timestampUtc,
};
}
function detectSystemTimeFormat(): boolean {
if (process.platform === "darwin") {
try {
const result = execSync("defaults read -g AppleICUForce24HourTime 2>/dev/null", {
encoding: "utf8",
timeout: 500,
}).trim();
if (result === "1") return true;
if (result === "0") return false;
} catch {
// Not set, fall through
}
}
if (process.platform === "win32") {
try {
const result = execSync(
'powershell -Command "(Get-Culture).DateTimeFormat.ShortTimePattern"',
{ encoding: "utf8", timeout: 1000 },
).trim();
if (result.startsWith("H")) return true;
if (result.startsWith("h")) return false;
} catch {
// Fall through
}
}
try {
const sample = new Date(2000, 0, 1, 13, 0);
const formatted = new Intl.DateTimeFormat(undefined, { hour: "numeric" }).format(sample);
return formatted.includes("13");
} catch {
return false;
}
}
function ordinalSuffix(day: number): string {
if (day >= 11 && day <= 13) return "th";
switch (day % 10) {
case 1:
return "st";
case 2:
return "nd";
case 3:
return "rd";
default:
return "th";
}
}
export function formatUserTime(
date: Date,
timeZone: string,
format: ResolvedTimeFormat,
): string | undefined {
const use24Hour = format === "24";
try {
const parts = new Intl.DateTimeFormat("en-US", {
timeZone,
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
hour: use24Hour ? "2-digit" : "numeric",
minute: "2-digit",
hourCycle: use24Hour ? "h23" : "h12",
}).formatToParts(date);
const map: Record<string, string> = {};
for (const part of parts) {
if (part.type !== "literal") map[part.type] = part.value;
}
if (!map.weekday || !map.year || !map.month || !map.day || !map.hour || !map.minute)
return undefined;
const dayNum = parseInt(map.day, 10);
const suffix = ordinalSuffix(dayNum);
const timePart = use24Hour
? `${map.hour}:${map.minute}`
: `${map.hour}:${map.minute} ${map.dayPeriod ?? ""}`.trim();
return `${map.weekday}, ${map.month} ${dayNum}${suffix}, ${map.year}${timePart}`;
} catch {
return undefined;
}
}