Skip to content

Intermodal

  • Container free time, demurrage, billing deadlines, bill of lading dates and EDI timestamps.

The intermodal layer’s functions fall into four groups. Free time and demurrage: freeTimeExpiry, chargeableDays and demurrageClock. Billing deadlines: billingTimeline. B/L dates and multimodal ETA: bolTimestamp and multimodalETA. EDI timestamps: a parser, a formatter and a validator for each kind of UN/EDIFACT and X12 value (parseEdifactDateTime, parseEdifactOffsetDateTime, parseX12DateAndTime, parseX12DateRange and the rest), the classifiers classifyEdifactDtmFormat, classifyX12DateTimePeriodFormat and classifyX12TimeCode, x12TimeCodeOffset and x12TimeCodeZone, and parseEpcisEvent, formatEpcisEvent and isValidEpcisEvent.

The mistakes around them are the ones that turn into disputed invoices and documents: a defaulted start day, a free-time table keyed by port, a clock counted from the wrong event, a day count taken from elapsed hours, a deadline window hard-coded or counted from the wrong date, a B/L dated in UTC, an ETA that forgets the schedule, an offsetless code read as UTC, a named time code treated as an offset, an X12 date and time joined by hand, and a two-digit year given a century by a pivot fixed in code. Every output shown here is what the function returns.

HIGH

Defaulting which day is free day one

Whether the day of discharge is free day one, or free time starts the following day, is a tariff term, and the two differ by a full day of charges. freeTimeExpiry and chargeableDays have no default for firstDay and return null without it; a silent default here is a wrong invoice.

Wrong

freeTimeExpiry("2024-06-14T19:00:00Z", 3, { basis: "calendar", timeZone: "America/New_York" });
// null — firstDay has no default

Right

import { freeTimeExpiry } from "@northguild/gmt/intermodal";
freeTimeExpiry("2024-06-14T19:00:00Z", 3, { basis: "calendar", timeZone: "America/New_York", firstDay: "eventDay" });
// { freeTimeStart: "2024-06-14", lastFreeDay: "2024-06-16", expiresAt: "2024-06-17T04:00:00Z" }
freeTimeExpiry("2024-06-14T19:00:00Z", 3, { basis: "calendar", timeZone: "America/New_York", firstDay: "nextDay" });
// { freeTimeStart: "2024-06-15", lastFreeDay: "2024-06-17", expiresAt: "2024-06-18T04:00:00Z" }
output 
HIGH

Looking free time up by port

Free time is set by carrier, lane, trade and service contract, not by the port, so a per-port free-time table encodes something that is not a fact. The terms are parameters: pass the tariff's free days, start-day convention and basis, and the terminal's IANA zone. GMT does not resolve a port code to a zone or a free-time allowance.

Wrong

const FREE_DAYS = { USNYC: 4, NLRTM: 5 };
const freeDays = FREE_DAYS[port]; // the carrier's contract says 3 on this lane

Right

import { freeTimeExpiry } from "@northguild/gmt/intermodal";
// The contract's terms, at the terminal's zone.
freeTimeExpiry("2024-06-14T19:00:00Z", 3, { basis: "calendar", timeZone: "America/New_York", firstDay: "eventDay" });
// { freeTimeStart: "2024-06-14", lastFreeDay: "2024-06-16", expiresAt: "2024-06-17T04:00:00Z" }
HIGH

Counting free days in UTC instead of at the terminal

Days are the terminal's local days. A discharge at 23:00 on Friday in New York is 03:00Z on Saturday, and counting in UTC moves the whole window by a day. Pass the terminal's zone; the local date of the discharge instant there is the event day.

Wrong

freeTimeExpiry("2024-06-15T03:00:00Z", 1, { basis: "calendar", timeZone: "UTC", firstDay: "eventDay" });
// { freeTimeStart: "2024-06-15", lastFreeDay: "2024-06-15", expiresAt: "2024-06-16T00:00:00Z" } — a day late

Right

