@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
package/README.md
CHANGED
|
@@ -17,8 +17,8 @@ Read the [docs](https://gmt-dox.northguild.workers.dev/), or ask us on [Discord]
|
|
|
17
17
|
|
|
18
18
|
- **100% Temporal, Temporal-first.** GMT is built directly on the TC39 `Temporal` standard (via `@js-temporal/polyfill`) — not a custom, homegrown date/time type system like `@internationalized/date`'s own `CalendarDate`/`ZonedDateTime` classes. No `Date` object anywhere, enforced by 3 dedicated lint packages.
|
|
19
19
|
- **A full replacement for any and all of them.** Luxon, date-fns, Moment.js, and react-aria's `@internationalized/date` don't have parity with each other — GMT covers the combined capabilities of all four in one library, plus what none of them do alone.
|
|
20
|
-
- **~
|
|
21
|
-
- **~
|
|
20
|
+
- **~55× more CI test executions than all four competitors combined**: 1,109,250 from 36,975 tests run in all 10 timezones × 3 Node versions, vs. their combined 20,190.
|
|
21
|
+
- **~96× more test cases than `@internationalized/date`**: 36,975 vs. 386 — Adobe's own library, run at its own commit.
|
|
22
22
|
- **The only one of the five that tests systematically across locales in CI at all.** Zero of the four comparison libraries run a locale-test matrix; GMT mandates all 17 locales on every locale-aware function.
|
|
23
23
|
- **The only one that runs its entire suite under a real `TZ` env var across real-world zones.** Luxon and `@internationalized/date` have no CI timezone matrix; date-fns's zone scope is unclear; Moment.js covers 6 zones but not its full suite.
|
|
24
24
|
- **Explicit DST disambiguation control on both construction _and_ arithmetic** — a control none of the others expose.
|
|
@@ -94,7 +94,7 @@ GMT's test suite balances **thoroughness** against **maintenance burden** by tes
|
|
|
94
94
|
- **Non-string input tables** — functions that guard with `typeof x !== "string"` return the same sentinel for `null`, `undefined`, `123`, `true`, `[]`, and `{}`. We test one representative non-string per argument position rather than all six types × N positions. The collapse is safe because all non-string types hit the identical early-return code path.
|
|
95
95
|
- **Redundant permutations** — adjacent/disjoint/reversed interval cases that produce identical results are not duplicated across every function variant. The `plain/`, `zoned/`, `utc/`, and `unix/` families share the same mathematical behavior; each family gets the minimum set of cases needed to prove correctness.
|
|
96
96
|
|
|
97
|
-
**Result:** 36,
|
|
97
|
+
**Result:** 36,975 tests across 674 files that exercise real behavior differences without redundant permutations. They run in CI as 1,109,250 executions — every one of them × 3 Node versions × 10 timezones.
|
|
98
98
|
|
|
99
99
|
## How GMT is tested, vs. the libraries it targets
|
|
100
100
|
|
|
@@ -110,9 +110,9 @@ GMT is measured directly against react-aria's **`@internationalized/date`**, **L
|
|
|
110
110
|
|
|
111
111
|
| Metric | GMT | `@internationalized/date` | Luxon | date-fns | Moment.js |
|
|
112
112
|
| ------------------------------- | -------------------------------------------------- | ------------------------------ | ------------------------------------ | ----------------------------------------- | -------------------------------- |
|
|
113
|
-
| Test files |
|
|
114
|
-
| Individual test cases | **36,
|
|
115
|
-
| Effective CI test<br>executions | **1,
|
|
113
|
+
| Test files | 674 | 6 | 58 / 60<br>(2 didn't run<br>locally) | 256 | 191<br>(52 core +<br>139 locale) |
|
|
114
|
+
| Individual test cases | **36,975** | 386 | 1,222 | 3,213 | 3,901 |
|
|
115
|
+
| Effective CI test<br>executions | **1,109,250**<br>(36,975 × 3 Node<br>× 10 timezones) | 386<br>(×1 Node) | 4,888<br>(1,222 × 4 Node) | 3,213<br>(×1 Node) | 11,703<br>(3,901 × 3 Node) |
|
|
116
116
|
| CI Node.js matrix | 22, 24, 26 | n/a — tests<br>React 16–canary | 20, 22, 24, 25 | not explicit<br>(`node = "latest"`) | LTS, LTS-1,<br>latest |
|
|
117
117
|
| CI timezone matrix | **10 zones × 3**<br>**Node, full suite** | none found | none found | dedicated workflow,<br>zone scope unclear | 6 zones,<br>partial suite only |
|
|
118
118
|
| Locale test matrix | **17 locales**,<br>every locale fn | none found | none found | none found | none found |
|
|
@@ -148,7 +148,7 @@ Specific, sourced claims — not a repeat of the metrics above.
|
|
|
148
148
|
| Only GMT enforces a mandatory<br>17-locale test matrix on every<br>locale-aware function | No CI-level or systematic<br>locale-matrix testing found<br>in any of the four |
|
|
149
149
|
| Only GMT exposes explicit DST<br>disambiguation control on both<br>construction _and_ arithmetic | Luxon's docs call this explicitly<br>undefined; `@internationalized/date`<br>only covers construction, not arithmetic |
|
|
150
150
|
| Only GMT is Temporal-native with<br>zero `Date` usage, enforced by<br>3 dedicated lint packages | Luxon, date-fns, and Moment.js all<br>still wrap or depend on `Date` internally |
|
|
151
|
-
| GMT's effective CI test<br>executions exceed all four<br>competitors **combined**<br>by ~
|
|
151
|
+
| GMT's effective CI test<br>executions exceed all four<br>competitors **combined**<br>by ~55× | 1,109,250 vs. 386 + 4,888 + 3,213<br>+ 11,703 = 20,190 |
|
|
152
152
|
|
|
153
153
|
## Package Layout
|
|
154
154
|
|
|
@@ -160,7 +160,7 @@ namespace objects: a namespace is a subpath.
|
|
|
160
160
|
import { addDate, getNow, formatRelativeZoned, Temporal } from "@northguild/gmt";
|
|
161
161
|
```
|
|
162
162
|
|
|
163
|
-
The
|
|
163
|
+
The fourteen namespace subpaths:
|
|
164
164
|
|
|
165
165
|
- `@northguild/gmt/calendar`: ISO week and ordinal dates, quarter and fiscal periods, zone-aware bucketing, and business calendars with holiday sets and roll conventions
|
|
166
166
|
- `@northguild/gmt/duration`: ISO 8601 duration string parsing, validation, and arithmetic
|
|
@@ -169,6 +169,8 @@ The twelve namespace subpaths:
|
|
|
169
169
|
- `@northguild/gmt/plain`: timezone-free helpers
|
|
170
170
|
- `@northguild/gmt/precision`: nanosecond (`bigint`) instants, their JSON bridge, storage truncation, and foreign epoch bridges
|
|
171
171
|
- `@northguild/gmt/span`: elapsed and wall-clock durations between two timestamps, as raw numbers
|
|
172
|
+
- `@northguild/gmt/transport`: transit legs as exact elapsed time, arrivals rendered where they land, and dwell measured in local calendar days
|
|
173
|
+
- `@northguild/gmt/intermodal`: free time, demurrage and detention counted in the terminal's local days, with the charged dates behind every count, and the invoice, dispute and resolution deadline chain with every window a caller parameter
|
|
172
174
|
- `@northguild/gmt/zoned`: timezone-aware helpers
|
|
173
175
|
- `@northguild/gmt/unix`: Unix epoch (seconds or milliseconds) helpers
|
|
174
176
|
- `@northguild/gmt/utc`: UTC instant helpers
|
|
@@ -2179,6 +2181,283 @@ isValidInterval({ start: "2024-01-01T17:00:00Z", end: "2024-01-01T09:00:00Z" });
|
|
|
2179
2181
|
is both a legitimate result (a fully covered subtraction) and the invalid-input sentinel.
|
|
2180
2182
|
`isValidInterval` tells them apart.
|
|
2181
2183
|
|
|
2184
|
+
### Transport legs and dwell
|
|
2185
|
+
|
|
2186
|
+
Every transport mode shares three operations: add a leg's duration to a departure, show the
|
|
2187
|
+
arrival in the zone where it lands, and measure how long something sat at a terminal, berth,
|
|
2188
|
+
gate or yard. Every mode also gets the same two things wrong: a leg across a DST transition is a
|
|
2189
|
+
fixed number of *elapsed* hours, and dwell is charged in *local calendar days*, not hours.
|
|
2190
|
+
|
|
2191
|
+
```typescript
|
|
2192
|
+
import { dwellTime, etaAtZone, transitTime } from "@northguild/gmt";
|
|
2193
|
+
|
|
2194
|
+
// A leg is exact time in the departure's own zone. Four elapsed hours across the
|
|
2195
|
+
// spring-forward: the wall clock reads 04:00, not 03:00.
|
|
2196
|
+
transitTime("2024-03-09T23:00:00-05:00[America/New_York]", "PT4H");
|
|
2197
|
+
// "2024-03-10T04:00:00-04:00[America/New_York]"
|
|
2198
|
+
|
|
2199
|
+
// A day is 24 exact hours, not "the same wall time tomorrow".
|
|
2200
|
+
transitTime("2024-03-09T12:00:00-05:00[America/New_York]", "P1D");
|
|
2201
|
+
// "2024-03-10T13:00:00-04:00[America/New_York]"
|
|
2202
|
+
|
|
2203
|
+
// The departure's form is preserved: Z stays Z, an offset stays an offset.
|
|
2204
|
+
transitTime("2024-06-15T10:00:00Z", "PT36H"); // "2024-06-16T22:00:00Z"
|
|
2205
|
+
transitTime("2024-06-15T10:00:00+09:00", "PT1H"); // "2024-06-15T11:00:00+09:00"
|
|
2206
|
+
transitTime("2024-06-15T10:00:00+05:30:15", "PT1H"); // "2024-06-15T11:00:00+05:30:15"
|
|
2207
|
+
|
|
2208
|
+
// Calendar units need a reference point; a zone that contradicts its offset is rejected.
|
|
2209
|
+
transitTime("2024-06-15T10:00:00Z", "P1M"); // ""
|
|
2210
|
+
transitTime("2024-06-15T10:00:00-05:00[America/New_York]", "PT2H"); // "" (June is -04:00)
|
|
2211
|
+
|
|
2212
|
+
// Render a moment where it will be read. No disambiguation arises: on a fall-back night
|
|
2213
|
+
// two arrivals an hour apart print the same wall time, and the offset tells them apart.
|
|
2214
|
+
etaAtZone("2024-06-15T12:30:00Z", "Asia/Tokyo"); // "2024-06-15T21:30:00+09:00[Asia/Tokyo]"
|
|
2215
|
+
etaAtZone("2024-11-03T05:30:00Z", "America/New_York"); // "2024-11-03T01:30:00-04:00[America/New_York]"
|
|
2216
|
+
etaAtZone("2024-11-03T06:30:00Z", "America/New_York"); // "2024-11-03T01:30:00-05:00[America/New_York]"
|
|
2217
|
+
|
|
2218
|
+
// Dwell: exact duration, zone-local entry and exit, and the local calendar days touched.
|
|
2219
|
+
dwellTime("2024-06-15T22:30:00Z", "2024-06-16T01:00:00Z", "Europe/London");
|
|
2220
|
+
// { duration: "PT2H30M",
|
|
2221
|
+
// enter: "2024-06-15T23:30:00+01:00[Europe/London]",
|
|
2222
|
+
// exit: "2024-06-16T02:00:00+01:00[Europe/London]",
|
|
2223
|
+
// calendarDays: 2 }
|
|
2224
|
+
|
|
2225
|
+
// The same two instants, one fewer day: the zone decides.
|
|
2226
|
+
dwellTime("2024-06-15T22:30:00Z", "2024-06-16T01:00:00Z", "Europe/Amsterdam");
|
|
2227
|
+
// { duration: "PT2H30M",
|
|
2228
|
+
// enter: "2024-06-16T00:30:00+02:00[Europe/Amsterdam]",
|
|
2229
|
+
// exit: "2024-06-16T03:00:00+02:00[Europe/Amsterdam]",
|
|
2230
|
+
// calendarDays: 1 }
|
|
2231
|
+
|
|
2232
|
+
// The zone comes from targetZone or from the entry's bracketed zone. Bare instants with
|
|
2233
|
+
// neither return null: an offset is not a place, and a day count needs one.
|
|
2234
|
+
dwellTime("2024-06-15T22:30:00+01:00[Europe/London]", "2024-06-16T02:00:00+01:00[Europe/London]");
|
|
2235
|
+
// { duration: "PT3H30M",
|
|
2236
|
+
// enter: "2024-06-15T22:30:00+01:00[Europe/London]",
|
|
2237
|
+
// exit: "2024-06-16T02:00:00+01:00[Europe/London]",
|
|
2238
|
+
// calendarDays: 2 }
|
|
2239
|
+
dwellTime("2024-06-15T22:30:00Z", "2024-06-16T01:00:00Z"); // null
|
|
2240
|
+
|
|
2241
|
+
// Inverted intervals return null.
|
|
2242
|
+
dwellTime("2024-06-16T01:00:00Z", "2024-06-15T22:30:00Z", "Europe/London"); // null
|
|
2243
|
+
```
|
|
2244
|
+
|
|
2245
|
+
- **`transitTime` adds exact time.** Hours, minutes, seconds and fractions are elapsed time and a
|
|
2246
|
+
`D` component is 24 hours exactly, so the arrival wall clock reflects any DST shift in between.
|
|
2247
|
+
Years, months and weeks return `""`: no leg takes "a month" without a reference point. A
|
|
2248
|
+
negative duration moves backwards, which is how a departure is recovered from an arrival.
|
|
2249
|
+
- **`etaAtZone` renders a moment.** The input is an instant (`Z`, an offset, or an offset with a
|
|
2250
|
+
bracketed zone), and only the instant is read; `targetZone` is where it is rendered. The zone
|
|
2251
|
+
is the caller's fact: GMT does not resolve a port, airport or station code to a timezone. A
|
|
2252
|
+
fixed offset such as `"+09:00"` is accepted as a zone, as `isValidTimeZone` accepts it.
|
|
2253
|
+
`dwellTime` accepts one as `targetZone` too, and counts days in that offset, which observes
|
|
2254
|
+
no DST.
|
|
2255
|
+
- **`dwellTime.calendarDays` counts local dates, not hours.** It is the number of distinct
|
|
2256
|
+
local dates the half-open interval `[entry, exit)` touches: same date is `1`, across one
|
|
2257
|
+
local midnight is `2`, and an exit exactly at local midnight does not touch the new day. It
|
|
2258
|
+
is counted by walking the zone's real transitions, so a 23- or 25-hour local day is one day,
|
|
2259
|
+
a date the zone deleted (`Pacific/Apia`, 2011-12-30) is never touched, and a date the clock
|
|
2260
|
+
falls back into (`America/Goose_Bay`, 2010-11-07) is counted once. `duration` is the exact elapsed time with hours as the largest unit, as
|
|
2261
|
+
`sumIntervals` reports it.
|
|
2262
|
+
|
|
2263
|
+
### Free time and demurrage
|
|
2264
|
+
|
|
2265
|
+
Free time is the money calculation in container logistics, and none of its terms is a fact about
|
|
2266
|
+
the port or fixed by a world standard. How many days are free, whether the day of discharge is
|
|
2267
|
+
free day one, how free days and charged days are counted, and which clock a charge runs on are set
|
|
2268
|
+
by the carrier's tariff and the service contract. DCSA defines what demurrage, detention and
|
|
2269
|
+
storage are, but not how their days are counted. So every term is a parameter, no counting term has a default, and what comes back is the free-time window and the specific dates charged.
|
|
2270
|
+
|
|
2271
|
+
```typescript
|
|
2272
|
+
import { chargeableDays, demurrageClock, freeTimeExpiry } from "@northguild/gmt";
|
|
2273
|
+
|
|
2274
|
+
const tariff = { basis: "calendar", chargeBasis: "calendar", timeZone: "America/New_York", firstDay: "eventDay" };
|
|
2275
|
+
|
|
2276
|
+
// A Friday afternoon discharge in New York with three free calendar days: the weekend is
|
|
2277
|
+
// consumed, and free time ends at Monday 00:00 local.
|
|
2278
|
+
freeTimeExpiry("2024-06-14T19:00:00Z", 3, tariff);
|
|
2279
|
+
// { freeTimeStart: "2024-06-14", lastFreeDay: "2024-06-16", expiresAt: "2024-06-17T04:00:00Z" }
|
|
2280
|
+
|
|
2281
|
+
// The same tariff read the other way: one more day. firstDay has no default.
|
|
2282
|
+
freeTimeExpiry("2024-06-14T19:00:00Z", 3, { ...tariff, firstDay: "nextDay" });
|
|
2283
|
+
// { freeTimeStart: "2024-06-15", lastFreeDay: "2024-06-17", expiresAt: "2024-06-18T04:00:00Z" }
|
|
2284
|
+
freeTimeExpiry("2024-06-14T19:00:00Z", 3, { basis: "calendar", timeZone: "America/New_York" }); // null
|
|
2285
|
+
|
|
2286
|
+
// Working days skip the weekend, and a terminal holiday moves expiry a day.
|
|
2287
|
+
const terminal = { weekend: [6, 7], holidays: [], timeZone: "America/New_York" };
|
|
2288
|
+
freeTimeExpiry("2024-06-14T19:00:00Z", 3, { ...tariff, basis: "working", calendar: terminal });
|
|
2289
|
+
// { freeTimeStart: "2024-06-14", lastFreeDay: "2024-06-18", expiresAt: "2024-06-19T04:00:00Z" }
|
|
2290
|
+
freeTimeExpiry("2024-06-14T19:00:00Z", 3, { ...tariff, basis: "working", calendar: { ...terminal, holidays: ["2024-06-17"] } });
|
|
2291
|
+
// { freeTimeStart: "2024-06-14", lastFreeDay: "2024-06-19", expiresAt: "2024-06-20T04:00:00Z" }
|
|
2292
|
+
|
|
2293
|
+
// Expiry is half-open: out exactly as free time ends is free, one second later is a charged day,
|
|
2294
|
+
// and the date is listed.
|
|
2295
|
+
chargeableDays("2024-06-14T19:00:00Z", "2024-06-17T04:00:00Z", 3, tariff);
|
|
2296
|
+
// { freeDaysUsed: 3, chargeableDays: 0, expiresAt: "2024-06-17T04:00:00Z",
|
|
2297
|
+
// chargedDates: [], byTier: [{ from: 1, to: null, days: 0 }] }
|
|
2298
|
+
chargeableDays("2024-06-14T19:00:00Z", "2024-06-17T04:00:01Z", 3, tariff);
|
|
2299
|
+
// { freeDaysUsed: 3, chargeableDays: 1, expiresAt: "2024-06-17T04:00:00Z",
|
|
2300
|
+
// chargedDates: ["2024-06-17"], byTier: [{ from: 1, to: null, days: 1 }] }
|
|
2301
|
+
|
|
2302
|
+
// Charged days are counted on their own basis. Where a tariff grants free time in working days,
|
|
2303
|
+
// the days after it are mostly charged as calendar days; some tariffs charge working days only.
|
|
2304
|
+
const juneteenth = { ...terminal, holidays: ["2024-06-19"] };
|
|
2305
|
+
chargeableDays("2024-06-14T19:00:00Z", "2024-06-24T15:00:00Z", 3, { ...tariff, basis: "working", calendar: juneteenth });
|
|
2306
|
+
// { freeDaysUsed: 3, chargeableDays: 6, expiresAt: "2024-06-19T04:00:00Z",
|
|
2307
|
+
// chargedDates: ["2024-06-19", "2024-06-20", "2024-06-21", "2024-06-22", "2024-06-23", "2024-06-24"],
|
|
2308
|
+
// byTier: [{ from: 1, to: null, days: 6 }] }
|
|
2309
|
+
chargeableDays("2024-06-14T19:00:00Z", "2024-06-24T15:00:00Z", 3, { ...tariff, basis: "working", chargeBasis: "working", calendar: juneteenth });
|
|
2310
|
+
// { freeDaysUsed: 3, chargeableDays: 3, expiresAt: "2024-06-19T04:00:00Z",
|
|
2311
|
+
// chargedDates: ["2024-06-20", "2024-06-21", "2024-06-24"],
|
|
2312
|
+
// byTier: [{ from: 1, to: null, days: 3 }] }
|
|
2313
|
+
|
|
2314
|
+
// Tiers are day bands, not rates: twelve charged days in bands of 5, 5 and the rest.
|
|
2315
|
+
chargeableDays("2024-06-14T19:00:00Z", "2024-06-28T15:00:00Z", 3, { ...tariff, tiers: [5, 10] });
|
|
2316
|
+
// { freeDaysUsed: 3, chargeableDays: 12, expiresAt: "2024-06-17T04:00:00Z",
|
|
2317
|
+
// chargedDates: ["2024-06-17", "2024-06-18", "2024-06-19", "2024-06-20", "2024-06-21",
|
|
2318
|
+
// "2024-06-22", "2024-06-23", "2024-06-24", "2024-06-25", "2024-06-26",
|
|
2319
|
+
// "2024-06-27", "2024-06-28"],
|
|
2320
|
+
// byTier: [{ from: 1, to: 5, days: 5 }, { from: 6, to: 10, days: 5 }, { from: 11, to: null, days: 2 }] }
|
|
2321
|
+
|
|
2322
|
+
// Which two events a charge runs between, on the import or the export leg.
|
|
2323
|
+
const imported = [
|
|
2324
|
+
{ type: "discharged", at: "2024-06-14T19:00:00Z" },
|
|
2325
|
+
{ type: "available", at: "2024-06-15T12:00:00Z" },
|
|
2326
|
+
{ type: "gatedOut", at: "2024-06-20T14:30:00Z" },
|
|
2327
|
+
{ type: "emptyReturned", at: "2024-06-27T09:00:00Z" },
|
|
2328
|
+
];
|
|
2329
|
+
demurrageClock(imported, "demurrage", { direction: "import" }); // { start: "2024-06-14T19:00:00Z", end: "2024-06-20T14:30:00Z" }
|
|
2330
|
+
demurrageClock(imported, "detention", { direction: "import" }); // { start: "2024-06-20T14:30:00Z", end: "2024-06-27T09:00:00Z" }
|
|
2331
|
+
demurrageClock(imported, "combined", { direction: "import" }); // { start: "2024-06-14T19:00:00Z", end: "2024-06-27T09:00:00Z" }
|
|
2332
|
+
demurrageClock(imported, "demurrage", { direction: "import", startEvent: "available" }); // { start: "2024-06-15T12:00:00Z", end: "2024-06-20T14:30:00Z" }
|
|
2333
|
+
const exported = [
|
|
2334
|
+
{ type: "emptyReleased", at: "2024-07-01T08:00:00Z" },
|
|
2335
|
+
{ type: "gatedIn", at: "2024-07-05T16:00:00Z" },
|
|
2336
|
+
{ type: "loaded", at: "2024-07-09T03:00:00Z" },
|
|
2337
|
+
];
|
|
2338
|
+
demurrageClock(exported, "demurrage", { direction: "export" }); // { start: "2024-07-05T16:00:00Z", end: "2024-07-09T03:00:00Z" }
|
|
2339
|
+
demurrageClock(exported, "detention", { direction: "export" }); // { start: "2024-07-01T08:00:00Z", end: "2024-07-05T16:00:00Z" }
|
|
2340
|
+
demurrageClock([{ type: "discharged", at: "2024-06-14T19:00:00Z" }], "demurrage", { direction: "import" }); // null (no gate-out yet)
|
|
2341
|
+
```
|
|
2342
|
+
|
|
2343
|
+
- **Days are the terminal's local days.** `clockStart` and `clockEnd` are instants; their local
|
|
2344
|
+
dates in `options.timeZone` are what is counted, over the zone's real day boundaries, the same
|
|
2345
|
+
ones `dwellTime` and `floorToZone` find. A 23- or 25-hour day is one day, a date the zone deleted
|
|
2346
|
+
is never a free or charged day, and a date the clock falls back into counts once. On the calendar
|
|
2347
|
+
basis with `firstDay: "eventDay"`, `freeDaysUsed + chargeableDays` is exactly
|
|
2348
|
+
`dwellTime(...).calendarDays` for the same dwell, except that a fall-back re-entering the day
|
|
2349
|
+
before the event day (Goose Bay, 7 November 2010) is a date `dwellTime` counts and a tariff
|
|
2350
|
+
never does.
|
|
2351
|
+
- **`firstDay` has no default.** `"eventDay"` makes the event day free day one; `"nextDay"` starts
|
|
2352
|
+
free time the following counted day. The two differ by a full day of charges, so omitting it
|
|
2353
|
+
returns `null`.
|
|
2354
|
+
- **`basis` counts free days; `chargeBasis` counts charged days.** `"calendar"` counts every local
|
|
2355
|
+
day; `"working"` counts only the working days of `options.calendar`, a `BusinessCalendar` (its
|
|
2356
|
+
weekend and holidays; its `timeZone` is not read), and returns `null` without one. Many tariffs
|
|
2357
|
+
count both in calendar days. Where a tariff grants free time in working days, the days after it
|
|
2358
|
+
are mostly charged as calendar days; some tariffs charge working days only. Neither has a default.
|
|
2359
|
+
"Calendar days excluding bank holidays" is `"working"` with an empty `weekend`.
|
|
2360
|
+
- **Expiry is half-open.** `expiresAt` is the first instant of the local day after `lastFreeDay`, as
|
|
2361
|
+
a UTC instant. A gate-out at exactly `expiresAt` is not a chargeable day; one nanosecond later is.
|
|
2362
|
+
- **`chargedDates` makes the count auditable.** It is the list a carrier's day-numbered tariff grid
|
|
2363
|
+
is applied to, and the dates an itemised invoice can list.
|
|
2364
|
+
- **Tiers are day bands, not rates.** `tiers: [5, 10]` names days 1–5, 6–10 and 11 onward; `byTier`
|
|
2365
|
+
says how many charged days fell in each band, empty bands included, so a rate table applies by
|
|
2366
|
+
index. Omitted, it is the single open band. GMT computes days, never money.
|
|
2367
|
+
- **`freeDays: 0`** is a tariff with no free time: `chargeableDays` charges every counted day from day
|
|
2368
|
+
one, and `freeTimeExpiry` returns `null` because there is no last free day to name.
|
|
2369
|
+
- **`demurrageClock` selects the pair, per leg.** DCSA's glossary puts demurrage inside the terminal
|
|
2370
|
+
or depot and detention outside it; the carriers' published tariffs agree on the events. Import: demurrage and
|
|
2371
|
+
storage run from `startEvent` (`"discharged"` by default, or `"available"`) to `gatedOut`, detention
|
|
2372
|
+
from `gatedOut` to `emptyReturned`, and the combined clock from `startEvent` to `emptyReturned`.
|
|
2373
|
+
Export: demurrage and storage run from `gatedIn` to `loaded`, detention from `emptyReleased` to
|
|
2374
|
+
`gatedIn`, and the combined clock from `emptyReleased` to `loaded`. `direction` has no default. The
|
|
2375
|
+
event names follow DCSA's Track & Trace equipment events (DISC, GTOT, GTIN, LOAD). A required event
|
|
2376
|
+
that is missing or present twice, or an end before its start, returns `null`, and the result is an
|
|
2377
|
+
`Interval` in the caller's own strings, ready for `chargeableDays(start, end, …)`.
|
|
2378
|
+
|
|
2379
|
+
### Billing deadlines
|
|
2380
|
+
|
|
2381
|
+
A billing regime or service contract can set up to three windows around a demurrage or
|
|
2382
|
+
detention invoice: one to issue it, counted from an anchor date; one to dispute it, counted from
|
|
2383
|
+
issuance; and one to resolve the dispute, counted from the request, unless the parties
|
|
2384
|
+
agree a date instead. The number of days in each is the caller's fact; GMT carries none of them. The arithmetic
|
|
2385
|
+
is the function's: each deadline is a date, counted in calendar days from a date, with the anchor
|
|
2386
|
+
as day zero and the deadline day itself inside the window.
|
|
2387
|
+
|
|
2388
|
+
```typescript
|
|
2389
|
+
import { billingTimeline, convertUtcToPlainDate } from "@northguild/gmt";
|
|
2390
|
+
|
|
2391
|
+
const windows = { issueDays: 30, disputeDays: 30, resolutionDays: 30 };
|
|
2392
|
+
|
|
2393
|
+
// Charges last accrued on 1 March. Day 30 is the last day by the deadline; day 31 is not.
|
|
2394
|
+
billingTimeline({ anchorOn: "2026-03-01", invoiceIssuedOn: "2026-03-31" }, windows);
|
|
2395
|
+
// { invoiceDeadline: "2026-03-31", issuedByDeadline: true, disputeDeadline: "2026-04-30",
|
|
2396
|
+
// requestedByDeadline: null, resolutionDeadline: null }
|
|
2397
|
+
billingTimeline({ anchorOn: "2026-03-01", invoiceIssuedOn: "2026-04-01" }, windows);
|
|
2398
|
+
// { invoiceDeadline: "2026-03-31", issuedByDeadline: false, disputeDeadline: "2026-05-01",
|
|
2399
|
+
// requestedByDeadline: null, resolutionDeadline: null }
|
|
2400
|
+
|
|
2401
|
+
// No invoice yet: a forecast. Only the invoice deadline is known.
|
|
2402
|
+
billingTimeline({ anchorOn: "2026-03-01" }, windows);
|
|
2403
|
+
// { invoiceDeadline: "2026-03-31", issuedByDeadline: null, disputeDeadline: null,
|
|
2404
|
+
// requestedByDeadline: null, resolutionDeadline: null }
|
|
2405
|
+
|
|
2406
|
+
// A dispute received on the dispute deadline, and one received a day later with a resolution
|
|
2407
|
+
// date the parties agreed, which replaces the computed one.
|
|
2408
|
+
billingTimeline({ anchorOn: "2026-03-01", invoiceIssuedOn: "2026-03-20", requestReceivedOn: "2026-04-19" }, windows);
|
|
2409
|
+
// { invoiceDeadline: "2026-03-31", issuedByDeadline: true, disputeDeadline: "2026-04-19",
|
|
2410
|
+
// requestedByDeadline: true, resolutionDeadline: "2026-05-19" }
|
|
2411
|
+
billingTimeline({ anchorOn: "2026-03-01", invoiceIssuedOn: "2026-03-20", requestReceivedOn: "2026-04-20" }, { ...windows, agreedResolutionOn: "2026-06-01" });
|
|
2412
|
+
// { invoiceDeadline: "2026-03-31", issuedByDeadline: true, disputeDeadline: "2026-04-19",
|
|
2413
|
+
// requestedByDeadline: false, resolutionDeadline: "2026-06-01" }
|
|
2414
|
+
|
|
2415
|
+
// A re-bill is anchored on the invoice it received (issued 10 March), not on the charge.
|
|
2416
|
+
billingTimeline({ anchorOn: "2026-03-10", invoiceIssuedOn: "2026-04-05" }, windows);
|
|
2417
|
+
// { invoiceDeadline: "2026-04-09", issuedByDeadline: true, disputeDeadline: "2026-05-05",
|
|
2418
|
+
// requestedByDeadline: null, resolutionDeadline: null }
|
|
2419
|
+
|
|
2420
|
+
// A 14/14/45 service contract through the same chain: the numbers are the caller's.
|
|
2421
|
+
billingTimeline({ anchorOn: "2026-03-01", invoiceIssuedOn: "2026-03-05", requestReceivedOn: "2026-03-18" }, { issueDays: 14, disputeDays: 14, resolutionDays: 45 });
|
|
2422
|
+
// { invoiceDeadline: "2026-03-15", issuedByDeadline: true, disputeDeadline: "2026-03-19",
|
|
2423
|
+
// requestedByDeadline: true, resolutionDeadline: "2026-05-02" }
|
|
2424
|
+
|
|
2425
|
+
// Windows have no defaults.
|
|
2426
|
+
billingTimeline({ anchorOn: "2026-03-01", invoiceIssuedOn: "2026-03-31" }, { disputeDays: 30, resolutionDays: 30 }); // null
|
|
2427
|
+
|
|
2428
|
+
// Deadlines are dates. Reduce an instant to the billing party's local date first.
|
|
2429
|
+
convertUtcToPlainDate("2024-06-17T04:00:01Z", { timeZone: "America/New_York" }); // "2024-06-17"
|
|
2430
|
+
```
|
|
2431
|
+
|
|
2432
|
+
- **Day zero is the anchor and the deadline is `anchor + days`** on the ISO calendar
|
|
2433
|
+
(`Temporal.PlainDate.add`); a date is by the deadline when it is on or before it
|
|
2434
|
+
(`Temporal.PlainDate.compare(date, deadline) <= 0`). That is the function's stated contract, not
|
|
2435
|
+
a reading of any rule; a regime that counts differently passes a different number. Thirty days
|
|
2436
|
+
across a leap day, a month end, a year end or a DST change is thirty dates, never thirty times
|
|
2437
|
+
24 hours: `2028-01-30` plus 30 is `2028-02-29`.
|
|
2438
|
+
- **Deadlines are dates, never instants.** Reduce an instant to the billing party's local date
|
|
2439
|
+
first with `convertUtcToPlainDate(instant, { timeZone })`; the function does not guess a zone,
|
|
2440
|
+
and a date-time or an instant passed as a date returns `null`.
|
|
2441
|
+
- **Windows have no defaults.** Each is a safe integer of at least `0`; a missing, negative or
|
|
2442
|
+
non-integer window returns `null`, because a silently defaulted window is a wrong deadline. The
|
|
2443
|
+
same rule as `firstDay`.
|
|
2444
|
+
- **The anchor is whatever date the caller counts from.** The last date a charge accrued is
|
|
2445
|
+
`chargeableDays(...).chargedDates.at(-1)`. A party re-billing a charge it was itself billed
|
|
2446
|
+
passes the issuance date of the invoice it received; the chain is the same.
|
|
2447
|
+
- **The chain fills in as its dates exist.** With only `anchorOn` the result is a forecast:
|
|
2448
|
+
`invoiceDeadline` is set and every other field is `null`. An invoice date sets
|
|
2449
|
+
`issuedByDeadline` and `disputeDeadline`; a request date sets `requestedByDeadline` and
|
|
2450
|
+
`resolutionDeadline`. A request without an invoice, or dated before it, is a data error and
|
|
2451
|
+
returns `null`; an invoice dated before the anchor is allowed, because an invoice may be issued
|
|
2452
|
+
while charges still accrue.
|
|
2453
|
+
- **An agreed date replaces the computed resolution deadline.** `agreedResolutionOn` must be a
|
|
2454
|
+
valid date on or after `requestReceivedOn`, else `null`; it is validated even when no request
|
|
2455
|
+
exists, but cannot act without one. Every emitted date is bare ISO, so an input annotation such
|
|
2456
|
+
as `[u-ca=iso8601]` does not reach the output.
|
|
2457
|
+
- **GMT computes dates, not liability.** `issuedByDeadline` and `requestedByDeadline` compare
|
|
2458
|
+
dates and say nothing else. Whether a charge is payable, whether a dispute must be heard, and
|
|
2459
|
+
any consequence are the consumer's, the sibling of "GMT computes days, never money".
|
|
2460
|
+
|
|
2182
2461
|
## API Surface
|
|
2183
2462
|
|
|
2184
2463
|
For the complete API listing, see the namespace documentation on GitHub:
|
|
@@ -2191,6 +2470,8 @@ For the complete API listing, see the namespace documentation on GitHub:
|
|
|
2191
2470
|
- [Span API](https://github.com/northguild/gmt/tree/main/packages/gmt/src/span) — elapsed and wall-clock durations as raw numbers
|
|
2192
2471
|
- [Calendar API](https://github.com/northguild/gmt/tree/main/packages/gmt/src/calendar) — ISO week and ordinal dates, quarter and fiscal periods, zone-aware bucketing, and business calendars with holiday sets and roll conventions
|
|
2193
2472
|
- [Interval API](https://github.com/northguild/gmt/tree/main/packages/gmt/src/interval) — half-open interval algebra over instants: overlap, intersect, clamp, merge, subtract, split, sum
|
|
2473
|
+
- [Transport API](https://github.com/northguild/gmt/tree/main/packages/gmt/src/transport) — transit legs as exact elapsed time, arrivals rendered in the zone where they land, dwell in local calendar days
|
|
2474
|
+
- [Intermodal API](https://github.com/northguild/gmt/tree/main/packages/gmt/src/intermodal) — free time, demurrage and detention: which clock, which start day, calendar or working days, and the charged dates an itemised invoice can list, plus the deadline chain around the invoice with every window a caller parameter
|
|
2194
2475
|
- [Instant API](https://github.com/northguild/gmt/tree/main/packages/gmt/src/instant) — the instant-plus-offset pair, and explicit local-time resolution
|
|
2195
2476
|
- [UTC API](https://github.com/northguild/gmt/tree/main/packages/gmt/src/utc) — UTC instant utilities
|
|
2196
2477
|
- [Regex API](https://github.com/northguild/gmt/tree/main/packages/gmt/src/regex) — composable regex patterns
|
package/dist/index.d.ts
CHANGED
|
@@ -2,11 +2,13 @@ export * from "@js-temporal/polyfill";
|
|
|
2
2
|
export * from "./calendar/index.js";
|
|
3
3
|
export * from "./duration/index.js";
|
|
4
4
|
export * from "./instant/index.js";
|
|
5
|
+
export * from "./intermodal/index.js";
|
|
5
6
|
export * from "./interval/index.js";
|
|
6
7
|
export * from "./plain/index.js";
|
|
7
8
|
export * from "./precision/index.js";
|
|
8
9
|
export * from "./regex/index.js";
|
|
9
10
|
export * from "./span/index.js";
|
|
11
|
+
export * from "./transport/index.js";
|
|
10
12
|
export * from "./types/index.js";
|
|
11
13
|
export * from "./unix/index.js";
|
|
12
14
|
export * from "./utc/index.js";
|
package/dist/index.js
CHANGED
|
@@ -2,11 +2,13 @@ export * from "@js-temporal/polyfill";
|
|
|
2
2
|
export * from "./calendar/index.js";
|
|
3
3
|
export * from "./duration/index.js";
|
|
4
4
|
export * from "./instant/index.js";
|
|
5
|
+
export * from "./intermodal/index.js";
|
|
5
6
|
export * from "./interval/index.js";
|
|
6
7
|
export * from "./plain/index.js";
|
|
7
8
|
export * from "./precision/index.js";
|
|
8
9
|
export * from "./regex/index.js";
|
|
9
10
|
export * from "./span/index.js";
|
|
11
|
+
export * from "./transport/index.js";
|
|
10
12
|
export * from "./types/index.js";
|
|
11
13
|
export * from "./unix/index.js";
|
|
12
14
|
export * from "./utc/index.js";
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Temporal } from "@js-temporal/polyfill";
|
|
2
|
-
import { formatUtcOffset, parseInstantNanoseconds, parseUtcOffsetNanoseconds, } from "../../internal/index.js";
|
|
2
|
+
import { formatUtcOffset, parseInstantNanoseconds, parseUtcOffsetNanoseconds, wallClockAtOffset, } from "../../internal/index.js";
|
|
3
3
|
import { utcOffset } from "../../regex/index.js";
|
|
4
4
|
/**
|
|
5
5
|
* Render an instant-plus-offset pair back to the local time it describes.
|
|
@@ -75,16 +75,8 @@ export function fromOffsetInstant(value) {
|
|
|
75
75
|
return zoned.offset === offset ? zoned.toString() : "";
|
|
76
76
|
}
|
|
77
77
|
// With no zone, the offset is the only thing that can shift the instant onto a wall
|
|
78
|
-
// clock
|
|
79
|
-
|
|
80
|
-
// would reject the last 14 hours of the representable range — where a pair like
|
|
81
|
-
// `{ "+275760-09-13T00:00:00Z", "+14:00" }` names a local time that exists. No offset
|
|
82
|
-
// time zone is used either, since Temporal refuses to build one from a sub-minute
|
|
83
|
-
// offset.
|
|
84
|
-
const wallClock = instant
|
|
85
|
-
.toZonedDateTimeISO("UTC")
|
|
86
|
-
.toPlainDateTime()
|
|
87
|
-
.add({ nanoseconds: Number(offsetNanoseconds) });
|
|
78
|
+
// clock (`wallClockAtOffset` keeps both range limits and sub-minute offsets).
|
|
79
|
+
const wallClock = wallClockAtOffset(instant, offsetNanoseconds);
|
|
88
80
|
return `${wallClock.toString()}${offset}`;
|
|
89
81
|
}
|
|
90
82
|
catch {
|
|
@@ -1,3 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An absolute instant paired with the local UTC offset in force where the event happened.
|
|
3
|
+
*
|
|
4
|
+
* @remarks Members:
|
|
5
|
+
*
|
|
6
|
+
* | Member | Type | Description |
|
|
7
|
+
* | --- | --- | --- |
|
|
8
|
+
* | `instant` | `string` | UTC instant, ISO 8601 ending in `Z` (e.g. `2024-07-15T16:00:00Z`). Orders events globally. |
|
|
9
|
+
* | `offset` | `string` | UTC offset in force where the event happened, `±HH:MM` (or `±HH:MM:SS` for a pre-1972 zone that did not run on a whole minute). Renders the event as the human on the ground saw it. |
|
|
10
|
+
* | `timeZone` | `string \| undefined` | IANA zone identifier, when one is known. Absent from most feeds, so absent from most pairs. |
|
|
11
|
+
*
|
|
12
|
+
* Neither `instant` nor `offset` derives from the other, which is why both are stored. It is
|
|
13
|
+
* the shape GS1 EPCIS 2.0 (`eventTime` + a required `eventTimeZoneOffset`), UN/EDIFACT DTM
|
|
14
|
+
* (qualifier `303`/`304`) and DICOM (`DT` + `&ZZXX`) all exchange.
|
|
15
|
+
*
|
|
16
|
+
* @example
|
|
17
|
+
* import { OffsetInstant } from "@northguild/gmt/instant";
|
|
18
|
+
* const pair: OffsetInstant = {
|
|
19
|
+
* instant: "2024-07-15T16:00:00Z",
|
|
20
|
+
* offset: "-04:00",
|
|
21
|
+
* timeZone: "America/New_York",
|
|
22
|
+
* };
|
|
23
|
+
*/
|
|
1
24
|
export interface OffsetInstant {
|
|
2
25
|
instant: string;
|
|
3
26
|
offset: string;
|
|
@@ -1,32 +1,9 @@
|
|
|
1
1
|
import { Temporal } from "@js-temporal/polyfill";
|
|
2
|
-
import { formatUtcOffset, isoStringBody, parseInstantNanoseconds, zonedDateTimeFrom, } from "../../internal/index.js";
|
|
2
|
+
import { formatUtcOffset, isoStringBody, parseInstantNanoseconds, TIME_ZONE_ANNOTATION, zonedDateTimeFrom, } from "../../internal/index.js";
|
|
3
3
|
import { utcOffset } from "../../regex/index.js";
|
|
4
4
|
import { isValidTimeZone } from "../../zoned/validate/index.js";
|
|
5
|
-
/**
|
|
6
|
-
* An absolute instant paired with the local UTC offset in force where the event happened.
|
|
7
|
-
*
|
|
8
|
-
* @remarks Members:
|
|
9
|
-
*
|
|
10
|
-
* | Member | Type | Description |
|
|
11
|
-
* | --- | --- | --- |
|
|
12
|
-
* | `instant` | `string` | UTC instant, ISO 8601 ending in `Z` (e.g. `2024-07-15T16:00:00Z`). Orders events globally. |
|
|
13
|
-
* | `offset` | `string` | UTC offset in force where the event happened, `±HH:MM` (or `±HH:MM:SS` for a pre-1972 zone that did not run on a whole minute). Renders the event as the human on the ground saw it. |
|
|
14
|
-
* | `timeZone` | `string \| undefined` | IANA zone identifier, when one is known. Absent from most feeds, so absent from most pairs. |
|
|
15
|
-
*
|
|
16
|
-
* Neither `instant` nor `offset` derives from the other, which is why both are stored. It is
|
|
17
|
-
* the shape GS1 EPCIS 2.0 (`eventTime` + a required `eventTimeZoneOffset`), UN/EDIFACT DTM
|
|
18
|
-
* (qualifier `303`/`304`) and DICOM (`DT` + `&ZZXX`) all exchange.
|
|
19
|
-
*
|
|
20
|
-
* @example
|
|
21
|
-
* import { OffsetInstant } from "@northguild/gmt/instant";
|
|
22
|
-
* const pair: OffsetInstant = {
|
|
23
|
-
* instant: "2024-07-15T16:00:00Z",
|
|
24
|
-
* offset: "-04:00",
|
|
25
|
-
* timeZone: "America/New_York",
|
|
26
|
-
* };
|
|
27
|
-
*/
|
|
28
5
|
/** A leading RFC 9557 time zone annotation: `[zone]` or `[!zone]`, with no `=`. */
|
|
29
|
-
const timeZoneAnnotation =
|
|
6
|
+
const timeZoneAnnotation = new RegExp(`^${TIME_ZONE_ANNOTATION}`);
|
|
30
7
|
/**
|
|
31
8
|
* Split an ISO 8601 instant string into the instant-plus-offset pair the world exchanges.
|
|
32
9
|
*
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/** The dates a billing timeline is counted from: the anchor, and the invoice and request once they exist. */
|
|
2
|
+
export interface BillingDates {
|
|
3
|
+
/**
|
|
4
|
+
* The date the issue window is counted from, as an ISO date, such as the date the charge was
|
|
5
|
+
* last incurred or, for a re-bill, the issuance date of the invoice received.
|
|
6
|
+
*/
|
|
7
|
+
anchorOn: string;
|
|
8
|
+
/** The date the invoice was issued, once it has been. */
|
|
9
|
+
invoiceIssuedOn?: string;
|
|
10
|
+
/** The date the dispute request was received, once it has been. */
|
|
11
|
+
requestReceivedOn?: string;
|
|
12
|
+
}
|
|
13
|
+
/** The three windows the caller supplies, in calendar days, none defaulted, and an optional agreed resolution date. */
|
|
14
|
+
export interface BillingWindows {
|
|
15
|
+
/** Days from `anchorOn` within which the invoice must be issued. */
|
|
16
|
+
issueDays: number;
|
|
17
|
+
/** Days from `invoiceIssuedOn` within which a dispute may be raised. */
|
|
18
|
+
disputeDays: number;
|
|
19
|
+
/** Days from `requestReceivedOn` within which the dispute must be resolved. */
|
|
20
|
+
resolutionDays: number;
|
|
21
|
+
/** A resolution date the parties agreed instead; it replaces the computed one. */
|
|
22
|
+
agreedResolutionOn?: string;
|
|
23
|
+
}
|
|
24
|
+
/** What `billingTimeline` returns: each deadline as an ISO date, and each comparison once its date exists. */
|
|
25
|
+
export interface BillingDeadlines {
|
|
26
|
+
/** `anchorOn + issueDays`. */
|
|
27
|
+
invoiceDeadline: string;
|
|
28
|
+
/** Whether `invoiceIssuedOn` is on or before `invoiceDeadline`; `null` until an invoice date exists. */
|
|
29
|
+
issuedByDeadline: boolean | null;
|
|
30
|
+
/** `invoiceIssuedOn + disputeDays`; `null` until an invoice date exists. */
|
|
31
|
+
disputeDeadline: string | null;
|
|
32
|
+
/** Whether `requestReceivedOn` is on or before `disputeDeadline`; `null` until a request date exists. */
|
|
33
|
+
requestedByDeadline: boolean | null;
|
|
34
|
+
/** `requestReceivedOn + resolutionDays`, or `agreedResolutionOn`; `null` until a request date exists. */
|
|
35
|
+
resolutionDeadline: string | null;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Lay out the deadline chain around a demurrage or detention invoice: the last date it may be
|
|
39
|
+
* issued, the last date it may be disputed, and the last date the dispute must be resolved.
|
|
40
|
+
*
|
|
41
|
+
* A billing regime or service contract can set up to three windows around an invoice: one to
|
|
42
|
+
* issue it, one to dispute it, and one to resolve the dispute. The number of days in each is the
|
|
43
|
+
* caller's fact; GMT carries none of them. The arithmetic is this function's: each deadline is a
|
|
44
|
+
* date, counted in calendar days from a date, with the anchor as day zero and the deadline day
|
|
45
|
+
* itself inside the window.
|
|
46
|
+
*
|
|
47
|
+
* - **Day zero is the anchor and the deadline is `anchor + days`** on the ISO 8601 calendar
|
|
48
|
+
* (`Temporal.PlainDate.add`); a date is "by the deadline" when
|
|
49
|
+
* `Temporal.PlainDate.compare(date, deadline) <= 0`. That is this function's stated contract,
|
|
50
|
+
* not a reading of any rule; a regime that counts differently passes a different number.
|
|
51
|
+
* - **Deadlines are dates, never instants.** Reduce an instant to the billing party's local date
|
|
52
|
+
* first with `convertUtcToPlainDate(instant, { timeZone })`; this function does not guess a
|
|
53
|
+
* zone. `anchorOn` is whatever date the caller counts from, such as the last charged date
|
|
54
|
+
* (`chargeableDays(...).chargedDates.at(-1)`) or, for a party re-billing a charge it was
|
|
55
|
+
* itself billed, the issuance date of the invoice it received.
|
|
56
|
+
* - **Windows have no defaults.** Each is a safe integer of at least `0`; a missing, negative or
|
|
57
|
+
* non-integer window returns `null`, because a silently defaulted window is a wrong deadline.
|
|
58
|
+
* - **The chain fills in as its dates exist.** With only `anchorOn` the result is a forecast:
|
|
59
|
+
* `invoiceDeadline` is set and every other field is `null`. An invoice date sets
|
|
60
|
+
* `issuedByDeadline` and `disputeDeadline`; a request date sets `requestedByDeadline` and
|
|
61
|
+
* `resolutionDeadline`. An explicit `undefined` is an omission; any other value must be a
|
|
62
|
+
* valid ISO date.
|
|
63
|
+
* - **The booleans compare dates and say nothing else.** Whether a charge is payable, whether a
|
|
64
|
+
* dispute must be heard, and any consequence are the consumer's. GMT computes dates, not
|
|
65
|
+
* liability.
|
|
66
|
+
* - **`agreedResolutionOn` replaces the computed `resolutionDeadline`** when given. It must be a
|
|
67
|
+
* valid date on or after `requestReceivedOn`, else `null`; it is validated even when no
|
|
68
|
+
* request exists, but cannot act without one.
|
|
69
|
+
* - **A request cannot precede the invoice it disputes.** `requestReceivedOn` without
|
|
70
|
+
* `invoiceIssuedOn`, or earlier than it, is a data error and returns `null`. An invoice earlier
|
|
71
|
+
* than the anchor is allowed: an invoice may be issued while charges still accrue.
|
|
72
|
+
* - Every emitted date is canonical bare ISO (`Temporal.PlainDate#toString()`), so an input
|
|
73
|
+
* annotation such as `[u-ca=iso8601]` does not reach the output, as `addDate` behaves.
|
|
74
|
+
* - Returns `null` when `dates` or `windows` is not an object, `anchorOn` or a given optional
|
|
75
|
+
* date is not a valid ISO date, a window is missing or not a safe integer of at least `0`, a
|
|
76
|
+
* request exists without an invoice or before it, `agreedResolutionOn` is before the request,
|
|
77
|
+
* or a deadline would leave Temporal's range.
|
|
78
|
+
*
|
|
79
|
+
* @param dates { anchorOn: string, invoiceIssuedOn?: string, requestReceivedOn?: string } ISO dates
|
|
80
|
+
* @param windows { issueDays: number, disputeDays: number, resolutionDays: number, agreedResolutionOn?: string } calendar-day windows, none defaulted
|
|
81
|
+
* @returns { invoiceDeadline, issuedByDeadline, disputeDeadline, requestedByDeadline, resolutionDeadline }, or null on invalid input
|
|
82
|
+
*
|
|
83
|
+
* @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)
|
|
84
|
+
* @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)
|
|
85
|
+
* @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)
|
|
86
|
+
* @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" }
|
|
87
|
+
* @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)
|
|
88
|
+
* @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)
|
|
89
|
+
* @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)
|
|
90
|
+
* @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)
|
|
91
|
+
* @example billingTimeline({ anchorOn: "2026-03-01", invoiceIssuedOn: "2026-03-31" }, { disputeDays: 30, resolutionDays: 30 }) // null (issueDays has no default)
|
|
92
|
+
* @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)
|
|
93
|
+
*/
|
|
94
|
+
export declare function billingTimeline(dates: BillingDates, windows: BillingWindows): BillingDeadlines | null;
|