refactor: extract cron schedule and test runner helpers

This commit is contained in:
Peter Steinberger
2026-03-23 19:52:31 -07:00
parent d4e3babdcc
commit 3ae5d33799
11 changed files with 357 additions and 190 deletions

View File

@@ -42,6 +42,13 @@ const writeTempJsonArtifact = (name, value) => {
fs.writeFileSync(filePath, `${JSON.stringify(value)}\n`, "utf8");
return filePath;
};
const sanitizeArtifactName = (value) => {
const normalized = value
.trim()
.replace(/[^a-z0-9._-]+/giu, "-")
.replace(/^-+|-+$/gu, "");
return normalized || "artifact";
};
const cleanupTempArtifacts = () => {
if (tempArtifactDir === null) {
return;
@@ -1055,6 +1062,13 @@ const heapSnapshotBaseDir = heapSnapshotEnabled
const ensureNodeOptionFlag = (nodeOptions, flagPrefix, nextValue) =>
nodeOptions.includes(flagPrefix) ? nodeOptions : `${nodeOptions} ${nextValue}`.trim();
const isNodeLikeProcess = (command) => /(?:^|\/)node(?:$|\.exe$)/iu.test(command);
const getShardLabel = (args) => {
const shardIndex = args.findIndex((arg) => arg === "--shard");
if (shardIndex < 0) {
return "";
}
return typeof args[shardIndex + 1] === "string" ? args[shardIndex + 1] : "";
};
const runOnce = (entry, extraArgs = []) =>
new Promise((resolve) => {
@@ -1072,6 +1086,19 @@ const runOnce = (entry, extraArgs = []) =>
...extraArgs,
]
: [...entryArgs, ...silentArgs, ...windowsCiArgs, ...extraArgs];
const shardLabel = getShardLabel(extraArgs);
const artifactStem = [
sanitizeArtifactName(entry.name),
shardLabel ? `shard-${sanitizeArtifactName(shardLabel)}` : "",
String(startedAt),
]
.filter(Boolean)
.join("-");
const laneLogPath = path.join(ensureTempArtifactDir(), `${artifactStem}.log`);
const laneLogStream = fs.createWriteStream(laneLogPath, { flags: "w" });
laneLogStream.write(`[test-parallel] entry=${entry.name}\n`);
laneLogStream.write(`[test-parallel] cwd=${process.cwd()}\n`);
laneLogStream.write(`[test-parallel] command=${[pnpm, ...args].join(" ")}\n\n`);
console.log(
`[test-parallel] start ${entry.name} workers=${maxWorkers ?? "default"} filters=${String(
countExplicitEntryFilters(entryArgs) ?? "all",
@@ -1264,6 +1291,7 @@ const runOnce = (entry, extraArgs = []) =>
}, heapSnapshotIntervalMs);
}
} catch (err) {
laneLogStream.end();
console.error(`[test-parallel] spawn failed: ${String(err)}`);
resolve(1);
return;
@@ -1273,6 +1301,7 @@ const runOnce = (entry, extraArgs = []) =>
const text = chunk.toString();
fatalSeen ||= hasFatalTestRunOutput(`${output}${text}`);
output = appendCapturedOutput(output, text);
laneLogStream.write(text);
logMemoryTraceForText(text);
process.stdout.write(chunk);
});
@@ -1280,11 +1309,13 @@ const runOnce = (entry, extraArgs = []) =>
const text = chunk.toString();
fatalSeen ||= hasFatalTestRunOutput(`${output}${text}`);
output = appendCapturedOutput(output, text);
laneLogStream.write(text);
logMemoryTraceForText(text);
process.stderr.write(chunk);
});
child.on("error", (err) => {
childError = err;
laneLogStream.write(`\n[test-parallel] child error: ${String(err)}\n`);
console.error(`[test-parallel] child error: ${String(err)}`);
});
child.on("close", (code, signal) => {
@@ -1296,15 +1327,36 @@ const runOnce = (entry, extraArgs = []) =>
}
children.delete(child);
const resolvedCode = resolveTestRunExitCode({ code, signal, output, fatalSeen, childError });
const elapsedMs = Date.now() - startedAt;
logMemoryTraceSummary();
if (resolvedCode !== 0) {
const failureTail = formatCapturedOutputTail(output);
const failureArtifactPath = writeTempJsonArtifact(`${artifactStem}-failure`, {
entry: entry.name,
command: [pnpm, ...args],
elapsedMs,
error: childError ? String(childError) : null,
exitCode: resolvedCode,
fatalSeen,
logPath: laneLogPath,
outputTail: failureTail,
signal: signal ?? null,
});
if (failureTail) {
console.error(`[test-parallel] failure tail ${entry.name}\n${failureTail}`);
}
console.error(
`[test-parallel] failure artifacts ${entry.name} log=${laneLogPath} meta=${failureArtifactPath}`,
);
}
laneLogStream.write(
`\n[test-parallel] done ${entry.name} code=${String(resolvedCode)} signal=${
signal ?? "none"
} elapsed=${formatElapsedMs(elapsedMs)}\n`,
);
laneLogStream.end();
console.log(
`[test-parallel] done ${entry.name} code=${String(resolvedCode)} elapsed=${formatElapsedMs(Date.now() - startedAt)}`,
`[test-parallel] done ${entry.name} code=${String(resolvedCode)} elapsed=${formatElapsedMs(elapsedMs)}`,
);
resolve(resolvedCode);
});

View File

@@ -614,6 +614,23 @@ describe("cron cli", () => {
]);
});
it("rejects --tz with --every on cron add", async () => {
await expectCronCommandExit([
"cron",
"add",
"--name",
"invalid",
"--every",
"10m",
"--tz",
"UTC",
"--session",
"main",
"--system-event",
"tick",
]);
});
it("applies --tz to --at for offset-less datetimes on cron add", async () => {
await runCronCommand([
"cron",

View File

@@ -5,12 +5,10 @@ import { defaultRuntime } from "../../runtime.js";
import type { GatewayRpcOpts } from "../gateway-rpc.js";
import { addGatewayClientOptions, callGatewayFromCli } from "../gateway-rpc.js";
import { parsePositiveIntOrUndefined } from "../program/helpers.js";
import { resolveCronCreateSchedule } from "./schedule-options.js";
import {
getCronChannelOptions,
handleCronCliError,
parseAt,
parseCronStaggerMs,
parseDurationMs,
printCronJson,
printCronList,
warnIfCronSchedulerDisabled,
@@ -104,47 +102,14 @@ export function registerCronAddCommand(cron: Command) {
.option("--json", "Output JSON", false)
.action(async (opts: GatewayRpcOpts & Record<string, unknown>, cmd?: Command) => {
try {
const staggerRaw = typeof opts.stagger === "string" ? opts.stagger.trim() : "";
const useExact = Boolean(opts.exact);
if (staggerRaw && useExact) {
throw new Error("Choose either --stagger or --exact, not both");
}
const schedule = (() => {
const at = typeof opts.at === "string" ? opts.at : "";
const every = typeof opts.every === "string" ? opts.every : "";
const cronExpr = typeof opts.cron === "string" ? opts.cron : "";
const chosen = [Boolean(at), Boolean(every), Boolean(cronExpr)].filter(Boolean).length;
if (chosen !== 1) {
throw new Error("Choose exactly one schedule: --at, --every, or --cron");
}
if ((useExact || staggerRaw) && !cronExpr) {
throw new Error("--stagger/--exact are only valid with --cron");
}
if (at) {
const tzRaw =
typeof opts.tz === "string" && opts.tz.trim() ? opts.tz.trim() : undefined;
const atIso = parseAt(at, tzRaw);
if (!atIso) {
throw new Error("Invalid --at; use ISO time or duration like 20m");
}
return { kind: "at" as const, at: atIso };
}
if (every) {
const everyMs = parseDurationMs(every);
if (!everyMs) {
throw new Error("Invalid --every; use e.g. 10m, 1h, 1d");
}
return { kind: "every" as const, everyMs };
}
const staggerMs = parseCronStaggerMs({ staggerRaw, useExact });
return {
kind: "cron" as const,
expr: cronExpr,
tz: typeof opts.tz === "string" && opts.tz.trim() ? opts.tz.trim() : undefined,
staggerMs,
};
})();
const schedule = resolveCronCreateSchedule({
at: opts.at,
cron: opts.cron,
every: opts.every,
exact: opts.exact,
stagger: opts.stagger,
tz: opts.tz,
});
const wakeModeRaw = typeof opts.wake === "string" ? opts.wake : "now";
const wakeMode = wakeModeRaw.trim() || "now";

View File

@@ -5,12 +5,10 @@ import { sanitizeAgentId } from "../../routing/session-key.js";
import { defaultRuntime } from "../../runtime.js";
import { addGatewayClientOptions, callGatewayFromCli } from "../gateway-rpc.js";
import {
getCronChannelOptions,
parseAt,
parseCronStaggerMs,
parseDurationMs,
warnIfCronSchedulerDisabled,
} from "./shared.js";
applyExistingCronSchedulePatch,
resolveCronEditScheduleRequest,
} from "./schedule-options.js";
import { getCronChannelOptions, parseDurationMs, warnIfCronSchedulerDisabled } from "./shared.js";
const assignIf = (
target: Record<string, unknown>,
@@ -97,13 +95,6 @@ export function registerCronEditCommand(cron: Command) {
if (opts.announce && typeof opts.deliver === "boolean") {
throw new Error("Choose --announce or --no-deliver (not multiple).");
}
const staggerRaw = typeof opts.stagger === "string" ? opts.stagger.trim() : "";
const useExact = Boolean(opts.exact);
if (staggerRaw && useExact) {
throw new Error("Choose either --stagger or --exact, not both");
}
const requestedStaggerMs = parseCronStaggerMs({ staggerRaw, useExact });
const patch: Record<string, unknown> = {};
if (typeof opts.name === "string") {
patch.name = opts.name;
@@ -154,38 +145,17 @@ export function registerCronEditCommand(cron: Command) {
patch.sessionKey = null;
}
const scheduleChosen = [opts.at, opts.every, opts.cron].filter(Boolean).length;
if (scheduleChosen > 1) {
throw new Error("Choose at most one schedule change");
}
if (typeof opts.tz === "string" && opts.every) {
throw new Error("--tz is only valid with --cron or offset-less --at");
}
if (requestedStaggerMs !== undefined && (opts.at || opts.every)) {
throw new Error("--stagger/--exact are only valid for cron schedules");
}
if (opts.at) {
const tzRaw =
typeof opts.tz === "string" && opts.tz.trim() ? opts.tz.trim() : undefined;
const atIso = parseAt(String(opts.at), tzRaw);
if (!atIso) {
throw new Error("Invalid --at");
}
patch.schedule = { kind: "at", at: atIso };
} else if (opts.every) {
const everyMs = parseDurationMs(String(opts.every));
if (!everyMs) {
throw new Error("Invalid --every");
}
patch.schedule = { kind: "every", everyMs };
} else if (opts.cron) {
patch.schedule = {
kind: "cron",
expr: String(opts.cron),
tz: typeof opts.tz === "string" && opts.tz.trim() ? opts.tz.trim() : undefined,
staggerMs: requestedStaggerMs,
};
} else if (requestedStaggerMs !== undefined || typeof opts.tz === "string") {
const scheduleRequest = resolveCronEditScheduleRequest({
at: opts.at,
cron: opts.cron,
every: opts.every,
exact: opts.exact,
stagger: opts.stagger,
tz: opts.tz,
});
if (scheduleRequest.kind === "direct") {
patch.schedule = scheduleRequest.schedule;
} else if (scheduleRequest.kind === "patch-existing-cron") {
const listed = (await callGatewayFromCli("cron.list", opts, {
includeDisabled: true,
})) as { jobs?: CronJob[] } | null;
@@ -193,18 +163,7 @@ export function registerCronEditCommand(cron: Command) {
if (!existing) {
throw new Error(`unknown cron job id: ${id}`);
}
if (existing.schedule.kind !== "cron") {
throw new Error("Current job is not a cron schedule; use --cron to convert first");
}
const tz =
typeof opts.tz === "string" ? opts.tz.trim() || undefined : existing.schedule.tz;
patch.schedule = {
kind: "cron",
expr: existing.schedule.expr,
tz,
staggerMs:
requestedStaggerMs !== undefined ? requestedStaggerMs : existing.schedule.staggerMs,
};
patch.schedule = applyExistingCronSchedulePatch(existing.schedule, scheduleRequest);
}
const hasSystemEventPatch = typeof opts.systemEvent === "string";

View File

@@ -0,0 +1,135 @@
import type { CronSchedule } from "../../cron/types.js";
import { parseAt, parseCronStaggerMs, parseDurationMs } from "./shared.js";
type ScheduleOptionInput = {
at?: unknown;
cron?: unknown;
every?: unknown;
exact?: unknown;
stagger?: unknown;
tz?: unknown;
};
type NormalizedScheduleOptions = {
at: string;
cronExpr: string;
every: string;
requestedStaggerMs: number | undefined;
tz: string | undefined;
};
export type CronEditScheduleRequest =
| { kind: "direct"; schedule: CronSchedule }
| { kind: "patch-existing-cron"; staggerMs: number | undefined; tz: string | undefined }
| { kind: "none" };
export function resolveCronCreateSchedule(options: ScheduleOptionInput): CronSchedule {
const normalized = normalizeScheduleOptions(options);
const chosen = countChosenSchedules(normalized);
if (chosen !== 1) {
throw new Error("Choose exactly one schedule: --at, --every, or --cron");
}
const schedule = resolveDirectSchedule(normalized);
if (!schedule) {
throw new Error("Choose exactly one schedule: --at, --every, or --cron");
}
return schedule;
}
export function resolveCronEditScheduleRequest(
options: ScheduleOptionInput,
): CronEditScheduleRequest {
const normalized = normalizeScheduleOptions(options);
const chosen = countChosenSchedules(normalized);
if (chosen > 1) {
throw new Error("Choose at most one schedule change");
}
const schedule = resolveDirectSchedule(normalized);
if (schedule) {
return { kind: "direct", schedule };
}
if (normalized.requestedStaggerMs !== undefined || normalized.tz !== undefined) {
return {
kind: "patch-existing-cron",
tz: normalized.tz,
staggerMs: normalized.requestedStaggerMs,
};
}
return { kind: "none" };
}
export function applyExistingCronSchedulePatch(
existingSchedule: CronSchedule,
request: Extract<CronEditScheduleRequest, { kind: "patch-existing-cron" }>,
): CronSchedule {
if (existingSchedule.kind !== "cron") {
throw new Error("Current job is not a cron schedule; use --cron to convert first");
}
return {
kind: "cron",
expr: existingSchedule.expr,
tz: request.tz ?? existingSchedule.tz,
staggerMs: request.staggerMs !== undefined ? request.staggerMs : existingSchedule.staggerMs,
};
}
function normalizeScheduleOptions(options: ScheduleOptionInput): NormalizedScheduleOptions {
const staggerRaw = readTrimmedString(options.stagger);
const useExact = Boolean(options.exact);
if (staggerRaw && useExact) {
throw new Error("Choose either --stagger or --exact, not both");
}
return {
at: readTrimmedString(options.at),
every: readTrimmedString(options.every),
cronExpr: readTrimmedString(options.cron),
tz: readOptionalString(options.tz),
requestedStaggerMs: parseCronStaggerMs({ staggerRaw, useExact }),
};
}
function countChosenSchedules(options: NormalizedScheduleOptions): number {
return [Boolean(options.at), Boolean(options.every), Boolean(options.cronExpr)].filter(Boolean)
.length;
}
function resolveDirectSchedule(options: NormalizedScheduleOptions): CronSchedule | undefined {
if (options.tz && options.every) {
throw new Error("--tz is only valid with --cron or offset-less --at");
}
if (options.requestedStaggerMs !== undefined && (options.at || options.every)) {
throw new Error("--stagger/--exact are only valid for cron schedules");
}
if (options.at) {
const atIso = parseAt(options.at, options.tz);
if (!atIso) {
throw new Error("Invalid --at; use ISO time or duration like 20m");
}
return { kind: "at", at: atIso };
}
if (options.every) {
const everyMs = parseDurationMs(options.every);
if (!everyMs) {
throw new Error("Invalid --every; use e.g. 10m, 1h, 1d");
}
return { kind: "every", everyMs };
}
if (options.cronExpr) {
return {
kind: "cron",
expr: options.cronExpr,
tz: options.tz,
staggerMs: options.requestedStaggerMs,
};
}
return undefined;
}
function readOptionalString(value: unknown): string | undefined {
const trimmed = readTrimmedString(value);
return trimmed || undefined;
}
function readTrimmedString(value: unknown): string {
return typeof value === "string" ? value.trim() : "";
}

View File

@@ -4,6 +4,10 @@ import { resolveCronStaggerMs } from "../../cron/stagger.js";
import type { CronJob, CronSchedule } from "../../cron/types.js";
import { danger } from "../../globals.js";
import { formatDurationHuman } from "../../infra/format-time/format-duration.ts";
import {
isOffsetlessIsoDateTime,
parseOffsetlessIsoDateTimeInTimeZone,
} from "../../infra/format-time/parse-offsetless-zoned-datetime.js";
import { defaultRuntime, type RuntimeEnv } from "../../runtime.js";
import { colorize, isRich, theme } from "../../terminal/theme.js";
import type { GatewayRpcOpts } from "../gateway-rpc.js";
@@ -89,8 +93,6 @@ export function parseCronStaggerMs(params: {
return parsed;
}
const OFFSETLESS_ISO_DATETIME_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2})?(\.\d+)?$/;
/**
* Parse a one-shot `--at` value into an ISO string (UTC).
*
@@ -106,8 +108,8 @@ export function parseAt(input: string, tz?: string): string | null {
// If a timezone is provided and the input looks like an offset-less ISO datetime,
// resolve it in the given IANA timezone so users get the time they expect.
if (tz && OFFSETLESS_ISO_DATETIME_RE.test(raw)) {
const resolved = parseOffsetlessAtInTimezone(raw, tz);
if (tz && isOffsetlessIsoDateTime(raw)) {
const resolved = parseOffsetlessIsoDateTimeInTimeZone(raw, tz);
if (resolved) {
return resolved;
}
@@ -124,62 +126,6 @@ export function parseAt(input: string, tz?: string): string | null {
return null;
}
function parseOffsetlessAtInTimezone(raw: string, tz: string): string | null {
try {
new Intl.DateTimeFormat("en-US", { timeZone: tz }).format(new Date());
const naiveMs = new Date(`${raw}Z`).getTime();
if (Number.isNaN(naiveMs)) {
return null;
}
// Re-check the offset at the first candidate instant so DST boundaries
// land on the intended wall-clock time instead of drifting by one hour.
const firstOffsetMs = getTimezoneOffsetMs(naiveMs, tz);
const candidateMs = naiveMs - firstOffsetMs;
const finalOffsetMs = getTimezoneOffsetMs(candidateMs, tz);
return new Date(naiveMs - finalOffsetMs).toISOString();
} catch {
return null;
}
}
/**
* Get the UTC offset in milliseconds for a given IANA timezone at a given UTC instant.
* Positive means ahead of UTC (e.g. +3600000 for CET).
*/
function getTimezoneOffsetMs(utcMs: number, tz: string): number {
const d = new Date(utcMs);
// Format parts in the target timezone
const parts = new Intl.DateTimeFormat("en-US", {
timeZone: tz,
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
}).formatToParts(d);
const get = (type: string) => {
const part = parts.find((p) => p.type === type);
return Number.parseInt(part?.value ?? "0", 10);
};
// Reconstruct the local time as if it were UTC
const localAsUtc = Date.UTC(
get("year"),
get("month") - 1,
get("day"),
get("hour"),
get("minute"),
get("second"),
);
return localAsUtc - utcMs;
}
const CRON_ID_PAD = 36;
const CRON_NAME_PAD = 24;
const CRON_SCHEDULE_PAD = 32;

View File

@@ -53,11 +53,7 @@ import {
validateConfigObjectRawWithPlugins,
validateConfigObjectWithPlugins,
} from "./validation.js";
import {
compareOpenClawVersions,
isSameOpenClawStableFamily,
parseOpenClawVersion,
} from "./version.js";
import { shouldWarnOnTouchedVersion } from "./version.js";
// Re-export for backwards compatibility
export { CircularIncludeError, ConfigIncludeError } from "./includes.js";
@@ -626,29 +622,7 @@ function warnIfConfigFromFuture(cfg: OpenClawConfig, logger: Pick<typeof console
if (!touched) {
return;
}
const currentVersion = parseOpenClawVersion(VERSION);
const touchedVersion = parseOpenClawVersion(touched);
if (!currentVersion || !touchedVersion) {
return;
}
if (isSameOpenClawStableFamily(VERSION, touched)) {
return;
}
const sameBaseRelease =
currentVersion.major === touchedVersion.major &&
currentVersion.minor === touchedVersion.minor &&
currentVersion.patch === touchedVersion.patch;
if (sameBaseRelease && touchedVersion.prerelease?.length) {
logger.warn(
`Config was last written by a newer OpenClaw (${touched}); current version is ${VERSION}.`,
);
return;
}
const cmp = compareOpenClawVersions(VERSION, touched);
if (cmp === null) {
return;
}
if (cmp < 0) {
if (shouldWarnOnTouchedVersion(VERSION, touched)) {
logger.warn(
`Config was last written by a newer OpenClaw (${touched}); current version is ${VERSION}.`,
);

View File

@@ -3,6 +3,7 @@ import {
compareOpenClawVersions,
isSameOpenClawStableFamily,
parseOpenClawVersion,
shouldWarnOnTouchedVersion,
} from "./version.js";
describe("parseOpenClawVersion", () => {
@@ -65,3 +66,18 @@ describe("isSameOpenClawStableFamily", () => {
expect(isSameOpenClawStableFamily("2026.3.23-beta.1", "2026.3.23")).toBe(false);
});
});
describe("shouldWarnOnTouchedVersion", () => {
it("skips same-base stable families", () => {
expect(shouldWarnOnTouchedVersion("2026.3.23", "2026.3.23-1")).toBe(false);
});
it("skips same-base prerelease configs when current is newer", () => {
expect(shouldWarnOnTouchedVersion("2026.3.23", "2026.3.23-beta.1")).toBe(false);
});
it("warns when the touched config is newer", () => {
expect(shouldWarnOnTouchedVersion("2026.3.23-beta.1", "2026.3.23")).toBe(true);
expect(shouldWarnOnTouchedVersion("2026.3.23", "2026.3.24")).toBe(true);
});
});

View File

@@ -95,6 +95,17 @@ export function compareOpenClawVersions(
return 0;
}
export function shouldWarnOnTouchedVersion(
current: string | null | undefined,
touched: string | null | undefined,
): boolean {
if (isSameOpenClawStableFamily(current, touched)) {
return false;
}
const cmp = compareOpenClawVersions(current, touched);
return cmp !== null && cmp < 0;
}
function normalizeLegacyDotBetaVersion(version: string): string {
const dotBetaMatch = /^([vV]?[0-9]+\.[0-9]+\.[0-9]+)\.beta(?:\.([0-9A-Za-z.-]+))?$/.exec(version);
if (!dotBetaMatch) {

View File

@@ -0,0 +1,34 @@
import { describe, expect, it } from "vitest";
import {
isOffsetlessIsoDateTime,
parseOffsetlessIsoDateTimeInTimeZone,
} from "./parse-offsetless-zoned-datetime.js";
describe("parseOffsetlessIsoDateTimeInTimeZone", () => {
it("detects offset-less ISO datetimes", () => {
expect(isOffsetlessIsoDateTime("2026-03-23T23:00:00")).toBe(true);
expect(isOffsetlessIsoDateTime("2026-03-23T23:00:00+02:00")).toBe(false);
expect(isOffsetlessIsoDateTime("+20m")).toBe(false);
});
it("converts offset-less datetimes in the requested timezone", () => {
expect(parseOffsetlessIsoDateTimeInTimeZone("2026-03-23T23:00:00", "Europe/Oslo")).toBe(
"2026-03-23T22:00:00.000Z",
);
});
it("keeps DST boundary conversions on the intended wall-clock time", () => {
expect(parseOffsetlessIsoDateTimeInTimeZone("2026-03-29T01:30:00", "Europe/Oslo")).toBe(
"2026-03-29T00:30:00.000Z",
);
});
it("returns null for invalid input", () => {
expect(parseOffsetlessIsoDateTimeInTimeZone("2026-03-23T23:00:00+02:00", "Europe/Oslo")).toBe(
null,
);
expect(parseOffsetlessIsoDateTimeInTimeZone("2026-03-23T23:00:00", "Invalid/Timezone")).toBe(
null,
);
});
});

View File

@@ -0,0 +1,58 @@
const OFFSETLESS_ISO_DATETIME_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2})?(\.\d+)?$/;
export function isOffsetlessIsoDateTime(raw: string): boolean {
return OFFSETLESS_ISO_DATETIME_RE.test(raw);
}
export function parseOffsetlessIsoDateTimeInTimeZone(raw: string, timeZone: string): string | null {
if (!isOffsetlessIsoDateTime(raw)) {
return null;
}
try {
new Intl.DateTimeFormat("en-US", { timeZone }).format(new Date());
const naiveMs = new Date(`${raw}Z`).getTime();
if (Number.isNaN(naiveMs)) {
return null;
}
// Re-check the offset at the first candidate instant so DST boundaries
// land on the intended wall-clock time instead of drifting by one hour.
const firstOffsetMs = getTimeZoneOffsetMs(naiveMs, timeZone);
const candidateMs = naiveMs - firstOffsetMs;
const finalOffsetMs = getTimeZoneOffsetMs(candidateMs, timeZone);
return new Date(naiveMs - finalOffsetMs).toISOString();
} catch {
return null;
}
}
function getTimeZoneOffsetMs(utcMs: number, timeZone: string): number {
const utcDate = new Date(utcMs);
const parts = new Intl.DateTimeFormat("en-US", {
timeZone,
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
}).formatToParts(utcDate);
const getNumericPart = (type: string) => {
const part = parts.find((candidate) => candidate.type === type);
return Number.parseInt(part?.value ?? "0", 10);
};
const localAsUtc = Date.UTC(
getNumericPart("year"),
getNumericPart("month") - 1,
getNumericPart("day"),
getNumericPart("hour"),
getNumericPart("minute"),
getNumericPart("second"),
);
return localAsUtc - utcMs;
}