import { freeTimeExpiry } from "@northguild/gmt/intermodal";
freeTimeExpiry("2024-06-15T03:00:00Z", 1, { basis: "calendar", timeZone: "America/New_York", firstDay: "eventDay" });
// { freeTimeStart: "2024-06-14", lastFreeDay: "2024-06-14", expiresAt: "2024-06-15T04:00:00Z" }
output 
HIGH

Charging the day the container left exactly as free time ended

The dwell is half-open: a gate-out at exactly expiresAt is not a chargeable day, and one second later is. Comparing dates instead of instants charges Monday for a box that left at Monday 00:00:00.

Wrong

const gatedOut = "2024-06-17T04:00:00Z"; // Monday 00:00 in New York
const expires = "2024-06-17T04:00:00Z";
gatedOut >= expires ? 1 : 0;
// 1 — nothing of Monday was used

Right

import { chargeableDays } from "@northguild/gmt/intermodal";
const tariff = { basis: "calendar", chargeBasis: "calendar", timeZone: "America/New_York", firstDay: "eventDay" };
chargeableDays("2024-06-14T19:00:00Z", "2024-06-17T04:00:00Z", 3, tariff).chargeableDays; // 0
chargeableDays("2024-06-14T19:00:00Z", "2024-06-17T04:00:01Z", 3, tariff).chargeableDays; // 1
output 
HIGH

Counting working days without the terminal's calendar

A working-day tariff needs the terminal's weekend and holidays; Saturday–Sunday is not universal and a holiday moves expiry by a day. The working basis returns null without a BusinessCalendar rather than assume one. Its timeZone field is not read: the options' timeZone is the counting zone.

Wrong

freeTimeExpiry("2024-06-14T19:00:00Z", 3, { basis: "working", timeZone: "America/New_York", firstDay: "eventDay" });
// null — a working-day count needs the terminal's calendar

Right

import { freeTimeExpiry } from "@northguild/gmt/intermodal";
freeTimeExpiry("2024-06-14T19:00:00Z", 3, {
basis: "working",
timeZone: "America/New_York",
firstDay: "eventDay",
calendar: { weekend: [6, 7], holidays: ["2024-06-17"], timeZone: "America/New_York" },
});
// { freeTimeStart: "2024-06-14", lastFreeDay: "2024-06-19", expiresAt: "2024-06-20T04:00:00Z" }
output 
HIGH

Starting the detention clock at discharge

Demurrage runs from discharge to gate-out; detention runs from gate-out to empty return; terminal storage runs over the demurrage window with its own free time. Counting detention from discharge bills the terminal days twice. demurrageClock selects the pair for the charge you name.

Wrong

chargeableDays("2024-06-14T19:00:00Z", "2024-06-27T09:00:00Z", 5, detentionTariff);
// discharge to empty return: the terminal days are billed as detention too

Right

import { demurrageClock } from "@northguild/gmt/intermodal";
const events = [
{ type: "discharged", at: "2024-06-14T19:00:00Z" },
{ type: "gatedOut", at: "2024-06-20T14:30:00Z" },
{ type: "emptyReturned", at: "2024-06-27T09:00:00Z" },
];
demurrageClock(events, "detention", { direction: "import" });
// { start: "2024-06-20T14:30:00Z", end: "2024-06-27T09:00:00Z" }
events
output 
HIGH

Assuming working-day free time means working-day charges

How free days are counted and how charged days are counted are separate tariff terms. Where a tariff grants free time in working days, most then charge every calendar day, weekends and holidays included; some tariffs charge working days only. Charging on the free-time basis under-bills the first and over-bills the second, so chargeBasis has no default.

Wrong

// Free time in working days, so charge working days too?
chargeableDays("2024-06-14T19:00:00Z", "2024-06-24T15:00:00Z", 3, {
basis: "working",
chargeBasis: "working",
timeZone: "America/New_York",
firstDay: "eventDay",
calendar: { weekend: [6, 7], holidays: ["2024-06-19"], timeZone: "America/New_York" },
}).chargedDates;
// ["2024-06-20", "2024-06-21", "2024-06-24"] — the tariff charges calendar days after expiry

Right

