Skip to content

Transport

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

The transport layer is transitTime, etaAtZone, dwellTime, scheduleDelivery, crossingTime, the cut-off functions, and scheduleDeviation, classifyPunctuality, punctualityRate, bestAvailable, estimateDrift and nextDeparture, and the mistakes around them are the ones every transport mode makes on its own: treating a running time as a wall-clock interval, treating an offset as a place, turning elapsed hours into chargeable days by division, reading a handoff through a fixed offset, checking a connection against the arrival instead of the arrival plus its handling time, counting a deadline from the wrong event or as the wrong kind of offset, calling an arrival on time with no stated tolerance, showing an estimate as an actual, and picking a departure the connection cannot make. Every output shown here is what the function returns.

HIGH

Dividing dwell hours by 24 to get chargeable days

Terminals count the local calendar days a container was present, not elapsed time rounded up. Two hours across a local midnight is two days. dwellTime returns calendarDays from the zone's real day boundaries; the duration is there too, for a different question.

Wrong

const hours = 2; // gated in 23:00, out 01:00
Math.ceil(hours / 24);
// 1 — the terminal bills 2

Right

import { dwellTime } from "@northguild/gmt/transport";
dwellTime("2024-06-15T23:00:00-04:00[America/New_York]", "2024-06-16T01:00:00-04:00[America/New_York]");
// { duration: "PT2H", enter: "2024-06-15T23:00:00-04:00[America/New_York]", exit: "2024-06-16T01:00:00-04:00[America/New_York]", calendarDays: 2 }
output 
HIGH

Counting dwell days from bare instants with no zone

A day count needs a place. Z and offset-only strings name a moment and nothing else, so with no targetZone there is no locality to count days in and dwellTime returns null rather than guessing. Pass the IANA zone of the terminal, or use entry strings that carry one.

Wrong

dwellTime("2024-06-16T03:00:00Z", "2024-06-16T05:00:00Z");
// null — no place to count days in

Right

import { dwellTime } from "@northguild/gmt/transport";
dwellTime("2024-06-16T03:00:00Z", "2024-06-16T05:00:00Z", "America/New_York").calendarDays; // 2
dwellTime("2024-06-16T03:00:00Z", "2024-06-16T05:00:00Z", "Europe/Amsterdam").calendarDays; // 1
HIGH

Adding a running time as a wall-clock duration

addZoned moves the wall clock by the date part of a duration, which is right for a recurring meeting and wrong for a running time. Across a spring-forward, one day and two hours on the wall clock is 25 elapsed hours. transitTime adds exact time, so P1DT2H is 26 hours and the arrival is an hour later, when the train is actually there.

Wrong

addZoned("2024-03-09T20:00:00-05:00[America/New_York]", { days: 1, hours: 2 });
// "2024-03-10T22:00:00-04:00[America/New_York]" — only 25 elapsed hours

Right

import { transitTime } from "@northguild/gmt/transport";
transitTime("2024-03-09T20:00:00-05:00[America/New_York]", "P1DT2H");
// "2024-03-10T23:00:00-04:00[America/New_York]" — 26 elapsed hours
output 
MEDIUM

Giving transitTime a calendar duration

Years, months and weeks have no fixed length, and a leg is measured in elapsed time, so transitTime returns the empty string for them rather than picking a month length. A dated connection, the same wall time a month on, is addZoned's job.

Wrong

transitTime("2024-06-15T10:00:00Z", "P1M");
// "" — no leg takes "a month" without a reference point

Right

import { addZoned } from "@northguild/gmt/zoned";
addZoned("2024-06-15T10:00:00+02:00[Europe/Berlin]", { months: 1 });
// "2024-07-15T10:00:00+02:00[Europe/Berlin]"
HIGH

Carrying last winter's offset into a summer departure

