Files
nanoclaw/src/timezone.ts
T
gavrielc c2e5b14bf8 fix: ISO storage + local-time display for all timestamps
Storage: every JS-side datetime('now') write becomes new Date().toISOString()
(messages_out all writers, processing_ack all stamps, delivered, retry
backoff, roles/members/destinations, skill SQL via strftime ISO-Z). Dormant
deliver_after raw string compares wrapped in datetime().

Display: ncl human output renders ISO instants as local "YYYY-MM-DD HH:mm"
(host + container renderers; --json stays ISO; the stamp shape round-trips
through parseZonedToUtc). Task run logs, conversation archive filenames,
clidash, the dashboard pusher, and the uninstall backup stamp all render
local; instruction texts updated in lockstep. CLAUDE.md documents the
convention.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A2YZQDTw9TQrH3m8NBtVAW
2026-07-10 21:06:33 +03:00

96 lines
2.6 KiB
TypeScript

/**
* Check whether a timezone string is a valid IANA identifier
* that Intl.DateTimeFormat can use.
*/
export function isValidTimezone(tz: string): boolean {
try {
Intl.DateTimeFormat(undefined, { timeZone: tz });
return true;
} catch {
return false;
}
}
/**
* Return the given timezone if valid IANA, otherwise fall back to UTC.
*/
export function resolveTimezone(tz: string): string {
return isValidTimezone(tz) ? tz : 'UTC';
}
/**
* Convert a UTC ISO timestamp to a localized display string.
* Uses the Intl API (no external dependencies).
* Falls back to UTC if the timezone is invalid.
*/
export function formatLocalTime(utcIso: string, timezone: string): string {
const date = new Date(utcIso);
return date.toLocaleString('en-US', {
timeZone: resolveTimezone(timezone),
year: 'numeric',
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
hour12: true,
});
}
/**
* Compact sortable local stamp for log lines: "YYYY-MM-DD HH:mm" in `timezone`.
* (sv-SE is the one locale whose default rendering is this exact shape.)
*/
export function formatLocalStamp(date: Date, timezone: string): string {
return date.toLocaleString('sv-SE', {
timeZone: resolveTimezone(timezone),
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false,
});
}
/**
* Interpret a naive ISO-like timestamp (no trailing `Z`, no offset) as wall-clock
* time in `tz` and return the corresponding UTC Date. Strings that already carry
* offset info (`Z` or `+-HH:MM`) are passed through to the Date constructor.
*/
export function parseZonedToUtc(input: string, tz: string): Date {
const hasOffset = /Z$|[+-]\d{2}:?\d{2}$/.test(input.trim());
if (hasOffset) return new Date(input);
const zone = resolveTimezone(tz);
const asIfUtc = new Date(input + 'Z');
if (Number.isNaN(asIfUtc.getTime())) return asIfUtc;
const fmt = new Intl.DateTimeFormat('en-US', {
timeZone: zone,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
});
const parts = Object.fromEntries(
fmt
.formatToParts(asIfUtc)
.filter((p) => p.type !== 'literal')
.map((p) => [p.type, p.value]),
);
const hour = parts.hour === '24' ? '00' : parts.hour;
const zonedAsUtcMs = Date.UTC(
Number(parts.year),
Number(parts.month) - 1,
Number(parts.day),
Number(hour),
Number(parts.minute),
Number(parts.second),
);
const offsetMs = zonedAsUtcMs - asIfUtc.getTime();
return new Date(asIfUtc.getTime() - offsetMs);
}