import { chargeableDays } from "@northguild/gmt/intermodal";
chargeableDays("2024-06-14T19:00:00Z", "2024-06-24T15:00:00Z", 3, {
basis: "working",
chargeBasis: "calendar",
timeZone: "America/New_York",
firstDay: "eventDay",
calendar: { weekend: [6, 7], holidays: ["2024-06-19"], timeZone: "America/New_York" },
}).chargedDates;
// ["2024-06-19", "2024-06-20", "2024-06-21", "2024-06-22", "2024-06-23", "2024-06-24"]
output 
MEDIUM

Turning elapsed hours into chargeable days

A container discharged Friday 15:00 and out Monday 00:00:01 has been there 57 hours and used four local days. Hours divided by 24 never sees a midnight, and this is the same mistake as dividing dwell hours. chargeableDays counts local days from the same boundary dwellTime uses, and freeDaysUsed plus chargeableDays equals dwellTime's calendarDays on the calendar basis.

Wrong

const hours = 57.0003;
Math.max(0, Math.ceil((hours - 72) / 24));
// 0 — the tariff charges Monday

Right

import { chargeableDays } from "@northguild/gmt/intermodal";
chargeableDays("2024-06-14T19:00:00Z", "2024-06-17T04:00:01Z", 3, { basis: "calendar", chargeBasis: "calendar", timeZone: "America/New_York", firstDay: "eventDay" });
// { freeDaysUsed: 3, chargeableDays: 1, expiresAt: "2024-06-17T04:00:00Z", chargedDates: ["2024-06-17"], byTier: [{ from: 1, to: null, days: 1 }] }
output 
MEDIUM

Putting rates inside the day count

Tiers are day bands, not rates. tiers names the last chargeable-day ordinal of each band, and byTier says how many charged days fell in each, empty bands included, so a rate table applies by index. Money is the caller's; a list that is not strictly ascending is refused.

Wrong

chargeableDays("2024-06-14T19:00:00Z", "2024-06-28T15:00:00Z", 3, { basis: "calendar", chargeBasis: "calendar", timeZone: "America/New_York", firstDay: "eventDay", tiers: [10, 5] });
// null — tiers must ascend

Right

import { chargeableDays } from "@northguild/gmt/intermodal";
chargeableDays("2024-06-14T19:00:00Z", "2024-06-28T15:00:00Z", 3, { basis: "calendar", chargeBasis: "calendar", timeZone: "America/New_York", firstDay: "eventDay", tiers: [5, 10] }).byTier;
// [{ from: 1, to: 5, days: 5 }, { from: 6, to: 10, days: 5 }, { from: 11, to: null, days: 2 }]
output 
HIGH

Hard-coding a deadline window

The windows are the contract's own numbers, not a constant reused across every contract. This one gives 14/14/45; passing 30/30/30 computes real dates for a window that is not this contract's.

Wrong

const WINDOWS = { issueDays: 30, disputeDays: 30, resolutionDays: 30 }; // for every contract
billingTimeline({ anchorOn: "2026-03-01", invoiceIssuedOn: "2026-03-05", requestReceivedOn: "2026-03-18" }, WINDOWS);
// { invoiceDeadline: "2026-03-31", issuedByDeadline: true, disputeDeadline: "2026-04-04", requestedByDeadline: true, resolutionDeadline: "2026-04-17" } — this contract's own windows are 14/14/45

Right

import { billingTimeline } from "@northguild/gmt/intermodal";
billingTimeline({ anchorOn: "2026-03-01", invoiceIssuedOn: "2026-03-05", requestReceivedOn: "2026-03-18" }, { issueDays: 14, disputeDays: 14, resolutionDays: 45 });
// { invoiceDeadline: "2026-03-15", issuedByDeadline: true, disputeDeadline: "2026-03-19", requestedByDeadline: true, resolutionDeadline: "2026-05-02" }
output 
HIGH

Counting working days where the window is in calendar days

billingTimeline counts every window in calendar days, never working days. A loop that skips weekends counts a longer window than the one the contract names.

Wrong

let d = new Date("2026-03-01T00:00:00Z"), n = 0;
while (n < 30) {
d = new Date(d.getTime() + 86400e3);
if (d.getUTCDay() % 6 !== 0) n++;
}
d.toISOString().slice(0, 10);
// "2026-04-10" — 30 working days, ten days later than 30 calendar days