A bracketed zone whose offset contradicts it is rejected, not reinterpreted: New York is -04:00 in June, so a departure written -05:00[America/New_York] is not a moment the zone ever showed, and transitTime returns the empty string as isValidZonedDateTime does. Write the offset the zone kept at that moment, or drop the bracket and keep only the offset if that is really all you know.

Wrong

transitTime("2024-06-15T10:00:00-05:00[America/New_York]", "PT2H");
// "" — the offset contradicts the zone

Right

import { transitTime } from "@northguild/gmt/transport";
transitTime("2024-06-15T10:00:00-04:00[America/New_York]", "PT2H");
// "2024-06-15T12:00:00-04:00[America/New_York]"
MEDIUM

Rendering an arrival in a fixed offset instead of the destination's zone

etaAtZone accepts a fixed offset as a zone, as Temporal does, but an offset observes no DST. On the fall-back night the destination is still on -04:00 for the first arrival and already on -05:00 for the second, so a fixed -05:00 shows the first an hour early (00:30 instead of 01:30). The second comes out right only because New York has just moved to -05:00, and all summer every arrival shows an hour early. GMT does not resolve an airport code to a zone, so pass the IANA identifier.

Wrong

etaAtZone("2024-11-03T05:30:00Z", "-05:00");
// "2024-11-03T00:30:00-05:00[-05:00]" — New York was still on EDT; the board is an hour early

Right

import { etaAtZone } from "@northguild/gmt/transport";
etaAtZone("2024-11-03T05:30:00Z", "America/New_York");
// "2024-11-03T01:30:00-04:00[America/New_York]"
output 
MEDIUM

Passing a zoneless wall time as an arrival

etaAtZone renders a moment, and a datetime with no offset designator is not a moment. It returns the empty string rather than assuming UTC. Resolve the local time to an instant first with resolveLocal or convertPlainDateTimeToZoned, stating the zone it was read in.

Wrong

etaAtZone("2024-06-15T12:30:00", "Asia/Tokyo");
// "" — no offset: not a moment

Right

import { etaAtZone } from "@northguild/gmt/transport";
etaAtZone("2024-06-15T12:30:00Z", "Asia/Tokyo");
// "2024-06-15T21:30:00+09:00[Asia/Tokyo]"

Each of these has a live tool: the Delivery Scheduler, the Connection Checker, the Timetable Reader and the Crossing Clock.

HIGH

Reading a handoff through an offset table

A fixed offset is a place's offset at one moment, not a standing fact. Reading an arrival that crosses a DST transition through the offset the table was built with misreads it by an hour. scheduleDelivery reads every handoff as an exact instant, never through a stale offset.

Wrong

// The naive approach: one UTC offset per place, from a table
const OFFSET_HOURS = { "America/Los_Angeles": -8 }; // Los Angeles, as the table was built in winter
const departed = Date.parse("2024-03-08T08:00:00-06:00"); // Chicago, 08:00
const arrived = departed + 46 * 36e5; // 2024-03-10T12:00:00.000Z
const toLocal = (ms) => new Date(ms + OFFSET_HOURS["America/Los_Angeles"] * 36e5).toISOString().slice(0, 16);
toLocal(arrived); // "2024-03-10T04:00" — an hour early: the clocks sprang forward that morning

Right

import { scheduleDelivery } from "@northguild/gmt/transport";
const truck = { departure: "2024-03-08T08:00:00-06:00[America/Chicago]", duration: "PT46H", timeZone: "America/Los_Angeles", dwellAfter: "PT2H", mode: "truck" };
scheduleDelivery([truck]);
// { eta: "2024-03-10T05:00:00-07:00[America/Los_Angeles]",
// legTimes: [{ arrival: "2024-03-10T12:00:00Z", localArrival: "2024-03-10T05:00:00-07:00[America/Los_Angeles]", dwellAfter: "PT2H", mode: "truck" }] } — 05:00, not 04:00
legs
output 
HIGH

Checking a connection against the arrival, not the arrival plus handling

