Offset-Preserving Instants and Local Resolution
An instant orders events globally. The UTC offset in force where the event happened renders
it as the human on the ground saw it. Neither derives from the other, which is why GS1 EPCIS
2.0 requires both fields (eventTime plus eventTimeZoneOffset). UN/EDIFACT DTM has format
codes 205 to 208 for the pair (a date and time with a ±HHMM offset), and DICOM appends
&ZZXX to a DT value.
The instant/ namespace is that pair:
toOffsetInstant and
fromOffsetInstant.
import { fromOffsetInstant, toOffsetInstant } from "@northguild/gmt";
toOffsetInstant("2024-07-15T12:00:00-04:00[America/New_York]");// { instant: "2024-07-15T16:00:00Z", offset: "-04:00", timeZone: "America/New_York" }
// Most feeds send no zone at all — the offset alone is what happened.toOffsetInstant("2024-07-15T12:00:00-04:00");// { instant: "2024-07-15T16:00:00Z", offset: "-04:00" }
// A UTC-only feed plus a zone you know from somewhere else.toOffsetInstant("2024-07-15T16:00:00Z", "America/New_York");// { instant: "2024-07-15T16:00:00Z", offset: "-04:00", timeZone: "America/New_York" }
fromOffsetInstant({ instant: "2024-07-15T16:00:00Z", offset: "-04:00" });// "2024-07-15T12:00:00-04:00"An offset is not a zone
Section titled “An offset is not a zone”-05:00 does not identify America/New_York. It is every zone sitting at -05:00 that
day, and it says nothing about what that zone will do next spring.
Store the zone for anything still to be scheduled. Store the offset for anything that already happened.
timeZone is optional because most feeds do not send one. A string whose offset contradicts
its own bracketed zone returns null rather than a guess.
Validate the offset
Section titled “Validate the offset”isValidUtcOffset checks the offset half
of the pair: ±HH:MM, or ±HH:MM:SS for a zone that was not on a whole minute. A function
whose result has no zone in it takes this offset in its time zone position too. A function
that returns a zoned string, or formats with Intl, takes a time zone identifier only: check
that with isValidTimeZone.
import { isValidTimeZone, isValidUtcOffset } from "@northguild/gmt";
isValidUtcOffset("-04:00"); // trueisValidUtcOffset("-00:44:30"); // true (Africa/Monrovia before 1972)isValidUtcOffset("Z"); // false (a designator, not an offset)isValidUtcOffset("+0530"); // false (no colon)
isValidTimeZone("-00:44:30"); // false (an offset with seconds is not a time zone identifier)isValidTimeZone("+0530"); // true (an offset to the minute is one)Resolve a wall time that has no offset
Section titled “Resolve a wall time that has no offset”The other half of the namespace is the reverse problem: a wall time that arrives with no
offset at all (“gate-out 08:00”). Resolving one needs a zone the sender did not send, plus a
policy for the two days a year the mapping is not one-to-one.
classifyLocal and
resolveLocal do it:
import { classifyLocal, resolveLocal } from "@northguild/gmt";
classifyLocal("2024-11-03T01:30:00", "America/New_York"); // "ambiguous"classifyLocal("2024-03-10T02:30:00", "America/New_York"); // "nonexistent"classifyLocal("2024-07-15T12:00:00", "America/New_York"); // "unique"
resolveLocal("2024-11-03T01:30:00", "America/New_York");// "2024-11-03T05:30:00Z" — the default "compatible" takes the first 01:30
resolveLocal("2024-11-03T01:30:00", "America/New_York", { disambiguation: "later" });// "2024-11-03T06:30:00Z" — the same wall clock, an hour of real time later
resolveLocal("2024-11-03T01:30:00", "America/New_York", { disambiguation: "reject" });// "" — ambiguous, and not guessedclassifyLocal exists so code can branch before a policy is applied. A demurrage clock,
a medication window or a duty limit should refuse an ambiguous wall time, or route it to a
human. It should not silently accept whichever of two instants an hour apart a default
handed it. See DST Disambiguation for the four
strategies in full.
Pass a stored offset where a time zone goes
Section titled “Pass a stored offset where a time zone goes”A stored offset is the offset the library returns for a zone at one instant: ±HH:MM, or
±HH:MM:SS where the zone was not on a whole minute.
getTimeZoneOffset,
getZonedOffset and toOffsetInstant return one. A
fixed offset is a complete rule for local time, so it goes straight back in as the time zone:
import { classifyLocal, convertUtcToPlainDateTime, getTimeZoneOffset, resolveLocal,} from "@northguild/gmt";
getTimeZoneOffset("Africa/Monrovia", "1970-01-01T12:00:00Z"); // "-00:44:30"
resolveLocal("1970-01-01T12:00:00", getTimeZoneOffset("Africa/Monrovia", "1970-01-01T12:00:00Z"));// "1970-01-01T12:44:30Z"resolveLocal("1970-01-01T12:00:00", "Africa/Monrovia"); // "1970-01-01T12:44:30Z" (the same instant)resolveLocal("1970-01-01T12:00:00", "+05:30:00"); // "1970-01-01T06:30:00Z" (the same as "+05:30")
convertUtcToPlainDateTime("1970-01-01T12:44:30Z", { timeZone: "-00:44:30" }); // "1970-01-01T12:00:00"
classifyLocal("1970-01-01T12:00:00", "-00:44:30"); // "unique"resolveLocal("1970-01-01T12:00:00", "-00:44:30", { disambiguation: "reject" }); // "1970-01-01T12:44:30Z"A fixed offset has no transitions, so a wall time at one is never ambiguous and never
nonexistent. classifyLocal returns "unique", and disambiguation has nothing to decide.
An offset with seconds is still not a time zone identifier. A written zone cannot carry
seconds (RFC 9557), and Intl.DateTimeFormat takes an offset to the minute only. So the
result of a function decides what it takes:
- The result has no zone in it (an instant, a plain value, a number or a yes or no). The
function takes anything
isValidTimeZoneorisValidUtcOffsetaccepts. These areresolveLocal,classifyLocal,toOffsetInstant,getTimeZoneOffset, thegetZoned*field readers, theutc/andunix/functions that convert, parse or calculate,floorToZone,bucketRange, the operating-hours functions,freeTimeExpiry,chargeableDaysandbolTimestamp. - The result carries the zone, or the zone goes to
Intl.DateTimeFormat. The function takes a time zone identifier only, and returns its empty result for an offset with seconds. These are the functions that return a zoned string, such asconvertUtcToZoned, the functions that take a business calendar, andformatUtc,formatCalendarUtc,formatUnix,formatCalendarUnixandformatTimeZoneName. Pass the IANA name, or callfromOffsetInstantfor the local time with its full offset and no bracket.
import { convertUtcToZoned, fromOffsetInstant } from "@northguild/gmt";
convertUtcToZoned("1970-01-01T12:44:30Z", "-00:44:30"); // ""convertUtcToZoned("1970-01-01T12:44:30Z", "Africa/Monrovia");// "1970-01-01T12:00:00-00:45[Africa/Monrovia]" — the written offset is rounded to the minute
fromOffsetInstant({ instant: "1970-01-01T12:44:30Z", offset: "-00:44:30" });// "1970-01-01T12:00:00-00:44:30"There is one limit. With an offset with seconds, a function that does arithmetic returns its
empty result within the offset’s seconds (under a minute) of one end of the range of instants
Temporal supports: the last instant for an offset east of UTC, the first for one west. A
function that only reads a value has no limit, and neither have resolveLocal,
classifyLocal, toOffsetInstant, getTimeZoneOffset and isBetweenUnix. An offset to the
minute has no limit at all.
Two notes on the boundaries of this namespace
Section titled “Two notes on the boundaries of this namespace”resolveLocalreturns an instant.convertPlainDateTimeToZonedreturns a zoned string. They share the underlying resolution but differ in output and precision.resolveLocalis exact to the nanosecond, whileconvertPlainDateTimeToZoneddefaults to milliseconds. Neither takes anoffsetoption. Reach for whichever shape you need next.offsetis±HH:MM, except where it is not. Most zones kept local mean time before they adopted a standard time, and local mean time is rarely a whole minute.Africa/Monroviastayed at-00:44:30until 1972. For those instants the offset is±HH:MM:SS. Rounding it would put the pair up to thirty seconds from the event it describes.
See also
Section titled “See also”- DST Disambiguation: the four strategies behind
resolveLocal - Nanosecond Precision: how zoned strings with sub-minute offsets are read
- Intermodal: EDI Timestamps: the interchange formats that carry an offset