Transport: Legs and Dwell
Every transport mode shares three operations: add a leg’s duration to a departure, show the arrival in the zone where it lands, and measure how long something sat at a terminal, berth, gate or yard. Every mode also gets the same two things wrong: a leg across a DST transition is a fixed number of elapsed hours, and dwell is charged in local calendar days, not hours. Chaining legs into one journey, with scheduled connections and crossings, is Transport: Multi-leg Scheduling. Deadlines counted back from a departure, a loading or an arrival are Deadlines from an anchor, at the end of this page.
import { dwellTime, etaAtZone, transitTime } from "@northguild/gmt/transport";The functions live in @northguild/gmt/transport, split into
@northguild/gmt/transport/calculate (transitTime, dwellTime) and
@northguild/gmt/transport/convert (etaAtZone).
A leg is exact time: transitTime
Section titled “A leg is exact time: transitTime”transitTime(departure, duration) adds an ISO
8601 duration to a departure as elapsed time, in the departure’s own zone. Hours, minutes and
seconds are exact, and a D component is 24 hours exactly. The arrival’s wall clock reflects
whatever DST shift happened in between, because that is when the vessel, train or aircraft
actually gets there.
import { transitTime } from "@northguild/gmt/transport";
// Four elapsed hours across New York's spring-forward: the clock reads 04:00, not 03:00.transitTime("2024-03-09T23:00:00-05:00[America/New_York]", "PT4H");// "2024-03-10T04:00:00-04:00[America/New_York]"
// A day is 24 exact hours, not "the same wall time tomorrow".transitTime("2024-03-09T20:00:00-05:00[America/New_York]", "P1DT2H");// "2024-03-10T23:00:00-04:00[America/New_York]"
// A negative duration recovers the departure from an arrival.transitTime("2024-06-15T12:30:00-04:00[America/New_York]", "-PT2H30M");// "2024-06-15T10:00:00-04:00[America/New_York]"The departure’s form is preserved. A bracketed IANA zone stays that zone, Z stays Z, and
an offset stays an offset, written as the departure wrote it, because an offset is not a
zone and nothing more can be inferred from it.
transitTime("2024-06-15T10:00:00Z", "PT36H"); // "2024-06-16T22:00:00Z"transitTime("2024-06-15T10:00:00+09:00", "PT1H"); // "2024-06-15T11:00:00+09:00"transitTime("2024-06-15T10:00:00+05:30:15", "PT1H"); // "2024-06-15T11:00:00+05:30:15"Two inputs are refused rather than reinterpreted. Years, months and weeks return "": no leg
takes “a month” in a sense that time arithmetic can settle without a reference point, and a
dated connection belongs to addZoned. A bracketed
zone whose offset contradicts it also returns "", as
isValidZonedDateTime rejects it.
transitTime("2024-06-15T10:00:00Z", "P1M"); // ""transitTime("2024-06-15T10:00:00-05:00[America/New_York]", "PT2H"); // "" — June is -04:00An arrival is rendered where it lands: etaAtZone
Section titled “An arrival is rendered where it lands: etaAtZone”etaAtZone(arrivalUtc, targetZone) takes an
instant and renders it in the zone the reader is in. Only the instant is read from the input;
targetZone is where it is shown.
import { etaAtZone } from "@northguild/gmt/transport";
etaAtZone("2024-06-15T12:30:00Z", "Asia/Tokyo");// "2024-06-15T21:30:00+09:00[Asia/Tokyo]"No disambiguation arises here. A moment has exactly one wall time in a zone, so the
disambiguation vocabulary of resolveLocal does
not apply. On a fall-back night two arrivals an hour apart print the same wall time, and the
offset in the result is what tells them apart. That is why the result carries its offset and
zone rather than a bare wall time.
etaAtZone("2024-11-03T05:30:00Z", "America/New_York");// "2024-11-03T01:30:00-04:00[America/New_York]" — the first 01:30etaAtZone("2024-11-03T06:30:00Z", "America/New_York");// "2024-11-03T01:30:00-05:00[America/New_York]" — the second; the offset tells them apartThe zone is the caller’s fact. GMT does not resolve a port, airport or station code to a
time zone, and takes an IANA identifier instead. A fixed offset such as "+09:00" is accepted
as a zone, as isValidTimeZone accepts it, but
it observes no DST: pass the place’s IANA zone when there is one.
Dwell is hours and days: dwellTime
Section titled “Dwell is hours and days: dwellTime”dwellTime(entry, exit, targetZone?) returns the
exact duration, the entry and exit rendered in the dwell zone, and calendarDays, the number
of distinct local dates the dwell touched.
import { dwellTime } from "@northguild/gmt/transport";
dwellTime("2024-06-15T22:30:00Z", "2024-06-16T01:00:00Z", "Europe/London");// { duration: "PT2H30M",// enter: "2024-06-15T23:30:00+01:00[Europe/London]",// exit: "2024-06-16T02:00:00+01:00[Europe/London]",// calendarDays: 2 }
// The same two instants, one fewer day: the zone decides.dwellTime("2024-06-15T22:30:00Z", "2024-06-16T01:00:00Z", "Europe/Amsterdam");// { duration: "PT2H30M",// enter: "2024-06-16T00:30:00+02:00[Europe/Amsterdam]",// exit: "2024-06-16T03:00:00+02:00[Europe/Amsterdam]",// calendarDays: 1 }calendarDays is not duration rounded up. It is the number of distinct local dates the
half-open interval [entry, exit) touches: entry and exit on the same date is 1, across one
local midnight is 2. An exit exactly at local midnight does not touch the new day, the same
half-open rule as bucketRange and the
interval functions. The count walks the zone’s real
transitions, so a 23- or 25-hour local day is one day, a date the zone deleted is never
touched (Samoa skipped 2011-12-30), and a date the clock falls back into is counted once
(America/Goose_Bay fell back from 00:01 on 7 November 2010 into the 6th). The Dwell Ledger draws this on a zone’s
real day grid: drag the exit across a midnight and watch the count change while the hours
barely move.
// An exit exactly at local midnight does not touch the new day.dwellTime("2024-06-15T22:00:00Z", "2024-06-16T04:00:00Z", "America/New_York");// { duration: "PT6H",// enter: "2024-06-15T18:00:00-04:00[America/New_York]",// exit: "2024-06-16T00:00:00-04:00[America/New_York]",// calendarDays: 1 }
// Seven elapsed hours across the spring-forward, one local day.dwellTime("2024-03-10T05:00:00Z", "2024-03-10T12:00:00Z", "America/New_York");// { duration: "PT7H",// enter: "2024-03-10T00:00:00-05:00[America/New_York]",// exit: "2024-03-10T08:00:00-04:00[America/New_York]",// calendarDays: 1 }duration is exact elapsed time with hours as the largest unit, the same form
sumIntervals reports, so it never depends on
a calendar.
A day count needs a place
Section titled “A day count needs a place”The zone comes from targetZone, or from the entry’s bracketed IANA zone when targetZone
is omitted. targetZone may also be a fixed offset such as "+02:00", as
etaAtZone accepts; days are then counted in that
offset, which observes no DST. Bare instants, Z or an offset, with no targetZone return null: an offset is
not a zone, and a day count in an unstated locality would be a guess. An inverted dwell, with
the exit before the entry, is a data error and also returns null.
dwellTime("2024-06-15T22:30:00Z", "2024-06-16T01:00:00Z"); // null — no place to count days indwellTime("2024-06-16T01:00:00Z", "2024-06-15T22:30:00Z", "Europe/London"); // null — inverteddwellTime reports dwell without judging it. Whether those days were free, chargeable,
calendar or working depends on a contract, not on the place. Free time and demurrage, laytime
and hospital length of stay will all count from this one day boundary rather than each
deciding what a midnight is.
Deadlines from an anchor
Section titled “Deadlines from an anchor”A logistics deadline is not a fixed timestamp. It counts back from an event, such as a
vessel’s departure, a container’s loading or an arrival, in the local time of the place that
enforces it, and it moves when that event moves. The four functions live in
@northguild/gmt/transport, split into @northguild/gmt/transport/calculate (cutoffAt,
cutoffSchedule) and @northguild/gmt/transport/compare (isPastCutoff, timeToCutoff).
import { cutoffAt, cutoffSchedule, isPastCutoff, timeToCutoff } from "@northguild/gmt/transport";Two days before is not 48 hours: cutoffAt
Section titled “Two days before is not 48 hours: cutoffAt”cutoffAt(anchor, offset, options) takes an offset
off an anchor event and reads the result in options.timeZone. A cut-off stated as “two days
before, 17:00” is 17:00 local on the date two days earlier. That is not the anchor minus 48
hours, which keeps the anchor’s time of day. atLocalTime pins the time of day.
import { cutoffAt } from "@northguild/gmt/transport";
// A vessel sails Friday 14 June 2024 at 18:00 in Amsterdam (16:00Z).cutoffAt("2024-06-14T16:00:00Z", "P2D", { timeZone: "Europe/Amsterdam", atLocalTime: "17:00" });// "2024-06-12T17:00:00+02:00[Europe/Amsterdam]"
// Subtracting 48 hours keeps the departure's 18:00: an hour late for a close-of-business rule.cutoffAt("2024-06-14T16:00:00Z", "PT48H", { timeZone: "Europe/Amsterdam" });// "2024-06-12T18:00:00+02:00[Europe/Amsterdam]"With atLocalTime, only the day the offset lands on matters. The offset’s exact part (hours
and smaller) comes off the anchor’s instant, its calendar part (years, months, weeks, days)
comes off that local date, and the cut-off is atLocalTime on that day. The exact part comes off first, so it can change
the day: from midnight, P2DT0.000000001S lands a day before P2D. Across a DST
transition the local time of day holds and the elapsed time moves. New York sprang forward on
Sunday 10 March 2024, so the pinned cut-off before a Monday 18:00 departure is 48 elapsed
hours earlier, not 49.
// Monday 11 March 2024, 18:00 EDT (22:00Z).cutoffAt("2024-03-11T22:00:00Z", "P2D", { timeZone: "America/New_York", atLocalTime: "17:00" });// "2024-03-09T17:00:00-05:00[America/New_York]"Without atLocalTime, the offset comes off the way Temporal’s ZonedDateTime#subtract takes
it: the calendar part keeps the anchor’s wall clock, and the exact part is exact elapsed time.
This is the shape of an advance-filing deadline stated in hours. 96 hours before an arrival is
96 hours, whatever the clock does in between.
// Arrival Tuesday 5 November 2024, 12:00 EST (17:00Z). New York fell back on the 3rd.cutoffAt("2024-11-05T17:00:00Z", "PT96H", { timeZone: "America/New_York" });// "2024-11-01T13:00:00-04:00[America/New_York]" — 96 hours; the clock reads 13:00cutoffAt("2024-11-05T17:00:00Z", "P4D", { timeZone: "America/New_York" });// "2024-11-01T12:00:00-04:00[America/New_York]" — four calendar days keep 12:00, 97 hoursThe Cut-off Ruler draws all three readings of “two days before” on one axis, across New York’s fall-back.
The anchor is your event
Section titled “The anchor is your event”Loading, departure and arrival are different instants, and the same offset from the wrong
one is late by days. cutoffAt takes the event you pass and never guesses which one a rule
means. The anchor is exact: an instant (Z or an offset) or a zoned string, whose bracket never
supplies the zone. timeZone alone is the local frame, and it may be a fixed offset such as "+02:00",
which observes no DST.
// One shipment: loaded at 08:00Z on 10 June, departing at 20:00Z on 12 June.cutoffAt("2024-06-10T08:00:00Z", "PT24H", { timeZone: "Asia/Shanghai" });// "2024-06-09T16:00:00+08:00[Asia/Shanghai]" — 24 hours before loadingcutoffAt("2024-06-12T20:00:00Z", "PT24H", { timeZone: "Asia/Shanghai" });// "2024-06-12T04:00:00+08:00[Asia/Shanghai]" — 24 hours before departure, 60 hours laterClosures roll the way you say
Section titled “Closures roll the way you say”With calendar, a BusinessCalendar, a
cut-off on a weekend or holiday moves by roll, as
rollDate moves it. Every industry answers “what if
it lands on a non-working day” differently, and no standard picks one, so the convention is
always yours: there is no default. calendar without roll, or roll without calendar,
returns "". The rolled day keeps the cut-off’s local time of day, and calendar.timeZone is
not read. Any RollConvention is accepted,
but "endOfMonth" is rollDate’s schedule tool, not a cut-off convention.
// Tuesday 18 June 2024, 18:00 in Amsterdam. Two days before is Sunday.const calendar = { weekend: [6, 7], holidays: [], timeZone: "Europe/Amsterdam" };cutoffAt("2024-06-18T16:00:00Z", "P2D", { timeZone: "Europe/Amsterdam", atLocalTime: "17:00", calendar, roll: "preceding" });// "2024-06-14T17:00:00+02:00[Europe/Amsterdam]" — back to FridaycutoffAt("2024-06-18T16:00:00Z", "P2D", { timeZone: "Europe/Amsterdam", atLocalTime: "17:00", calendar, roll: "following" });// "2024-06-17T17:00:00+02:00[Europe/Amsterdam]" — on to MondaycutoffAt("2024-06-18T16:00:00Z", "P2D", { timeZone: "Europe/Amsterdam", atLocalTime: "17:00", calendar });// "" — a calendar with no roll conventioncutoffAt does not check that the cut-off comes before the anchor. A negative offset, P0D
with a late atLocalTime, "following" or "endOfMonth" can each put it after, and that is
the rule’s answer, not an error.
Skipped and repeated hours
Section titled “Skipped and repeated hours”A cut-off is a local wall time, so it inherits
resolveLocal’s vocabulary. atLocalTime, the
calendar part of an unpinned offset and a rolled day all land on wall times. A repeated hour
takes the earlier pass: RFC 5545 §3.3.5
reads a local time that occurs more than once as “the first occurrence of the referenced
time”. A skipped hour, or a day a zone deleted, returns "" rather than shifting the deadline.
cutoffAt("2024-11-04T23:00:00Z", "P1D", { timeZone: "America/New_York", atLocalTime: "01:30" });// "2024-11-03T01:30:00-04:00[America/New_York]" — the first 01:30cutoffAt("2024-03-11T22:00:00Z", "P1D", { timeZone: "America/New_York", atLocalTime: "02:30" });// "" — 02:30 on 10 March never happenedThe whole stack: cutoffSchedule
Section titled “The whole stack: cutoffSchedule”cutoffSchedule(anchor, cutoffs, options)
computes every cut-off against one anchor, earliest first. Each entry is cutoffAt with its
own offset and atLocalTime, and options carries the zone, calendar and roll they share.
Reschedule the vessel and call it again: the functions are pure, so every deadline follows the
new departure and nothing cached goes stale.
import { cutoffSchedule } from "@northguild/gmt/transport";
cutoffSchedule("2024-06-14T16:00:00Z", [ { name: "gate-in", offset: "P1D" }, { name: "document", offset: "P2D", atLocalTime: "17:00" }, { name: "customs", offset: "PT30H" }, { name: "VGM", offset: "P1D", atLocalTime: "10:00" },], { timeZone: "Europe/Amsterdam" });// [ { name: "document", at: "2024-06-12T17:00:00+02:00[Europe/Amsterdam]" },// { name: "VGM", at: "2024-06-13T10:00:00+02:00[Europe/Amsterdam]" },// { name: "customs", at: "2024-06-13T12:00:00+02:00[Europe/Amsterdam]" },// { name: "gate-in", at: "2024-06-13T18:00:00+02:00[Europe/Amsterdam]" } ]The sort is by instant, not by the text of the local time: on a fall-back night the first
01:30 comes before the second 01:15. Entries on the same instant keep the order you gave. One
invalid entry, or one cut-off in a skipped hour, returns [], never a stack with a deadline
silently missing.
The Cut-off Stack draws a sailing’s stack on a day timeline, with closed days shaded and each rolled cut-off drawn where it landed and where it would have been.
Has it passed: isPastCutoff
Section titled “Has it passed: isPastCutoff”isPastCutoff(now, cutoff) is true from the
cut-off instant on. The window to meet a cut-off is half-open, like GMT’s
intervals: at the cut-off itself it has closed. Both
arguments are compared as instants, and now is always yours, so the answer is reproducible.
import { isPastCutoff } from "@northguild/gmt/transport";
isPastCutoff("2024-06-12T14:00:00Z", "2024-06-12T17:00:00+02:00[Europe/Amsterdam]"); // falseisPastCutoff("2024-06-12T15:00:00Z", "2024-06-12T17:00:00+02:00[Europe/Amsterdam]"); // true — at the cut-offisPastCutoff returns false for invalid input, including cutoffAt’s "", and false
reads as “not past”. Check the cut-off is not "" before trusting a false.
How long is left: timeToCutoff
Section titled “How long is left: timeToCutoff”timeToCutoff(now, cutoff) returns the exact time
to the cut-off as an ISO 8601 duration with hours as the largest unit. It is PT0S at the
cut-off and negative after it, which tells you how late you are.
import { timeToCutoff } from "@northguild/gmt/transport";
timeToCutoff("2024-06-10T15:00:00Z", "2024-06-12T17:00:00+02:00[Europe/Amsterdam]"); // "PT48H"timeToCutoff("2024-06-12T16:00:00Z", "2024-06-12T17:00:00+02:00[Europe/Amsterdam]"); // "-PT1H"timeToCutoff("2024-11-02T17:00:00-04:00[America/New_York]", "2024-11-03T17:00:00-05:00[America/New_York]");// "PT25H" — across the fall-backisPastCutoff and timeToCutoff both compare instants, and now is always yours. Both return
their sentinel for cutoffAt’s "": false from isPastCutoff and "" from timeToCutoff.
Check the cut-off first.
timeToCutoff("2024-06-10T15:00:00Z", ""); // "" — the cut-off was invalidThe Cut-off Countdown compares a cut-off with your clock, or with a time you drag, and shows both calls.
The exported types
Section titled “The exported types”CutoffOptions, Cutoff, CutoffTime and
CutoffScheduleOptions are exported as types.
See it break, then work
Section titled “See it break, then work”- Cut-off Stack: a Monday sailing’s weekend cut-offs, rolled back to Friday
- Cut-off Ruler: two days before, three ways, across the fall-back
- Cut-off Countdown: twenty minutes late for gate-in
- Dwell Ledger: drag a dwell across local midnights
- Container dwell: two hours, two days
- A 26-hour rail leg across spring-forward
- Two arrivals, one wall time: the fall-back night
- Two days before is not 48 hours
- A filing deadline anchored to the wrong event
- Transport mistakes