@northguild/gmt 1.16.0 → 1.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/README.md +289 -8
  2. package/dist/index.d.ts +2 -0
  3. package/dist/index.js +2 -0
  4. package/dist/instant/convert/fromOffsetInstant.js +3 -11
  5. package/dist/instant/convert/toOffsetInstant.d.ts +23 -0
  6. package/dist/instant/convert/toOffsetInstant.js +2 -25
  7. package/dist/intermodal/calculate/billingTimeline.d.ts +94 -0
  8. package/dist/intermodal/calculate/billingTimeline.js +168 -0
  9. package/dist/intermodal/calculate/chargeableDays.d.ts +98 -0
  10. package/dist/intermodal/calculate/chargeableDays.js +101 -0
  11. package/dist/intermodal/calculate/demurrageClock.d.ts +93 -0
  12. package/dist/intermodal/calculate/demurrageClock.js +140 -0
  13. package/dist/intermodal/calculate/freeTimeExpiry.d.ts +66 -0
  14. package/dist/intermodal/calculate/freeTimeExpiry.js +82 -0
  15. package/dist/intermodal/calculate/index.d.ts +4 -0
  16. package/dist/intermodal/calculate/index.js +4 -0
  17. package/dist/intermodal/index.d.ts +2 -0
  18. package/dist/intermodal/index.js +3 -0
  19. package/dist/internal/freeTimeLedger.d.ts +93 -0
  20. package/dist/internal/freeTimeLedger.js +262 -0
  21. package/dist/internal/index.d.ts +4 -2
  22. package/dist/internal/index.js +4 -2
  23. package/dist/internal/isoStringBody.d.ts +26 -0
  24. package/dist/internal/isoStringBody.js +22 -2
  25. package/dist/internal/wallClockAtOffset.d.ts +22 -0
  26. package/dist/internal/wallClockAtOffset.js +26 -0
  27. package/dist/internal/zonedWallClockDifference.js +5 -4
  28. package/dist/transport/calculate/dwellTime.d.ts +72 -0
  29. package/dist/transport/calculate/dwellTime.js +126 -0
  30. package/dist/transport/calculate/index.d.ts +2 -0
  31. package/dist/transport/calculate/index.js +2 -0
  32. package/dist/transport/calculate/transitTime.d.ts +53 -0
  33. package/dist/transport/calculate/transitTime.js +118 -0
  34. package/dist/transport/convert/etaAtZone.d.ts +36 -0
  35. package/dist/transport/convert/etaAtZone.js +53 -0
  36. package/dist/transport/convert/index.d.ts +1 -0
  37. package/dist/transport/convert/index.js +1 -0
  38. package/dist/transport/index.d.ts +3 -0
  39. package/dist/transport/index.js +4 -0
  40. package/dist/types/free-time-options.d.ts +34 -0
  41. package/dist/types/free-time-options.js +0 -0
  42. package/dist/types/index.d.ts +1 -0
  43. package/dist/types/index.js +1 -0
  44. package/package.json +36 -1
  45. package/skills/gmt-arithmetic/SKILL.md +1 -1
  46. package/skills/gmt-basics/SKILL.md +1 -1
  47. package/skills/gmt-integration/SKILL.md +1 -1
  48. package/skills/gmt-timezone/SKILL.md +79 -12