Right

import { billingTimeline } from "@northguild/gmt/intermodal";
billingTimeline({ anchorOn: "2026-03-01" }, { issueDays: 30, disputeDays: 30, resolutionDays: 30 });
// { invoiceDeadline: "2026-03-31", issuedByDeadline: null, disputeDeadline: null, requestedByDeadline: null, resolutionDeadline: null }
output 
HIGH

Anchoring a re-bill on the charge

A party re-billing a charge it was itself billed counts its own window from the invoice it received, not from the original charge. Anchoring on the charge computes a deadline that has often already passed.

Wrong

billingTimeline({ anchorOn: "2026-03-01", invoiceIssuedOn: "2026-04-05" }, { issueDays: 30, disputeDays: 30, resolutionDays: 30 });
// { invoiceDeadline: "2026-03-31", issuedByDeadline: false, disputeDeadline: "2026-05-05", requestedByDeadline: null, resolutionDeadline: null } — counted from the charge, so the re-bill reads after the deadline

Right

import { billingTimeline } from "@northguild/gmt/intermodal";
billingTimeline({ anchorOn: "2026-03-10", invoiceIssuedOn: "2026-04-05" }, { issueDays: 30, disputeDays: 30, resolutionDays: 30 });
// { invoiceDeadline: "2026-04-09", issuedByDeadline: true, disputeDeadline: "2026-05-05", requestedByDeadline: null, resolutionDeadline: null }
output 
MEDIUM

Moving the anchor to a reissue date

The anchor is the date the regime or contract names, such as the last date a charge accrued. Correcting or reissuing an invoice does not move it. Whether a reissue opens a new window is the regime's rule, not GMT's, so pass the anchor that rule names.

Wrong

billingTimeline({ anchorOn: "2026-03-20", invoiceIssuedOn: "2026-04-10" }, { issueDays: 30, disputeDays: 30, resolutionDays: 30 });
// { invoiceDeadline: "2026-04-19", issuedByDeadline: true, disputeDeadline: "2026-05-10", requestedByDeadline: null, resolutionDeadline: null } — anchored on the first invoice's own date

Right

import { billingTimeline } from "@northguild/gmt/intermodal";
billingTimeline({ anchorOn: "2026-03-01", invoiceIssuedOn: "2026-04-10" }, { issueDays: 30, disputeDays: 30, resolutionDays: 30 });
// { invoiceDeadline: "2026-03-31", issuedByDeadline: false, disputeDeadline: "2026-05-10", requestedByDeadline: null, resolutionDeadline: null }
output 
HIGH

Reducing an instant to its UTC date

The window counts from the billing party's local date, not the UTC date of an instant. Slicing an ISO string reads the wrong calendar day whenever the local offset crosses midnight.

Wrong

"2026-04-01T02:30:00Z".slice(0, 10);
// "2026-04-01"
billingTimeline({ anchorOn: "2026-03-01", invoiceIssuedOn: "2026-04-01" }, { issueDays: 30, disputeDays: 30, resolutionDays: 30 });
// { invoiceDeadline: "2026-03-31", issuedByDeadline: false, disputeDeadline: "2026-05-01", requestedByDeadline: null, resolutionDeadline: null } — a day off from the UTC date

Right

import { convertUtcToPlainDate } from "@northguild/gmt/utc";
import { billingTimeline } from "@northguild/gmt/intermodal";
convertUtcToPlainDate("2026-04-01T02:30:00Z", { timeZone: "America/New_York" });
// "2026-03-31"
billingTimeline({ anchorOn: "2026-03-01", invoiceIssuedOn: "2026-03-31" }, { issueDays: 30, disputeDays: 30, resolutionDays: 30 });
// { invoiceDeadline: "2026-03-31", issuedByDeadline: true, disputeDeadline: "2026-04-30", requestedByDeadline: null, resolutionDeadline: null }
output 
MEDIUM

Treating the deadline day as late

Comparing dates with strict less-than instead of on-or-before excludes the deadline day itself. billingTimeline compares with `<=`: the deadline day is on or before the deadline.

