Intermodal
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.
Free time and demurrage
Section titled “Free time and demurrage”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 defaultRight
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" }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 laneRight
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" }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 lateRight
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" }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 Yorkconst expires = "2024-06-17T04:00:00Z";gatedOut >= expires ? 1 : 0;// 1 — nothing of Monday was usedRight
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; // 0chargeableDays("2024-06-14T19:00:00Z", "2024-06-17T04:00:01Z", 3, tariff).chargeableDays; // 1Counting 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 calendarRight
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" }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 tooRight
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" }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 expiryRight
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"]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 MondayRight
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 }] }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 ascendRight
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 }]Billing deadlines
Section titled “Billing deadlines”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 contractbillingTimeline({ 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/45Right
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" }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 daysRight
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 }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 deadlineRight
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 }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 dateRight
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 }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 dateRight
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 }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 lateRight
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 }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 disputesRight
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" }B/L dates and multimodal ETA
Section titled “B/L dates and multimodal ETA”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 YorkRight
import { bolTimestamp } from "@northguild/gmt/intermodal";
bolTimestamp("2024-06-16T01:00:00Z", "shippedOnBoard", { timeZone: "America/New_York" });// "2024-06-15"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'sRight
import { bolTimestamp } from "@northguild/gmt/intermodal";
bolTimestamp("2024-06-16T03:00:00+02:00[Europe/Copenhagen]", "shippedOnBoard", { timeZone: "America/New_York" });// "2024-06-15"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 endsRight
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" }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 numberRight
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" }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 dwellAfterdwellHours.reduce((sum, hours) => sum + hours);// 89 — the last 5 hours follow no handoffRight
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" }EDI timestamps
Section titled “EDI timestamps”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 statedRight
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"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 timeRight
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"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-05const time = "14300012"; // AT7-06, HHMMSSDD// AT7-07 is ET, read as an offset of -05:00const 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 valueRight
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 offsetReading 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-12Right
import { x12TimeCodeOffset } from "@northguild/gmt/intermodal/parse";
x12TimeCodeOffset("13");// "-12:00"x12TimeCodeOffset("24");// "-01:00"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 19xxconst century = (yy) => (yy < 50 ? 2000 + yy : 1900 + yy);century(99); // 1999century(49); // 2049 — the pivot chose the century, not the dataRight
import { classifyEdifactDtmFormat } from "@northguild/gmt/intermodal/parse";import { parseDateWithPattern } from "@northguild/gmt/plain/parse";
classifyEdifactDtmFormat("101");// null — no EDI function reads a two-digit yearparseDateWithPattern("240615", "yyMMdd");// "" — no window, no centuryparseDateWithPattern("240615", "yyMMdd", undefined, { yearWindow: 2000 });// "2024-06-15"parseDateWithPattern("990615", "yyMMdd", undefined, { yearWindow: 1950 });// "1999-06-15"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 valueRight
import { parseEdifactOffsetDateTime } from "@northguild/gmt/intermodal/parse";
parseEdifactOffsetDateTime("202406151430+02", "303");// "2024-06-15T14:30:00+02:00"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 JuneRight
import { parseEdifactOffsetDateTime } from "@northguild/gmt/intermodal/parse";import { resolveLocal } from "@northguild/gmt/instant/convert";
parseEdifactOffsetDateTime("202406151430CET", "303");// "" — an abbreviation is not an offsetresolveLocal("2024-06-15T14:30:00", "Europe/Berlin");// "2024-06-15T12:30:00Z"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 foundRight
import { parseEdifactDatePeriod, parseX12DateRange } from "@northguild/gmt/intermodal/parse";
parseEdifactDatePeriod("2024061520240620", "718");// { start: "2024-06-15", end: "2024-06-20" }parseEdifactDatePeriod("20240615-20240620", "718");// nullparseX12DateRange("20240615-20240620", "RD8");// { start: "2024-06-15", end: "2024-06-20" }parseX12DateRange("2024061520240620", "RD8");// nullDropping 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 JuneRight
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" });// nullisValidEpcisEvent({ eventTime: "2024-06-15T23:30:00Z" });// falseWriting 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" });// nullparseEpcisEvent({ eventTime: "2024-06-15T23:30:00Z", eventTimeZoneOffset: "+0200" });// nullRight
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" }