@@ -0,0 +1,66 @@
1
+ import type { FreeTimeOptions } from "../../types/index.js";
2
+ /** What `freeTimeExpiry` returns: the free-time window as local dates, and the instant it ends. */
3
+ export interface FreeTime {
4
+ /** Free day one, as a local date in the counting zone. */
5
+ freeTimeStart: string;
6
+ /** The last free day, as a local date in the counting zone. */
7
+ lastFreeDay: string;
8
+ /** The instant free time ends: the start of the local day after `lastFreeDay`, as a UTC instant. */
9
+ expiresAt: string;
10
+ }
11
+ /**
12
+ * Lay out a container's free time from the instant its clock started, and say when it ends.
13
+ *
14
+ * Free time is the money calculation in container logistics, and none of its terms is a fact
15
+ * about the port: the number of free days, whether the day of discharge is free day one, and
16
+ * whether weekends count are all set by the carrier's tariff or the service contract. So every
17
+ * term is a parameter. No regulation or industry standard fixes how free time is counted (DCSA types only its unit: calendar days, working days, hours or day of discharge; GMT counts
18
+ * days); the result is the window every tariff states, and what an invoice can print about it:
19
+ * the free time allowed and the dates it starts and ends.
20
+ *
21
+ * - **Days are the terminal's local days.** `clockStart` is an instant (`Z`, an offset, or a
22
+ * bracketed zone, of which only the instant is read); its local date in `options.timeZone` is
23
+ * the event day. A container discharged at 23:00 has used a free day by 00:01 under
24
+ * `firstDay: "eventDay"`. Day boundaries are the zone's real ones, as `floorToZone` and
25
+ * `dwellTime` find them: a 23- or 25-hour day is one day, a date the zone deleted is never a
26
+ * free day, and a fall-back that re-enters an earlier date never moves the count backwards.
27
+ * - **`firstDay` has no default.** `"eventDay"` makes the event day free day one; `"nextDay"`
28
+ * starts free time on the following counted day. The two differ by exactly one day of charges,
29
+ * and a silent default here is a wrong invoice, so omitting it returns `null`.
30
+ * - **`basis` decides which days count.** `"calendar"` counts every local day, so a weekend
31
+ * burns two free days while the terminal is shut. `"working"` counts only the working days of
32
+ * `options.calendar` (its weekend and holidays; `calendar.timeZone` is not read), and is
33
+ * required to have one: a working-day count without a calendar is a guess and returns `null`.
34
+ * Under `"working"` a Saturday discharge starts free time on Monday under either `firstDay`.
35
+ * A tariff counting "calendar days excluding bank holidays" (Hapag-Lloyd Japan, MSC Vietnam) is
36
+ * `"working"` with an empty `weekend`. How days after expiry are charged is a separate term;
37
+ * see `chargeableDays`'s `chargeBasis`.
38
+ * - **`expiresAt` is half-open.** It is the first instant of the local day after `lastFreeDay`,
39
+ * as a UTC instant. A gate-out at exactly `expiresAt` is not a chargeable day; see
40
+ * `chargeableDays`. Under `"working"` it can fall on a weekend, which is then simply not
41
+ * chargeable.
42
+ * - `freeDays` is a whole number of at least `1`: with no free days there is no last free day
43
+ * to name, and `chargeableDays` accepts `0` for that tariff.
44
+ * - Returns `null` when `clockStart` is not an instant, `freeDays` is not a whole number of at
45
+ * least `1`, `options` is not an object, `basis` or `firstDay` is missing or unknown,
46
+ * `timeZone` is invalid, `basis` is `"working"` without a valid `BusinessCalendar`, or the walk
47
+ * would pass 10,000 local days.
48
+ *
49
+ * @param clockStart ISO 8601 instant string of the event that starts the clock (discharge, availability or gate-out)
50
+ * @param freeDays whole number of free days the tariff allows, at least 1
51
+ * @param options { basis: "calendar" | "working", timeZone: string, firstDay: "eventDay" | "nextDay", calendar?: BusinessCalendar }
52
+ * @returns the free-time start and last free day as local dates, and the expiry instant, or null on invalid input
53
+ *
54
+ * @example freeTimeExpiry("2024-06-14T19:00:00Z", 3, { basis: "calendar", timeZone: "America/New_York", firstDay: "eventDay" }) // { freeTimeStart: "2024-06-14", lastFreeDay: "2024-06-16", expiresAt: "2024-06-17T04:00:00Z" } (a Friday discharge: the weekend is consumed)
55
+ * @example freeTimeExpiry("2024-06-14T19:00:00Z", 3, { basis: "calendar", timeZone: "America/New_York", firstDay: "nextDay" }) // { freeTimeStart: "2024-06-15", lastFreeDay: "2024-06-17", expiresAt: "2024-06-18T04:00:00Z" } (the same tariff read the other way: one more day)
56
+ * @example freeTimeExpiry("2024-06-14T19:00:00Z", 3, { basis: "working", timeZone: "America/New_York", firstDay: "eventDay", calendar: { weekend: [6, 7], holidays: [], timeZone: "America/New_York" } }) // { freeTimeStart: "2024-06-14", lastFreeDay: "2024-06-18", expiresAt: "2024-06-19T04:00:00Z" } (Friday, Monday, Tuesday: the weekend does not count)
57
+ * @example freeTimeExpiry("2024-06-14T19:00:00Z", 3, { basis: "working", timeZone: "America/New_York", firstDay: "eventDay", calendar: { weekend: [6, 7], holidays: ["2024-06-17"], timeZone: "America/New_York" } }) // { freeTimeStart: "2024-06-14", lastFreeDay: "2024-06-19", expiresAt: "2024-06-20T04:00:00Z" } (a terminal holiday on the Monday moves expiry a day)
58
+ * @example freeTimeExpiry("2024-06-15T14:00:00Z", 2, { basis: "working", timeZone: "America/New_York", firstDay: "eventDay", calendar: { weekend: [6, 7], holidays: [], timeZone: "America/New_York" } }) // { freeTimeStart: "2024-06-17", lastFreeDay: "2024-06-18", expiresAt: "2024-06-19T04:00:00Z" } (a Saturday discharge: free time starts Monday)
59
+ * @example freeTimeExpiry("2024-06-15T03:00:00Z", 1, { basis: "calendar", timeZone: "America/New_York", firstDay: "eventDay" }) // { freeTimeStart: "2024-06-14", lastFreeDay: "2024-06-14", expiresAt: "2024-06-15T04:00:00Z" } (23:00 local on the 14th: the free day is over an hour later)
60
+ * @example freeTimeExpiry("2024-09-07T15:00:00Z", 1, { basis: "calendar", timeZone: "America/Santiago", firstDay: "eventDay" }) // { freeTimeStart: "2024-09-07", lastFreeDay: "2024-09-07", expiresAt: "2024-09-08T04:00:00Z" } (Santiago skipped midnight on the 8th, so the day starts at 01:00)
61
+ * @example freeTimeExpiry("2024-06-14T19:00:00Z", 3, { basis: "calendar", timeZone: "America/New_York" }) // null (firstDay has no default)
62
+ * @example freeTimeExpiry("2024-06-14T19:00:00Z", 3, { basis: "working", timeZone: "America/New_York", firstDay: "eventDay" }) // null (a working-day count needs the terminal's calendar)
63
+ * @example freeTimeExpiry("2024-06-14T19:00:00Z", 0, { basis: "calendar", timeZone: "America/New_York", firstDay: "eventDay" }) // null (no free days means no last free day)
64
+ * @example freeTimeExpiry("2024-06-14T19:00:00", 3, { basis: "calendar", timeZone: "America/New_York", firstDay: "eventDay" }) // null (no offset: not an instant)
65
+ */
66
+ export declare function freeTimeExpiry(clockStart: string, freeDays: number, options: FreeTimeOptions): FreeTime | null;
@@ -0,0 +1,82 @@
1
+ import { Temporal } from "@js-temporal/polyfill";
2
+ import { parseFreeDays, parseFreeTimeTerms, walkFreeTime, } from "../../internal/index.js";
3
+ import { isValidInstant } from "../../precision/validate/isValidInstant.js";
4
+ /**
5
+ * Lay out a container's free time from the instant its clock started, and say when it ends.
6
+ *
7
+ * Free time is the money calculation in container logistics, and none of its terms is a fact
8
+ * about the port: the number of free days, whether the day of discharge is free day one, and
9
+ * whether weekends count are all set by the carrier's tariff or the service contract. So every
10
+ * term is a parameter. No regulation or industry standard fixes how free time is counted (DCSA types only its unit: calendar days, working days, hours or day of discharge; GMT counts
11
+ * days); the result is the window every tariff states, and what an invoice can print about it:
12
+ * the free time allowed and the dates it starts and ends.
13
+ *
14
+ * - **Days are the terminal's local days.** `clockStart` is an instant (`Z`, an offset, or a
15
+ * bracketed zone, of which only the instant is read); its local date in `options.timeZone` is
16
+ * the event day. A container discharged at 23:00 has used a free day by 00:01 under
17
+ * `firstDay: "eventDay"`. Day boundaries are the zone's real ones, as `floorToZone` and
18
+ * `dwellTime` find them: a 23- or 25-hour day is one day, a date the zone deleted is never a
19
+ * free day, and a fall-back that re-enters an earlier date never moves the count backwards.
20
+ * - **`firstDay` has no default.** `"eventDay"` makes the event day free day one; `"nextDay"`
21
+ * starts free time on the following counted day. The two differ by exactly one day of charges,
22
+ * and a silent default here is a wrong invoice, so omitting it returns `null`.
23
+ * - **`basis` decides which days count.** `"calendar"` counts every local day, so a weekend
24
+ * burns two free days while the terminal is shut. `"working"` counts only the working days of
25
+ * `options.calendar` (its weekend and holidays; `calendar.timeZone` is not read), and is
26
+ * required to have one: a working-day count without a calendar is a guess and returns `null`.
27
+ * Under `"working"` a Saturday discharge starts free time on Monday under either `firstDay`.
28
+ * A tariff counting "calendar days excluding bank holidays" (Hapag-Lloyd Japan, MSC Vietnam) is
29
+ * `"working"` with an empty `weekend`. How days after expiry are charged is a separate term;
30
+ * see `chargeableDays`'s `chargeBasis`.
31
+ * - **`expiresAt` is half-open.** It is the first instant of the local day after `lastFreeDay`,
32
+ * as a UTC instant. A gate-out at exactly `expiresAt` is not a chargeable day; see
33
+ * `chargeableDays`. Under `"working"` it can fall on a weekend, which is then simply not
34
+ * chargeable.
35
+ * - `freeDays` is a whole number of at least `1`: with no free days there is no last free day
36
+ * to name, and `chargeableDays` accepts `0` for that tariff.
37
+ * - Returns `null` when `clockStart` is not an instant, `freeDays` is not a whole number of at
38
+ * least `1`, `options` is not an object, `basis` or `firstDay` is missing or unknown,
39
+ * `timeZone` is invalid, `basis` is `"working"` without a valid `BusinessCalendar`, or the walk
40
+ * would pass 10,000 local days.
41
+ *
42
+ * @param clockStart ISO 8601 instant string of the event that starts the clock (discharge, availability or gate-out)
43
+ * @param freeDays whole number of free days the tariff allows, at least 1
44
+ * @param options { basis: "calendar" | "working", timeZone: string, firstDay: "eventDay" | "nextDay", calendar?: BusinessCalendar }
45
+ * @returns the free-time start and last free day as local dates, and the expiry instant, or null on invalid input
46
+ *
47
+ * @example freeTimeExpiry("2024-06-14T19:00:00Z", 3, { basis: "calendar", timeZone: "America/New_York", firstDay: "eventDay" }) // { freeTimeStart: "2024-06-14", lastFreeDay: "2024-06-16", expiresAt: "2024-06-17T04:00:00Z" } (a Friday discharge: the weekend is consumed)
48
+ * @example freeTimeExpiry("2024-06-14T19:00:00Z", 3, { basis: "calendar", timeZone: "America/New_York", firstDay: "nextDay" }) // { freeTimeStart: "2024-06-15", lastFreeDay: "2024-06-17", expiresAt: "2024-06-18T04:00:00Z" } (the same tariff read the other way: one more day)
49
+ * @example freeTimeExpiry("2024-06-14T19:00:00Z", 3, { basis: "working", timeZone: "America/New_York", firstDay: "eventDay", calendar: { weekend: [6, 7], holidays: [], timeZone: "America/New_York" } }) // { freeTimeStart: "2024-06-14", lastFreeDay: "2024-06-18", expiresAt: "2024-06-19T04:00:00Z" } (Friday, Monday, Tuesday: the weekend does not count)
50
+ * @example freeTimeExpiry("2024-06-14T19:00:00Z", 3, { basis: "working", timeZone: "America/New_York", firstDay: "eventDay", calendar: { weekend: [6, 7], holidays: ["2024-06-17"], timeZone: "America/New_York" } }) // { freeTimeStart: "2024-06-14", lastFreeDay: "2024-06-19", expiresAt: "2024-06-20T04:00:00Z" } (a terminal holiday on the Monday moves expiry a day)
51
+ * @example freeTimeExpiry("2024-06-15T14:00:00Z", 2, { basis: "working", timeZone: "America/New_York", firstDay: "eventDay", calendar: { weekend: [6, 7], holidays: [], timeZone: "America/New_York" } }) // { freeTimeStart: "2024-06-17", lastFreeDay: "2024-06-18", expiresAt: "2024-06-19T04:00:00Z" } (a Saturday discharge: free time starts Monday)
52
+ * @example freeTimeExpiry("2024-06-15T03:00:00Z", 1, { basis: "calendar", timeZone: "America/New_York", firstDay: "eventDay" }) // { freeTimeStart: "2024-06-14", lastFreeDay: "2024-06-14", expiresAt: "2024-06-15T04:00:00Z" } (23:00 local on the 14th: the free day is over an hour later)
53
+ * @example freeTimeExpiry("2024-09-07T15:00:00Z", 1, { basis: "calendar", timeZone: "America/Santiago", firstDay: "eventDay" }) // { freeTimeStart: "2024-09-07", lastFreeDay: "2024-09-07", expiresAt: "2024-09-08T04:00:00Z" } (Santiago skipped midnight on the 8th, so the day starts at 01:00)
54
+ * @example freeTimeExpiry("2024-06-14T19:00:00Z", 3, { basis: "calendar", timeZone: "America/New_York" }) // null (firstDay has no default)
55
+ * @example freeTimeExpiry("2024-06-14T19:00:00Z", 3, { basis: "working", timeZone: "America/New_York", firstDay: "eventDay" }) // null (a working-day count needs the terminal's calendar)
56
+ * @example freeTimeExpiry("2024-06-14T19:00:00Z", 0, { basis: "calendar", timeZone: "America/New_York", firstDay: "eventDay" }) // null (no free days means no last free day)
57
+ * @example freeTimeExpiry("2024-06-14T19:00:00", 3, { basis: "calendar", timeZone: "America/New_York", firstDay: "eventDay" }) // null (no offset: not an instant)
58
+ */
59
+ export function freeTimeExpiry(clockStart, freeDays, options) {
60
+ try {
61
+ const terms = parseFreeTimeTerms(options);
62
+ const allowed = parseFreeDays(freeDays, 1);
63
+ if (!isValidInstant(clockStart) || terms === null || allowed === null) {
64
+ return null;
65
+ }
66
+ const start = Temporal.Instant.from(clockStart).toZonedDateTimeISO(terms.timeZone);
67
+ const ledger = walkFreeTime(start, allowed, terms);
68
+ if (ledger === null) {
69
+ return null;
70
+ }
71
+ return {
72
+ freeTimeStart: ledger.free[0].date,
73
+ lastFreeDay: ledger.free[allowed - 1].date,
74
+ expiresAt: ledger.expiresAt.toInstant().toString(),
75
+ };
76
+ }
77
+ catch {
78
+ // Never throws (Core Rule 3): a hostile
79
+ // argument is invalid input, not an exception.
80
+ return null;
81
+ }
82
+ }
@@ -0,0 +1,4 @@
1
+ export * from "./billingTimeline.js";
2
+ export * from "./chargeableDays.js";
3
+ export * from "./demurrageClock.js";
4
+ export * from "./freeTimeExpiry.js";
@@ -0,0 +1,4 @@
1
+ export * from "./billingTimeline.js";
2
+ export * from "./chargeableDays.js";
3
+ export * from "./demurrageClock.js";
4
+ export * from "./freeTimeExpiry.js";
@@ -0,0 +1,2 @@
1
+ export * from "@js-temporal/polyfill";
2
+ export * from "./calculate/index.js";
@@ -0,0 +1,3 @@
1
+ // Re-export the Temporal polyfill (`Temporal`, `Intl`, `toTemporalInstant`) so every namespace subpath offers it
2
+ export * from "@js-temporal/polyfill";
3
+ export * from "./calculate/index.js";
@@ -0,0 +1,93 @@
1
+ import { Temporal } from "@js-temporal/polyfill";
2
+ import { type ResolvedBusinessCalendar } from "./businessCalendar.js";
3
+ /**
4
+ * Local days a free-time walk may visit before giving up: the same cap as `bucketRange`. A
5
+ * valid business calendar leaves at least one working day a week, so a working-day count needs
6
+ * at most about seven local days per free or chargeable day. A walk that runs out answers with
7
+ * the sentinel, never a partial ledger.
8
+ */
9
+ export declare const MAX_WALKED_DAYS = 10000;
10
+ /**
11
+ * The tariff terms reduced to what the walk reads. `calendar` is the working-day calendar free
12
+ * days are counted against, `null` on the calendar basis; `chargeCalendar` is the one charged days
13
+ * are counted against, `null` when every local day after expiry is charged.
14
+ */
15
+ export type FreeTimeTerms = {
16
+ timeZone: string;
17
+ firstDay: "eventDay" | "nextDay";
18
+ calendar: ResolvedBusinessCalendar | null;
19
+ chargeCalendar: ResolvedBusinessCalendar | null;
20
+ };
21
+ /**
22
+ * Read a `FreeTimeOptions` bag, or `null` when it is not one: a non-object, an unknown `basis`
23
+ * or `firstDay`, a missing `firstDay` (no default: the two conventions differ by a day of
24
+ * charges), an invalid `timeZone`, or a `"working"` basis without a valid `BusinessCalendar`.
25
+ * The `calendar` is read only when a basis that needs it is `"working"`.
26
+ *
27
+ * With `readChargeBasis`, `chargeBasis` is required too, and has no default for the same reason:
28
+ * where free time is in working days, tariffs mostly charge every calendar day after it, but some
29
+ * tariffs charge working days only, and the two differ by every closed day after expiry.
30
+ */
31
+ export declare function parseFreeTimeTerms(options: unknown, readChargeBasis?: boolean): FreeTimeTerms | null;
32
+ /** A whole number of free days no smaller than `minimum`, or `null`. */
33
+ export declare function parseFreeDays(freeDays: unknown, minimum: 0 | 1): number | null;
34
+ /**
35
+ * Read a `tiers` option: omitted means no bands, otherwise a strictly ascending list of positive
36
+ * safe whole chargeable-day ordinals, each the last day of its band, with no holes. Anything
37
+ * else is `null`.
38
+ */
39
+ export declare function parseTiers(tiers: unknown): number[] | null;
40
+ /**
41
+ * Split `chargeable` days into the bands `tiers` describes: `[5, 10]` is days 1–5, 6–10 and 11
42
+ * onward. Every band is listed, an empty one with `days: 0`, so a caller's rate table lines up
43
+ * by index; the last band is always open (`to: null`).
44
+ */
45
+ export declare function bandsByTier(tiers: number[], chargeable: number): {
46
+ from: number;
47
+ to: number | null;
48
+ days: number;
49
+ }[];
50
+ /** One local date of the terminal: its first instant, and its ISO label. */
51
+ export interface LedgerDay {
52
+ start: Temporal.ZonedDateTime;
53
+ date: string;
54
+ /**
55
+ * True when the date began before the first instant Temporal represents, so `start` is the
56
+ * clock start standing in for it. Such a start is never output: it can only anchor the walk.
57
+ */
58
+ clamped?: boolean;
59
+ }
60
+ /** What `walkFreeTime` returns: the free days, the expiry, and what the dwell used and owes. */
61
+ export interface FreeTimeLedger {
62
+ /** The free days in order, exactly `freeDays` of them. */
63
+ free: LedgerDay[];
64
+ /** The first instant of the local day after the last free day; with no free days, of day one. */
65
+ expiresAt: Temporal.ZonedDateTime;
66
+ /** Free days the half-open dwell `[clockStart, clockEnd)` touched; `0` with no `clockEnd`. */
67
+ used: number;
68
+ /** Counted days from `expiresAt` that the dwell touched, in order; empty with no `clockEnd`. */
69
+ charged: LedgerDay[];
70
+ }
71
+ /**
72
+ * Walk the terminal's real local days from the clock start and lay out free time and charges.
73
+ *
74
+ * Each day is a bucket `zonedUnitStart` / `nextZonedBucketStart` returns for `"day"`, the same
75
+ * boundaries `floorToZone` and `bucketRange` use, so a 23- or 25-hour day is one day, a date the
76
+ * zone deleted is never visited, and a date the clock falls back into is one day. Free days and
77
+ * charged days are counted against their own terms: on a working basis a weekend day or holiday is
78
+ * visited but not counted; on a calendar basis every day counts. The day of the event is neither
79
+ * free nor charged under `"nextDay"`.
80
+ *
81
+ * - Free days are the first `freeDays` days counted on `calendar`. `expiresAt` is the start of the
82
+ * day after the last of them, whether or not that day counts; with `freeDays` of `0` it is the
83
+ * start of the first counted day.
84
+ * - With a `clockEnd`, the dwell `[clockStart, clockEnd)` is half-open: a day is touched when it
85
+ * starts before `clockEnd`, and a zero-length dwell touches the day it sits on, as `dwellTime`
86
+ * counts. `used` is the free days touched; `charged` the days from `expiresAt` touched that count
87
+ * on `chargeCalendar`.
88
+ * - An event day that began before the first representable instant is still walked from the
89
+ * clock start; only an expiry that would be that unrepresentable start is refused.
90
+ * - Returns `null` when the walk would visit more than `MAX_WALKED_DAYS` local days, when a day
91
+ * boundary cannot be found, or when the expiry lies outside the representable range.
92
+ */
93
+ export declare function walkFreeTime(clockStart: Temporal.ZonedDateTime, freeDays: number, terms: FreeTimeTerms, clockEnd?: Temporal.ZonedDateTime): FreeTimeLedger | null;
@@ -0,0 +1,262 @@
1
+ import { Temporal } from "@js-temporal/polyfill";
2
+ import { isValidTimeZone } from "../zoned/validate/isValidTimeZone.js";
3
+ import { isBusinessDate, parseBusinessCalendar, } from "./businessCalendar.js";
4
+ import { isObject } from "./isObject.js";
5
+ import { nextZonedBucketStart, zonedUnitStart } from "./zonedBucket.js";
6
+ /**
7
+ * Local days a free-time walk may visit before giving up: the same cap as `bucketRange`. A
8
+ * valid business calendar leaves at least one working day a week, so a working-day count needs
9
+ * at most about seven local days per free or chargeable day. A walk that runs out answers with
10
+ * the sentinel, never a partial ledger.
11
+ */
12
+ export const MAX_WALKED_DAYS = 10_000;
13
+ /**
14
+ * Buckets stepped over while looking for a later date. A fall-back that re-enters the previous
15
+ * date (`America/Goose_Bay`, 2010-11-07 00:01 back into the 6th) opens a one-minute 7 November
16
+ * bucket, then a 6 November one, then 7 November again: two buckets in a row whose date is not
17
+ * later than the day being left. One transition can do no more than that, so eight is a
18
+ * generous bound.
19
+ */
20
+ const MAX_REVISITED_BUCKETS = 8;
21
+ /** `parseFreeTimeTerms` without the guard; every read of `options` happens here. */
22
+ function readFreeTimeTerms(options, readChargeBasis = false) {
23
+ if (!isObject(options)) {
24
+ return null;
25
+ }
26
+ const { basis, chargeBasis, firstDay, timeZone, calendar } = options;
27
+ const isBasis = (value) => value === "calendar" || value === "working";
28
+ if (!isBasis(basis)) {
29
+ return null;
30
+ }
31
+ if (readChargeBasis && !isBasis(chargeBasis)) {
32
+ return null;
33
+ }
34
+ if (firstDay !== "eventDay" && firstDay !== "nextDay") {
35
+ return null;
36
+ }
37
+ if (typeof timeZone !== "string" || !isValidTimeZone(timeZone)) {
38
+ return null;
39
+ }
40
+ const needsCalendar = basis === "working" || (readChargeBasis && chargeBasis === "working");
41
+ const resolved = needsCalendar ? parseBusinessCalendar(calendar) : null;
42
+ if (needsCalendar && resolved === null) {
43
+ return null;
44
+ }
45
+ return {
46
+ timeZone,
47
+ firstDay,
48
+ calendar: basis === "working" ? resolved : null,
49
+ chargeCalendar: readChargeBasis && chargeBasis === "working" ? resolved : null,
50
+ };
51
+ }
52
+ /**
53
+ * Read a `FreeTimeOptions` bag, or `null` when it is not one: a non-object, an unknown `basis`
54
+ * or `firstDay`, a missing `firstDay` (no default: the two conventions differ by a day of
55
+ * charges), an invalid `timeZone`, or a `"working"` basis without a valid `BusinessCalendar`.
56
+ * The `calendar` is read only when a basis that needs it is `"working"`.
57
+ *
58
+ * With `readChargeBasis`, `chargeBasis` is required too, and has no default for the same reason:
59
+ * where free time is in working days, tariffs mostly charge every calendar day after it, but some
60
+ * tariffs charge working days only, and the two differ by every closed day after expiry.
61
+ */
62
+ export function parseFreeTimeTerms(options, readChargeBasis = false) {
63
+ try {
64
+ return readFreeTimeTerms(options, readChargeBasis);
65
+ }
66
+ catch {
67
+ // Never throws (Core Rule 3): a Proxy or getter that throws on access is invalid input.
68
+ return null;
69
+ }
70
+ }
71
+ /** A whole number of free days no smaller than `minimum`, or `null`. */
72
+ export function parseFreeDays(freeDays, minimum) {
73
+ return typeof freeDays === "number" &&
74
+ Number.isSafeInteger(freeDays) &&
75
+ freeDays >= minimum
76
+ ? freeDays
77
+ : null;
78
+ }
79
+ /**
80
+ * Read a `tiers` option: omitted means no bands, otherwise a strictly ascending list of positive
81
+ * safe whole chargeable-day ordinals, each the last day of its band, with no holes. Anything
82
+ * else is `null`.
83
+ */
84
+ export function parseTiers(tiers) {
85
+ if (tiers === undefined) {
86
+ return [];
87
+ }
88
+ if (!Array.isArray(tiers)) {
89
+ return null;
90
+ }
91
+ // `Array.from` fills holes with `undefined`, which the check below rejects; `every` alone
92
+ // would skip them and let `[5, , ]` through to a `NaN` band.
93
+ const items = Array.from(tiers);
94
+ const ascending = items.every((tier, index) => typeof tier === "number" &&
95
+ Number.isSafeInteger(tier) &&
96
+ tier >= 1 &&
97
+ (index === 0 || tier > items[index - 1]));
98
+ return ascending ? items : null;
99
+ }
100
+ /**
101
+ * Split `chargeable` days into the bands `tiers` describes: `[5, 10]` is days 1–5, 6–10 and 11
102
+ * onward. Every band is listed, an empty one with `days: 0`, so a caller's rate table lines up
103
+ * by index; the last band is always open (`to: null`).
104
+ */
105
+ export function bandsByTier(tiers, chargeable) {
106
+ const bands = [];
107
+ let from = 1;
108
+ for (const to of tiers) {
109
+ bands.push({
110
+ from,
111
+ to,
112
+ days: Math.max(0, Math.min(chargeable, to) - from + 1),
113
+ });
114
+ from = to + 1;
115
+ }
116
+ bands.push({ from, to: null, days: Math.max(0, chargeable - from + 1) });
117
+ return bands;
118
+ }
119
+ /** The local day holding `zoned`, or `null` when its start cannot be found. */
120
+ function localDayOf(zoned) {
121
+ let start;
122
+ try {
123
+ start = zonedUnitStart(zoned, "day");
124
+ }
125
+ catch (error) {
126
+ if (!(error instanceof RangeError))
127
+ throw error;
128
+ // The event day began before Temporal's first instant (-271821-04-20T00:00Z in a zone west
129
+ // of UTC). Its date is still known, and every later day boundary is representable, so the
130
+ // clock start anchors the walk in its place.
131
+ return {
132
+ start: zoned,
133
+ date: zoned.toPlainDate().toString(),
134
+ clamped: true,
135
+ };
136
+ }
137
+ return start === null
138
+ ? null
139
+ : { start, date: start.toPlainDate().toString() };
140
+ }
141
+ /**
142
+ * The first local day after `day` whose date is later than `day`'s. A bucket that re-enters an
143
+ * earlier or equal date is folded into the day already laid out: a clock that starts in the
144
+ * one-minute first pass of Goose Bay's 7 November must not free or charge the 6th, and
145
+ * `countZonedLocalDates` counts a re-entered date once. `undefined` when the next day starts
146
+ * past the last representable instant, `null` when the walk gives up.
147
+ */
148
+ function nextLocalDay(day) {
149
+ const current = day.start.toPlainDate();
150
+ let cursor = day.start;
151
+ for (let i = 0; i < MAX_REVISITED_BUCKETS; i++) {
152
+ let next;
153
+ try {
154
+ next = nextZonedBucketStart(cursor, "day");
155
+ }
156
+ catch (error) {
157
+ if (error instanceof RangeError)
158
+ return undefined;
159
+ throw error;
160
+ }
161
+ if (next === null) {
162
+ return null;
163
+ }
164
+ const date = next.toPlainDate();
165
+ if (Temporal.PlainDate.compare(date, current) > 0) {
166
+ return { start: next, date: date.toString() };
167
+ }
168
+ cursor = next;
169
+ }
170
+ return null;
171
+ }
172
+ /**
173
+ * Walk the terminal's real local days from the clock start and lay out free time and charges.
174
+ *
175
+ * Each day is a bucket `zonedUnitStart` / `nextZonedBucketStart` returns for `"day"`, the same
176
+ * boundaries `floorToZone` and `bucketRange` use, so a 23- or 25-hour day is one day, a date the
177
+ * zone deleted is never visited, and a date the clock falls back into is one day. Free days and
178
+ * charged days are counted against their own terms: on a working basis a weekend day or holiday is
179
+ * visited but not counted; on a calendar basis every day counts. The day of the event is neither
180
+ * free nor charged under `"nextDay"`.
181
+ *
182
+ * - Free days are the first `freeDays` days counted on `calendar`. `expiresAt` is the start of the
183
+ * day after the last of them, whether or not that day counts; with `freeDays` of `0` it is the
184
+ * start of the first counted day.
185
+ * - With a `clockEnd`, the dwell `[clockStart, clockEnd)` is half-open: a day is touched when it
186
+ * starts before `clockEnd`, and a zero-length dwell touches the day it sits on, as `dwellTime`
187
+ * counts. `used` is the free days touched; `charged` the days from `expiresAt` touched that count
188
+ * on `chargeCalendar`.
189
+ * - An event day that began before the first representable instant is still walked from the
190
+ * clock start; only an expiry that would be that unrepresentable start is refused.
191
+ * - Returns `null` when the walk would visit more than `MAX_WALKED_DAYS` local days, when a day
192
+ * boundary cannot be found, or when the expiry lies outside the representable range.
193
+ */
194
+ export function walkFreeTime(clockStart, freeDays, terms, clockEnd) {
195
+ // The dwell's last instant, one nanosecond before the exit; a zero-length dwell is its own point.
196
+ const last = clockEnd === undefined
197
+ ? undefined
198
+ : Temporal.ZonedDateTime.compare(clockStart, clockEnd) < 0
199
+ ? clockEnd.subtract({ nanoseconds: 1 })
200
+ : clockEnd;
201
+ const touched = (day) => last !== undefined && Temporal.ZonedDateTime.compare(day.start, last) <= 0;
202
+ const counts = (day) => terms.calendar === null ||
203
+ isBusinessDate(day.start.toPlainDate(), terms.calendar);
204
+ const charges = (day) => terms.chargeCalendar === null ||
205
+ isBusinessDate(day.start.toPlainDate(), terms.chargeCalendar);
206
+ const free = [];
207
+ const charged = [];
208
+ let used = 0;
209
+ let expiresAt = null;
210
+ let expiryPending = false;
211
+ let skipEventDay = terms.firstDay === "nextDay";
212
+ let day = localDayOf(clockStart);
213
+ if (day === null) {
214
+ return null;
215
+ }
216
+ // One more visit than the cap: the day after the last touched one is what ends the walk.
217
+ for (let i = 0; i <= MAX_WALKED_DAYS; i++) {
218
+ const eventDaySkipped = skipEventDay;
219
+ const counted = !skipEventDay && counts(day);
220
+ skipEventDay = false;
221
+ if (expiryPending) {
222
+ expiresAt = day.start;
223
+ expiryPending = false;
224
+ }
225
+ else if (expiresAt === null && counted) {
226
+ if (freeDays === 0) {
227
+ // With no free time the expiry is day one's start, which must be a real instant.
228
+ if (day.clamped)
229
+ return null;
230
+ expiresAt = day.start;
231
+ }
232
+ else {
233
+ free.push(day);
234
+ if (touched(day))
235
+ used++;
236
+ expiryPending = free.length === freeDays;
237
+ }
238
+ }
239
+ if (expiresAt !== null) {
240
+ // Free time has ended; with no clock end there is nothing more to lay out.
241
+ if (last === undefined) {
242
+ return { free, expiresAt, used, charged };
243
+ }
244
+ if (Temporal.ZonedDateTime.compare(day.start, last) > 0) {
245
+ return { free, expiresAt, used, charged };
246
+ }
247
+ // The day of the event under `nextDay` is never charged: free time has not started on it.
248
+ if (!eventDaySkipped && charges(day))
249
+ charged.push(day);
250
+ }
251
+ const next = nextLocalDay(day);
252
+ if (next === undefined) {
253
+ // No representable day follows: free time can still be laid out only if it has ended.
254
+ return expiresAt === null ? null : { free, expiresAt, used, charged };
255
+ }
256
+ if (next === null) {
257
+ return null;
258
+ }
259
+ day = next;
260
+ }
261
+ return null;
262
+ }
@@ -15,8 +15,9 @@ export { dateCycleFieldBounds } from "./dateCycleFieldBounds.js";
15
15
  export { timeCycleFieldBounds } from "./timeCycleFieldBounds.js";