Wrong

"2026-03-31" < "2026-03-31";
// false — used as "on time", this reads the deadline day itself as late

Right

import { billingTimeline } from "@northguild/gmt/intermodal";
billingTimeline({ anchorOn: "2026-03-01", invoiceIssuedOn: "2026-03-31" }, { issueDays: 30, disputeDays: 30, resolutionDays: 30 });
// { invoiceDeadline: "2026-03-31", issuedByDeadline: true, disputeDeadline: "2026-04-30", requestedByDeadline: null, resolutionDeadline: null }
output 
MEDIUM

A dispute request dated before its invoice

A dispute request cannot precede the invoice it disputes. billingTimeline returns null both when the request has no invoice date and when it is dated earlier than one.

Wrong

billingTimeline({ anchorOn: "2026-03-01", requestReceivedOn: "2026-04-19" }, { issueDays: 30, disputeDays: 30, resolutionDays: 30 });
// null — a request needs the invoice it disputes
billingTimeline({ anchorOn: "2026-03-01", invoiceIssuedOn: "2026-03-20", requestReceivedOn: "2026-03-19" }, { issueDays: 30, disputeDays: 30, resolutionDays: 30 });
// null — the request is dated before the invoice it disputes

Right

import { billingTimeline } from "@northguild/gmt/intermodal";
billingTimeline({ anchorOn: "2026-03-01", invoiceIssuedOn: "2026-03-20", requestReceivedOn: "2026-04-19" }, { issueDays: 30, disputeDays: 30, resolutionDays: 30 });
// { invoiceDeadline: "2026-03-31", issuedByDeadline: true, disputeDeadline: "2026-04-19", requestedByDeadline: true, resolutionDeadline: "2026-05-19" }
output 
HIGH

Dating a bill of lading from the UTC date

A B/L date is the local date where the event happened. Slicing an ISO instant, or reducing it without a zone, reads the UTC date, which is a day late for an evening west of Greenwich and a day early for a morning east of it. bolTimestamp requires the zone and has no UTC default.

Wrong

"2024-06-16T01:00:00Z".slice(0, 10);
// "2024-06-16" — loaded at 21:00 on the 15th in New York

Right

import { bolTimestamp } from "@northguild/gmt/intermodal";
bolTimestamp("2024-06-16T01:00:00Z", "shippedOnBoard", { timeZone: "America/New_York" });
// "2024-06-15"
output 
HIGH

Dating a B/L in the zone of the system that logged it

A timestamp often carries the zone of the server or head office that recorded it, not of the port. bolTimestamp reads only the instant and dates it in the timeZone you pass, so pass the loading terminal's zone for a received-for-shipment or shipped-on-board date, and the place of issue's zone for the issue date.

Wrong

bolTimestamp("2024-06-16T03:00:00+02:00[Europe/Copenhagen]", "shippedOnBoard", { timeZone: "Europe/Copenhagen" });
// "2024-06-16" — the head office's date, not the port's

Right

import { bolTimestamp } from "@northguild/gmt/intermodal";
bolTimestamp("2024-06-16T03:00:00+02:00[Europe/Copenhagen]", "shippedOnBoard", { timeZone: "America/New_York" });
// "2024-06-15"
output 
HIGH

Assuming every leg leaves when the dwell ends

Adding every duration and every dwell to the first departure assumes each leg leaves the moment its handoff clears. A ship or a train leaves on its schedule, and the cargo waits. multimodalETA honours a leg's scheduled departure, returns null when it falls inside the dwell, and counts the wait as dwell.

Wrong

const hours = 6 + 48 + 342 + 36 + 52;
new Date(Date.parse("2024-06-10T00:00:00Z") + hours * 3600e3).toISOString();
// "2024-06-30T04:00:00.000Z" — the ship sails 20 hours after the dwell ends

Right

