Skip to content

Transport: Legs and Dwell

  • Legs, dwell, cut-offs and punctuality, shared by road, rail, sea and air.

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

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:00

An 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:30
etaAtZone("2024-11-03T06:30:00Z", "America/New_York");
// "2024-11-03T01:30:00-05:00[America/New_York]" — the second; the offset tells them apart

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

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.

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 in
dwellTime("2024-06-16T01:00:00Z", "2024-06-15T22:30:00Z", "Europe/London"); // null — inverted

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

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";

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:00
cutoffAt("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 hours

The Cut-off Ruler draws all three readings of “two days before” on one axis, across New York’s fall-back.

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 loading
cutoffAt("2024-06-12T20:00:00Z", "PT24H", { timeZone: "Asia/Shanghai" });
// "2024-06-12T04:00:00+08:00[Asia/Shanghai]" — 24 hours before departure, 60 hours later

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 Friday
cutoffAt("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 Monday
cutoffAt("2024-06-18T16:00:00Z", "P2D", { timeZone: "Europe/Amsterdam", atLocalTime: "17:00", calendar });
// "" — a calendar with no roll convention

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

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:30
cutoffAt("2024-03-11T22:00:00Z", "P1D", { timeZone: "America/New_York", atLocalTime: "02:30" });
// "" — 02:30 on 10 March never happened

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.

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]"); // false
isPastCutoff("2024-06-12T15:00:00Z", "2024-06-12T17:00:00+02:00[Europe/Amsterdam]"); // true — at the cut-off

isPastCutoff returns false for invalid input, including cutoffAt’s "", and false reads as “not past”. Check the cut-off is not "" before trusting a false.

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

isPastCutoff 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 invalid

The Cut-off Countdown compares a cut-off with your clock, or with a time you drag, and shows both calls.

CutoffOptions, Cutoff, CutoffTime and CutoffScheduleOptions are exported as types.