@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.
- package/README.md +289 -8
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/instant/convert/fromOffsetInstant.js +3 -11
- package/dist/instant/convert/toOffsetInstant.d.ts +23 -0
- package/dist/instant/convert/toOffsetInstant.js +2 -25
- package/dist/intermodal/calculate/billingTimeline.d.ts +94 -0
- package/dist/intermodal/calculate/billingTimeline.js +168 -0
- package/dist/intermodal/calculate/chargeableDays.d.ts +98 -0
- package/dist/intermodal/calculate/chargeableDays.js +101 -0
- package/dist/intermodal/calculate/demurrageClock.d.ts +93 -0
- package/dist/intermodal/calculate/demurrageClock.js +140 -0
- package/dist/intermodal/calculate/freeTimeExpiry.d.ts +66 -0
- package/dist/intermodal/calculate/freeTimeExpiry.js +82 -0
- package/dist/intermodal/calculate/index.d.ts +4 -0
- package/dist/intermodal/calculate/index.js +4 -0
- package/dist/intermodal/index.d.ts +2 -0
- package/dist/intermodal/index.js +3 -0
- package/dist/internal/freeTimeLedger.d.ts +93 -0
- package/dist/internal/freeTimeLedger.js +262 -0
- package/dist/internal/index.d.ts +4 -2
- package/dist/internal/index.js +4 -2
- package/dist/internal/isoStringBody.d.ts +26 -0
- package/dist/internal/isoStringBody.js +22 -2
- package/dist/internal/wallClockAtOffset.d.ts +22 -0
- package/dist/internal/wallClockAtOffset.js +26 -0
- package/dist/internal/zonedWallClockDifference.js +5 -4
- package/dist/transport/calculate/dwellTime.d.ts +72 -0
- package/dist/transport/calculate/dwellTime.js +126 -0
- package/dist/transport/calculate/index.d.ts +2 -0
- package/dist/transport/calculate/index.js +2 -0
- package/dist/transport/calculate/transitTime.d.ts +53 -0
- package/dist/transport/calculate/transitTime.js +118 -0
- package/dist/transport/convert/etaAtZone.d.ts +36 -0
- package/dist/transport/convert/etaAtZone.js +53 -0
- package/dist/transport/convert/index.d.ts +1 -0
- package/dist/transport/convert/index.js +1 -0
- package/dist/transport/index.d.ts +3 -0
- package/dist/transport/index.js +4 -0
- package/dist/types/free-time-options.d.ts +34 -0
- package/dist/types/free-time-options.js +0 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/index.js +1 -0
- package/package.json +36 -1
- package/skills/gmt-arithmetic/SKILL.md +1 -1
- package/skills/gmt-basics/SKILL.md +1 -1
- package/skills/gmt-integration/SKILL.md +1 -1
- 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
|
+
}
|