import { multimodalETA } from "@northguild/gmt/intermodal";
multimodalETA([
{ departure: "2024-06-10T08:00:00+08:00[Asia/Shanghai]", duration: "PT6H", timeZone: "Asia/Shanghai", dwellAfter: "P2D" },
{ departure: "2024-06-13T10:00:00+08:00[Asia/Shanghai]", duration: "P14DT6H", timeZone: "America/Los_Angeles", dwellAfter: "PT36H" },
{ duration: "PT52H", timeZone: "America/Chicago" },
]);
// { eta: "2024-06-30T19:00:00-05:00[America/Chicago]", totalLegs: 3, totalTransit: "PT400H", totalDwell: "PT104H" }
legs
output 
MEDIUM

Reporting one total for a multimodal move

Time moving and time waiting at handoffs are forecast differently and slip for different reasons, so one total hides the part a planner needs. multimodalETA reports totalTransit and totalDwell apart, and the two add up to the elapsed time.

Wrong

const totalHours = 6 + 48 + 342 + 36 + 52;
// 484 — the sea, the road and two terminals in one number

Right

import { multimodalETA } from "@northguild/gmt/intermodal";
multimodalETA([
{ departure: "2024-06-10T08:00:00+08:00[Asia/Shanghai]", duration: "PT6H", timeZone: "Asia/Shanghai", dwellAfter: "P2D" },
{ duration: "P14DT6H", timeZone: "America/Los_Angeles", dwellAfter: "PT36H" },
{ duration: "PT52H", timeZone: "America/Chicago" },
]);
// { eta: "2024-06-29T23:00:00-05:00[America/Chicago]", totalLegs: 3, totalTransit: "PT400H", totalDwell: "PT84H" }
legs
output 
MEDIUM

Counting the last leg's dwell

A dwellAfter on the last leg has no handoff after it: it moves nothing, so it is not part of the journey's dwell. Adding up every dwellAfter counts it anyway. multimodalETA leaves it out of totalDwell and out of the eta.

Wrong

const dwellHours = [48, 36, 5]; // every leg's dwellAfter
dwellHours.reduce((sum, hours) => sum + hours);
// 89 — the last 5 hours follow no handoff

Right

import { multimodalETA } from "@northguild/gmt/intermodal";
multimodalETA([
{ departure: "2024-06-10T08:00:00+08:00[Asia/Shanghai]", duration: "PT6H", timeZone: "Asia/Shanghai", dwellAfter: "P2D" },
{ duration: "P14DT6H", timeZone: "America/Los_Angeles", dwellAfter: "PT36H" },
{ duration: "PT52H", timeZone: "America/Chicago", dwellAfter: "PT5H" },
]);
// { eta: "2024-06-29T23:00:00-05:00[America/Chicago]", totalLegs: 3, totalTransit: "PT400H", totalDwell: "PT84H" }
legs
output 
HIGH

Reading an offsetless code as UTC

UN/EDIFACT code 203 is CCYYMMDDHHMM with no offset: a local time at a place the value does not name. Appending Z invents the place. parseEdifactDateTime returns a local date-time and no instant, and resolveLocal turns it into an instant once you name the zone.

Wrong

const value = "202406151430"; // DTM+137:202406151430:203'
const iso = value.slice(0, 4) + "-" + value.slice(4, 6) + "-" + value.slice(6, 8) + "T" + value.slice(8, 10) + ":" + value.slice(10, 12) + ":00Z";
new Date(iso).toISOString();
// "2024-06-15T14:30:00.000Z" — an instant the message never stated

Right

import { parseEdifactDateTime } from "@northguild/gmt/intermodal/parse";
import { resolveLocal } from "@northguild/gmt/instant/convert";
parseEdifactDateTime("202406151430", "203");
// "2024-06-15T14:30:00"
resolveLocal("2024-06-15T14:30:00", "Asia/Shanghai");
// "2024-06-15T06:30:00Z"
output 
HIGH

Treating a named time code as an offset

X12 data element 623 names a zone for codes such as ET, ES and ED and states no offset for them. The offset depends on the date and on which IANA zone the sender means, so a lookup table is an hour out for half the year. x12TimeCodeZone returns the name and the daylight flag, and resolveLocal reads the local time in the zone you pick.

Wrong

const OFFSETS = { ET: "-05:00", CT: "-06:00", MT: "-07:00", PT: "-08:00" };
new Date("2024-06-15T14:30:00" + OFFSETS.ET).toISOString();
// "2024-06-15T19:30:00.000Z" — an hour late: in June the Eastern zone is on daylight time

