Files
reverse-engineering-anything/src/domain/offsetDateTime.ts
T

225 lines
8.2 KiB
TypeScript

import { z } from "zod";
export interface OffsetDateTimeInstant {
readonly epochSecond: number;
/** Exact fractional second with insignificant trailing zeroes removed. */
readonly fraction: string;
}
const CALENDAR_DATE = String.raw`(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))`;
const CLOCK_TIME = String.raw`(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?`;
const UTC_OFFSET = String.raw`(?:Z|[+\x2d](?:[01]\d|2[0-3]):[0-5]\d)`;
/**
* REA's ISO-8601 timestamps preserve optional seconds and arbitrary fractional
* precision, including inputs admitted before Zod 4.6.6 required seconds.
* Keep this grammar independent of the installed validator version. The
* escaped offset hyphen also compiles in ECMA-262 Unicode-set (`v`) mode.
*/
export const offsetDateTimeSchema = z
.string()
.regex(
new RegExp(`^${CALENDAR_DATE}T${CLOCK_TIME}${UTC_OFFSET}$`, "u"),
"Use an ISO-8601 date-time with an explicit UTC offset",
);
/** The same established timestamp grammar with the UTC `Z` designator only. */
export const utcDateTimeSchema = z
.string()
.regex(
new RegExp(`^${CALENDAR_DATE}T${CLOCK_TIME}Z$`, "u"),
"Use an ISO-8601 date-time in UTC",
);
/** Parse an already validated offset timestamp without losing sub-millisecond precision. */
export const parseOffsetDateTimeInstant = (
value: string,
): OffsetDateTimeInstant => {
const match = /^(.*?)(?:\.(\d+))?(Z|[+\x2d]\d{2}:\d{2})$/u.exec(value);
if (match === null)
throw new TypeError("Expected a validated offset date-time.");
const [, dateTime, fractionalDigits, offset] = match;
if (dateTime === undefined || offset === undefined)
throw new TypeError("Expected a validated offset date-time.");
const wholeSecondMilliseconds = Date.parse(`${dateTime}${offset}`);
if (
!Number.isSafeInteger(wholeSecondMilliseconds) ||
wholeSecondMilliseconds % 1000 !== 0
)
throw new TypeError(
"Offset date-time is outside the supported time range.",
);
return {
epochSecond: wholeSecondMilliseconds / 1000,
fraction: fractionalDigits?.replace(/0+$/u, "") ?? "",
};
};
export interface RecordedDateTimeReading {
readonly instant: OffsetDateTimeInstant;
readonly spelling: {
readonly separator: "T" | " ";
readonly secondsPresent: boolean;
readonly fractionDigits: number;
readonly zone: string;
};
}
/** Distinguish invalid supported spellings from formats this reader does not interpret. */
const inspectRecordedDateTime = (
value: string,
):
| { readonly kind: "valid"; readonly reading: RecordedDateTimeReading }
| { readonly kind: "invalid" | "unrecognized" } => {
const match =
/^(\d{4}-\d{2}-\d{2})([T ])(\d{2}:\d{2})(?::(\d{2})(?:\.(\d+))?)?(Z|[+-]\d{2}:?\d{2})?$/u.exec(
value,
);
if (match === null) return { kind: "unrecognized" };
const [, date, separator, time, seconds, fraction = "", zone = ""] = match;
const offset =
zone === ""
? "Z"
: zone === "Z" || zone.includes(":")
? zone
: `${zone.slice(0, 3)}:${zone.slice(3)}`;
const normalized = offsetDateTimeSchema.safeParse(
`${date}T${time}${seconds === undefined ? "" : `:${seconds}`}${fraction === "" ? "" : `.${fraction}`}${offset}`,
);
if (!normalized.success) return { kind: "invalid" };
return {
kind: "valid",
reading: {
instant: parseOffsetDateTimeInstant(normalized.data),
spelling: {
separator: separator as "T" | " ",
secondsPresent: seconds !== undefined,
fractionDigits: fraction.length,
zone,
},
},
};
};
/** Parse recorded ISO spelling once, retaining its exact instant and formatting facts. */
export const readRecordedDateTime = (
value: string,
): RecordedDateTimeReading | undefined => {
const inspected = inspectRecordedDateTime(value);
return inspected.kind === "valid" ? inspected.reading : undefined;
};
/** Interpret recorded starts once, preserving exact ISO facts and legacy native-Date admission. */
export const readRecordedStart = (
value: string,
):
| {
readonly instant: OffsetDateTimeInstant;
readonly interpretation: "recorded-iso" | "native-date";
}
| undefined => {
const inspected = inspectRecordedDateTime(value);
if (inspected.kind === "valid")
return {
instant: inspected.reading.instant,
interpretation: "recorded-iso",
};
if (inspected.kind === "invalid") return undefined;
const milliseconds = Date.parse(value);
return Number.isFinite(milliseconds)
? {
instant: offsetDateTimeInstantFromEpochMilliseconds(milliseconds),
interpretation: "native-date",
}
: undefined;
};
/** Convert a Date.now-style integer into the same exact comparison shape. */
export const offsetDateTimeInstantFromEpochMilliseconds = (
epochMilliseconds: number,
): OffsetDateTimeInstant => {
if (!Number.isFinite(epochMilliseconds))
throw new TypeError("Expected finite epoch milliseconds.");
if (Math.abs(epochMilliseconds) > Number.MAX_SAFE_INTEGER)
return { epochSecond: epochMilliseconds / 1000, fraction: "" };
const epochSecond = Math.floor(epochMilliseconds / 1000);
const millisecondsWithinSecond = epochMilliseconds - epochSecond * 1000;
return {
epochSecond,
fraction: secondsFromMilliseconds(millisecondsWithinSecond).fraction,
};
};
const secondsFromMilliseconds = (
milliseconds: number,
): { readonly seconds: number; readonly fraction: string } => {
if (milliseconds === 0) return { seconds: 0, fraction: "" };
const [coefficient = "", exponentText] = String(milliseconds)
.toLowerCase()
.split("e");
const exponent = exponentText === undefined ? 0 : Number(exponentText);
const point = coefficient.indexOf(".");
const decimalPosition =
(point < 0 ? coefficient.length : point) + exponent - 3;
const digits = coefficient.replace(".", "");
const fraction =
decimalPosition <= 0
? `0.${"0".repeat(-decimalPosition)}${digits}`
: decimalPosition < digits.length
? `${digits.slice(0, decimalPosition)}.${digits.slice(decimalPosition)}`
: `${digits}${"0".repeat(decimalPosition - digits.length)}`;
const separator = fraction.indexOf(".");
return {
seconds: Number(separator < 0 ? fraction : fraction.slice(0, separator)),
fraction: (separator < 0 ? "" : fraction.slice(separator + 1)).replace(
/0+$/u,
"",
),
};
};
/** Add a recorded nonnegative decimal millisecond duration without rounding away either fraction. */
export const addOffsetDateTimeMilliseconds = (
start: OffsetDateTimeInstant,
milliseconds: number,
): OffsetDateTimeInstant | undefined => {
if (!Number.isFinite(milliseconds) || milliseconds < 0) return undefined;
if (milliseconds === 0) return start;
const duration = secondsFromMilliseconds(milliseconds);
const wholeSeconds = start.epochSecond + duration.seconds;
if (!Number.isSafeInteger(wholeSeconds)) return undefined;
const length = Math.max(start.fraction.length, duration.fraction.length);
const digits: string[] = [];
let carry = 0;
for (let index = length - 1; index >= 0; index--) {
const sum =
(start.fraction.charCodeAt(index) || 48) -
48 +
(duration.fraction.charCodeAt(index) || 48) -
48 +
carry;
digits.push(String(sum % 10));
carry = Math.floor(sum / 10);
}
const epochSecond = wholeSeconds + carry;
return Number.isSafeInteger(epochSecond)
? { epochSecond, fraction: digits.reverse().join("").replace(/0+$/u, "") }
: undefined;
};
/** Compare instants exactly while treating absent fraction digits as zeroes. */
export const compareOffsetDateTimeInstants = (
left: OffsetDateTimeInstant,
right: OffsetDateTimeInstant,
): number => {
if (left.epochSecond !== right.epochSecond)
return left.epochSecond < right.epochSecond ? -1 : 1;
const length = Math.max(left.fraction.length, right.fraction.length);
for (let index = 0; index < length; index += 1) {
const leftDigit = left.fraction.charCodeAt(index) || 48;
const rightDigit = right.fraction.charCodeAt(index) || 48;
if (leftDigit !== rightDigit) return leftDigit < rightDigit ? -1 : 1;
}
return 0;
};