Skip to content

Offset-Preserving Instants and Local Resolution

  • The core functions, which every industry uses.

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"

-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.

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"); // true
isValidUtcOffset("-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)

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 guessed

classifyLocal 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 isValidTimeZone or isValidUtcOffset accepts. These are resolveLocal, classifyLocal, toOffsetInstant, getTimeZoneOffset, the getZoned* field readers, the utc/ and unix/ functions that convert, parse or calculate, floorToZone, bucketRange, the operating-hours functions, freeTimeExpiry, chargeableDays and bolTimestamp.
  • 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 as convertUtcToZoned, the functions that take a business calendar, and formatUtc, formatCalendarUtc, formatUnix, formatCalendarUnix and formatTimeZoneName. Pass the IANA name, or call fromOffsetInstant for 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”
  • resolveLocal returns an instant. convertPlainDateTimeToZoned returns a zoned string. They share the underlying resolution but differ in output and precision. resolveLocal is exact to the nanosecond, while convertPlainDateTimeToZoned defaults to milliseconds. Neither takes an offset option. Reach for whichever shape you need next.
  • offset is ±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/Monrovia stayed at -00:44:30 until 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.