Transport
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.
Legs and dwell
Section titled “Legs and dwell”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:00Math.ceil(hours / 24);// 1 — the terminal bills 2Right
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 }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 inRight
import { dwellTime } from "@northguild/gmt/transport";
dwellTime("2024-06-16T03:00:00Z", "2024-06-16T05:00:00Z", "America/New_York").calendarDays; // 2dwellTime("2024-06-16T03:00:00Z", "2024-06-16T05:00:00Z", "Europe/Amsterdam").calendarDays; // 1Adding 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 hoursRight
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 hoursGiving 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 pointRight
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]"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 zoneRight
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]"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 earlyRight
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]"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 momentRight
import { etaAtZone } from "@northguild/gmt/transport";
etaAtZone("2024-06-15T12:30:00Z", "Asia/Tokyo");// "2024-06-15T21:30:00+09:00[Asia/Tokyo]"Multi-leg scheduling
Section titled “Multi-leg scheduling”Each of these has a live tool: the Delivery Scheduler, the Connection Checker, the Timetable Reader and the Crossing Clock.
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 tableconst OFFSET_HOURS = { "America/Los_Angeles": -8 }; // Los Angeles, as the table was built in winterconst departed = Date.parse("2024-03-08T08:00:00-06:00"); // Chicago, 08:00const arrived = departed + 46 * 36e5; // 2024-03-10T12:00:00.000Zconst 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 morningRight
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:00Checking 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 itRight
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 — missedscheduleDelivery([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, madeStamping 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 UTCRight
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 UTCTiming 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 allRight
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]" }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 skippedRight
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 instantCut-offs
Section titled “Cut-offs”Each of these has a live tool: the Cut-off Stack, the Cut-off Ruler and the Cut-off Countdown.
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 lateRight
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 loadingSubtracting 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 closedRight
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]"Punctuality and timestamps
Section titled “Punctuality and timestamps”Each of these has a live tool: the Punctuality Board, the ETA Drift Chart and the Departure Board.
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 comparisonconst 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 timeRight
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"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 actualRight
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" }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 connectionRight
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"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 timeconst 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 departureRight
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