Right

import { x12TimeCodeZone } from "@northguild/gmt/intermodal/parse";
import { resolveLocal } from "@northguild/gmt/instant/convert";
x12TimeCodeZone("ES");
// { zone: "Eastern", daylight: false }
x12TimeCodeZone("ED");
// { zone: "Eastern", daylight: true }
x12TimeCodeZone("ET");
// { zone: "Eastern", daylight: null }
resolveLocal("2024-06-15T14:30:00", "America/New_York");
// "2024-06-15T18:30:00Z"
output 
HIGH

Joining an X12 date and time by hand

An X12 status carries a date (373), a time (337) and a time code (623) as three elements. A hand-built string assumes a four-digit time and reads the time code as an offset. The time has four forms, a blank time code means local to the event, and ET names a zone and states no offset. parseX12DateAndTime reads the date and the time, and the time code is read on its own by x12TimeCodeOffset or x12TimeCodeZone.

Wrong

const date = "20240615"; // AT7-05
const time = "14300012"; // AT7-06, HHMMSSDD
// AT7-07 is ET, read as an offset of -05:00
const iso = date.slice(0, 4) + "-" + date.slice(4, 6) + "-" + date.slice(6, 8) + "T" + time.slice(0, 2) + ":" + time.slice(2) + "-05:00";
new Date(iso).toISOString();
// RangeError: Invalid time value

Right

import { parseX12DateAndTime, x12TimeCodeOffset } from "@northguild/gmt/intermodal/parse";
parseX12DateAndTime("20240615", "14300012");
// "2024-06-15T14:30:00.12"
parseX12DateAndTime("20240615", "1430");
// "2024-06-15T14:30:00"
x12TimeCodeOffset("UT");
// "+00:00"
x12TimeCodeOffset("ET");
// "" — a named zone states no offset
output 
HIGH

Reading time codes 13 to 24 in ascending order

X12 time codes 13 to 24 run downward: 13 is UTC-12 and 24 is UTC-1. Counting up from 13 turns a code for twelve hours behind UTC into one hour behind.

Wrong

const code = 13;
const hours = code <= 12 ? code : -(code - 12);
// -1 — code 13 is UTC-12

Right

import { x12TimeCodeOffset } from "@northguild/gmt/intermodal/parse";
x12TimeCodeOffset("13");
// "-12:00"
x12TimeCodeOffset("24");
// "-01:00"
output 
HIGH

A two-digit year given a century by a pivot fixed in code

Neither UN/EDIFACT nor X12 says which century a two-digit year belongs to. A pivot fixed in code is right today and wrong later, so no EDI function reads a two-digit year, and its classifier returns null. The pattern parsers read the digits with a window you state. A two-digit year is a legacy form; ask a partner for a four-digit code.

Wrong

// A pivot fixed in code: 00-49 are 20xx, 50-99 are 19xx
const century = (yy) => (yy < 50 ? 2000 + yy : 1900 + yy);
century(99); // 1999
century(49); // 2049 — the pivot chose the century, not the data

Right

import { classifyEdifactDtmFormat } from "@northguild/gmt/intermodal/parse";
import { parseDateWithPattern } from "@northguild/gmt/plain/parse";
classifyEdifactDtmFormat("101");
// null — no EDI function reads a two-digit year
parseDateWithPattern("240615", "yyMMdd");
// "" — no window, no century
parseDateWithPattern("240615", "yyMMdd", undefined, { yearWindow: 2000 });
// "2024-06-15"
parseDateWithPattern("990615", "yyMMdd", undefined, { yearWindow: 1950 });
// "1999-06-15"
output 
MEDIUM

Passing the value with its release character

+ is the UN/EDIFACT data element separator, so an interchange transmits +02 as ?+02. The functions take the value after the interchange has been unescaped, and the escaped form returns the sentinel.

Wrong

parseEdifactOffsetDateTime("202406151430?+02", "303");
// "" — the release character is still in the value

Right