An arrival is not the moment cargo can leave again: the handling time at the handoff, the minimum connect time, still has to pass. scheduleDelivery checks a scheduled departure against the arrival plus that dwell, and a departure inside it is a missed connection, not a comparison between two instants.

Wrong

Date.parse("2024-03-10T13:30:00Z") >= Date.parse("2024-03-10T12:00:00Z");
// true — but the handoff needs two hours, and the ship sails inside it

Right

import { scheduleDelivery } from "@northguild/gmt/transport";
const truck = { departure: "2024-03-08T08:00:00-06:00[America/Chicago]", duration: "PT46H", timeZone: "America/Los_Angeles", dwellAfter: "PT2H", mode: "truck" };
const ship = { departure: "2024-03-10T06:30:00[America/Los_Angeles]", duration: "P11D", timeZone: "Asia/Tokyo", mode: "ship" };
const laterShip = { departure: "2024-03-10T07:30:00[America/Los_Angeles]", duration: "P11D", timeZone: "Asia/Tokyo", mode: "ship" };
scheduleDelivery([truck, ship]); // null — missed
scheduleDelivery([truck, laterShip]);
// { eta: "2024-03-21T23:30:00+09:00[Asia/Tokyo]",
// legTimes: [{ arrival: "2024-03-10T12:00:00Z", localArrival: "2024-03-10T05:00:00-07:00[America/Los_Angeles]", dwellAfter: "PT2H", mode: "truck" },
// { arrival: "2024-03-21T14:30:00Z", localArrival: "2024-03-21T23:30:00+09:00[Asia/Tokyo]", dwellAfter: "PT0S", mode: "ship" }] } — half an hour later, made
legs
output 
MEDIUM

Stamping a published local time as UTC

A published departure with no offset is a local time in the zone it was announced in, not UTC. Adding a bare Z reads New York's 10:00 as 10:00 UTC — four hours early in June. Read it through startTimeZone and scheduleDelivery adds the zone's real offset.

Wrong

import { scheduleDelivery } from "@northguild/gmt/transport";
scheduleDelivery([{ departure: "2024-06-15T10:00:00Z", duration: "PT1H", timeZone: "UTC" }]);
// { eta: "2024-06-15T11:00:00+00:00[UTC]",
// legTimes: [{ arrival: "2024-06-15T11:00:00Z", localArrival: "2024-06-15T11:00:00+00:00[UTC]", dwellAfter: "PT0S" }] } — read New York's 10:00 as UTC

Right

import { scheduleDelivery } from "@northguild/gmt/transport";
scheduleDelivery([{ departure: "2024-06-15T10:00:00", duration: "PT1H", timeZone: "UTC" }], { startTimeZone: "America/New_York" });
// { eta: "2024-06-15T15:00:00+00:00[UTC]",
// legTimes: [{ arrival: "2024-06-15T15:00:00Z", localArrival: "2024-06-15T15:00:00+00:00[UTC]", dwellAfter: "PT0S" }] } — 10:00 in New York, four hours later in UTC
legs
output 
MEDIUM

Timing a crossing from its wall clocks

Subtracting two printed clock readings assumes both ends share one offset. On a DST night they don't: New York's fall-back makes an hour-long crossing read as zero. crossingTime reads the exact elapsed time, never the printed digits.

Wrong

(Date.parse("2024-11-03T01:30:00") - Date.parse("2024-11-03T01:30:00")) / 36e5;
// 0 — both ends read 01:30, so the naive subtraction sees no time pass at all

Right

import { crossingTime } from "@northguild/gmt/transport";
crossingTime("2024-11-03T05:30:00Z", "2024-11-03T06:30:00Z", "America/New_York");
// { duration: "PT1H", enter: "2024-11-03T01:30:00-04:00[America/New_York]", exit: "2024-11-03T01:30:00-05:00[America/New_York]" }
output 
MEDIUM

Writing an offset into a skipped hour

