@northguild/gmt 1.16.0 → 1.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +289 -8
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/instant/convert/fromOffsetInstant.js +3 -11
- package/dist/instant/convert/toOffsetInstant.d.ts +23 -0
- package/dist/instant/convert/toOffsetInstant.js +2 -25
- package/dist/intermodal/calculate/billingTimeline.d.ts +94 -0
- package/dist/intermodal/calculate/billingTimeline.js +168 -0
- package/dist/intermodal/calculate/chargeableDays.d.ts +98 -0
- package/dist/intermodal/calculate/chargeableDays.js +101 -0
- package/dist/intermodal/calculate/demurrageClock.d.ts +93 -0
- package/dist/intermodal/calculate/demurrageClock.js +140 -0
- package/dist/intermodal/calculate/freeTimeExpiry.d.ts +66 -0
- package/dist/intermodal/calculate/freeTimeExpiry.js +82 -0
- package/dist/intermodal/calculate/index.d.ts +4 -0
- package/dist/intermodal/calculate/index.js +4 -0
- package/dist/intermodal/index.d.ts +2 -0
- package/dist/intermodal/index.js +3 -0
- package/dist/internal/freeTimeLedger.d.ts +93 -0
- package/dist/internal/freeTimeLedger.js +262 -0
- package/dist/internal/index.d.ts +4 -2
- package/dist/internal/index.js +4 -2
- package/dist/internal/isoStringBody.d.ts +26 -0
- package/dist/internal/isoStringBody.js +22 -2
- package/dist/internal/wallClockAtOffset.d.ts +22 -0
- package/dist/internal/wallClockAtOffset.js +26 -0
- package/dist/internal/zonedWallClockDifference.js +5 -4
- package/dist/transport/calculate/dwellTime.d.ts +72 -0
- package/dist/transport/calculate/dwellTime.js +126 -0
- package/dist/transport/calculate/index.d.ts +2 -0
- package/dist/transport/calculate/index.js +2 -0
- package/dist/transport/calculate/transitTime.d.ts +53 -0
- package/dist/transport/calculate/transitTime.js +118 -0
- package/dist/transport/convert/etaAtZone.d.ts +36 -0
- package/dist/transport/convert/etaAtZone.js +53 -0
- package/dist/transport/convert/index.d.ts +1 -0
- package/dist/transport/convert/index.js +1 -0
- package/dist/transport/index.d.ts +3 -0
- package/dist/transport/index.js +4 -0
- package/dist/types/free-time-options.d.ts +34 -0
- package/dist/types/free-time-options.js +0 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/index.js +1 -0
- package/package.json +36 -1
- package/skills/gmt-arithmetic/SKILL.md +1 -1
- package/skills/gmt-basics/SKILL.md +1 -1
- package/skills/gmt-integration/SKILL.md +1 -1
- package/skills/gmt-timezone/SKILL.md +79 -12
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { Temporal } from "@js-temporal/polyfill";
|
|
2
|
+
/**
|
|
3
|
+
* The wall clock an instant reads at a fixed UTC offset, including a sub-minute or fractional
|
|
4
|
+
* one. Callers write the offset text themselves.
|
|
5
|
+
*
|
|
6
|
+
* The shift happens on the wall clock, never on the instant: a `PlainDateTime` reaches a day
|
|
7
|
+
* further either side than an `Instant` does (TC39 `ISODateTimeWithinLimits` against
|
|
8
|
+
* `IsValidEpochNanoseconds`), so shifting the instant first would reject the last 14 hours of
|
|
9
|
+
* the representable range, where `+275760-09-13T00:00:00Z` at `+14:00` names a local time that
|
|
10
|
+
* exists. No offset time zone is built either: Temporal's offset time-zone identifiers are
|
|
11
|
+
* minute precision, and an offset such as `-00:44:30` or `+05:30:15.25` is not.
|
|
12
|
+
*
|
|
13
|
+
* Throws what Temporal throws; callers wrap it in their own try/catch.
|
|
14
|
+
*
|
|
15
|
+
* @param instant the exact time
|
|
16
|
+
* @param offsetNanoseconds UTC offset in nanoseconds, east positive
|
|
17
|
+
* @returns the local wall clock at that offset
|
|
18
|
+
*
|
|
19
|
+
* @example wallClockAtOffset(Temporal.Instant.from("1970-01-01T00:00:00Z"), -2_670_000_000_000n).toString() // "1969-12-31T23:15:30"
|
|
20
|
+
* @example wallClockAtOffset(Temporal.Instant.from("+275760-09-13T00:00:00Z"), 50_400_000_000_000n).toString() // "+275760-09-13T14:00:00"
|
|
21
|
+
*/
|
|
22
|
+
export declare function wallClockAtOffset(instant: Temporal.Instant, offsetNanoseconds: bigint): Temporal.PlainDateTime;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The wall clock an instant reads at a fixed UTC offset, including a sub-minute or fractional
|
|
3
|
+
* one. Callers write the offset text themselves.
|
|
4
|
+
*
|
|
5
|
+
* The shift happens on the wall clock, never on the instant: a `PlainDateTime` reaches a day
|
|
6
|
+
* further either side than an `Instant` does (TC39 `ISODateTimeWithinLimits` against
|
|
7
|
+
* `IsValidEpochNanoseconds`), so shifting the instant first would reject the last 14 hours of
|
|
8
|
+
* the representable range, where `+275760-09-13T00:00:00Z` at `+14:00` names a local time that
|
|
9
|
+
* exists. No offset time zone is built either: Temporal's offset time-zone identifiers are
|
|
10
|
+
* minute precision, and an offset such as `-00:44:30` or `+05:30:15.25` is not.
|
|
11
|
+
*
|
|
12
|
+
* Throws what Temporal throws; callers wrap it in their own try/catch.
|
|
13
|
+
*
|
|
14
|
+
* @param instant the exact time
|
|
15
|
+
* @param offsetNanoseconds UTC offset in nanoseconds, east positive
|
|
16
|
+
* @returns the local wall clock at that offset
|
|
17
|
+
*
|
|
18
|
+
* @example wallClockAtOffset(Temporal.Instant.from("1970-01-01T00:00:00Z"), -2_670_000_000_000n).toString() // "1969-12-31T23:15:30"
|
|
19
|
+
* @example wallClockAtOffset(Temporal.Instant.from("+275760-09-13T00:00:00Z"), 50_400_000_000_000n).toString() // "+275760-09-13T14:00:00"
|
|
20
|
+
*/
|
|
21
|
+
export function wallClockAtOffset(instant, offsetNanoseconds) {
|
|
22
|
+
return instant
|
|
23
|
+
.toZonedDateTimeISO("UTC")
|
|
24
|
+
.toPlainDateTime()
|
|
25
|
+
.add({ nanoseconds: Number(offsetNanoseconds) });
|
|
26
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Temporal } from "@js-temporal/polyfill";
|
|
2
|
+
import { TIME_ZONE_ANNOTATION } from "./isoStringBody.js";
|
|
2
3
|
import { MAX_EPOCH_NANOSECONDS, MIN_EPOCH_NANOSECONDS, } from "./epochNanoseconds.js";
|
|
3
4
|
import { calendarDateAdd as compatCalendarDateAdd, calendarDateUntil as compatCalendarDateUntil, isCalendarArithmeticCompatNeeded, isNudgeWindowCompatNeeded, } from "./temporalCompat/index.js";
|
|
4
5
|
import { epochNanosecondsFor, isNamedTimeZone, isValidEpoch, utcEpochNanoseconds, zonedDateTimeFrom, } from "./zonedWallClock.js";
|
|
@@ -43,7 +44,7 @@ const UTC_EQUIVALENT_OFFSET_ZONE = "+00:00";
|
|
|
43
44
|
const OPTIONS_PROBE = new Temporal.ZonedDateTime(0n, "UTC");
|
|
44
45
|
const ZERO_DURATION = new Temporal.Duration();
|
|
45
46
|
/** An RFC 9557 time zone annotation (`[Europe/London]`, `[!+01:00]`), never a `key=value` one. */
|
|
46
|
-
const
|
|
47
|
+
const timeZoneAnnotation = new RegExp(TIME_ZONE_ANNOTATION);
|
|
47
48
|
const ZERO_DATE_DURATION = {
|
|
48
49
|
years: 0,
|
|
49
50
|
months: 0,
|
|
@@ -766,7 +767,7 @@ function zonedRelativeTo(relativeTo) {
|
|
|
766
767
|
return relativeTo;
|
|
767
768
|
try {
|
|
768
769
|
if (typeof relativeTo === "string") {
|
|
769
|
-
return
|
|
770
|
+
return timeZoneAnnotation.test(relativeTo)
|
|
770
771
|
? zonedDateTimeFrom(relativeTo)
|
|
771
772
|
: null;
|
|
772
773
|
}
|
|
@@ -868,7 +869,7 @@ function plainRelativeToNeedingCompat(relativeTo) {
|
|
|
868
869
|
}
|
|
869
870
|
else if (typeof relativeTo === "string" &&
|
|
870
871
|
relativeTo.includes("u-ca=") &&
|
|
871
|
-
!
|
|
872
|
+
!timeZoneAnnotation.test(relativeTo)) {
|
|
872
873
|
plain = Temporal.PlainDate.from(relativeTo);
|
|
873
874
|
}
|
|
874
875
|
else {
|
|
@@ -974,7 +975,7 @@ function plainRelativeTo(relativeTo) {
|
|
|
974
975
|
return relativeTo.toPlainDate();
|
|
975
976
|
}
|
|
976
977
|
if (typeof relativeTo === "string" &&
|
|
977
|
-
!
|
|
978
|
+
!timeZoneAnnotation.test(relativeTo)) {
|
|
978
979
|
return Temporal.PlainDate.from(relativeTo);
|
|
979
980
|
}
|
|
980
981
|
if (relativeTo !== null &&
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/** What `dwellTime` returns: exact elapsed time plus the local view every charging regime counts in. */
|
|
2
|
+
export interface Dwell {
|
|
3
|
+
/** Exact elapsed time from entry to exit, as an ISO 8601 duration with hours as the largest unit. */
|
|
4
|
+
duration: string;
|
|
5
|
+
/** The entry, rendered in the dwell zone. */
|
|
6
|
+
enter: string;
|
|
7
|
+
/** The exit, rendered in the dwell zone. */
|
|
8
|
+
exit: string;
|
|
9
|
+
/** Distinct local calendar dates the dwell touched, counted half-open at the exit. */
|
|
10
|
+
calendarDays: number;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Measure how long something sat somewhere, and how many local calendar days it sat there.
|
|
14
|
+
*
|
|
15
|
+
* Terminals, depots, yards, stations, gates and berths all charge dwell, and none of them
|
|
16
|
+
* charge by elapsed hours. They count the local calendar days the thing was present, so a
|
|
17
|
+
* container that gates in at 23:00 and out at 01:00 has been there two hours and two days.
|
|
18
|
+
* `dwellTime` returns both, and is the one "local days crossed" primitive in the library: free
|
|
19
|
+
* time and demurrage (INT-12), laytime day counts (MAR-19) and hospital length of stay
|
|
20
|
+
* all take their day boundary from here rather than each deciding what a midnight is.
|
|
21
|
+
*
|
|
22
|
+
* - **`duration` is exact elapsed time.** A dwell spanning a DST transition reports the hours
|
|
23
|
+
* that actually elapsed; the local wall clocks at either end can be an hour apart from that.
|
|
24
|
+
* Hours is the largest unit, so the value never depends on a calendar.
|
|
25
|
+
* - **`calendarDays` is not `duration` rounded up.** It is the number of distinct local dates
|
|
26
|
+
* in `targetZone` that the half-open interval `[entry, exit)` touches: entry and exit on the
|
|
27
|
+
* same local date is `1`, across one local midnight is `2`, and so on. A dwell that ends
|
|
28
|
+
* exactly at a local midnight does not touch the new day, because the exit instant itself
|
|
29
|
+
* is excluded — the same half-open rule as `bucketRange` and CORE-6's intervals. A zero-length
|
|
30
|
+
* dwell touches one date. The count walks the zone's real transitions: between two of them
|
|
31
|
+
* the wall clock runs forward without a break, so each stretch touches every date from its
|
|
32
|
+
* first instant to its last, and the count is the size of the union of those dates. A 23- or
|
|
33
|
+
* 25-hour local day is one day, not 0.96 or 1.04 of one; a date the zone deleted is never
|
|
34
|
+
* touched (`Pacific/Apia` skipped 2011-12-30); and a date the clock falls back into is still
|
|
35
|
+
* one date (`America/Goose_Bay` fell back from 00:01 on 7 November 2010 into the 6th).
|
|
36
|
+
* - **The zone decides the count, so it has to be stated.** `targetZone` is used when given.
|
|
37
|
+
* Without it, the entry's bracketed IANA zone is used. As in `Temporal.Instant.from`, a
|
|
38
|
+
* bracketed zone is otherwise not read, so it is not validated either: the entry's is read
|
|
39
|
+
* (and must exist and agree with its offset) only when it supplies the dwell zone, because
|
|
40
|
+
* `targetZone` is omitted, and the exit's is never read. If the entry and exit are bare
|
|
41
|
+
* instants — `Z` or an offset — and no `targetZone` is given, the result is `null`: an
|
|
42
|
+
* offset is not a zone (CORE-4), and a day count in an unstated locality would be a guess.
|
|
43
|
+
* - `enter` and `exit` are the two instants rendered in the dwell zone, so a charging record
|
|
44
|
+
* shows the local times the terminal's own clock showed.
|
|
45
|
+
* - `targetZone` is whatever `isValidTimeZone` accepts, which is what Temporal accepts: an IANA
|
|
46
|
+
* identifier, or a fixed offset such as `"+02:00"`, as `etaAtZone` also takes. Days are then
|
|
47
|
+
* counted in that offset, which observes no DST, so pass the place's IANA zone when there is one.
|
|
48
|
+
* - GMT does not resolve a port, terminal or station code to a zone; the caller supplies the
|
|
49
|
+
* identifier.
|
|
50
|
+
* - Long dwells stay cheap: the walk visits zone transitions, not days. A dwell that crosses
|
|
51
|
+
* more than 10,000 transitions (about 5,000 years of twice-yearly DST) returns `null`.
|
|
52
|
+
* - Returns `null` when either instant is invalid, when `exit` is before `entry` (an inverted
|
|
53
|
+
* dwell is a data error, not a negative stay), or when no zone can be determined.
|
|
54
|
+
*
|
|
55
|
+
* @param entry ISO 8601 zoned datetime or instant string of the gate-in, arrival or admission
|
|
56
|
+
* @param exit ISO 8601 zoned datetime or instant string of the gate-out, departure or discharge
|
|
57
|
+
* @param targetZone IANA timeZone identifier or fixed offset the days are counted in; defaults to `entry`'s bracketed zone
|
|
58
|
+
* @returns exact duration, zone-local entry and exit, and the local calendar days touched, or null on invalid input
|
|
59
|
+
*
|
|
60
|
+
* @example dwellTime("2024-06-15T23:00:00-04:00[America/New_York]", "2024-06-16T01:00:00-04:00[America/New_York]") // { duration: "PT2H", enter: "2024-06-15T23:00:00-04:00[America/New_York]", exit: "2024-06-16T01:00:00-04:00[America/New_York]", calendarDays: 2 }
|
|
61
|
+
* @example dwellTime("2024-06-15T08:00:00Z", "2024-06-15T17:30:00Z", "Europe/Berlin") // { duration: "PT9H30M", enter: "2024-06-15T10:00:00+02:00[Europe/Berlin]", exit: "2024-06-15T19:30:00+02:00[Europe/Berlin]", calendarDays: 1 }
|
|
62
|
+
* @example dwellTime("2024-06-15T22:30:00Z", "2024-06-16T01:00:00Z", "Europe/London") // { duration: "PT2H30M", enter: "2024-06-15T23:30:00+01:00[Europe/London]", exit: "2024-06-16T02:00:00+01:00[Europe/London]", calendarDays: 2 }
|
|
63
|
+
* @example dwellTime("2024-06-15T22:30:00Z", "2024-06-16T01:00:00Z", "Europe/Amsterdam") // { duration: "PT2H30M", enter: "2024-06-16T00:30:00+02:00[Europe/Amsterdam]", exit: "2024-06-16T03:00:00+02:00[Europe/Amsterdam]", calendarDays: 1 } (the same two instants, one fewer day: the zone decides)
|
|
64
|
+
* @example dwellTime("2024-06-15T22:00:00Z", "2024-06-16T04:00:00Z", "America/New_York") // { duration: "PT6H", enter: "2024-06-15T18:00:00-04:00[America/New_York]", exit: "2024-06-16T00:00:00-04:00[America/New_York]", calendarDays: 1 } (an exit exactly at local midnight does not touch the new day)
|
|
65
|
+
* @example dwellTime("2024-03-10T05:00:00Z", "2024-03-10T12:00:00Z", "America/New_York") // { duration: "PT7H", enter: "2024-03-10T00:00:00-05:00[America/New_York]", exit: "2024-03-10T08:00:00-04:00[America/New_York]", calendarDays: 1 } (seven elapsed hours across the spring-forward, one local day)
|
|
66
|
+
* @example dwellTime("2011-12-30T08:00:00Z", "2011-12-30T11:00:00Z", "Pacific/Apia") // { duration: "PT3H", enter: "2011-12-29T22:00:00-10:00[Pacific/Apia]", exit: "2011-12-31T01:00:00+14:00[Pacific/Apia]", calendarDays: 2 } (29 and 31 December: Samoa deleted the 30th)
|
|
67
|
+
* @example dwellTime("2024-06-15T22:30:00Z", "2024-06-16T01:00:00Z", "+02:00") // { duration: "PT2H30M", enter: "2024-06-16T00:30:00+02:00[+02:00]", exit: "2024-06-16T03:00:00+02:00[+02:00]", calendarDays: 1 } (a fixed offset counts days with no DST)
|
|
68
|
+
* @example dwellTime("2024-06-15T22:30:00Z", "2024-06-16T01:00:00Z") // null (bare instants and no zone: the day count has no locality)
|
|
69
|
+
* @example dwellTime("2024-06-16T01:00:00Z", "2024-06-15T22:30:00Z", "Europe/London") // null (exit before entry)
|
|
70
|
+
* @example dwellTime("2024-06-15T22:30:00Z", "2024-06-16T01:00:00Z", "Europe/Londres") // null
|
|
71
|
+
*/
|
|
72
|
+
export declare function dwellTime(entry: string, exit: string, targetZone?: string): Dwell | null;
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { Temporal } from "@js-temporal/polyfill";
|
|
2
|
+
import { countZonedLocalDates, zonedDateTimeFrom } from "../../internal/index.js";
|
|
3
|
+
import { isValidInstant } from "../../precision/validate/isValidInstant.js";
|
|
4
|
+
import { isValidTimeZone } from "../../zoned/validate/isValidTimeZone.js";
|
|
5
|
+
import { isValidZonedDateTime } from "../../zoned/validate/isValidZonedDateTime.js";
|
|
6
|
+
/**
|
|
7
|
+
* The zone a dwell is counted in: the caller's, or the one the entry string carries.
|
|
8
|
+
*
|
|
9
|
+
* An entry written with a bracketed IANA zone names a place; `Z` and offset-only strings name a
|
|
10
|
+
* moment and nothing else, so with no `targetZone` there is no locality to count days in.
|
|
11
|
+
*/
|
|
12
|
+
function dwellZone(entry, targetZone) {
|
|
13
|
+
if (targetZone !== undefined) {
|
|
14
|
+
return isValidTimeZone(targetZone) ? targetZone : null;
|
|
15
|
+
}
|
|
16
|
+
if (!isValidZonedDateTime(entry)) {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
try {
|
|
20
|
+
return zonedDateTimeFrom(entry).timeZoneId;
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Measure how long something sat somewhere, and how many local calendar days it sat there.
|
|
28
|
+
*
|
|
29
|
+
* Terminals, depots, yards, stations, gates and berths all charge dwell, and none of them
|
|
30
|
+
* charge by elapsed hours. They count the local calendar days the thing was present, so a
|
|
31
|
+
* container that gates in at 23:00 and out at 01:00 has been there two hours and two days.
|
|
32
|
+
* `dwellTime` returns both, and is the one "local days crossed" primitive in the library: free
|
|
33
|
+
* time and demurrage (INT-12), laytime day counts (MAR-19) and hospital length of stay
|
|
34
|
+
* all take their day boundary from here rather than each deciding what a midnight is.
|
|
35
|
+
*
|
|
36
|
+
* - **`duration` is exact elapsed time.** A dwell spanning a DST transition reports the hours
|
|
37
|
+
* that actually elapsed; the local wall clocks at either end can be an hour apart from that.
|
|
38
|
+
* Hours is the largest unit, so the value never depends on a calendar.
|
|
39
|
+
* - **`calendarDays` is not `duration` rounded up.** It is the number of distinct local dates
|
|
40
|
+
* in `targetZone` that the half-open interval `[entry, exit)` touches: entry and exit on the
|
|
41
|
+
* same local date is `1`, across one local midnight is `2`, and so on. A dwell that ends
|
|
42
|
+
* exactly at a local midnight does not touch the new day, because the exit instant itself
|
|
43
|
+
* is excluded — the same half-open rule as `bucketRange` and CORE-6's intervals. A zero-length
|
|
44
|
+
* dwell touches one date. The count walks the zone's real transitions: between two of them
|
|
45
|
+
* the wall clock runs forward without a break, so each stretch touches every date from its
|
|
46
|
+
* first instant to its last, and the count is the size of the union of those dates. A 23- or
|
|
47
|
+
* 25-hour local day is one day, not 0.96 or 1.04 of one; a date the zone deleted is never
|
|
48
|
+
* touched (`Pacific/Apia` skipped 2011-12-30); and a date the clock falls back into is still
|
|
49
|
+
* one date (`America/Goose_Bay` fell back from 00:01 on 7 November 2010 into the 6th).
|
|
50
|
+
* - **The zone decides the count, so it has to be stated.** `targetZone` is used when given.
|
|
51
|
+
* Without it, the entry's bracketed IANA zone is used. As in `Temporal.Instant.from`, a
|
|
52
|
+
* bracketed zone is otherwise not read, so it is not validated either: the entry's is read
|
|
53
|
+
* (and must exist and agree with its offset) only when it supplies the dwell zone, because
|
|
54
|
+
* `targetZone` is omitted, and the exit's is never read. If the entry and exit are bare
|
|
55
|
+
* instants — `Z` or an offset — and no `targetZone` is given, the result is `null`: an
|
|
56
|
+
* offset is not a zone (CORE-4), and a day count in an unstated locality would be a guess.
|
|
57
|
+
* - `enter` and `exit` are the two instants rendered in the dwell zone, so a charging record
|
|
58
|
+
* shows the local times the terminal's own clock showed.
|
|
59
|
+
* - `targetZone` is whatever `isValidTimeZone` accepts, which is what Temporal accepts: an IANA
|
|
60
|
+
* identifier, or a fixed offset such as `"+02:00"`, as `etaAtZone` also takes. Days are then
|
|
61
|
+
* counted in that offset, which observes no DST, so pass the place's IANA zone when there is one.
|
|
62
|
+
* - GMT does not resolve a port, terminal or station code to a zone; the caller supplies the
|
|
63
|
+
* identifier.
|
|
64
|
+
* - Long dwells stay cheap: the walk visits zone transitions, not days. A dwell that crosses
|
|
65
|
+
* more than 10,000 transitions (about 5,000 years of twice-yearly DST) returns `null`.
|
|
66
|
+
* - Returns `null` when either instant is invalid, when `exit` is before `entry` (an inverted
|
|
67
|
+
* dwell is a data error, not a negative stay), or when no zone can be determined.
|
|
68
|
+
*
|
|
69
|
+
* @param entry ISO 8601 zoned datetime or instant string of the gate-in, arrival or admission
|
|
70
|
+
* @param exit ISO 8601 zoned datetime or instant string of the gate-out, departure or discharge
|
|
71
|
+
* @param targetZone IANA timeZone identifier or fixed offset the days are counted in; defaults to `entry`'s bracketed zone
|
|
72
|
+
* @returns exact duration, zone-local entry and exit, and the local calendar days touched, or null on invalid input
|
|
73
|
+
*
|
|
74
|
+
* @example dwellTime("2024-06-15T23:00:00-04:00[America/New_York]", "2024-06-16T01:00:00-04:00[America/New_York]") // { duration: "PT2H", enter: "2024-06-15T23:00:00-04:00[America/New_York]", exit: "2024-06-16T01:00:00-04:00[America/New_York]", calendarDays: 2 }
|
|
75
|
+
* @example dwellTime("2024-06-15T08:00:00Z", "2024-06-15T17:30:00Z", "Europe/Berlin") // { duration: "PT9H30M", enter: "2024-06-15T10:00:00+02:00[Europe/Berlin]", exit: "2024-06-15T19:30:00+02:00[Europe/Berlin]", calendarDays: 1 }
|
|
76
|
+
* @example dwellTime("2024-06-15T22:30:00Z", "2024-06-16T01:00:00Z", "Europe/London") // { duration: "PT2H30M", enter: "2024-06-15T23:30:00+01:00[Europe/London]", exit: "2024-06-16T02:00:00+01:00[Europe/London]", calendarDays: 2 }
|
|
77
|
+
* @example dwellTime("2024-06-15T22:30:00Z", "2024-06-16T01:00:00Z", "Europe/Amsterdam") // { duration: "PT2H30M", enter: "2024-06-16T00:30:00+02:00[Europe/Amsterdam]", exit: "2024-06-16T03:00:00+02:00[Europe/Amsterdam]", calendarDays: 1 } (the same two instants, one fewer day: the zone decides)
|
|
78
|
+
* @example dwellTime("2024-06-15T22:00:00Z", "2024-06-16T04:00:00Z", "America/New_York") // { duration: "PT6H", enter: "2024-06-15T18:00:00-04:00[America/New_York]", exit: "2024-06-16T00:00:00-04:00[America/New_York]", calendarDays: 1 } (an exit exactly at local midnight does not touch the new day)
|
|
79
|
+
* @example dwellTime("2024-03-10T05:00:00Z", "2024-03-10T12:00:00Z", "America/New_York") // { duration: "PT7H", enter: "2024-03-10T00:00:00-05:00[America/New_York]", exit: "2024-03-10T08:00:00-04:00[America/New_York]", calendarDays: 1 } (seven elapsed hours across the spring-forward, one local day)
|
|
80
|
+
* @example dwellTime("2011-12-30T08:00:00Z", "2011-12-30T11:00:00Z", "Pacific/Apia") // { duration: "PT3H", enter: "2011-12-29T22:00:00-10:00[Pacific/Apia]", exit: "2011-12-31T01:00:00+14:00[Pacific/Apia]", calendarDays: 2 } (29 and 31 December: Samoa deleted the 30th)
|
|
81
|
+
* @example dwellTime("2024-06-15T22:30:00Z", "2024-06-16T01:00:00Z", "+02:00") // { duration: "PT2H30M", enter: "2024-06-16T00:30:00+02:00[+02:00]", exit: "2024-06-16T03:00:00+02:00[+02:00]", calendarDays: 1 } (a fixed offset counts days with no DST)
|
|
82
|
+
* @example dwellTime("2024-06-15T22:30:00Z", "2024-06-16T01:00:00Z") // null (bare instants and no zone: the day count has no locality)
|
|
83
|
+
* @example dwellTime("2024-06-16T01:00:00Z", "2024-06-15T22:30:00Z", "Europe/London") // null (exit before entry)
|
|
84
|
+
* @example dwellTime("2024-06-15T22:30:00Z", "2024-06-16T01:00:00Z", "Europe/Londres") // null
|
|
85
|
+
*/
|
|
86
|
+
export function dwellTime(entry, exit, targetZone) {
|
|
87
|
+
try {
|
|
88
|
+
if (!isValidInstant(entry) || !isValidInstant(exit)) {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
const zone = dwellZone(entry, targetZone);
|
|
92
|
+
if (zone === null) {
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
const entryInstant = Temporal.Instant.from(entry);
|
|
96
|
+
const exitInstant = Temporal.Instant.from(exit);
|
|
97
|
+
if (Temporal.Instant.compare(entryInstant, exitInstant) > 0) {
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
const enter = entryInstant.toZonedDateTimeISO(zone);
|
|
101
|
+
const leave = exitInstant.toZonedDateTimeISO(zone);
|
|
102
|
+
// Half-open at the exit: the dwell's last instant is one nanosecond before it, so an exit
|
|
103
|
+
// exactly on a local day boundary does not touch the new day. A zero-length dwell still
|
|
104
|
+
// touches the one date it sits on.
|
|
105
|
+
const last = Temporal.Instant.compare(entryInstant, exitInstant) < 0
|
|
106
|
+
? leave.subtract({ nanoseconds: 1 })
|
|
107
|
+
: leave;
|
|
108
|
+
const calendarDays = countZonedLocalDates(enter, last);
|
|
109
|
+
if (calendarDays === null) {
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
return {
|
|
113
|
+
duration: entryInstant
|
|
114
|
+
.until(exitInstant, { largestUnit: "hours" })
|
|
115
|
+
.toString(),
|
|
116
|
+
enter: enter.toString(),
|
|
117
|
+
exit: leave.toString(),
|
|
118
|
+
calendarDays,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
// Never throws (Core Rule 3): a hostile
|
|
123
|
+
// argument is invalid input, not an exception.
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Add a transit duration to a departure and return the arrival, in the departure's own zone.
|
|
3
|
+
*
|
|
4
|
+
* The first of the three shared transport operations: a leg is a departure plus how long it
|
|
5
|
+
* takes. Every mode publishes it that way, and every mode gets the same thing wrong — a leg
|
|
6
|
+
* that crosses a DST transition is a fixed number of *elapsed* hours, not a wall-clock
|
|
7
|
+
* interval, so the local arrival time reflects the shift.
|
|
8
|
+
*
|
|
9
|
+
* - **Transit time is exact time.** Hours, minutes, seconds and fractions are added as elapsed
|
|
10
|
+
* time, and a day component means 24 hours, exactly. That is TC39 Temporal's rule:
|
|
11
|
+
* `Duration.prototype.round` and `total` without a `relativeTo` treat a day as 24 hours and
|
|
12
|
+
* require a `relativeTo` for weeks, months and years. A 26-hour leg that starts the evening
|
|
13
|
+
* before a spring-forward arrives at a wall time one hour later than a wall-clock reading would
|
|
14
|
+
* suggest, because that is when the vessel, train or aircraft actually gets there.
|
|
15
|
+
* - **Calendar units are refused.** A `duration` with years, months or weeks returns `""`: no
|
|
16
|
+
* leg takes "a month" in a sense that time arithmetic can fix without a reference point, and
|
|
17
|
+
* Temporal will not add them to an instant either. Scheduled connections that are dated rather
|
|
18
|
+
* than timed belong to `scheduleDelivery`.
|
|
19
|
+
* - **The departure's zone is preserved.** A bracketed IANA zone (`…-05:00[America/New_York]`)
|
|
20
|
+
* stays that zone, so the arrival's offset is whatever is in force there at arrival. A `Z`
|
|
21
|
+
* instant returns a `Z` instant; an offset-only instant keeps its offset, written exactly as
|
|
22
|
+
* the departure wrote it, sub-minute offsets such as `+05:30:15` included (an offset is not a
|
|
23
|
+
* zone, so nothing else can be inferred from it — see `toOffsetInstant`). A bracketed zone
|
|
24
|
+
* that does not exist, or that contradicts its offset, is rejected as `isValidZonedDateTime`
|
|
25
|
+
* rejects it; it does not fall back to the offset.
|
|
26
|
+
* - RFC 9557 annotations are read as `Temporal.Instant.from` reads them: a bracket without `=`
|
|
27
|
+
* is a time zone, and a `key=value` bracket with an unknown key is ignored when elective and
|
|
28
|
+
* rejected when critical (`[!…]`). A calendar on a departure without a zone is accepted and
|
|
29
|
+
* changes nothing, since an instant has no calendar; a zoned departure's calendar must be ISO,
|
|
30
|
+
* as `isValidZonedDateTime` requires.
|
|
31
|
+
* - A negative duration is allowed and moves backwards; that is how a departure is recovered
|
|
32
|
+
* from an arrival.
|
|
33
|
+
* - Returns `""` when `departure` is not a zoned datetime or instant string, or `duration` is
|
|
34
|
+
* not an ISO 8601 duration.
|
|
35
|
+
*
|
|
36
|
+
* @param departure ISO 8601 zoned datetime (bracketed zone) or instant string (`Z` or offset)
|
|
37
|
+
* @param duration ISO 8601 duration of the leg; time units and 24-hour days only
|
|
38
|
+
* @returns the arrival in the departure's zone or offset form, or "" on invalid input
|
|
39
|
+
*
|
|
40
|
+
* @example transitTime("2024-06-15T10:00:00-04:00[America/New_York]", "PT2H30M") // "2024-06-15T12:30:00-04:00[America/New_York]"
|
|
41
|
+
* @example transitTime("2024-03-09T23:00:00-05:00[America/New_York]", "PT4H") // "2024-03-10T04:00:00-04:00[America/New_York]" (four elapsed hours across the spring-forward, so the wall clock reads 04:00, not 03:00)
|
|
42
|
+
* @example transitTime("2024-03-09T12:00:00-05:00[America/New_York]", "P1D") // "2024-03-10T13:00:00-04:00[America/New_York]" (a day is 24 elapsed hours, not "the same wall time tomorrow")
|
|
43
|
+
* @example transitTime("2024-06-15T10:00:00Z", "PT36H") // "2024-06-16T22:00:00Z"
|
|
44
|
+
* @example transitTime("2024-06-15T10:00:00+09:00", "PT1H") // "2024-06-15T11:00:00+09:00" (an offset stays an offset)
|
|
45
|
+
* @example transitTime("2024-06-15T10:00:00+05:30:15", "PT1H") // "2024-06-15T11:00:00+05:30:15" (a sub-minute offset is kept as written)
|
|
46
|
+
* @example transitTime("2024-06-15T10:00:00Z[foo=bar]", "PT1H") // "2024-06-15T11:00:00Z" (an elective unknown annotation is ignored)
|
|
47
|
+
* @example transitTime("2024-06-15T12:30:00-04:00[America/New_York]", "-PT2H30M") // "2024-06-15T10:00:00-04:00[America/New_York]"
|
|
48
|
+
* @example transitTime("2024-06-15T10:00:00-04:00[America/New_York]", "P1M") // "" (calendar units need a reference point)
|
|
49
|
+
* @example transitTime("2024-06-15T10:00:00-05:00[America/New_York]", "PT2H") // "" (New York is -04:00 in June: the offset contradicts the zone)
|
|
50
|
+
* @example transitTime("2024-06-15T10:00:00", "PT2H") // "" (no zone and no offset: not a moment)
|
|
51
|
+
* @example transitTime("2024-06-15T10:00:00Z", "2 hours") // ""
|
|
52
|
+
*/
|
|
53
|
+
export declare function transitTime(departure: string, duration: string): string;
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { Temporal } from "@js-temporal/polyfill";
|
|
2
|
+
import { EXTENDED_UTC_OFFSET, TIME_ZONE_ANNOTATION, utcOffsetStringNanoseconds, wallClockAtOffset, zonedDateTimeFrom, } from "../../internal/index.js";
|
|
3
|
+
import { isValidDuration } from "../../duration/validate/isValidDuration.js";
|
|
4
|
+
import { isValidInstant } from "../../precision/validate/isValidInstant.js";
|
|
5
|
+
import { isValidZonedDateTime } from "../../zoned/validate/isValidZonedDateTime.js";
|
|
6
|
+
/**
|
|
7
|
+
* Trailing `Z` or extended offset of an instant string, before any annotation. The offset grammar
|
|
8
|
+
* is `isValidInstant`'s own (`EXTENDED_UTC_OFFSET`), so every offset it accepts, `,` fractions
|
|
9
|
+
* included, is captured as written.
|
|
10
|
+
*/
|
|
11
|
+
const trailingOffset = new RegExp(`(Z|${EXTENDED_UTC_OFFSET})(?:\\[[^\\]]*\\])*$`);
|
|
12
|
+
/**
|
|
13
|
+
* A time-zone annotation anywhere in the string (`TIME_ZONE_ANNOTATION`). Every `key=value`
|
|
14
|
+
* bracket is a tagged annotation, which `isValidInstant` already decides.
|
|
15
|
+
*/
|
|
16
|
+
const zoneAnnotation = new RegExp(TIME_ZONE_ANNOTATION);
|
|
17
|
+
/**
|
|
18
|
+
* Add a transit duration to a departure and return the arrival, in the departure's own zone.
|
|
19
|
+
*
|
|
20
|
+
* The first of the three shared transport operations: a leg is a departure plus how long it
|
|
21
|
+
* takes. Every mode publishes it that way, and every mode gets the same thing wrong — a leg
|
|
22
|
+
* that crosses a DST transition is a fixed number of *elapsed* hours, not a wall-clock
|
|
23
|
+
* interval, so the local arrival time reflects the shift.
|
|
24
|
+
*
|
|
25
|
+
* - **Transit time is exact time.** Hours, minutes, seconds and fractions are added as elapsed
|
|
26
|
+
* time, and a day component means 24 hours, exactly. That is TC39 Temporal's rule:
|
|
27
|
+
* `Duration.prototype.round` and `total` without a `relativeTo` treat a day as 24 hours and
|
|
28
|
+
* require a `relativeTo` for weeks, months and years. A 26-hour leg that starts the evening
|
|
29
|
+
* before a spring-forward arrives at a wall time one hour later than a wall-clock reading would
|
|
30
|
+
* suggest, because that is when the vessel, train or aircraft actually gets there.
|
|
31
|
+
* - **Calendar units are refused.** A `duration` with years, months or weeks returns `""`: no
|
|
32
|
+
* leg takes "a month" in a sense that time arithmetic can fix without a reference point, and
|
|
33
|
+
* Temporal will not add them to an instant either. Scheduled connections that are dated rather
|
|
34
|
+
* than timed belong to `scheduleDelivery`.
|
|
35
|
+
* - **The departure's zone is preserved.** A bracketed IANA zone (`…-05:00[America/New_York]`)
|
|
36
|
+
* stays that zone, so the arrival's offset is whatever is in force there at arrival. A `Z`
|
|
37
|
+
* instant returns a `Z` instant; an offset-only instant keeps its offset, written exactly as
|
|
38
|
+
* the departure wrote it, sub-minute offsets such as `+05:30:15` included (an offset is not a
|
|
39
|
+
* zone, so nothing else can be inferred from it — see `toOffsetInstant`). A bracketed zone
|
|
40
|
+
* that does not exist, or that contradicts its offset, is rejected as `isValidZonedDateTime`
|
|
41
|
+
* rejects it; it does not fall back to the offset.
|
|
42
|
+
* - RFC 9557 annotations are read as `Temporal.Instant.from` reads them: a bracket without `=`
|
|
43
|
+
* is a time zone, and a `key=value` bracket with an unknown key is ignored when elective and
|
|
44
|
+
* rejected when critical (`[!…]`). A calendar on a departure without a zone is accepted and
|
|
45
|
+
* changes nothing, since an instant has no calendar; a zoned departure's calendar must be ISO,
|
|
46
|
+
* as `isValidZonedDateTime` requires.
|
|
47
|
+
* - A negative duration is allowed and moves backwards; that is how a departure is recovered
|
|
48
|
+
* from an arrival.
|
|
49
|
+
* - Returns `""` when `departure` is not a zoned datetime or instant string, or `duration` is
|
|
50
|
+
* not an ISO 8601 duration.
|
|
51
|
+
*
|
|
52
|
+
* @param departure ISO 8601 zoned datetime (bracketed zone) or instant string (`Z` or offset)
|
|
53
|
+
* @param duration ISO 8601 duration of the leg; time units and 24-hour days only
|
|
54
|
+
* @returns the arrival in the departure's zone or offset form, or "" on invalid input
|
|
55
|
+
*
|
|
56
|
+
* @example transitTime("2024-06-15T10:00:00-04:00[America/New_York]", "PT2H30M") // "2024-06-15T12:30:00-04:00[America/New_York]"
|
|
57
|
+
* @example transitTime("2024-03-09T23:00:00-05:00[America/New_York]", "PT4H") // "2024-03-10T04:00:00-04:00[America/New_York]" (four elapsed hours across the spring-forward, so the wall clock reads 04:00, not 03:00)
|
|
58
|
+
* @example transitTime("2024-03-09T12:00:00-05:00[America/New_York]", "P1D") // "2024-03-10T13:00:00-04:00[America/New_York]" (a day is 24 elapsed hours, not "the same wall time tomorrow")
|
|
59
|
+
* @example transitTime("2024-06-15T10:00:00Z", "PT36H") // "2024-06-16T22:00:00Z"
|
|
60
|
+
* @example transitTime("2024-06-15T10:00:00+09:00", "PT1H") // "2024-06-15T11:00:00+09:00" (an offset stays an offset)
|
|
61
|
+
* @example transitTime("2024-06-15T10:00:00+05:30:15", "PT1H") // "2024-06-15T11:00:00+05:30:15" (a sub-minute offset is kept as written)
|
|
62
|
+
* @example transitTime("2024-06-15T10:00:00Z[foo=bar]", "PT1H") // "2024-06-15T11:00:00Z" (an elective unknown annotation is ignored)
|
|
63
|
+
* @example transitTime("2024-06-15T12:30:00-04:00[America/New_York]", "-PT2H30M") // "2024-06-15T10:00:00-04:00[America/New_York]"
|
|
64
|
+
* @example transitTime("2024-06-15T10:00:00-04:00[America/New_York]", "P1M") // "" (calendar units need a reference point)
|
|
65
|
+
* @example transitTime("2024-06-15T10:00:00-05:00[America/New_York]", "PT2H") // "" (New York is -04:00 in June: the offset contradicts the zone)
|
|
66
|
+
* @example transitTime("2024-06-15T10:00:00", "PT2H") // "" (no zone and no offset: not a moment)
|
|
67
|
+
* @example transitTime("2024-06-15T10:00:00Z", "2 hours") // ""
|
|
68
|
+
*/
|
|
69
|
+
export function transitTime(departure, duration) {
|
|
70
|
+
// A non-string would reach the regex tests below, which coerce it: a throwing `toString`, a
|
|
71
|
+
// Symbol or a hostile Proxy would escape. Refuse it before any read.
|
|
72
|
+
if (typeof departure !== "string" || typeof duration !== "string") {
|
|
73
|
+
return "";
|
|
74
|
+
}
|
|
75
|
+
try {
|
|
76
|
+
return addLeg(departure, duration);
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
// Never throws (Core Rule 3): a hostile
|
|
80
|
+
// argument is invalid input, not an exception.
|
|
81
|
+
return "";
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
/** `transitTime` for two strings, without the guard. */
|
|
85
|
+
function addLeg(departure, duration) {
|
|
86
|
+
if (!isValidDuration(duration)) {
|
|
87
|
+
return "";
|
|
88
|
+
}
|
|
89
|
+
const zoned = isValidZonedDateTime(departure);
|
|
90
|
+
// A string that names a zone must name a real one that agrees with its offset; only a string
|
|
91
|
+
// that names none may fall back to the instant path.
|
|
92
|
+
if (!zoned &&
|
|
93
|
+
(zoneAnnotation.test(departure) || !isValidInstant(departure))) {
|
|
94
|
+
return "";
|
|
95
|
+
}
|
|
96
|
+
try {
|
|
97
|
+
const parsed = Temporal.Duration.from(duration);
|
|
98
|
+
if (parsed.years !== 0 || parsed.months !== 0 || parsed.weeks !== 0) {
|
|
99
|
+
return "";
|
|
100
|
+
}
|
|
101
|
+
// Days become 24 exact hours; every remaining unit is already exact time.
|
|
102
|
+
const exact = parsed.round({ largestUnit: "hours" });
|
|
103
|
+
if (zoned) {
|
|
104
|
+
return zonedDateTimeFrom(departure).add(exact).toString();
|
|
105
|
+
}
|
|
106
|
+
const arrival = Temporal.Instant.from(departure).add(exact);
|
|
107
|
+
const offset = trailingOffset.exec(departure)?.[1] ?? "Z";
|
|
108
|
+
if (offset === "Z") {
|
|
109
|
+
return arrival.toString();
|
|
110
|
+
}
|
|
111
|
+
// The arrival's wall clock in the departure's own offset, written with that offset's text.
|
|
112
|
+
const wall = wallClockAtOffset(arrival, utcOffsetStringNanoseconds(offset));
|
|
113
|
+
return `${wall.toString()}${offset}`;
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
return "";
|
|
117
|
+
}
|
|
118
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Render an arrival instant as local time in the zone where it will be read.
|
|
3
|
+
*
|
|
4
|
+
* Schedules are computed in UTC and displayed where the cargo lands. The zone is the caller's
|
|
5
|
+
* fact: GMT does not resolve a port, airport, terminal or station code to a timezone, and
|
|
6
|
+
* takes an IANA identifier instead (see the parked registries note in the epic's painpoints).
|
|
7
|
+
*
|
|
8
|
+
* - `arrivalUtc` is any instant `isValidInstant` accepts — `Z`, an offset, or an offset with a
|
|
9
|
+
* bracketed zone. Only the instant is read; a bracketed zone in the input is not the zone the
|
|
10
|
+
* result is rendered in, `targetZone` is.
|
|
11
|
+
* - **No disambiguation arises.** The input is a moment, and a moment has exactly one wall
|
|
12
|
+
* time in any zone, so the `disambiguation` vocabulary of `resolveLocal` does not apply here.
|
|
13
|
+
* On a fall-back night two moments an hour apart print the same wall time with different
|
|
14
|
+
* offsets, and the offset in the result is what tells them apart; on a spring-forward night
|
|
15
|
+
* the skipped hour simply never appears. That is also why the result carries its offset and
|
|
16
|
+
* zone rather than a bare wall time.
|
|
17
|
+
* - Returns a zoned ISO 8601 string, `Temporal.ZonedDateTime.prototype.toString()`'s form:
|
|
18
|
+
* wall time, offset, bracketed zone.
|
|
19
|
+
* - `targetZone` is whatever `isValidTimeZone` accepts, which is what Temporal accepts: an IANA
|
|
20
|
+
* identifier, or a fixed offset such as `"+09:00"`, which renders with the offset as its zone.
|
|
21
|
+
* A fixed offset is a legitimate rendering choice for a ship's log (MAR-18); it is not a place.
|
|
22
|
+
* - Returns `""` when the instant or the zone is invalid.
|
|
23
|
+
*
|
|
24
|
+
* @param arrivalUtc ISO 8601 instant string of the arrival
|
|
25
|
+
* @param targetZone IANA timeZone identifier of the place the arrival is read in
|
|
26
|
+
* @returns zoned ISO 8601 string of the local arrival, or "" on invalid input
|
|
27
|
+
*
|
|
28
|
+
* @example etaAtZone("2024-06-15T12:30:00Z", "Asia/Tokyo") // "2024-06-15T21:30:00+09:00[Asia/Tokyo]"
|
|
29
|
+
* @example etaAtZone("2024-06-15T12:30:00Z", "America/Los_Angeles") // "2024-06-15T05:30:00-07:00[America/Los_Angeles]"
|
|
30
|
+
* @example etaAtZone("2024-06-15T12:30:00+02:00", "Pacific/Apia") // "2024-06-15T23:30:00+13:00[Pacific/Apia]" (the offset fixes the instant; the zone renders it)
|
|
31
|
+
* @example etaAtZone("2024-11-03T05:30:00Z", "America/New_York") // "2024-11-03T01:30:00-04:00[America/New_York]" (the first 01:30 of the fall-back night)
|
|
32
|
+
* @example etaAtZone("2024-11-03T06:30:00Z", "America/New_York") // "2024-11-03T01:30:00-05:00[America/New_York]" (the second; the offset tells them apart)
|
|
33
|
+
* @example etaAtZone("2024-06-15T12:30:00", "Asia/Tokyo") // "" (no offset designator: not a moment)
|
|
34
|
+
* @example etaAtZone("2024-06-15T12:30:00Z", "Asia/Tokio") // ""
|
|
35
|
+
*/
|
|
36
|
+
export declare function etaAtZone(arrivalUtc: string, targetZone: string): string;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { Temporal } from "@js-temporal/polyfill";
|
|
2
|
+
import { isValidInstant } from "../../precision/validate/isValidInstant.js";
|
|
3
|
+
import { isValidTimeZone } from "../../zoned/validate/isValidTimeZone.js";
|
|
4
|
+
/**
|
|
5
|
+
* Render an arrival instant as local time in the zone where it will be read.
|
|
6
|
+
*
|
|
7
|
+
* Schedules are computed in UTC and displayed where the cargo lands. The zone is the caller's
|
|
8
|
+
* fact: GMT does not resolve a port, airport, terminal or station code to a timezone, and
|
|
9
|
+
* takes an IANA identifier instead (see the parked registries note in the epic's painpoints).
|
|
10
|
+
*
|
|
11
|
+
* - `arrivalUtc` is any instant `isValidInstant` accepts — `Z`, an offset, or an offset with a
|
|
12
|
+
* bracketed zone. Only the instant is read; a bracketed zone in the input is not the zone the
|
|
13
|
+
* result is rendered in, `targetZone` is.
|
|
14
|
+
* - **No disambiguation arises.** The input is a moment, and a moment has exactly one wall
|
|
15
|
+
* time in any zone, so the `disambiguation` vocabulary of `resolveLocal` does not apply here.
|
|
16
|
+
* On a fall-back night two moments an hour apart print the same wall time with different
|
|
17
|
+
* offsets, and the offset in the result is what tells them apart; on a spring-forward night
|
|
18
|
+
* the skipped hour simply never appears. That is also why the result carries its offset and
|
|
19
|
+
* zone rather than a bare wall time.
|
|
20
|
+
* - Returns a zoned ISO 8601 string, `Temporal.ZonedDateTime.prototype.toString()`'s form:
|
|
21
|
+
* wall time, offset, bracketed zone.
|
|
22
|
+
* - `targetZone` is whatever `isValidTimeZone` accepts, which is what Temporal accepts: an IANA
|
|
23
|
+
* identifier, or a fixed offset such as `"+09:00"`, which renders with the offset as its zone.
|
|
24
|
+
* A fixed offset is a legitimate rendering choice for a ship's log (MAR-18); it is not a place.
|
|
25
|
+
* - Returns `""` when the instant or the zone is invalid.
|
|
26
|
+
*
|
|
27
|
+
* @param arrivalUtc ISO 8601 instant string of the arrival
|
|
28
|
+
* @param targetZone IANA timeZone identifier of the place the arrival is read in
|
|
29
|
+
* @returns zoned ISO 8601 string of the local arrival, or "" on invalid input
|
|
30
|
+
*
|
|
31
|
+
* @example etaAtZone("2024-06-15T12:30:00Z", "Asia/Tokyo") // "2024-06-15T21:30:00+09:00[Asia/Tokyo]"
|
|
32
|
+
* @example etaAtZone("2024-06-15T12:30:00Z", "America/Los_Angeles") // "2024-06-15T05:30:00-07:00[America/Los_Angeles]"
|
|
33
|
+
* @example etaAtZone("2024-06-15T12:30:00+02:00", "Pacific/Apia") // "2024-06-15T23:30:00+13:00[Pacific/Apia]" (the offset fixes the instant; the zone renders it)
|
|
34
|
+
* @example etaAtZone("2024-11-03T05:30:00Z", "America/New_York") // "2024-11-03T01:30:00-04:00[America/New_York]" (the first 01:30 of the fall-back night)
|
|
35
|
+
* @example etaAtZone("2024-11-03T06:30:00Z", "America/New_York") // "2024-11-03T01:30:00-05:00[America/New_York]" (the second; the offset tells them apart)
|
|
36
|
+
* @example etaAtZone("2024-06-15T12:30:00", "Asia/Tokyo") // "" (no offset designator: not a moment)
|
|
37
|
+
* @example etaAtZone("2024-06-15T12:30:00Z", "Asia/Tokio") // ""
|
|
38
|
+
*/
|
|
39
|
+
export function etaAtZone(arrivalUtc, targetZone) {
|
|
40
|
+
try {
|
|
41
|
+
if (!isValidInstant(arrivalUtc) || !isValidTimeZone(targetZone)) {
|
|
42
|
+
return "";
|
|
43
|
+
}
|
|
44
|
+
return Temporal.Instant.from(arrivalUtc)
|
|
45
|
+
.toZonedDateTimeISO(targetZone)
|
|
46
|
+
.toString();
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
// Never throws (Core Rule 3): a hostile
|
|
50
|
+
// argument is invalid input, not an exception.
|
|
51
|
+
return "";
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./etaAtZone.js";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./etaAtZone.js";
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { BusinessCalendar } from "./business-calendar.js";
|
|
2
|
+
/**
|
|
3
|
+
* How days are counted: every local calendar day, or only the terminal's working days. Used for
|
|
4
|
+
* free time (`basis`) and, separately, for the days charged after it (`chargeBasis`), because
|
|
5
|
+
* published tariffs commonly count the two differently.
|
|
6
|
+
*/
|
|
7
|
+
export type FreeTimeBasis = "calendar" | "working";
|
|
8
|
+
/**
|
|
9
|
+
* Whether the day of the triggering event is free day one (`"eventDay"`), or free time starts
|
|
10
|
+
* on the following counted day (`"nextDay"`). The two conventions differ by one full day of
|
|
11
|
+
* charges, so no function defaults it.
|
|
12
|
+
*/
|
|
13
|
+
export type FreeTimeFirstDay = "eventDay" | "nextDay";
|
|
14
|
+
/**
|
|
15
|
+
* The tariff terms a free-time count needs. Free time is set by carrier, lane, trade and service
|
|
16
|
+
* contract, never by port, so every term is a parameter and no counting term has a default.
|
|
17
|
+
*
|
|
18
|
+
* - `basis`: `"calendar"` counts every local day, so a weekend burns two free days while the
|
|
19
|
+
* terminal is shut; `"working"` counts only the days `calendar` marks as working, and the
|
|
20
|
+
* clock does not run on weekends or holidays.
|
|
21
|
+
* - `timeZone`: the IANA identifier of the terminal, depot or yard whose local midnight ends a
|
|
22
|
+
* day. GMT does not resolve a port or terminal code to a zone. A fixed offset such as
|
|
23
|
+
* `"+02:00"` is accepted, as `isValidTimeZone` accepts it, but observes no DST.
|
|
24
|
+
* - `firstDay`: `"eventDay"` or `"nextDay"`, see `FreeTimeFirstDay`.
|
|
25
|
+
* - `calendar`: the terminal's `BusinessCalendar`, required when `basis` is `"working"` and not
|
|
26
|
+
* read when it is `"calendar"`. Its `timeZone` field is not read either, as the business-day
|
|
27
|
+
* functions never read it: `timeZone` above is the counting zone.
|
|
28
|
+
*/
|
|
29
|
+
export type FreeTimeOptions = {
|
|
30
|
+
basis: FreeTimeBasis;
|
|
31
|
+
timeZone: string;
|
|
32
|
+
firstDay: FreeTimeFirstDay;
|
|
33
|
+
calendar?: BusinessCalendar;
|
|
34
|
+
};
|
|
File without changes
|