@northguild/gmt 1.16.0 → 1.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/README.md +289 -8
  2. package/dist/index.d.ts +2 -0
  3. package/dist/index.js +2 -0
  4. package/dist/instant/convert/fromOffsetInstant.js +3 -11
  5. package/dist/instant/convert/toOffsetInstant.d.ts +23 -0
  6. package/dist/instant/convert/toOffsetInstant.js +2 -25
  7. package/dist/intermodal/calculate/billingTimeline.d.ts +94 -0
  8. package/dist/intermodal/calculate/billingTimeline.js +168 -0
  9. package/dist/intermodal/calculate/chargeableDays.d.ts +98 -0
  10. package/dist/intermodal/calculate/chargeableDays.js +101 -0
  11. package/dist/intermodal/calculate/demurrageClock.d.ts +93 -0
  12. package/dist/intermodal/calculate/demurrageClock.js +140 -0
  13. package/dist/intermodal/calculate/freeTimeExpiry.d.ts +66 -0
  14. package/dist/intermodal/calculate/freeTimeExpiry.js +82 -0
  15. package/dist/intermodal/calculate/index.d.ts +4 -0
  16. package/dist/intermodal/calculate/index.js +4 -0
  17. package/dist/intermodal/index.d.ts +2 -0
  18. package/dist/intermodal/index.js +3 -0
  19. package/dist/internal/freeTimeLedger.d.ts +93 -0
  20. package/dist/internal/freeTimeLedger.js +262 -0
  21. package/dist/internal/index.d.ts +4 -2
  22. package/dist/internal/index.js +4 -2
  23. package/dist/internal/isoStringBody.d.ts +26 -0
  24. package/dist/internal/isoStringBody.js +22 -2
  25. package/dist/internal/wallClockAtOffset.d.ts +22 -0
  26. package/dist/internal/wallClockAtOffset.js +26 -0
  27. package/dist/internal/zonedWallClockDifference.js +5 -4
  28. package/dist/transport/calculate/dwellTime.d.ts +72 -0
  29. package/dist/transport/calculate/dwellTime.js +126 -0
  30. package/dist/transport/calculate/index.d.ts +2 -0
  31. package/dist/transport/calculate/index.js +2 -0
  32. package/dist/transport/calculate/transitTime.d.ts +53 -0
  33. package/dist/transport/calculate/transitTime.js +118 -0
  34. package/dist/transport/convert/etaAtZone.d.ts +36 -0
  35. package/dist/transport/convert/etaAtZone.js +53 -0
  36. package/dist/transport/convert/index.d.ts +1 -0
  37. package/dist/transport/convert/index.js +1 -0
  38. package/dist/transport/index.d.ts +3 -0
  39. package/dist/transport/index.js +4 -0
  40. package/dist/types/free-time-options.d.ts +34 -0
  41. package/dist/types/free-time-options.js +0 -0
  42. package/dist/types/index.d.ts +1 -0
  43. package/dist/types/index.js +1 -0
  44. package/package.json +36 -1
  45. package/skills/gmt-arithmetic/SKILL.md +1 -1
  46. package/skills/gmt-basics/SKILL.md +1 -1
  47. package/skills/gmt-integration/SKILL.md +1 -1
  48. package/skills/gmt-timezone/SKILL.md +79 -12