02:30 on 10 March 2024 never showed on a New York clock, so no offset names it. Leave the offset off and it resolves to the later instant, 03:30.

Wrong

import { scheduleDelivery } from "@northguild/gmt/transport";
scheduleDelivery([{ departure: "2024-03-10T02:30:00-05:00[America/New_York]", duration: "PT1H", timeZone: "America/New_York" }], { startTimeZone: "America/New_York" });
// null — no offset names a time the clock skipped

Right

import { scheduleDelivery } from "@northguild/gmt/transport";
scheduleDelivery([{ departure: "2024-03-10T02:30:00", duration: "PT1H", timeZone: "America/New_York" }], { startTimeZone: "America/New_York" });
// { eta: "2024-03-10T04:30:00-04:00[America/New_York]",
// legTimes: [{ arrival: "2024-03-10T08:30:00Z", localArrival: "2024-03-10T04:30:00-04:00[America/New_York]", dwellAfter: "PT0S" }] } — resolves to 03:30, the later instant
legs
output 

Each of these has a live tool: the Cut-off Stack, the Cut-off Ruler and the Cut-off Countdown.

HIGH

Counting a filing deadline from departure when the rule says loading

An advance filing rule names the event it counts from: loading, departure or arrival. They are different instants, so the same 24 hours from the wrong one is late by days. cutoffAt takes the event as its anchor and never guesses which one a rule means, so pass the one the rule names.

Wrong

import { cutoffAt } from "@northguild/gmt/transport";
// The rule: 24 hours before loading. Loading is 08:00Z on 10 June; departure is 20:00Z on 12 June.
cutoffAt("2024-06-12T20:00:00Z", "PT24H", { timeZone: "Asia/Shanghai" });
// "2024-06-12T04:00:00+08:00[Asia/Shanghai]" — counted from departure: 60 hours late

Right

import { cutoffAt } from "@northguild/gmt/transport";
cutoffAt("2024-06-10T08:00:00Z", "PT24H", { timeZone: "Asia/Shanghai" });
// "2024-06-09T16:00:00+08:00[Asia/Shanghai]" — 24 hours before loading
output 
HIGH

Subtracting P2D where the rule means 17:00 local two days before

A cut-off of two days before, 17:00, names a day and a time of day on the terminal's clock. Subtracting P2D keeps the departure's own time of day, so an 18:00 sailing gets an 18:00 cut-off, an hour after the terminal closed. atLocalTime pins the time of day on the date two days earlier.

Wrong

import { cutoffAt } from "@northguild/gmt/transport";
// The vessel sails Friday 14 June 2024 at 18:00 in Amsterdam.
cutoffAt("2024-06-14T16:00:00Z", "P2D", { timeZone: "Europe/Amsterdam" });
// "2024-06-12T18:00:00+02:00[Europe/Amsterdam]" — an hour after documentation closed

Right

import { cutoffAt } from "@northguild/gmt/transport";
cutoffAt("2024-06-14T16:00:00Z", "P2D", { timeZone: "Europe/Amsterdam", atLocalTime: "17:00" });
// "2024-06-12T17:00:00+02:00[Europe/Amsterdam]"
output 

Each of these has a live tool: the Punctuality Board, the ETA Drift Chart and the Departure Board.

HIGH

Calling an arrival on time with no stated tolerance

On time is a measurement only against a tolerance someone stated. A threshold buried in a comparison hides its number and its edge: here exactly 15 minutes counts as on time, and the next person who reads it cannot tell whether that was meant. classifyPunctuality takes the tolerance as an argument, holds no default, and puts both edges outside the on-time window.

Wrong

// "On time" with the tolerance buried in a comparison
const late = (plannedMs, actualMs) => actualMs - plannedMs > 15 * 60 * 1000;
late(Date.parse("2024-06-15T10:00:00Z"), Date.parse("2024-06-15T10:15:00Z")); // false — exactly 15 minutes counted as on time

