448 lines
18 KiB
JavaScript
448 lines
18 KiB
JavaScript
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;
|
|
} |