import { tzOffset } from "../tzOffset/index.js"; // TZDate stores time in two `Date` objects because native `Date` can only // expose local fields in the system time zone. // // The external date (`this`) stores the real timestamp. It is the value used by // `getTime()`, `valueOf()`, UTC getters/setters, comparisons, and arithmetic. // Its local getters are not used for target-zone fields because they would read // the timestamp through the system time zone. // // The internal date (`this.internal`) stores the target-zone wall-clock fields. // We read and write those fields through UTC getters/setters so the host system // time zone cannot reinterpret them. For example, `getHours()` returns // `internal.getUTCHours()`, and `setHours()` writes `internal.setUTCHours(...)`. // // Syncing moves data between the two representations: // // - `syncToInternal` starts from the external timestamp and rebuilds internal // wall-clock fields using `this.timeZone` offset. // // - `syncFromInternal` starts from internal wall-clock fields, writes them into // the external date, then adjusts that timestamp for the system/target offset // difference and DST edge cases. // // The public TZDate value is the combination of both: external is the instant, // internal is the wall-clock view of that instant in `this.timeZone`. export class TZDateMini extends Date { //#region static constructor(...args) { super(); // Time zone string is always the last string argument unless date string // is passed (as a single argument). if (args.length > 1 && typeof args[args.length - 1] === "string") { this.timeZone = args.pop(); } this.internal = new Date(); // Validate the time zone by checking its offset. if (isNaN(tzOffset(this.timeZone, this))) { this.setTime(NaN); } else { if (!args.length) { // No arguments passed: use current time this.setTime(Date.now()); } else if (typeof args[0] === "number" && (args.length === 1 || args.length === 2 && typeof args[1] !== "number")) { // Timestamp passed: use it as is this.setTime(args[0]); } else if (typeof args[0] === "string") { // `Date` string passed: parse it as external date this.setTime(+new Date(args[0])); } else if (args[0] instanceof Date) { // `Date` passed: use its timestamp this.setTime(+args[0]); } else { // `Date` values passed: // Set it as external date. this.setTime(+new Date(...args)); // Adjust internal and external dates considering that we might have // landed on the DST hour. adjustToSystemTZ(this, args); } } } static tz(tz, ...args) { return args.length ? new TZDateMini(...args, tz) : new TZDateMini(Date.now(), tz); } //#endregion //#region time zone withTimeZone(timeZone) { return new TZDateMini(+this, timeZone); } getTimezoneOffset() { const offset = -tzOffset(this.timeZone, this); // Remove the seconds offset using `Math.floor` for negative UTC time zones // and `Math.ceil` for positive UTC time zones. return offset > 0 ? Math.floor(offset) : Math.ceil(offset); } //#endregion //#region time setTime(_time) { // Use the native `setTime` to the external date time. Date.prototype.setTime.apply(this, arguments); // Then apply it to the internal date adjusting to the timezone offset. syncToInternal(this); return +this; } //#endregion //#region date-fns integration [Symbol.for("constructDateFrom")](date) { return new TZDateMini(+new Date(date), this.timeZone); } //#endregion } // Assign getters and setters const re = /^(get|set)(?!UTC)/; Object.getOwnPropertyNames(Date.prototype).forEach(method => { if (!re.test(method)) return; const utcMethod = method.replace(re, "$1UTC"); // Filter out methods without UTC counterparts if (!TZDateMini.prototype[utcMethod]) return; if (method.startsWith("get")) { // Delegate to internal date's UTC method TZDateMini.prototype[method] = function () { return this.internal[utcMethod](); }; } else { // Assign regular setter TZDateMini.prototype[method] = function () { Date.prototype[utcMethod].apply(this.internal, arguments); syncFromInternal(this); return +this; }; // Assign UTC setter TZDateMini.prototype[utcMethod] = function () { Date.prototype[utcMethod].apply(this, arguments); syncToInternal(this); return +this; }; } }); /** * @internal * Function syncs time to internal date, applying the current time zone offset. * * @param {Date} date - `Date` to sync */ function syncToInternal(date) { // Start internal from the same real timestamp as external. date.internal.setTime(+date); // Shift internal by the target offset so its UTC fields become the target-zone // wall-clock fields for the external timestamp. // // o internal UTC fields: 2024-02-11 00:00 // | // | + Asia/Singapore offset (+08:00) // v // x internal UTC fields: 2024-02-11 08:00 // date.internal.setUTCSeconds(date.internal.getUTCSeconds() - // Round after converting minutes to seconds to avoid fractional offset // precision errors from historical offsets. Math.round(-tzOffset(date.timeZone, date) * 60)); } /** * @internal * Function syncs internal wall-clock fields into the external date. * * @param {Date} date - The date to sync */ function syncFromInternal(date) { // Copy target-zone wall-clock fields from internal into external using native // local setters. At this point external holds the right field values but they // are interpreted in the system time zone; `adjustToSystemTZ` fixes that. Date.prototype.setFullYear.call(date, date.internal.getUTCFullYear(), date.internal.getUTCMonth(), date.internal.getUTCDate()); Date.prototype.setHours.call(date, date.internal.getUTCHours(), date.internal.getUTCMinutes(), date.internal.getUTCSeconds(), date.internal.getUTCMilliseconds()); // Now we have to adjust the date to the system time zone adjustToSystemTZ(date); } /** * @internal * Function adjusts the date to the system time zone. It uses the time zone * differences to calculate the offset and adjust the date. * * We use it when constructing `TZDate` and syncing the external date from * the internal one. * * @param {TZDate} date - `TZDate` to adjust. */ function adjustToSystemTZ(date, constructorArgs) { // Keep the intended target-zone wall-clock value before native Date/system // time zone normalization can move it. Later corrections compare against this // value to distinguish intended DST normalization from accidental drift. const expectedInternalTime = Array.isArray(constructorArgs) ? constructorArgsToInternalTime(constructorArgs) : +date.internal; //#region Initial offset calculation // Current target time zone offset at the native timestamp. It may include // historical offset seconds, which we preserve for the seconds adjustment. const offsetWithSeconds = tzOffset(date.timeZone, date); // Minute-precision target offset used by minute-based timestamp adjustments. // Historical offsets can contain seconds, so we strip the fractional minute // here and handle remaining seconds in the seconds adjustment below. const offset = offsetWithSeconds > 0 ? Math.floor(offsetWithSeconds) : Math.ceil(offsetWithSeconds); //#endregion //#region System DST adjustment // Native `Date` may normalize the requested wall time when the system time // zone skips that hour for DST. // // We compare the system wall-clock hour represented by the external date with // the wall-clock hour stored in the internal date. If they differ, the next // offset-diff calculation may need the previous system offset, because the // current external timestamp is already after the system DST jump. // Previous-hour reference used to detect whether the system offset changed // immediately before the current native timestamp. const prevHour = new Date(+date); // Use UTC math so subtracting one hour cannot be normalized back into the // same missing local DST hour. prevHour.setUTCHours(prevHour.getUTCHours() - 1); // Current system offset at the native timestamp. const systemOffset = -new Date(+date).getTimezoneOffset(); // System offset one real hour before the native timestamp. const prevHourSystemOffset = -new Date(+prevHour).getTimezoneOffset(); // Non-zero when the system offset changed between `prevHour` and `date`. const systemDSTChange = systemOffset - prevHourSystemOffset; // System offset to use in the later system-target offset difference. // Defaults to the current system offset and switches to the previous offset // only when native `Date` normalized a missing system wall time. let systemOffsetForDiff = systemOffset; if (systemDSTChange && systemOffset !== offset) { // System wall-clock hour represented by the current external timestamp. const systemHour = Date.prototype.getHours.apply(date); // Target wall-clock hour currently stored in internal fields. Constructors // get it from arguments because internal may have already been synced from // the normalized external timestamp; setters get it directly from internal. const expectedHour = Array.isArray(constructorArgs) ? constructorArgs[3] || 0 : date.internal.getUTCHours(); if (systemHour !== expectedHour) { // Check whether using the current system offset would keep the target // offset unchanged. If so, the only DST jump we crossed is the system // one, so the diff should use the pre-DST system offset. const testDate = new Date(+date); // Difference that the later offset-diff step would apply with the // current system offset. const testOffsetDiff = systemOffset - offset; if (testOffsetDiff) testDate.setUTCMinutes(testDate.getUTCMinutes() + testOffsetDiff); // Target offset after applying the current system offset difference. const testOffsetWithSeconds = tzOffset(date.timeZone, testDate); // Target offset without historical seconds, matching `offset`. const testOffset = testOffsetWithSeconds > 0 ? Math.floor(testOffsetWithSeconds) : Math.ceil(testOffsetWithSeconds); if (testOffset === offset) systemOffsetForDiff = prevHourSystemOffset; } } //#endregion //#region System diff adjustment // Move external from the system-zone interpretation of internal fields toward // the timestamp that represents those fields in the target time zone. // // At this point native `Date` has treated the internal wall-clock fields as if // they belonged to the system zone. The system-target offset difference moves // external by the distance between that system interpretation and the target // interpretation. // Difference between the system offset selected above and the minute-precision // target offset. Positive values move external forward; negative values move // it backward. const offsetDiff = systemOffsetForDiff - offset; if (offsetDiff) // Apply the system-target minute difference to external. Internal is // rebuilt from the final external timestamp after all adjustments are // complete. // // o external as target: 2023-01-31 23:00 // | // | add `offsetDiff` // v // x external as target: 2023-02-01 12:00 // Date.prototype.setUTCMinutes.call(date, Date.prototype.getUTCMinutes.call(date) + offsetDiff); //#endregion //#region Seconds system diff adjustment // Historical time zone offsets can include seconds, but the minute-based // offset adjustment above intentionally used minute precision. // // This adjustment applies the remaining seconds difference to external. For // example, historical Singapore used UTC+06:55:25, while ISO formatting shows // only `+06:55`; without the seconds correction, setter paths keep the wrong // wall-clock seconds. // Clone external as a native `Date` so we can inspect how the system time zone // represents the same timestamp. const systemDate = new Date(+date); // Zero UTC seconds before reading system seconds. Any remaining local seconds // then come from the system time zone offset rather than from the timestamp's // own seconds value. systemDate.setUTCSeconds(0); // Seconds part contributed by the system time zone offset. Negative offsets // need wrapping because `Date#getSeconds()` returns values in the 0..59 range. const systemSecondsOffset = systemOffset > 0 ? systemDate.getSeconds() : (systemDate.getSeconds() - 60) % 60; // Seconds part contributed by the target time zone offset. const secondsOffset = Math.round(-(tzOffset(date.timeZone, date) * 60)) % 60; if (secondsOffset || systemSecondsOffset) // Apply the remaining second-level system-target difference to external. // // o external as Asia/Singapore: 1900-01-01 00:00:56 // | // | + `secondsOffset` // | + `systemSecondsOffset` // v // x external as Asia/Singapore: 1900-01-01 00:00:31 // Date.prototype.setUTCSeconds.call(date, Date.prototype.getUTCSeconds.call(date) + secondsOffset + systemSecondsOffset); //#endregion //#region Post-adjustment DST fix // The first system-target offset move can cross a target-zone DST boundary. // // When that happens, the target offset at the new external timestamp differs // from the target offset used for `offsetDiff`. We compare the original move // with the move that would be calculated at the new timestamp, then apply the // difference to external. // Target offset at the current external timestamp, including historical // seconds for the later seconds adjustment. const postOffsetWithSeconds = tzOffset(date.timeZone, date); // Minute-precision target offset at the current external timestamp. const postOffset = postOffsetWithSeconds > 0 ? Math.floor(postOffsetWithSeconds) : Math.ceil(postOffsetWithSeconds); // System offset at the current external timestamp. const postSystemOffset = -new Date(+date).getTimezoneOffset(); // System-target offset difference at the current external timestamp. const postOffsetDiff = postSystemOffset - postOffset; // Whether the target offset changed after the first offset move. const offsetChanged = postOffset !== offset; // Difference between the current offset move and the move already applied. const postDiff = postOffsetDiff - offsetDiff; // If the first offset move already normalized a target DST gap forward, the // generic post-DST correction below would undo that valid normalization. This // happens, for example, with America/New_York 02:00 during spring-forward when // the system zone also changes offset around the same instant. const targetDSTShift = postOffset - offset; // Candidate timestamp that would represent the requested wall-clock time with // the post-transition target offset. If it still does not round-trip to the // requested wall-clock fields, the requested time is inside a target DST gap. const postOffsetCandidate = expectedInternalTime - postOffset * 60 * 1000; // Only positive target offset shifts are spring-forward gaps. Negative shifts // are fall-back overlaps and still need the regular post-adjustment path. const normalizedTargetDSTGap = targetDSTShift > 0 && targetInternalTime(date) - expectedInternalTime === targetDSTShift * 60 * 1000 && targetInternalTime(date, postOffsetCandidate) !== expectedInternalTime; if (offsetChanged && postDiff && !normalizedTargetDSTGap) { // Apply the target-DST correction to external. In the backward-crossing case // shown here, this lands on an intermediate value that needs `offsetChange` // below. // // o external as America/New_York: 2023-03-12 03:00 EDT // | // | + `postDiff` // v // x external as America/New_York: 2023-03-12 01:00 EST // Date.prototype.setUTCMinutes.call(date, Date.prototype.getUTCMinutes.call(date) + postDiff); // Target offset after applying `postDiff`. It may change again if the // correction itself crosses a target-zone DST boundary. const newOffsetWithSeconds = tzOffset(date.timeZone, date); // Minute-precision target offset after applying `postDiff`. const newOffset = newOffsetWithSeconds > 0 ? Math.floor(newOffsetWithSeconds) : Math.ceil(newOffsetWithSeconds); // Offset change caused by the `postDiff` move itself. const offsetChange = postOffset - newOffset; // If the correction moved external backward across the target DST boundary, // apply the boundary change so external lands on the valid target-zone // timestamp. Forward crossings are already normalized by native Date, and // applying this correction there would undo the valid post-DST result. if (offsetChange && postDiff < 0) { // Apply the second target-DST correction to external. // // o external as America/New_York: 2023-03-12 01:00 EST // | // | + `offsetChange` // v // x external as America/New_York: 2023-03-12 03:00 EDT // Date.prototype.setUTCMinutes.call(date, Date.prototype.getUTCMinutes.call(date) + offsetChange); } } //#endregion // Rebuild internal wall-clock fields from the final external timestamp. syncToInternal(date); // Native Date can normalize historical system offsets with minute and second // precision before we adjust to the target time zone. Correct only small // historical drift so DST gap normalization (usually one hour) remains intact. const expectedTime = constructorArgs ? expectedInternalTime : expectedInternalTime + secondsOffset * 1000; const drift = expectedTime - +date.internal; if (drift && Math.abs(drift) < 30 * 60 * 1000) { Date.prototype.setTime.call(date, +date + drift); syncToInternal(date); } } function constructorArgsToInternalTime(args) { // Mirror Date's date-value constructor defaults while preserving explicit // `undefined` behavior. Missing month/day default, but passed `undefined` // should remain invalid just like `Date.UTC(year, undefined, ...)`. return Date.UTC(args[0], args.length > 1 ? args[1] : 0, args.length > 2 ? args[2] : 1, ...args.slice(3)); } function targetInternalTime(date, time) { // Compute the target-zone wall-clock representation for a timestamp without // mutating the TZDate. This mirrors syncToInternal for temporary checks. const internal = new Date(time ?? +date); internal.setUTCSeconds(internal.getUTCSeconds() - Math.round(-tzOffset(date.timeZone, internal) * 60)); return +internal; }