Right

import { classifyPunctuality } from "@northguild/gmt/transport";
classifyPunctuality("2024-06-15T10:00:00Z", "2024-06-15T10:15:00Z", { late: "PT15M" }); // "late"
classifyPunctuality("2024-06-15T10:00:00Z", "2024-06-15T11:30:00Z", { late: "PT60M" }); // "late"
classifyPunctuality("2024-06-15T10:00:00Z", "2024-06-15T11:30:00Z", { late: "PT120M" }); // "onTime"
output 
HIGH

Showing an estimate in the actual column

When one field carries planned, estimated, requested and actual times, the newest record is not the best one. An estimate a late feed delivers after the arrival replaces the actual on a latest-wins dashboard. bestAvailable returns an ACT whenever one exists, and returns its class with it.

Wrong

const events = [
{ classifier: "ACT", at: "2024-06-15T12:52:00Z", recordedAt: "2024-06-15T12:53:00Z" },
{ classifier: "EST", at: "2024-06-15T13:05:00Z", recordedAt: "2024-06-15T14:00:00Z" },
];
events.reduce((a, e) => (Date.parse(e.recordedAt) >= Date.parse(a.recordedAt) ? e : a)).at;
// "2024-06-15T13:05:00Z" — an estimate, shown as the actual

Right

import { bestAvailable } from "@northguild/gmt/transport";
bestAvailable([{ classifier: "ACT", at: "2024-06-15T12:52:00Z", recordedAt: "2024-06-15T12:53:00Z" }, { classifier: "EST", at: "2024-06-15T13:05:00Z", recordedAt: "2024-06-15T14:00:00Z" }]); // { at: "2024-06-15T12:52:00Z", classifier: "ACT" }
events
output 
HIGH

Picking the next departure without the minimum connection

A departure ten minutes after you arrive is on the timetable, not within reach. The threshold is the arrival plus the time it takes to connect. nextDeparture takes minimumConnection and counts a departure exactly at the threshold as made.

Wrong

import { nextDeparture } from "@northguild/gmt/transport";
// The train arrives at 09:20; boarding the ferry takes 45 minutes.
nextDeparture("2024-06-15T09:20:00Z", ["2024-06-15T09:30:00Z", "2024-06-15T11:00:00Z"]);
// "2024-06-15T09:30:00Z" — ten minutes to make a 45-minute connection

Right

import { nextDeparture } from "@northguild/gmt/transport";
nextDeparture("2024-06-15T09:20:00Z", ["2024-06-15T09:30:00Z", "2024-06-15T11:00:00Z"], { minimumConnection: "PT45M" });
// "2024-06-15T11:00:00Z"
output 
MEDIUM

Treating a headway window's end as a departure

A service every 20 minutes from 06:00 to 09:00 stops at 09:00; 09:00 is the end of service, not a departure. A loop that steps up to and including the end time sells one departure too many. nextDeparture reads the window as half-open, [from, to), as a GTFS frequencies.txt end_time is read.

Wrong

// The naive approach: step every 20 minutes up to and including the end time
const departures = [];
for (let t = Date.parse("2024-06-15T06:00:00+02:00"); t <= Date.parse("2024-06-15T09:00:00+02:00"); t += 20 * 60 * 1000) departures.push(new Date(t).toISOString());
departures.at(-1); // "2024-06-15T07:00:00.000Z" — 09:00 local, the end of service, sold as a departure

Right

import { nextDeparture } from "@northguild/gmt/transport";
const shuttle = { headway: "PT20M", from: "2024-06-15T06:00:00+02:00", to: "2024-06-15T09:00:00+02:00" };
nextDeparture("2024-06-15T08:40:00+02:00", shuttle); // "2024-06-15T08:40:00+02:00"
nextDeparture("2024-06-15T08:55:00+02:00", shuttle); // "" — the last departure is 08:40
output