@@ -0,0 +1,168 @@
1
+ import { Temporal } from "@js-temporal/polyfill";
2
+ import { isObject } from "../../internal/index.js";
3
+ import { isValidDate } from "../../plain/validate/isValidDate.js";
4
+ /**
5
+ * Lay out the deadline chain around a demurrage or detention invoice: the last date it may be
6
+ * issued, the last date it may be disputed, and the last date the dispute must be resolved.
7
+ *
8
+ * A billing regime or service contract can set up to three windows around an invoice: one to
9
+ * issue it, one to dispute it, and one to resolve the dispute. The number of days in each is the
10
+ * caller's fact; GMT carries none of them. The arithmetic is this function's: each deadline is a
11
+ * date, counted in calendar days from a date, with the anchor as day zero and the deadline day
12
+ * itself inside the window.
13
+ *
14
+ * - **Day zero is the anchor and the deadline is `anchor + days`** on the ISO 8601 calendar
15
+ * (`Temporal.PlainDate.add`); a date is "by the deadline" when
16
+ * `Temporal.PlainDate.compare(date, deadline) <= 0`. That is this function's stated contract,
17
+ * not a reading of any rule; a regime that counts differently passes a different number.
18
+ * - **Deadlines are dates, never instants.** Reduce an instant to the billing party's local date
19
+ * first with `convertUtcToPlainDate(instant, { timeZone })`; this function does not guess a
20
+ * zone. `anchorOn` is whatever date the caller counts from, such as the last charged date
21
+ * (`chargeableDays(...).chargedDates.at(-1)`) or, for a party re-billing a charge it was
22
+ * itself billed, the issuance date of the invoice it received.
23
+ * - **Windows have no defaults.** Each is a safe integer of at least `0`; a missing, negative or
24
+ * non-integer window returns `null`, because a silently defaulted window is a wrong deadline.
25
+ * - **The chain fills in as its dates exist.** With only `anchorOn` the result is a forecast:
26
+ * `invoiceDeadline` is set and every other field is `null`. An invoice date sets
27
+ * `issuedByDeadline` and `disputeDeadline`; a request date sets `requestedByDeadline` and
28
+ * `resolutionDeadline`. An explicit `undefined` is an omission; any other value must be a
29
+ * valid ISO date.
30
+ * - **The booleans compare dates and say nothing else.** Whether a charge is payable, whether a
31
+ * dispute must be heard, and any consequence are the consumer's. GMT computes dates, not
32
+ * liability.
33
+ * - **`agreedResolutionOn` replaces the computed `resolutionDeadline`** when given. It must be a
34
+ * valid date on or after `requestReceivedOn`, else `null`; it is validated even when no
35
+ * request exists, but cannot act without one.
36
+ * - **A request cannot precede the invoice it disputes.** `requestReceivedOn` without
37
+ * `invoiceIssuedOn`, or earlier than it, is a data error and returns `null`. An invoice earlier
38
+ * than the anchor is allowed: an invoice may be issued while charges still accrue.
39
+ * - Every emitted date is canonical bare ISO (`Temporal.PlainDate#toString()`), so an input
40
+ * annotation such as `[u-ca=iso8601]` does not reach the output, as `addDate` behaves.
41
+ * - Returns `null` when `dates` or `windows` is not an object, `anchorOn` or a given optional
42
+ * date is not a valid ISO date, a window is missing or not a safe integer of at least `0`, a
43
+ * request exists without an invoice or before it, `agreedResolutionOn` is before the request,
44
+ * or a deadline would leave Temporal's range.
45
+ *
46
+ * @param dates { anchorOn: string, invoiceIssuedOn?: string, requestReceivedOn?: string } ISO dates
47
+ * @param windows { issueDays: number, disputeDays: number, resolutionDays: number, agreedResolutionOn?: string } calendar-day windows, none defaulted
48
+ * @returns { invoiceDeadline, issuedByDeadline, disputeDeadline, requestedByDeadline, resolutionDeadline }, or null on invalid input
49
+ *
50
+ * @example billingTimeline({ anchorOn: "2026-03-01" }, { issueDays: 30, disputeDays: 30, resolutionDays: 30 }) // { invoiceDeadline: "2026-03-31", issuedByDeadline: null, disputeDeadline: null, requestedByDeadline: null, resolutionDeadline: null } (no invoice yet: a forecast)
51
+ * @example 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 } (day 30 is the last day by the deadline)
52
+ * @example 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 } (day 31)
53
+ * @example 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" }
54
+ * @example billingTimeline({ anchorOn: "2026-03-01", invoiceIssuedOn: "2026-03-20", requestReceivedOn: "2026-04-20" }, { issueDays: 30, disputeDays: 30, resolutionDays: 30, agreedResolutionOn: "2026-06-01" }) // { invoiceDeadline: "2026-03-31", issuedByDeadline: true, disputeDeadline: "2026-04-19", requestedByDeadline: false, resolutionDeadline: "2026-06-01" } (an agreed date replaces the computed one)
55
+ * @example 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 } (a re-bill anchored on the invoice it received, not on the charge)
56
+ * @example 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" } (a service contract's own windows)
57
+ * @example billingTimeline({ anchorOn: "2028-01-30" }, { issueDays: 30, disputeDays: 30, resolutionDays: 30 }) // { invoiceDeadline: "2028-02-29", issuedByDeadline: null, disputeDeadline: null, requestedByDeadline: null, resolutionDeadline: null } (calendar days across a leap day)
58
+ * @example billingTimeline({ anchorOn: "2026-03-01", invoiceIssuedOn: "2026-03-31" }, { disputeDays: 30, resolutionDays: 30 }) // null (issueDays has no default)
59
+ * @example billingTimeline({ anchorOn: "2026-03-01", requestReceivedOn: "2026-04-19" }, { issueDays: 30, disputeDays: 30, resolutionDays: 30 }) // null (a request cannot precede the invoice it disputes)
60
+ */
61
+ export function billingTimeline(dates, windows) {
62
+ try {
63
+ return layOutDeadlines(dates, windows);
64
+ }
65
+ catch {
66
+ // Never throws (Core Rule 3): a hostile
67
+ // argument is invalid input, not an exception.
68
+ return null;
69
+ }
70
+ }
71
+ /** A window is a whole number of calendar days, zero or more. */
72
+ function isWindow(value) {
73
+ return typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
74
+ }
75
+ /** An optional date is absent (`undefined`) or a valid ISO date; anything else is invalid. */
76
+ function optionalDate(value) {
77
+ if (value === undefined) {
78
+ return undefined;
79
+ }
80
+ return typeof value === "string" && isValidDate(value) ? value : null;
81
+ }
82
+ /** `date + days` on the ISO calendar, canonical. Throws `RangeError` past Temporal's range. */
83
+ function plusDays(date, days) {
84
+ return Temporal.PlainDate.from(date).add({ days }).toString();
85
+ }
86
+ /** Whether `date` is on or before `deadline`. */
87
+ function byDeadline(date, deadline) {
88
+ return (Temporal.PlainDate.compare(Temporal.PlainDate.from(date), Temporal.PlainDate.from(deadline)) <= 0);
89
+ }
90
+ /** The dates, each a valid ISO date, with no request before its invoice; `null` otherwise. */
91
+ function readDates(dates) {
92
+ const { anchorOn } = dates;
93
+ if (typeof anchorOn !== "string" || !isValidDate(anchorOn)) {
94
+ return null;
95
+ }
96
+ const invoiceIssuedOn = optionalDate(dates.invoiceIssuedOn);
97
+ const requestReceivedOn = optionalDate(dates.requestReceivedOn);
98
+ if (invoiceIssuedOn === null || requestReceivedOn === null) {
99
+ return null;
100
+ }
101
+ // A request cannot precede the invoice it disputes.
102
+ if (requestReceivedOn !== undefined &&
103
+ (invoiceIssuedOn === undefined ||
104
+ !byDeadline(invoiceIssuedOn, requestReceivedOn))) {
105
+ return null;
106
+ }
107
+ return { anchorOn, invoiceIssuedOn, requestReceivedOn };
108
+ }
109
+ /** The three windows, each present and valid, and the agreed date if given; `null` otherwise. */
110
+ function readWindows(windows) {
111
+ const { issueDays, disputeDays, resolutionDays } = windows;
112
+ if (!isWindow(issueDays) ||
113
+ !isWindow(disputeDays) ||
114
+ !isWindow(resolutionDays)) {
115
+ return null;
116
+ }
117
+ const agreedResolutionOn = optionalDate(windows.agreedResolutionOn);
118
+ if (agreedResolutionOn === null) {
119
+ return null;
120
+ }
121
+ return { issueDays, disputeDays, resolutionDays, agreedResolutionOn };
122
+ }
123
+ /** The agreed date when given, else `requestReceivedOn + resolutionDays`; `null` with no request. */
124
+ function resolutionDeadline(requestReceivedOn, windows) {
125
+ if (requestReceivedOn === undefined) {
126
+ return null;
127
+ }
128
+ return windows.agreedResolutionOn === undefined
129
+ ? plusDays(requestReceivedOn, windows.resolutionDays)
130
+ : Temporal.PlainDate.from(windows.agreedResolutionOn).toString();
131
+ }
132
+ /** Whether `date` is on or before `deadline`; `null` until both exist. */
133
+ function onOrBefore(date, deadline) {
134
+ return date === undefined || deadline === null
135
+ ? null
136
+ : byDeadline(date, deadline);
137
+ }
138
+ /** An agreed resolution date cannot precede the request it resolves. */
139
+ function agreedAfterRequest(requestReceivedOn, agreedResolutionOn) {
140
+ return (requestReceivedOn === undefined ||
141
+ agreedResolutionOn === undefined ||
142
+ byDeadline(requestReceivedOn, agreedResolutionOn));
143
+ }
144
+ /** `billingTimeline` without the guard; every read of its arguments happens here. */
145
+ function layOutDeadlines(dates, windows) {
146
+ if (!isObject(dates) || !isObject(windows)) {
147
+ return null;
148
+ }
149
+ const read = readDates(dates);
150
+ const counts = readWindows(windows);
151
+ if (read === null ||
152
+ counts === null ||
153
+ !agreedAfterRequest(read.requestReceivedOn, counts.agreedResolutionOn)) {
154
+ return null;
155
+ }
156
+ const { anchorOn, invoiceIssuedOn, requestReceivedOn } = read;
157
+ const invoiceDeadline = plusDays(anchorOn, counts.issueDays);
158
+ const disputeDeadline = invoiceIssuedOn === undefined
159
+ ? null
160
+ : plusDays(invoiceIssuedOn, counts.disputeDays);
161
+ return {
162
+ invoiceDeadline,
163
+ issuedByDeadline: onOrBefore(invoiceIssuedOn, invoiceDeadline),
164
+ disputeDeadline,
165
+ requestedByDeadline: onOrBefore(requestReceivedOn, disputeDeadline),
166
+ resolutionDeadline: resolutionDeadline(requestReceivedOn, counts),
167
+ };
168
+ }
@@ -0,0 +1,98 @@
1
+ import type { FreeTimeBasis, FreeTimeOptions } from "../../types/index.js";
2
+ /** A band of chargeable-day ordinals and how many of the charged days fell in it. */
3
+ export interface TierBand {
4
+ /** First chargeable-day ordinal of the band, from 1. */
5
+ from: number;
6
+ /** Last ordinal of the band, or `null` for the open final band. */
7
+ to: number | null;
8
+ /** Charged days that fell in the band. */
9
+ days: number;
10
+ }
11
+ /** What `chargeableDays` returns: the day count an invoice bills, and the dates that back it. */
12
+ export interface FreeTimeCharges {
13
+ /** Free days the dwell touched, at most the tariff's `freeDays`. */
14
+ freeDaysUsed: number;
15
+ /** Counted days on or after `expiresAt` the dwell touched. */
16
+ chargeableDays: number;
17
+ /** The instant free time ended, as a UTC instant, as `freeTimeExpiry` reports it. */
18
+ expiresAt: string;
19
+ /**
20
+ * The local dates charged, ascending. Carriers' day-numbered tariff grids and progressive tiers
21
+ * are applied to this list, and its entries are the dates an itemised invoice can list.
22
+ */
23
+ chargedDates: string[];
24
+ /** The charged days split into the `tiers` bands; one open band when no tiers were given. */
25
+ byTier: TierBand[];
26
+ }
27
+ /** `FreeTimeOptions` plus how charged days are counted, and the optional tier boundaries. */
28
+ export type FreeTimeChargeOptions = FreeTimeOptions & {
29
+ /**
30
+ * How days after expiry are charged: `"calendar"` charges every local day, `"working"` only the
31
+ * working days of `calendar`. No default; see `chargeableDays`.
32
+ */
33
+ chargeBasis: FreeTimeBasis;
34
+ tiers?: number[];
35
+ };
36
+ /**
37
+ * Count the days a container is charged for between the start of its clock and its exit,
38
+ * and list the dates behind the count.
39
+ *
40
+ * Lays out free time with the same walk as `freeTimeExpiry`, then counts the days on or after
41
+ * the expiry that the half-open dwell `[clockStart, clockEnd)` touched. The result is the
42
+ * auditable form of a demurrage or detention charge: `chargedDates` lists the days behind the
43
+ * count, which is the list a day-numbered tariff grid is applied to, and the dates an itemised
44
+ * invoice can list. No regulation or industry standard fixes how the days are counted; every term
45
+ * below is the tariff's. GMT computes days, never money: `byTier` says how many days fell in each
46
+ * band so the caller can apply its rates.
47
+ *
48
+ * - **Half-open at the exit.** A day is charged when it started before `clockEnd`, so a
49
+ * gate-out at exactly `expiresAt` is `0` chargeable days and one nanosecond later is `1`. A
50
+ * zero-length dwell touches the day it sits on, as `dwellTime` counts. On the calendar basis
51
+ * with `firstDay: "eventDay"`, `freeDaysUsed + chargeableDays` equals `dwellTime(...).calendarDays`,
52
+ * with one exception: a fall-back that re-enters the day before the event day (`America/Goose_Bay`,
53
+ * 00:01 on 7 November 2010 back into the 6th) is a date `dwellTime` counts as touched, and a
54
+ * tariff never counts a date before the event day.
55
+ * - **The free-time terms are `freeTimeExpiry`'s**: `basis`, `timeZone`, `firstDay` and
56
+ * `calendar` mean the same, and `firstDay` has no default.
57
+ * - **`chargeBasis` says how days after expiry are charged, and has no default.** Many
58
+ * tariffs count free time and charges alike in calendar days. Where a tariff grants free time in
59
+ * working days, the days after it are mostly charged as calendar days, weekends and holidays
60
+ * included (Hapag-Lloyd's tables: "Rate per Calendar day"; ACL: "Once free time expires ...
61
+ * charged on calendar days"): `basis: "working", chargeBasis: "calendar"`. Some tariffs charge
62
+ * working days only, for free time and charges alike, so neither counts a day the gate is
63
+ * closed or a holiday: both `"working"`.
64
+ * Either `"working"` needs `calendar`. The two differ by every closed day after expiry, so
65
+ * neither is assumed.
66
+ * - **`freeDays` may be `0`** for a tariff with no free time: `expiresAt` is the start of day one,
67
+ * the first day counted on `basis` (under `"nextDay"`, the first after the event day), and days
68
+ * before it are neither free nor charged.
69
+ * - **`tiers` are day bands, not rates.** `[5, 10]` names the last chargeable-day ordinal of
70
+ * each band: days 1–5, 6–10 and 11 onward. Every band is returned, an empty one with
71
+ * `days: 0`, and the last is always open. Omitted, `byTier` is the single open band
72
+ * `{ from: 1, to: null }`. A list that is not strictly ascending positive whole numbers
73
+ * returns `null`.
74
+ * - Returns `null` when either instant is invalid, `clockEnd` is before `clockStart` (an
75
+ * inverted dwell is a data error), `freeDays` is not a whole number of at least `0`, the
76
+ * options fail as they do for `freeTimeExpiry`, `tiers` has a hole, an unsafe integer or does not ascend, or the walk would pass
77
+ * 10,000 local days.
78
+ *
79
+ * @param clockStart ISO 8601 instant string of the event that starts the clock
80
+ * @param clockEnd ISO 8601 instant string of the event that stops it (gate-out or empty return)
81
+ * @param freeDays whole number of free days the tariff allows, 0 or more
82
+ * @param options { basis, chargeBasis, timeZone, firstDay, calendar?, tiers?: number[] }
83
+ * @returns the free days used, the chargeable days, the expiry, the charged dates and the days per tier band, or null on invalid input
84
+ *
85
+ * @example chargeableDays("2024-06-14T19:00:00Z", "2024-06-17T04:00:00Z", 3, { basis: "calendar", chargeBasis: "calendar", timeZone: "America/New_York", firstDay: "eventDay" }) // { freeDaysUsed: 3, chargeableDays: 0, expiresAt: "2024-06-17T04:00:00Z", chargedDates: [], byTier: [{ from: 1, to: null, days: 0 }] } (gate-out exactly at expiry is free)
86
+ * @example 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 }] } (one second later is a charged day)
87
+ * @example chargeableDays("2024-06-14T19:00:00Z", "2024-06-17T04:00:01Z", 3, { basis: "calendar", chargeBasis: "calendar", timeZone: "America/New_York", firstDay: "nextDay" }) // { freeDaysUsed: 3, chargeableDays: 0, expiresAt: "2024-06-18T04:00:00Z", chargedDates: [], byTier: [{ from: 1, to: null, days: 0 }] } (the other start-day convention: still free)
88
+ * @example 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] }) // { freeDaysUsed: 3, chargeableDays: 12, expiresAt: "2024-06-17T04:00:00Z", chargedDates: ["2024-06-17", "2024-06-18", "2024-06-19", "2024-06-20", "2024-06-21", "2024-06-22", "2024-06-23", "2024-06-24", "2024-06-25", "2024-06-26", "2024-06-27", "2024-06-28"], byTier: [{ from: 1, to: 5, days: 5 }, { from: 6, to: 10, days: 5 }, { from: 11, to: null, days: 2 }] }
89
+ * @example 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" } }) // { freeDaysUsed: 3, chargeableDays: 6, expiresAt: "2024-06-19T04:00:00Z", chargedDates: ["2024-06-19", "2024-06-20", "2024-06-21", "2024-06-22", "2024-06-23", "2024-06-24"], byTier: [{ from: 1, to: null, days: 6 }] } (free days in working days, every calendar day after expiry charged)
90
+ * @example 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" } }) // { freeDaysUsed: 3, chargeableDays: 3, expiresAt: "2024-06-19T04:00:00Z", chargedDates: ["2024-06-20", "2024-06-21", "2024-06-24"], byTier: [{ from: 1, to: null, days: 3 }] } (working days throughout: closed days after expiry are not charged)
91
+ * @example chargeableDays("2024-06-14T19:00:00Z", "2024-06-15T15:00:00Z", 3, { basis: "calendar", chargeBasis: "calendar", timeZone: "America/New_York", firstDay: "eventDay" }) // { freeDaysUsed: 2, chargeableDays: 0, expiresAt: "2024-06-17T04:00:00Z", chargedDates: [], byTier: [{ from: 1, to: null, days: 0 }] } (out on the second free day)
92
+ * @example chargeableDays("2024-06-14T19:00:00Z", "2024-06-16T15:00:00Z", 0, { basis: "calendar", chargeBasis: "calendar", timeZone: "America/New_York", firstDay: "eventDay" }) // { freeDaysUsed: 0, chargeableDays: 3, expiresAt: "2024-06-14T04:00:00Z", chargedDates: ["2024-06-14", "2024-06-15", "2024-06-16"], byTier: [{ from: 1, to: null, days: 3 }] } (no free time: every day is charged)
93
+ * @example 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)
94
+ * @example chargeableDays("2024-06-17T04:00:01Z", "2024-06-14T19:00:00Z", 3, { basis: "calendar", chargeBasis: "calendar", timeZone: "America/New_York", firstDay: "eventDay" }) // null (exit before the clock started)
95
+ * @example chargeableDays("2024-06-14T19:00:00Z", "2024-06-17T04:00:01Z", 3, { basis: "calendar", chargeBasis: "calendar", timeZone: "America/New_York" }) // null (firstDay has no default)
96
+ * @example chargeableDays("2024-06-14T19:00:00Z", "2024-06-17T04:00:01Z", 3, { basis: "calendar", timeZone: "America/New_York", firstDay: "eventDay" }) // null (chargeBasis has no default)
97
+ */
98
+ export declare function chargeableDays(clockStart: string, clockEnd: string, freeDays: number, options: FreeTimeChargeOptions): FreeTimeCharges | null;
@@ -0,0 +1,101 @@
1
+ import { Temporal } from "@js-temporal/polyfill";
2
+ import { bandsByTier, parseFreeDays, parseFreeTimeTerms, parseTiers, walkFreeTime, } from "../../internal/index.js";
3
+ import { isValidInstant } from "../../precision/validate/isValidInstant.js";
4
+ /**
5
+ * Count the days a container is charged for between the start of its clock and its exit,
6
+ * and list the dates behind the count.
7
+ *
8
+ * Lays out free time with the same walk as `freeTimeExpiry`, then counts the days on or after
9
+ * the expiry that the half-open dwell `[clockStart, clockEnd)` touched. The result is the
10
+ * auditable form of a demurrage or detention charge: `chargedDates` lists the days behind the
11
+ * count, which is the list a day-numbered tariff grid is applied to, and the dates an itemised
12
+ * invoice can list. No regulation or industry standard fixes how the days are counted; every term
13
+ * below is the tariff's. GMT computes days, never money: `byTier` says how many days fell in each
14
+ * band so the caller can apply its rates.
15
+ *
16
+ * - **Half-open at the exit.** A day is charged when it started before `clockEnd`, so a
17
+ * gate-out at exactly `expiresAt` is `0` chargeable days and one nanosecond later is `1`. A
18
+ * zero-length dwell touches the day it sits on, as `dwellTime` counts. On the calendar basis
19
+ * with `firstDay: "eventDay"`, `freeDaysUsed + chargeableDays` equals `dwellTime(...).calendarDays`,
20
+ * with one exception: a fall-back that re-enters the day before the event day (`America/Goose_Bay`,
21
+ * 00:01 on 7 November 2010 back into the 6th) is a date `dwellTime` counts as touched, and a
22
+ * tariff never counts a date before the event day.
23
+ * - **The free-time terms are `freeTimeExpiry`'s**: `basis`, `timeZone`, `firstDay` and
24
+ * `calendar` mean the same, and `firstDay` has no default.
25
+ * - **`chargeBasis` says how days after expiry are charged, and has no default.** Many
26
+ * tariffs count free time and charges alike in calendar days. Where a tariff grants free time in
27
+ * working days, the days after it are mostly charged as calendar days, weekends and holidays
28
+ * included (Hapag-Lloyd's tables: "Rate per Calendar day"; ACL: "Once free time expires ...
29
+ * charged on calendar days"): `basis: "working", chargeBasis: "calendar"`. Some tariffs charge
30
+ * working days only, for free time and charges alike, so neither counts a day the gate is
31
+ * closed or a holiday: both `"working"`.
32
+ * Either `"working"` needs `calendar`. The two differ by every closed day after expiry, so
33
+ * neither is assumed.
34
+ * - **`freeDays` may be `0`** for a tariff with no free time: `expiresAt` is the start of day one,
35
+ * the first day counted on `basis` (under `"nextDay"`, the first after the event day), and days
36
+ * before it are neither free nor charged.
37
+ * - **`tiers` are day bands, not rates.** `[5, 10]` names the last chargeable-day ordinal of
38
+ * each band: days 1–5, 6–10 and 11 onward. Every band is returned, an empty one with
39
+ * `days: 0`, and the last is always open. Omitted, `byTier` is the single open band
40
+ * `{ from: 1, to: null }`. A list that is not strictly ascending positive whole numbers
41
+ * returns `null`.
42
+ * - Returns `null` when either instant is invalid, `clockEnd` is before `clockStart` (an
43
+ * inverted dwell is a data error), `freeDays` is not a whole number of at least `0`, the
44
+ * options fail as they do for `freeTimeExpiry`, `tiers` has a hole, an unsafe integer or does not ascend, or the walk would pass
45
+ * 10,000 local days.
46
+ *
47
+ * @param clockStart ISO 8601 instant string of the event that starts the clock
48
+ * @param clockEnd ISO 8601 instant string of the event that stops it (gate-out or empty return)
49
+ * @param freeDays whole number of free days the tariff allows, 0 or more
50
+ * @param options { basis, chargeBasis, timeZone, firstDay, calendar?, tiers?: number[] }
51
+ * @returns the free days used, the chargeable days, the expiry, the charged dates and the days per tier band, or null on invalid input
52
+ *
53
+ * @example chargeableDays("2024-06-14T19:00:00Z", "2024-06-17T04:00:00Z", 3, { basis: "calendar", chargeBasis: "calendar", timeZone: "America/New_York", firstDay: "eventDay" }) // { freeDaysUsed: 3, chargeableDays: 0, expiresAt: "2024-06-17T04:00:00Z", chargedDates: [], byTier: [{ from: 1, to: null, days: 0 }] } (gate-out exactly at expiry is free)
54
+ * @example 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 }] } (one second later is a charged day)
55
+ * @example chargeableDays("2024-06-14T19:00:00Z", "2024-06-17T04:00:01Z", 3, { basis: "calendar", chargeBasis: "calendar", timeZone: "America/New_York", firstDay: "nextDay" }) // { freeDaysUsed: 3, chargeableDays: 0, expiresAt: "2024-06-18T04:00:00Z", chargedDates: [], byTier: [{ from: 1, to: null, days: 0 }] } (the other start-day convention: still free)
56
+ * @example 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] }) // { freeDaysUsed: 3, chargeableDays: 12, expiresAt: "2024-06-17T04:00:00Z", chargedDates: ["2024-06-17", "2024-06-18", "2024-06-19", "2024-06-20", "2024-06-21", "2024-06-22", "2024-06-23", "2024-06-24", "2024-06-25", "2024-06-26", "2024-06-27", "2024-06-28"], byTier: [{ from: 1, to: 5, days: 5 }, { from: 6, to: 10, days: 5 }, { from: 11, to: null, days: 2 }] }
57
+ * @example 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" } }) // { freeDaysUsed: 3, chargeableDays: 6, expiresAt: "2024-06-19T04:00:00Z", chargedDates: ["2024-06-19", "2024-06-20", "2024-06-21", "2024-06-22", "2024-06-23", "2024-06-24"], byTier: [{ from: 1, to: null, days: 6 }] } (free days in working days, every calendar day after expiry charged)
58
+ * @example 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" } }) // { freeDaysUsed: 3, chargeableDays: 3, expiresAt: "2024-06-19T04:00:00Z", chargedDates: ["2024-06-20", "2024-06-21", "2024-06-24"], byTier: [{ from: 1, to: null, days: 3 }] } (working days throughout: closed days after expiry are not charged)
59
+ * @example chargeableDays("2024-06-14T19:00:00Z", "2024-06-15T15:00:00Z", 3, { basis: "calendar", chargeBasis: "calendar", timeZone: "America/New_York", firstDay: "eventDay" }) // { freeDaysUsed: 2, chargeableDays: 0, expiresAt: "2024-06-17T04:00:00Z", chargedDates: [], byTier: [{ from: 1, to: null, days: 0 }] } (out on the second free day)
60
+ * @example chargeableDays("2024-06-14T19:00:00Z", "2024-06-16T15:00:00Z", 0, { basis: "calendar", chargeBasis: "calendar", timeZone: "America/New_York", firstDay: "eventDay" }) // { freeDaysUsed: 0, chargeableDays: 3, expiresAt: "2024-06-14T04:00:00Z", chargedDates: ["2024-06-14", "2024-06-15", "2024-06-16"], byTier: [{ from: 1, to: null, days: 3 }] } (no free time: every day is charged)
61
+ * @example 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)
62
+ * @example chargeableDays("2024-06-17T04:00:01Z", "2024-06-14T19:00:00Z", 3, { basis: "calendar", chargeBasis: "calendar", timeZone: "America/New_York", firstDay: "eventDay" }) // null (exit before the clock started)
63
+ * @example chargeableDays("2024-06-14T19:00:00Z", "2024-06-17T04:00:01Z", 3, { basis: "calendar", chargeBasis: "calendar", timeZone: "America/New_York" }) // null (firstDay has no default)
64
+ * @example chargeableDays("2024-06-14T19:00:00Z", "2024-06-17T04:00:01Z", 3, { basis: "calendar", timeZone: "America/New_York", firstDay: "eventDay" }) // null (chargeBasis has no default)
65
+ */
66
+ export function chargeableDays(clockStart, clockEnd, freeDays, options) {
67
+ try {
68
+ const terms = parseFreeTimeTerms(options, true);
69
+ const allowed = parseFreeDays(freeDays, 0);
70
+ // Read after the terms, so a non-object `options` is never read from.
71
+ const tiers = terms === null ? null : parseTiers(options.tiers);
72
+ if (!isValidInstant(clockStart) ||
73
+ !isValidInstant(clockEnd) ||
74
+ terms === null ||
75
+ allowed === null ||
76
+ tiers === null) {
77
+ return null;
78
+ }
79
+ const start = Temporal.Instant.from(clockStart).toZonedDateTimeISO(terms.timeZone);
80
+ const end = Temporal.Instant.from(clockEnd).toZonedDateTimeISO(terms.timeZone);
81
+ if (Temporal.ZonedDateTime.compare(start, end) > 0) {
82
+ return null;
83
+ }
84
+ const ledger = walkFreeTime(start, allowed, terms, end);
85
+ if (ledger === null) {
86
+ return null;
87
+ }
88
+ return {
89
+ freeDaysUsed: ledger.used,
90
+ chargeableDays: ledger.charged.length,
91
+ expiresAt: ledger.expiresAt.toInstant().toString(),
92
+ chargedDates: ledger.charged.map((day) => day.date),
93
+ byTier: bandsByTier(tiers, ledger.charged.length),
94
+ };
95
+ }
96
+ catch {
97
+ // Never throws (Core Rule 3): a hostile
98
+ // argument is invalid input, not an exception.
99
+ return null;
100
+ }
101
+ }
@@ -0,0 +1,93 @@
1
+ import type { Interval } from "../../types/index.js";
2
+ /**
3
+ * The events a container's charging clocks run between, named after the DCSA Track & Trace
4
+ * equipment events they correspond to (`equipmentEventTypeCode` with `emptyIndicatorCode`):
5
+ *
6
+ * - `discharged`: DISC, laden — the full container is discharged from the vessel (import).
7
+ * - `available`: no DCSA event — the container availability date some import tariffs start the
8
+ * clock at.
9
+ * - `gatedOut`: GTOT, laden — the full container leaves the terminal (import).
10
+ * - `emptyReturned`: GTIN, empty — the empty is returned to the terminal or depot (import).
11
+ * - `emptyReleased`: GTOT or PICK, empty — the empty is picked up for stuffing (export).
12
+ * - `gatedIn`: GTIN, laden — the full container is received at the terminal (export).
13
+ * - `loaded`: LOAD, laden — the full container is loaded on board (export).
14
+ */
15
+ export type ClockEventType = "discharged" | "available" | "gatedOut" | "emptyReturned" | "emptyReleased" | "gatedIn" | "loaded";
16
+ /** A container event: what happened and the instant it happened. */
17
+ export interface ClockEvent {
18
+ type: ClockEventType;
19
+ /** ISO 8601 instant string: `Z`, an offset, or a bracketed zone. */
20
+ at: string;
21
+ }
22
+ /** Which charge is being clocked. `combined` is the merged demurrage-and-detention clock. */
23
+ export type ClockScope = "demurrage" | "detention" | "storage" | "combined";
24
+ /** Which leg of the container's cycle the clock is on. */
25
+ export type ClockDirection = "import" | "export";
26
+ /** Where an import demurrage, storage or combined clock starts, when the tariff says so. */
27
+ export type ClockStartEvent = "discharged" | "available";
28
+ /** The options `demurrageClock` reads. */
29
+ export interface ClockOptions {
30
+ /** `"import"` or `"export"`; required, because the same scope runs between different events in each. */
31
+ direction: ClockDirection;
32
+ /** Import only: `"discharged"` (the default) or `"available"`. Not read for export or detention. */
33
+ startEvent?: ClockStartEvent;
34
+ }
35
+ /**
36
+ * Pick the two events a charge's clock runs between.
37
+ *
38
+ * No regulation or industry standard fixes these events; the carriers' published tariffs agree
39
+ * on them (import: Maersk's terms, CMA CGM's general terms, Hapag-Lloyd, ACL; export: Maersk, CMA
40
+ * CGM, ACL), and DCSA's glossary draws the line they rest on: demurrage is charged while the
41
+ * container is inside the terminal or depot, detention while it is outside, and storage is the
42
+ * charge for the terminal's space, paid to the terminal or to the carrier on its behalf. Export
43
+ * storage (`gatedIn` → `loaded`) follows from that storage definition; no tariff read quotes it.
44
+ * The merged clock runs demurrage and detention as one period, a named tariff product at Maersk
45
+ * ("Combined"), CMA CGM ("merged") and MSC. `demurrageClock` selects the pair for the charge and
46
+ * direction asked for, so the wrong pair can never reach `chargeableDays`.
47
+ *
48
+ * | scope | import | export |
49
+ * | --- | --- | --- |
50
+ * | `demurrage`, `storage` | `startEvent` → `gatedOut` | `gatedIn` → `loaded` |
51
+ * | `detention` | `gatedOut` → `emptyReturned` | `emptyReleased` → `gatedIn` |
52
+ * | `combined` | `startEvent` → `emptyReturned` | `emptyReleased` → `loaded` |
53
+ *
54
+ * - **`direction` has no default.** The same scope name selects different events on the import and
55
+ * export legs, and guessing it from the events present would be a silent guess.
56
+ * - **`startEvent` is import only**: `"discharged"` by default, the classic definition, or
57
+ * `"available"` for tariffs that start at the availability date. It is not read for detention
58
+ * or export, so one options object can serve every scope on a leg. A tariff that starts at
59
+ * customs release (Hapag-Lloyd Japan) or ends export demurrage at the cut-off or the scheduled
60
+ * sailing passes that instant as the event's `at`; GMT does not invent an event for one tariff.
61
+ * - Events may be in any order, and every one must be `{ type, at }` with a known type and a
62
+ * valid instant, used or not, with no holes in the array: a list with a bad entry is a bad list.
63
+ * - The result is an `Interval`, half-open `[start, end)`, in the caller's own strings, and feeds
64
+ * `chargeableDays(start, end, …)` directly.
65
+ * - Some tariffs differ in words, not events: MSC USA calls the in-terminal container charge
66
+ * "detention" and the out-of-terminal one "per diem". GMT's scope names follow the global usage.
67
+ * - Returns `null` when `events` is not an array or holds an invalid event or a hole, `scope`,
68
+ * `direction` or `startEvent` is unknown, `options` is not an object, a required event is
69
+ * missing or occurs more than once (a container discharged twice is a data error, not a choice),
70
+ * or the end event precedes the start event.
71
+ *
72
+ * @param events the container's events, `{ type, at }`, `type` one of discharged, available, gatedOut, emptyReturned, emptyReleased, gatedIn, loaded
73
+ * @param scope "demurrage" | "detention" | "storage" | "combined"
74
+ * @param options { direction: "import" | "export", startEvent?: "discharged" | "available" }
75
+ * @returns { start, end } of the requested clock, or null on invalid input
76
+ *
77
+ * @example demurrageClock([{ type: "discharged", at: "2024-06-14T19:00:00Z" }, { type: "available", at: "2024-06-15T12:00:00Z" }, { type: "gatedOut", at: "2024-06-20T14:30:00Z" }, { type: "emptyReturned", at: "2024-06-27T09:00:00Z" }], "demurrage", { direction: "import" }) / { start: "2024-06-14T19:00:00Z", end: "2024-06-20T14:30:00Z" }
78
+ * @example demurrageClock([{ type: "discharged", at: "2024-06-14T19:00:00Z" }, { type: "available", at: "2024-06-15T12:00:00Z" }, { type: "gatedOut", at: "2024-06-20T14:30:00Z" }, { type: "emptyReturned", at: "2024-06-27T09:00:00Z" }], "detention", { direction: "import" }) / { start: "2024-06-20T14:30:00Z", end: "2024-06-27T09:00:00Z" } (gate-out to empty return, not discharge)
79
+ * @example demurrageClock([{ type: "discharged", at: "2024-06-14T19:00:00Z" }, { type: "available", at: "2024-06-15T12:00:00Z" }, { type: "gatedOut", at: "2024-06-20T14:30:00Z" }, { type: "emptyReturned", at: "2024-06-27T09:00:00Z" }], "combined", { direction: "import" }) / { start: "2024-06-14T19:00:00Z", end: "2024-06-27T09:00:00Z" } (merged: discharge to empty return)
80
+ * @example demurrageClock([{ type: "discharged", at: "2024-06-14T19:00:00Z" }, { type: "available", at: "2024-06-15T12:00:00Z" }, { type: "gatedOut", at: "2024-06-20T14:30:00Z" }], "demurrage", { direction: "import", startEvent: "available" }) // { start: "2024-06-15T12:00:00Z", end: "2024-06-20T14:30:00Z" }
81
+ * @example demurrageClock([{ type: "discharged", at: "2024-06-14T19:00:00Z" }, { type: "gatedOut", at: "2024-06-20T14:30:00Z" }], "storage", { direction: "import" }) / { start: "2024-06-14T19:00:00Z", end: "2024-06-20T14:30:00Z" }
82
+ * @example demurrageClock([{ type: "emptyReleased", at: "2024-07-01T08:00:00Z" }, { type: "gatedIn", at: "2024-07-05T16:00:00Z" }, { type: "loaded", at: "2024-07-09T03:00:00Z" }], "demurrage", { direction: "export" }) // { start: "2024-07-05T16:00:00Z", end: "2024-07-09T03:00:00Z" } (gate-in full to loaded on board)
83
+ * @example demurrageClock([{ type: "emptyReleased", at: "2024-07-01T08:00:00Z" }, { type: "gatedIn", at: "2024-07-05T16:00:00Z" }, { type: "loaded", at: "2024-07-09T03:00:00Z" }], "detention", { direction: "export" }) // { start: "2024-07-01T08:00:00Z", end: "2024-07-05T16:00:00Z" } (empty picked up to gate-in full)
84
+ * @example demurrageClock([{ type: "emptyReleased", at: "2024-07-01T08:00:00Z" }, { type: "gatedIn", at: "2024-07-05T16:00:00Z" }, { type: "loaded", at: "2024-07-09T03:00:00Z" }], "combined", { direction: "export" }) // { start: "2024-07-01T08:00:00Z", end: "2024-07-09T03:00:00Z" }
85
+ * @example demurrageClock([{ type: "gatedOut", at: "2024-06-20T10:30:00-04:00[America/New_York]" }, { type: "emptyReturned", at: "2024-06-27T09:00:00Z" }], "detention", { direction: "import", startEvent: "available" }) // { start: "2024-06-20T10:30:00-04:00[America/New_York]", end: "2024-06-27T09:00:00Z" } (startEvent is not read for detention; strings are echoed as given)
86
+ * @example demurrageClock([{ type: "discharged", at: "2024-06-14T19:00:00Z" }, { type: "gatedOut", at: "2024-06-20T14:30:00Z" }], "demurrage") // null (direction has no default)
87
+ * @example demurrageClock([{ type: "discharged", at: "2024-06-14T19:00:00Z" }, { type: "gatedOut", at: "2024-06-20T14:30:00Z" }], "demurrage", { direction: "import", startEvent: "available" }) // null (no availability event)
88
+ * @example demurrageClock([{ type: "discharged", at: "2024-06-14T19:00:00Z" }], "demurrage", { direction: "import" }) // null (still in the terminal: no gate-out)
89
+ * @example demurrageClock([{ type: "discharged", at: "2024-06-14T19:00:00Z" }, { type: "discharged", at: "2024-06-15T19:00:00Z" }, { type: "gatedOut", at: "2024-06-20T14:30:00Z" }], "demurrage", { direction: "import" }) // null (discharged twice)
90
+ * @example demurrageClock([{ type: "discharged", at: "2024-06-21T19:00:00Z" }, { type: "gatedOut", at: "2024-06-20T14:30:00Z" }], "demurrage", { direction: "import" }) // null (gate-out before discharge)
91
+ * @example demurrageClock([{ type: "discharged", at: "2024-06-14T19:00:00Z" }, { type: "gatedOut", at: "2024-06-20T14:30:00Z" }], "laytime", { direction: "import" }) // null (not a clock this function selects)
92
+ */
93
+ export declare function demurrageClock(events: ClockEvent[], scope: ClockScope, options: ClockOptions): Interval | null;
@@ -0,0 +1,140 @@
1
+ import { Temporal } from "@js-temporal/polyfill";
2
+ import { isObject } from "../../internal/index.js";
3
+ import { isValidInstant } from "../../precision/validate/isValidInstant.js";
4
+ const CLOCK_EVENT_TYPES = [
5
+ "discharged",
6
+ "available",
7
+ "gatedOut",
8
+ "emptyReturned",
9
+ "emptyReleased",
10
+ "gatedIn",
11
+ "loaded",
12
+ ];
13
+ const CLOCK_SCOPES = [
14
+ "demurrage",
15
+ "detention",
16
+ "storage",
17
+ "combined",
18
+ ];
19
+ const CLOCK_START_EVENTS = [
20
+ "discharged",
21
+ "available",
22
+ ];
23
+ /** The export clocks: fixed pairs, no start-event choice. */
24
+ const EXPORT_CLOCKS = {
25
+ demurrage: ["gatedIn", "loaded"],
26
+ storage: ["gatedIn", "loaded"],
27
+ detention: ["emptyReleased", "gatedIn"],
28
+ combined: ["emptyReleased", "loaded"],
29
+ };
30
+ /**
31
+ * Pick the two events a charge's clock runs between.
32
+ *
33
+ * No regulation or industry standard fixes these events; the carriers' published tariffs agree
34
+ * on them (import: Maersk's terms, CMA CGM's general terms, Hapag-Lloyd, ACL; export: Maersk, CMA
35
+ * CGM, ACL), and DCSA's glossary draws the line they rest on: demurrage is charged while the
36
+ * container is inside the terminal or depot, detention while it is outside, and storage is the
37
+ * charge for the terminal's space, paid to the terminal or to the carrier on its behalf. Export
38
+ * storage (`gatedIn` → `loaded`) follows from that storage definition; no tariff read quotes it.
39
+ * The merged clock runs demurrage and detention as one period, a named tariff product at Maersk
40
+ * ("Combined"), CMA CGM ("merged") and MSC. `demurrageClock` selects the pair for the charge and
41
+ * direction asked for, so the wrong pair can never reach `chargeableDays`.
42
+ *
43
+ * | scope | import | export |
44
+ * | --- | --- | --- |
45
+ * | `demurrage`, `storage` | `startEvent` → `gatedOut` | `gatedIn` → `loaded` |
46
+ * | `detention` | `gatedOut` → `emptyReturned` | `emptyReleased` → `gatedIn` |
47
+ * | `combined` | `startEvent` → `emptyReturned` | `emptyReleased` → `loaded` |
48
+ *
49
+ * - **`direction` has no default.** The same scope name selects different events on the import and
50
+ * export legs, and guessing it from the events present would be a silent guess.
51
+ * - **`startEvent` is import only**: `"discharged"` by default, the classic definition, or
52
+ * `"available"` for tariffs that start at the availability date. It is not read for detention
53
+ * or export, so one options object can serve every scope on a leg. A tariff that starts at
54
+ * customs release (Hapag-Lloyd Japan) or ends export demurrage at the cut-off or the scheduled
55
+ * sailing passes that instant as the event's `at`; GMT does not invent an event for one tariff.
56
+ * - Events may be in any order, and every one must be `{ type, at }` with a known type and a
57
+ * valid instant, used or not, with no holes in the array: a list with a bad entry is a bad list.
58
+ * - The result is an `Interval`, half-open `[start, end)`, in the caller's own strings, and feeds
59
+ * `chargeableDays(start, end, …)` directly.
60
+ * - Some tariffs differ in words, not events: MSC USA calls the in-terminal container charge
61
+ * "detention" and the out-of-terminal one "per diem". GMT's scope names follow the global usage.
62
+ * - Returns `null` when `events` is not an array or holds an invalid event or a hole, `scope`,
63
+ * `direction` or `startEvent` is unknown, `options` is not an object, a required event is
64
+ * missing or occurs more than once (a container discharged twice is a data error, not a choice),
65
+ * or the end event precedes the start event.
66
+ *
67
+ * @param events the container's events, `{ type, at }`, `type` one of discharged, available, gatedOut, emptyReturned, emptyReleased, gatedIn, loaded
68
+ * @param scope "demurrage" | "detention" | "storage" | "combined"
69
+ * @param options { direction: "import" | "export", startEvent?: "discharged" | "available" }
70
+ * @returns { start, end } of the requested clock, or null on invalid input
71
+ *
72
+ * @example demurrageClock([{ type: "discharged", at: "2024-06-14T19:00:00Z" }, { type: "available", at: "2024-06-15T12:00:00Z" }, { type: "gatedOut", at: "2024-06-20T14:30:00Z" }, { type: "emptyReturned", at: "2024-06-27T09:00:00Z" }], "demurrage", { direction: "import" }) / { start: "2024-06-14T19:00:00Z", end: "2024-06-20T14:30:00Z" }
73
+ * @example demurrageClock([{ type: "discharged", at: "2024-06-14T19:00:00Z" }, { type: "available", at: "2024-06-15T12:00:00Z" }, { type: "gatedOut", at: "2024-06-20T14:30:00Z" }, { type: "emptyReturned", at: "2024-06-27T09:00:00Z" }], "detention", { direction: "import" }) / { start: "2024-06-20T14:30:00Z", end: "2024-06-27T09:00:00Z" } (gate-out to empty return, not discharge)
74
+ * @example demurrageClock([{ type: "discharged", at: "2024-06-14T19:00:00Z" }, { type: "available", at: "2024-06-15T12:00:00Z" }, { type: "gatedOut", at: "2024-06-20T14:30:00Z" }, { type: "emptyReturned", at: "2024-06-27T09:00:00Z" }], "combined", { direction: "import" }) / { start: "2024-06-14T19:00:00Z", end: "2024-06-27T09:00:00Z" } (merged: discharge to empty return)
75
+ * @example demurrageClock([{ type: "discharged", at: "2024-06-14T19:00:00Z" }, { type: "available", at: "2024-06-15T12:00:00Z" }, { type: "gatedOut", at: "2024-06-20T14:30:00Z" }], "demurrage", { direction: "import", startEvent: "available" }) // { start: "2024-06-15T12:00:00Z", end: "2024-06-20T14:30:00Z" }
76
+ * @example demurrageClock([{ type: "discharged", at: "2024-06-14T19:00:00Z" }, { type: "gatedOut", at: "2024-06-20T14:30:00Z" }], "storage", { direction: "import" }) / { start: "2024-06-14T19:00:00Z", end: "2024-06-20T14:30:00Z" }
77
+ * @example demurrageClock([{ type: "emptyReleased", at: "2024-07-01T08:00:00Z" }, { type: "gatedIn", at: "2024-07-05T16:00:00Z" }, { type: "loaded", at: "2024-07-09T03:00:00Z" }], "demurrage", { direction: "export" }) // { start: "2024-07-05T16:00:00Z", end: "2024-07-09T03:00:00Z" } (gate-in full to loaded on board)
78
+ * @example demurrageClock([{ type: "emptyReleased", at: "2024-07-01T08:00:00Z" }, { type: "gatedIn", at: "2024-07-05T16:00:00Z" }, { type: "loaded", at: "2024-07-09T03:00:00Z" }], "detention", { direction: "export" }) // { start: "2024-07-01T08:00:00Z", end: "2024-07-05T16:00:00Z" } (empty picked up to gate-in full)
79
+ * @example demurrageClock([{ type: "emptyReleased", at: "2024-07-01T08:00:00Z" }, { type: "gatedIn", at: "2024-07-05T16:00:00Z" }, { type: "loaded", at: "2024-07-09T03:00:00Z" }], "combined", { direction: "export" }) // { start: "2024-07-01T08:00:00Z", end: "2024-07-09T03:00:00Z" }
80
+ * @example demurrageClock([{ type: "gatedOut", at: "2024-06-20T10:30:00-04:00[America/New_York]" }, { type: "emptyReturned", at: "2024-06-27T09:00:00Z" }], "detention", { direction: "import", startEvent: "available" }) // { start: "2024-06-20T10:30:00-04:00[America/New_York]", end: "2024-06-27T09:00:00Z" } (startEvent is not read for detention; strings are echoed as given)
81
+ * @example demurrageClock([{ type: "discharged", at: "2024-06-14T19:00:00Z" }, { type: "gatedOut", at: "2024-06-20T14:30:00Z" }], "demurrage") // null (direction has no default)
82
+ * @example demurrageClock([{ type: "discharged", at: "2024-06-14T19:00:00Z" }, { type: "gatedOut", at: "2024-06-20T14:30:00Z" }], "demurrage", { direction: "import", startEvent: "available" }) // null (no availability event)
83
+ * @example demurrageClock([{ type: "discharged", at: "2024-06-14T19:00:00Z" }], "demurrage", { direction: "import" }) // null (still in the terminal: no gate-out)
84
+ * @example demurrageClock([{ type: "discharged", at: "2024-06-14T19:00:00Z" }, { type: "discharged", at: "2024-06-15T19:00:00Z" }, { type: "gatedOut", at: "2024-06-20T14:30:00Z" }], "demurrage", { direction: "import" }) // null (discharged twice)
85
+ * @example demurrageClock([{ type: "discharged", at: "2024-06-21T19:00:00Z" }, { type: "gatedOut", at: "2024-06-20T14:30:00Z" }], "demurrage", { direction: "import" }) // null (gate-out before discharge)
86
+ * @example demurrageClock([{ type: "discharged", at: "2024-06-14T19:00:00Z" }, { type: "gatedOut", at: "2024-06-20T14:30:00Z" }], "laytime", { direction: "import" }) // null (not a clock this function selects)
87
+ */
88
+ export function demurrageClock(events, scope, options) {
89
+ try {
90
+ return selectClock(events, scope, options);
91
+ }
92
+ catch {
93
+ // Never throws (Core Rule 3): a hostile
94
+ // argument is invalid input, not an exception.
95
+ return null;
96
+ }
97
+ }
98
+ /** `demurrageClock` without the guard; every read of its arguments happens here. */
99
+ function selectClock(events, scope, options) {
100
+ if (!Array.isArray(events) ||
101
+ !CLOCK_SCOPES.includes(scope) ||
102
+ !isObject(options)) {
103
+ return null;
104
+ }
105
+ const { direction } = options;
106
+ if (direction !== "import" && direction !== "export") {
107
+ return null;
108
+ }
109
+ // An explicit `undefined` is an omission (TC39 GetOption); `null` is a value, and not a valid one.
110
+ const startEvent = options.startEvent === undefined ? "discharged" : options.startEvent;
111
+ if (!CLOCK_START_EVENTS.includes(startEvent)) {
112
+ return null;
113
+ }
114
+ // `Array.from` turns a hole into `undefined`, which the check rejects; `every` would skip it.
115
+ const list = Array.from(events);
116
+ const wellFormed = list.every((event) => isObject(event) &&
117
+ CLOCK_EVENT_TYPES.includes(event.type) &&
118
+ typeof event.at === "string" &&
119
+ isValidInstant(event.at));
120
+ if (!wellFormed) {
121
+ return null;
122
+ }
123
+ const [startType, endType] = direction === "export"
124
+ ? EXPORT_CLOCKS[scope]
125
+ : scope === "detention"
126
+ ? ["gatedOut", "emptyReturned"]
127
+ : [startEvent, scope === "combined" ? "emptyReturned" : "gatedOut"];
128
+ const only = (type) => {
129
+ const matches = list.filter((event) => event.type === type);
130
+ return matches.length === 1 ? matches[0].at : null;
131
+ };
132
+ const start = only(startType);
133
+ const end = only(endType);
134
+ if (start === null || end === null) {
135
+ return null;
136
+ }
137
+ return Temporal.Instant.compare(Temporal.Instant.from(start), Temporal.Instant.from(end)) > 0
138
+ ? null
139
+ : { start, end };
140
+ }