import { parseEdifactOffsetDateTime } from "@northguild/gmt/intermodal/parse";
parseEdifactOffsetDateTime("202406151430+02", "303");
// "2024-06-15T14:30:00+02:00"
output 
MEDIUM

Deciding what a zone abbreviation means

No UN/EDIFACT text defines the abbreviations a ZZZ field may hold, and code 303 holds a signed hour, UTC or GMT, so parseEdifactOffsetDateTime returns the sentinel for CET. A table that maps it to a fixed offset is wrong for part of the year. Resolve the local time in the IANA zone you know the sender is in.

Wrong

const ZONES = { CET: "+01:00" };
new Date("2024-06-15T14:30:00" + ZONES.CET).toISOString();
// "2024-06-15T13:30:00.000Z" — an hour late if the sender is in Berlin, which is on +02:00 in June

Right

import { parseEdifactOffsetDateTime } from "@northguild/gmt/intermodal/parse";
import { resolveLocal } from "@northguild/gmt/instant/convert";
parseEdifactOffsetDateTime("202406151430CET", "303");
// "" — an abbreviation is not an offset
resolveLocal("2024-06-15T14:30:00", "Europe/Berlin");
// "2024-06-15T12:30:00Z"
output 
MEDIUM

Splitting a period on a hyphen

UN/EDIFACT transmits a period as consecutive characters with no hyphen, and X12 transmits a range with its hyphen. A split on - finds nothing in the first and works only for the second. Pass the value and its code to the parser for the standard: parseEdifactDatePeriod refuses a hyphen, and parseX12DateRange refuses a range without one.

Wrong

"2024061520240620".split("-");
// ["2024061520240620"] — no hyphen, so no period was found

Right

import { parseEdifactDatePeriod, parseX12DateRange } from "@northguild/gmt/intermodal/parse";
parseEdifactDatePeriod("2024061520240620", "718");
// { start: "2024-06-15", end: "2024-06-20" }
parseEdifactDatePeriod("20240615-20240620", "718");
// null
parseX12DateRange("20240615-20240620", "RD8");
// { start: "2024-06-15", end: "2024-06-20" }
parseX12DateRange("2024061520240620", "RD8");
// null
output 
HIGH

Dropping eventTimeZoneOffset

EPCIS requires both eventTime and eventTimeZoneOffset because neither derives from the other. Store the instant alone and the local date and time at the site cannot be shown again. parseEpcisEvent keeps both and returns the sentinel when the offset is missing.

Wrong

const event = { eventTime: "2024-06-15T23:30:00Z", eventTimeZoneOffset: "+02:00" };
new Date(event.eventTime).toISOString().slice(0, 10);
// "2024-06-15" — on the dock it was already 16 June

Right

import { parseEpcisEvent, isValidEpcisEvent } from "@northguild/gmt/intermodal";
parseEpcisEvent({ eventTime: "2024-06-15T23:30:00Z", eventTimeZoneOffset: "+02:00" });
// { instant: "2024-06-15T23:30:00Z", offset: "+02:00", local: "2024-06-16T01:30:00" }
parseEpcisEvent({ eventTime: "2024-06-15T23:30:00Z" });
// null
isValidEpcisEvent({ eventTime: "2024-06-15T23:30:00Z" });
// false
output 
MEDIUM

Writing Z or +0200 as the EPCIS offset

GS1's eventTimeZoneOffset grammar is ±HH:MM from -14:00 to +14:00. Z is a designator, not an offset, and +0200 has no colon, so both fail the pattern. formatEpcisEvent writes the offset in the form the schema accepts.

Wrong

parseEpcisEvent({ eventTime: "2024-06-15T23:30:00Z", eventTimeZoneOffset: "Z" });
// null
parseEpcisEvent({ eventTime: "2024-06-15T23:30:00Z", eventTimeZoneOffset: "+0200" });
// null

Right

import { formatEpcisEvent } from "@northguild/gmt/intermodal";
formatEpcisEvent({ local: "2024-06-15T23:30:00", offset: "+02:00", instant: "2024-06-15T23:30:00Z" });
// { eventTime: "2024-06-15T23:30:00Z", eventTimeZoneOffset: "+02:00" }
output