16
16
  export { durationUntilString } from "./durationUntilString.js";
17
17
  export { formatDateInCalendar } from "./formatDateInCalendar.js";
18
+ export { bandsByTier, parseFreeDays, parseFreeTimeTerms, parseTiers, walkFreeTime, } from "./freeTimeLedger.js";
18
19
  export { halfOpenAbuts, halfOpenContainsPoint, halfOpenContainsSpan, halfOpenDifference, halfOpenIntersection, halfOpenMerge, halfOpenOverlap, halfOpenUnion, halfOpenXor, } from "./halfOpenIntervals.js";
19
- export { isoStringBody } from "./isoStringBody.js";
20
+ export { EXTENDED_UTC_OFFSET, isoStringBody, TIME_ZONE_ANNOTATION, } from "./isoStringBody.js";
20
21
  export { formatHourDuration } from "./hourDurationString.js";
21
22
  export { canonicalInstantIntervals } from "./instantIntervalText.js";
22
23
  export { parseInstantNanoseconds } from "./instantNanoseconds.js";
@@ -47,7 +48,8 @@ export { parseUnixEpochInterval, parseUnixEpochIntervalList, parseUnixEpochInter
47
48
  export { tileByUnit } from "./splitStep.js";
48
49
  export { countZonedBuckets, nextZonedBucketStart, type WeekStartDay, zonedUnitEnd, zonedUnitStart, } from "./zonedBucket.js";
49
50
  export { countZonedLocalDates } from "./zonedLocalDates.js";
50
- export { zonedDateTimeFrom } from "./zonedWallClock.js";
51
+ export { utcOffsetStringNanoseconds, zonedDateTimeFrom, } from "./zonedWallClock.js";
52
+ export { wallClockAtOffset } from "./wallClockAtOffset.js";
51
53
  export { durationCompare, durationRound, durationTotal, isCalendarDifferenceAcrossZones, plainUntilWithRounding, zonedUntil, } from "./zonedWallClockDifference.js";
52
54
  export { addToZoned, addToZonedDisambiguated, plainToZoned, roundZonedDateTime, subtractFromZoned, withZonedFields, zonedHoursInDay, zonedNextTransition, zonedStartOfDay, } from "./zonedWallClockOperations.js";
53
55
  export { zonelessCalendarDate } from "./zonelessCalendarDate.js";
@@ -15,8 +15,9 @@ export { dateCycleFieldBounds } from "./dateCycleFieldBounds.js";
15
15
  export { timeCycleFieldBounds } from "./timeCycleFieldBounds.js";
16
16
  export { durationUntilString } from "./durationUntilString.js";
17
17
  export { formatDateInCalendar } from "./formatDateInCalendar.js";
18
+ export { bandsByTier, parseFreeDays, parseFreeTimeTerms, parseTiers, walkFreeTime, } from "./freeTimeLedger.js";
18
19
  export { halfOpenAbuts, halfOpenContainsPoint, halfOpenContainsSpan, halfOpenDifference, halfOpenIntersection, halfOpenMerge, halfOpenOverlap, halfOpenUnion, halfOpenXor, } from "./halfOpenIntervals.js";
19
- export { isoStringBody } from "./isoStringBody.js";
20
+ export { EXTENDED_UTC_OFFSET, isoStringBody, TIME_ZONE_ANNOTATION, } from "./isoStringBody.js";
20
21
  export { formatHourDuration } from "./hourDurationString.js";
21
22
  export { canonicalInstantIntervals } from "./instantIntervalText.js";
22
23
  export { parseInstantNanoseconds } from "./instantNanoseconds.js";
@@ -47,7 +48,8 @@ export { parseUnixEpochInterval, parseUnixEpochIntervalList, parseUnixEpochInter
47
48
  export { tileByUnit } from "./splitStep.js";
48
49
  export { countZonedBuckets, nextZonedBucketStart, zonedUnitEnd, zonedUnitStart, } from "./zonedBucket.js";
49
50
  export { countZonedLocalDates } from "./zonedLocalDates.js";
50
- export { zonedDateTimeFrom } from "./zonedWallClock.js";
51
+ export { utcOffsetStringNanoseconds, zonedDateTimeFrom, } from "./zonedWallClock.js";
52
+ export { wallClockAtOffset } from "./wallClockAtOffset.js";
51
53
  export { durationCompare, durationRound, durationTotal, isCalendarDifferenceAcrossZones, plainUntilWithRounding, zonedUntil, } from "./zonedWallClockDifference.js";
52
54
  export { addToZoned, addToZonedDisambiguated, plainToZoned, roundZonedDateTime, subtractFromZoned, withZonedFields, zonedHoursInDay, zonedNextTransition, zonedStartOfDay, } from "./zonedWallClockOperations.js";
53
55
  export { zonelessCalendarDate } from "./zonelessCalendarDate.js";
@@ -17,6 +17,32 @@
17
17
  * @example isoStringBody("12:30") // "12:30"
18
18
  */
19
19
  export declare function isoStringBody(value: string): string;
20
+ /**
21
+ * An ISO 8601 extended UTC offset, as RFC 3339 §5.6 `time-numoffset` writes it (`±HH:MM`), with
22
+ * the seconds and fraction Temporal's `UTCOffset` grammar adds (`±HH:MM:SS[.fraction]`, the
23
+ * fraction after either decimal sign, `.` or `,`, as `TemporalDecimalSeparator` allows). Hours
24
+ * 00–23. Basic (`-0400`) and hour-only (`-04`) offsets, which Temporal also reads, do not match.
25
+ *
26
+ * A regular-expression source with no anchors and no capture groups, so callers can embed it:
27
+ * GMT's one statement of the offset grammar its instant and zoned validators accept.
28
+ *
29
+ * @example new RegExp(`^${EXTENDED_UTC_OFFSET}$`).test("+05:30:00,5") // true
30
+ * @example new RegExp(`^${EXTENDED_UTC_OFFSET}$`).test("-0400") // false (basic format)
31
+ */
32
+ export declare const EXTENDED_UTC_OFFSET = "[+-](?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:[.,]\\d{1,9})?)?";
33
+ /**
34
+ * An RFC 9557 time-zone annotation: a bracket whose content has no `=` (§4.1 `time-zone`,
35
+ * against `suffix-tag`'s `key=value`), with an optional leading `!` marking it critical (§3.3).
36
+ * `[Europe/London]`, `[!+01:00]`; never `[u-ca=hebrew]` or `[!foo=bar]`. Every `key=value`
37
+ * bracket is a tagged annotation, which Temporal reads (or rejects) on its own.
38
+ *
39
+ * A regular-expression source with no anchors and no capture groups, so callers can embed or
40
+ * anchor it: GMT's one statement of "this bracket names a time zone".
41
+ *
42
+ * @example new RegExp(TIME_ZONE_ANNOTATION).test("2024-06-15T10:00:00Z[!Europe/London]") // true
43
+ * @example new RegExp(TIME_ZONE_ANNOTATION).test("2024-06-15T10:00:00Z[u-ca=gregory]") // false
44
+ */
45
+ export declare const TIME_ZONE_ANNOTATION = "\\[!?[^\\]=]+\\]";
20
46
  /**
21
47
  * True when `value` is a string whose part before the first annotation is `zonedDateTimeBody`.
22
48
  * The shape gate every GMT zoned string input passes before `Temporal.ZonedDateTime.from`.
@@ -26,10 +26,30 @@ export function isoStringBody(value) {
26
26
  }
27
27
  /**
28
28
  * An ISO 8601 extended UTC offset, as RFC 3339 §5.6 `time-numoffset` writes it (`±HH:MM`), with
29
- * the seconds and fraction Temporal's `UTCOffset` grammar adds (`±HH:MM:SS[.fraction]`). Hours
29
+ * the seconds and fraction Temporal's `UTCOffset` grammar adds (`±HH:MM:SS[.fraction]`, the
30
+ * fraction after either decimal sign, `.` or `,`, as `TemporalDecimalSeparator` allows). Hours
30
31
  * 00–23. Basic (`-0400`) and hour-only (`-04`) offsets, which Temporal also reads, do not match.
32
+ *
33
+ * A regular-expression source with no anchors and no capture groups, so callers can embed it:
34
+ * GMT's one statement of the offset grammar its instant and zoned validators accept.
35
+ *
36
+ * @example new RegExp(`^${EXTENDED_UTC_OFFSET}$`).test("+05:30:00,5") // true
37
+ * @example new RegExp(`^${EXTENDED_UTC_OFFSET}$`).test("-0400") // false (basic format)
38
+ */
39
+ export const EXTENDED_UTC_OFFSET = "[+-](?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:[.,]\\d{1,9})?)?";
40
+ /**
41
+ * An RFC 9557 time-zone annotation: a bracket whose content has no `=` (§4.1 `time-zone`,
42
+ * against `suffix-tag`'s `key=value`), with an optional leading `!` marking it critical (§3.3).
43
+ * `[Europe/London]`, `[!+01:00]`; never `[u-ca=hebrew]` or `[!foo=bar]`. Every `key=value`
44
+ * bracket is a tagged annotation, which Temporal reads (or rejects) on its own.
45
+ *
46
+ * A regular-expression source with no anchors and no capture groups, so callers can embed or
47
+ * anchor it: GMT's one statement of "this bracket names a time zone".
48
+ *
49
+ * @example new RegExp(TIME_ZONE_ANNOTATION).test("2024-06-15T10:00:00Z[!Europe/London]") // true
50
+ * @example new RegExp(TIME_ZONE_ANNOTATION).test("2024-06-15T10:00:00Z[u-ca=gregory]") // false
31
51
  */
32
- const EXTENDED_UTC_OFFSET = "[+-](?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:[.,]\\d{1,9})?)?";
52
+ export const TIME_ZONE_ANNOTATION = "\\[!?[^\\]=]+\\]";
33
53
  /** `plainDateTime` without its anchors: `<date>T<time>` with an optional fraction. */
34
54
  const EXTENDED_DATE_TIME = plainDateTime.source.slice(1, -1);
35
55
  /**