@northguild/gmt 1.18.0 → 1.19.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 (593) hide show
  1. package/README.md +55 -2920
  2. package/dist/calendar/calculate/bucketRange.d.ts +10 -2
  3. package/dist/calendar/calculate/bucketRange.js +18 -61
  4. package/dist/calendar/calculate/floorToZone.d.ts +10 -2
  5. package/dist/calendar/calculate/floorToZone.js +15 -7
  6. package/dist/calendar/hours/addOperatingTime.d.ts +6 -0
  7. package/dist/calendar/hours/addOperatingTime.js +7 -1
  8. package/dist/calendar/hours/isOpenAt.d.ts +6 -0
  9. package/dist/calendar/hours/isOpenAt.js +6 -0
  10. package/dist/calendar/hours/nextCloseAt.d.ts +6 -0
  11. package/dist/calendar/hours/nextCloseAt.js +7 -1
  12. package/dist/calendar/hours/nextOpenAt.d.ts +6 -0
  13. package/dist/calendar/hours/nextOpenAt.js +7 -1
  14. package/dist/calendar/hours/operatingIntervals.d.ts +5 -0
  15. package/dist/calendar/hours/operatingIntervals.js +5 -0
  16. package/dist/calendar/hours/operatingTimeBetween.d.ts +5 -0
  17. package/dist/calendar/hours/operatingTimeBetween.js +5 -0
  18. package/dist/calendar/hours/recurringWindows.d.ts +8 -1
  19. package/dist/calendar/hours/recurringWindows.js +12 -4
  20. package/dist/duration/calculate/durationFromUnits.d.ts +47 -0
  21. package/dist/duration/calculate/durationFromUnits.js +67 -0
  22. package/dist/duration/calculate/getDurationUnit.d.ts +2 -2
  23. package/dist/duration/calculate/getDurationUnit.js +2 -2
  24. package/dist/duration/calculate/index.d.ts +3 -1
  25. package/dist/duration/calculate/index.js +3 -1
  26. package/dist/duration/calculate/negateDuration.d.ts +1 -1
  27. package/dist/duration/calculate/negateDuration.js +1 -1
  28. package/dist/duration/calculate/{durationAs.d.ts → totalDuration.d.ts} +25 -19
  29. package/dist/duration/calculate/{durationAs.js → totalDuration.js} +25 -19
  30. package/dist/duration/calculate/totalUnits.d.ts +49 -0
  31. package/dist/duration/calculate/totalUnits.js +73 -0
  32. package/dist/duration/compare/compareDurations.d.ts +1 -1
  33. package/dist/duration/compare/compareDurations.js +1 -1
  34. package/dist/duty/calculate/dutyDayFor.d.ts +38 -0
  35. package/dist/duty/calculate/dutyDayFor.js +71 -0
  36. package/dist/duty/calculate/dutyTotals.d.ts +24 -0
  37. package/dist/duty/calculate/dutyTotals.js +41 -0
  38. package/dist/duty/calculate/dutyViolations.d.ts +74 -0
  39. package/dist/duty/calculate/dutyViolations.js +65 -0
  40. package/dist/duty/calculate/evaluateDutyLog.d.ts +77 -0
  41. package/dist/duty/calculate/evaluateDutyLog.js +75 -0
  42. package/dist/duty/calculate/index.d.ts +7 -0
  43. package/dist/duty/calculate/index.js +7 -0
  44. package/dist/duty/calculate/longestRun.d.ts +28 -0
  45. package/dist/duty/calculate/longestRun.js +54 -0
  46. package/dist/duty/calculate/qualifyingRestPairs.d.ts +37 -0
  47. package/dist/duty/calculate/qualifyingRestPairs.js +58 -0
  48. package/dist/duty/calculate/qualifyingRests.d.ts +27 -0
  49. package/dist/duty/calculate/qualifyingRests.js +49 -0
  50. package/dist/duty/index.d.ts +3 -0
  51. package/dist/duty/index.js +4 -0
  52. package/dist/duty/validate/index.d.ts +1 -0
  53. package/dist/duty/validate/index.js +1 -0
  54. package/dist/duty/validate/isValidDutyLog.d.ts +24 -0
  55. package/dist/duty/validate/isValidDutyLog.js +34 -0
  56. package/dist/index.d.ts +1 -0
  57. package/dist/index.js +1 -0
  58. package/dist/instant/convert/classifyLocal.d.ts +5 -1
  59. package/dist/instant/convert/classifyLocal.js +16 -6
  60. package/dist/instant/convert/fromOffsetInstant.d.ts +4 -0
  61. package/dist/instant/convert/fromOffsetInstant.js +6 -1
  62. package/dist/instant/convert/resolveLocal.d.ts +7 -2
  63. package/dist/instant/convert/resolveLocal.js +16 -8
  64. package/dist/instant/convert/toOffsetInstant.d.ts +12 -8
  65. package/dist/instant/convert/toOffsetInstant.js +19 -11
  66. package/dist/instant/index.d.ts +1 -0
  67. package/dist/instant/index.js +1 -0
  68. package/dist/instant/validate/index.d.ts +1 -0
  69. package/dist/instant/validate/index.js +1 -0
  70. package/dist/instant/validate/isValidUtcOffset.d.ts +32 -0
  71. package/dist/instant/validate/isValidUtcOffset.js +35 -0
  72. package/dist/intermodal/calculate/chargeableDays.d.ts +6 -0
  73. package/dist/intermodal/calculate/chargeableDays.js +12 -4
  74. package/dist/intermodal/calculate/freeTimeExpiry.d.ts +7 -0
  75. package/dist/intermodal/calculate/freeTimeExpiry.js +15 -4
  76. package/dist/intermodal/calculate/index.d.ts +1 -0
  77. package/dist/intermodal/calculate/index.js +1 -0
  78. package/dist/intermodal/calculate/multimodalETA.d.ts +76 -0
  79. package/dist/intermodal/calculate/multimodalETA.js +145 -0
  80. package/dist/intermodal/format/bolTimestamp.d.ts +76 -0
  81. package/dist/intermodal/format/bolTimestamp.js +78 -0
  82. package/dist/intermodal/format/formatEdifactDate.d.ts +34 -0
  83. package/dist/intermodal/format/formatEdifactDate.js +42 -0
  84. package/dist/intermodal/format/formatEdifactDatePeriod.d.ts +35 -0
  85. package/dist/intermodal/format/formatEdifactDatePeriod.js +43 -0
  86. package/dist/intermodal/format/formatEdifactDateTime.d.ts +37 -0
  87. package/dist/intermodal/format/formatEdifactDateTime.js +45 -0
  88. package/dist/intermodal/format/formatEdifactDateTimePeriod.d.ts +40 -0
  89. package/dist/intermodal/format/formatEdifactDateTimePeriod.js +48 -0
  90. package/dist/intermodal/format/formatEdifactOffsetDateTime.d.ts +62 -0
  91. package/dist/intermodal/format/formatEdifactOffsetDateTime.js +70 -0
  92. package/dist/intermodal/format/formatEdifactTime.d.ts +35 -0
  93. package/dist/intermodal/format/formatEdifactTime.js +43 -0
  94. package/dist/intermodal/format/formatEpcisEvent.d.ts +47 -0
  95. package/dist/intermodal/format/formatEpcisEvent.js +79 -0
  96. package/dist/intermodal/format/formatX12Date.d.ts +35 -0
  97. package/dist/intermodal/format/formatX12Date.js +43 -0
  98. package/dist/intermodal/format/formatX12DateRange.d.ts +37 -0
  99. package/dist/intermodal/format/formatX12DateRange.js +45 -0
  100. package/dist/intermodal/format/formatX12DateTime.d.ts +39 -0
  101. package/dist/intermodal/format/formatX12DateTime.js +47 -0
  102. package/dist/intermodal/format/formatX12DateTimeRange.d.ts +40 -0
  103. package/dist/intermodal/format/formatX12DateTimeRange.js +48 -0
  104. package/dist/intermodal/format/formatX12Time.d.ts +42 -0
  105. package/dist/intermodal/format/formatX12Time.js +50 -0
  106. package/dist/intermodal/format/formatX12TimeElement.d.ts +46 -0
  107. package/dist/intermodal/format/formatX12TimeElement.js +54 -0
  108. package/dist/intermodal/format/index.d.ts +14 -0
  109. package/dist/intermodal/format/index.js +14 -0
  110. package/dist/intermodal/index.d.ts +3 -0
  111. package/dist/intermodal/index.js +3 -0
  112. package/dist/intermodal/parse/classifyEdifactDtmFormat.d.ts +48 -0
  113. package/dist/intermodal/parse/classifyEdifactDtmFormat.js +55 -0
  114. package/dist/intermodal/parse/classifyX12DateTimePeriodFormat.d.ts +50 -0
  115. package/dist/intermodal/parse/classifyX12DateTimePeriodFormat.js +57 -0
  116. package/dist/intermodal/parse/classifyX12TimeCode.d.ts +46 -0
  117. package/dist/intermodal/parse/classifyX12TimeCode.js +57 -0
  118. package/dist/intermodal/parse/index.d.ts +18 -0
  119. package/dist/intermodal/parse/index.js +18 -0
  120. package/dist/intermodal/parse/parseEdifactDate.d.ts +37 -0
  121. package/dist/intermodal/parse/parseEdifactDate.js +45 -0
  122. package/dist/intermodal/parse/parseEdifactDatePeriod.d.ts +43 -0
  123. package/dist/intermodal/parse/parseEdifactDatePeriod.js +51 -0
  124. package/dist/intermodal/parse/parseEdifactDateTime.d.ts +43 -0
  125. package/dist/intermodal/parse/parseEdifactDateTime.js +51 -0
  126. package/dist/intermodal/parse/parseEdifactDateTimePeriod.d.ts +45 -0
  127. package/dist/intermodal/parse/parseEdifactDateTimePeriod.js +53 -0
  128. package/dist/intermodal/parse/parseEdifactOffsetDateTime.d.ts +60 -0
  129. package/dist/intermodal/parse/parseEdifactOffsetDateTime.js +68 -0
  130. package/dist/intermodal/parse/parseEdifactTime.d.ts +37 -0
  131. package/dist/intermodal/parse/parseEdifactTime.js +45 -0
  132. package/dist/intermodal/parse/parseEpcisEvent.d.ts +95 -0
  133. package/dist/intermodal/parse/parseEpcisEvent.js +102 -0
  134. package/dist/intermodal/parse/parseX12Date.d.ts +39 -0
  135. package/dist/intermodal/parse/parseX12Date.js +47 -0
  136. package/dist/intermodal/parse/parseX12DateAndTime.d.ts +52 -0
  137. package/dist/intermodal/parse/parseX12DateAndTime.js +61 -0
  138. package/dist/intermodal/parse/parseX12DateRange.d.ts +44 -0
  139. package/dist/intermodal/parse/parseX12DateRange.js +52 -0
  140. package/dist/intermodal/parse/parseX12DateTime.d.ts +45 -0
  141. package/dist/intermodal/parse/parseX12DateTime.js +53 -0
  142. package/dist/intermodal/parse/parseX12DateTimeRange.d.ts +46 -0
  143. package/dist/intermodal/parse/parseX12DateTimeRange.js +54 -0
  144. package/dist/intermodal/parse/parseX12Time.d.ts +65 -0
  145. package/dist/intermodal/parse/parseX12Time.js +77 -0
  146. package/dist/intermodal/parse/x12TimeCodeOffset.d.ts +57 -0
  147. package/dist/intermodal/parse/x12TimeCodeOffset.js +110 -0
  148. package/dist/intermodal/parse/x12TimeCodeZone.d.ts +57 -0
  149. package/dist/intermodal/parse/x12TimeCodeZone.js +105 -0
  150. package/dist/intermodal/validate/index.d.ts +18 -0
  151. package/dist/intermodal/validate/index.js +18 -0
  152. package/dist/intermodal/validate/isValidEdifactDate.d.ts +26 -0
  153. package/dist/intermodal/validate/isValidEdifactDate.js +28 -0
  154. package/dist/intermodal/validate/isValidEdifactDatePeriod.d.ts +28 -0
  155. package/dist/intermodal/validate/isValidEdifactDatePeriod.js +30 -0
  156. package/dist/intermodal/validate/isValidEdifactDateTime.d.ts +30 -0
  157. package/dist/intermodal/validate/isValidEdifactDateTime.js +32 -0
  158. package/dist/intermodal/validate/isValidEdifactDateTimePeriod.d.ts +28 -0
  159. package/dist/intermodal/validate/isValidEdifactDateTimePeriod.js +30 -0
  160. package/dist/intermodal/validate/isValidEdifactDtmFormat.d.ts +39 -0
  161. package/dist/intermodal/validate/isValidEdifactDtmFormat.js +42 -0
  162. package/dist/intermodal/validate/isValidEdifactOffsetDateTime.d.ts +36 -0
  163. package/dist/intermodal/validate/isValidEdifactOffsetDateTime.js +38 -0
  164. package/dist/intermodal/validate/isValidEdifactTime.d.ts +28 -0
  165. package/dist/intermodal/validate/isValidEdifactTime.js +30 -0
  166. package/dist/intermodal/validate/isValidEpcisEvent.d.ts +41 -0
  167. package/dist/intermodal/validate/isValidEpcisEvent.js +58 -0
  168. package/dist/intermodal/validate/isValidEpcisEventTime.d.ts +33 -0
  169. package/dist/intermodal/validate/isValidEpcisEventTime.js +48 -0
  170. package/dist/intermodal/validate/isValidEpcisTimeZoneOffset.d.ts +30 -0
  171. package/dist/intermodal/validate/isValidEpcisTimeZoneOffset.js +38 -0
  172. package/dist/intermodal/validate/isValidX12Date.d.ts +28 -0
  173. package/dist/intermodal/validate/isValidX12Date.js +30 -0
  174. package/dist/intermodal/validate/isValidX12DateAndTime.d.ts +32 -0
  175. package/dist/intermodal/validate/isValidX12DateAndTime.js +35 -0
  176. package/dist/intermodal/validate/isValidX12DateRange.d.ts +29 -0
  177. package/dist/intermodal/validate/isValidX12DateRange.js +31 -0
  178. package/dist/intermodal/validate/isValidX12DateTime.d.ts +30 -0
  179. package/dist/intermodal/validate/isValidX12DateTime.js +32 -0
  180. package/dist/intermodal/validate/isValidX12DateTimePeriodFormat.d.ts +43 -0
  181. package/dist/intermodal/validate/isValidX12DateTimePeriodFormat.js +46 -0
  182. package/dist/intermodal/validate/isValidX12DateTimeRange.d.ts +31 -0
  183. package/dist/intermodal/validate/isValidX12DateTimeRange.js +33 -0
  184. package/dist/intermodal/validate/isValidX12Time.d.ts +38 -0
  185. package/dist/intermodal/validate/isValidX12Time.js +40 -0
  186. package/dist/intermodal/validate/isValidX12TimeCode.d.ts +38 -0
  187. package/dist/intermodal/validate/isValidX12TimeCode.js +41 -0
  188. package/dist/internal/businessCalendar.js +4 -9
  189. package/dist/internal/dutyEngine.d.ts +125 -0
  190. package/dist/internal/dutyEngine.js +1746 -0
  191. package/dist/internal/dutyLog.d.ts +27 -0
  192. package/dist/internal/dutyLog.js +47 -0
  193. package/dist/internal/dutyPeriods.d.ts +135 -0
  194. package/dist/internal/dutyPeriods.js +369 -0
  195. package/dist/internal/dutyRules.d.ts +197 -0
  196. package/dist/internal/dutyRules.js +484 -0
  197. package/dist/internal/ediDateTimeFields.d.ts +97 -0
  198. package/dist/internal/ediDateTimeFields.js +288 -0
  199. package/dist/internal/ediDateTimeWriter.d.ts +92 -0
  200. package/dist/internal/ediDateTimeWriter.js +298 -0
  201. package/dist/internal/ediGrammar.d.ts +158 -0
  202. package/dist/internal/ediGrammar.js +384 -0
  203. package/dist/internal/englishCalendarNames.d.ts +3 -1
  204. package/dist/internal/englishCalendarNames.js +3 -1
  205. package/dist/internal/freeTimeLedger.d.ts +5 -0
  206. package/dist/internal/freeTimeLedger.js +11 -2
  207. package/dist/internal/index.d.ts +5 -2
  208. package/dist/internal/index.js +9 -2
  209. package/dist/internal/intervalNanoseconds.js +2 -1
  210. package/dist/internal/isObject.d.ts +13 -6
  211. package/dist/internal/isObject.js +14 -7
  212. package/dist/internal/isoYearString.d.ts +21 -0
  213. package/dist/internal/isoYearString.js +27 -0
  214. package/dist/internal/maxPieces.js +2 -1
  215. package/dist/internal/normalizeTimeZone.d.ts +4 -1
  216. package/dist/internal/normalizeTimeZone.js +4 -1
  217. package/dist/internal/operatingSchedule.d.ts +13 -4
  218. package/dist/internal/operatingSchedule.js +146 -58
  219. package/dist/internal/patternToken.d.ts +10 -3
  220. package/dist/internal/patternToken.js +31 -12
  221. package/dist/internal/readDurationUnits.d.ts +34 -0
  222. package/dist/internal/readDurationUnits.js +78 -0
  223. package/dist/internal/readSetFields.d.ts +50 -0
  224. package/dist/internal/readSetFields.js +179 -0
  225. package/dist/internal/resolveRelativeUnit.d.ts +1 -1
  226. package/dist/internal/resolveRelativeUnit.js +1 -1
  227. package/dist/internal/resolveUnixIntervalPair.d.ts +8 -2
  228. package/dist/internal/resolveUnixIntervalPair.js +11 -7
  229. package/dist/internal/startOrEndOfUnix.d.ts +2 -1
  230. package/dist/internal/startOrEndOfUnix.js +9 -6
  231. package/dist/internal/twoDigitYear.d.ts +19 -0
  232. package/dist/internal/twoDigitYear.js +22 -0
  233. package/dist/internal/unixEpochValue.js +3 -2
  234. package/dist/internal/unixWallClock.d.ts +24 -0
  235. package/dist/internal/unixWallClock.js +35 -0
  236. package/dist/internal/utcWallClock.d.ts +20 -0
  237. package/dist/internal/utcWallClock.js +32 -0
  238. package/dist/internal/yearWindowStart.d.ts +28 -0
  239. package/dist/internal/yearWindowStart.js +52 -0
  240. package/dist/internal/zoneBucketCap.d.ts +18 -0
  241. package/dist/internal/zoneBucketCap.js +64 -0
  242. package/dist/internal/zoneFrame.d.ts +180 -0
  243. package/dist/internal/zoneFrame.js +221 -0
  244. package/dist/internal/zonedNowUnitValue.d.ts +7 -4
  245. package/dist/internal/zonedNowUnitValue.js +8 -4
  246. package/dist/internal/zonedWallClockDifference.js +4 -4
  247. package/dist/internal/zonedWallClockOperations.d.ts +5 -2
  248. package/dist/internal/zonedWallClockOperations.js +7 -10
  249. package/dist/plain/calculate/addDate.js +4 -7
  250. package/dist/plain/calculate/addDateTime.js +4 -7
  251. package/dist/plain/calculate/addTime.js +4 -7
  252. package/dist/plain/calculate/endOfTime.d.ts +1 -1
  253. package/dist/plain/calculate/setDate.d.ts +20 -3
  254. package/dist/plain/calculate/setDate.js +27 -7
  255. package/dist/plain/calculate/setDateTime.d.ts +15 -3
  256. package/dist/plain/calculate/setDateTime.js +22 -7
  257. package/dist/plain/calculate/setTime.d.ts +13 -1
  258. package/dist/plain/calculate/setTime.js +21 -6
  259. package/dist/plain/calculate/startOfTime.d.ts +1 -1
  260. package/dist/plain/calculate/subtractDate.js +4 -7
  261. package/dist/plain/calculate/subtractDateTime.js +4 -7
  262. package/dist/plain/calculate/subtractTime.js +4 -7
  263. package/dist/plain/format/formatCalendar.js +2 -1
  264. package/dist/plain/format/formatRelativeDate.js +2 -1
  265. package/dist/plain/format/formatRelativeDateTime.js +2 -1
  266. package/dist/plain/format/formatRelativeTime.js +2 -1
  267. package/dist/plain/format/{formatSql.d.ts → formatSqlDateTime.d.ts} +7 -7
  268. package/dist/plain/format/{formatSql.js → formatSqlDateTime.js} +7 -7
  269. package/dist/plain/format/index.d.ts +1 -1
  270. package/dist/plain/format/index.js +1 -1
  271. package/dist/plain/get/getNowUnit.d.ts +2 -0
  272. package/dist/plain/get/getNowUnit.js +2 -0
  273. package/dist/plain/get/getYear.d.ts +2 -0
  274. package/dist/plain/get/getYear.js +4 -3
  275. package/dist/plain/interval/intervalXorAllDate.js +2 -2
  276. package/dist/plain/interval/intervalXorAllDateTime.js +2 -2
  277. package/dist/plain/interval/intervalXorAllTime.js +2 -2
  278. package/dist/plain/interval/mergeIntervalsDate.js +2 -2
  279. package/dist/plain/interval/mergeIntervalsDateTime.js +2 -2
  280. package/dist/plain/interval/mergeIntervalsTime.js +2 -2
  281. package/dist/plain/parse/index.d.ts +1 -1
  282. package/dist/plain/parse/index.js +1 -1
  283. package/dist/plain/parse/parseDateTimeWithPattern.d.ts +16 -3
  284. package/dist/plain/parse/parseDateTimeWithPattern.js +20 -5
  285. package/dist/plain/parse/parseDateWithPattern.d.ts +17 -3
  286. package/dist/plain/parse/parseDateWithPattern.js +21 -5
  287. package/dist/plain/parse/{parseSql.d.ts → parseSqlDateTime.d.ts} +8 -8
  288. package/dist/plain/parse/{parseSql.js → parseSqlDateTime.js} +8 -8
  289. package/dist/plain/parse/parseTimeWithPattern.js +1 -0
  290. package/dist/plain/parse/parseUnitFromDate.d.ts +3 -0
  291. package/dist/plain/parse/parseUnitFromDate.js +5 -1
  292. package/dist/plain/parse/parseUnitFromDateTime.d.ts +3 -0
  293. package/dist/plain/parse/parseUnitFromDateTime.js +5 -1
  294. package/dist/plain/parse/parseYearFromDate.d.ts +5 -2
  295. package/dist/plain/parse/parseYearFromDate.js +7 -3
  296. package/dist/plain/parse/parseYearFromDateTime.d.ts +4 -2
  297. package/dist/plain/parse/parseYearFromDateTime.js +6 -3
  298. package/dist/plain/validate/index.d.ts +1 -0
  299. package/dist/plain/validate/index.js +1 -0
  300. package/dist/plain/validate/isValidSqlDateTime.d.ts +33 -0
  301. package/dist/plain/validate/isValidSqlDateTime.js +36 -0
  302. package/dist/precision/convert/fromNanoseconds.d.ts +9 -3
  303. package/dist/precision/convert/fromNanoseconds.js +9 -3
  304. package/dist/regex/epcis.d.ts +60 -0
  305. package/dist/regex/epcis.js +60 -0
  306. package/dist/regex/http-date.d.ts +6 -4
  307. package/dist/regex/http-date.js +6 -4
  308. package/dist/regex/index.d.ts +1 -0
  309. package/dist/regex/index.js +1 -0
  310. package/dist/regex/rfc-2822.d.ts +2 -0
  311. package/dist/regex/rfc-2822.js +2 -0
  312. package/dist/regex/rfc-3339.d.ts +3 -1
  313. package/dist/regex/rfc-3339.js +3 -1
  314. package/dist/regex/sql-date-time.d.ts +2 -1
  315. package/dist/regex/sql-date-time.js +2 -1
  316. package/dist/regex/time-zone-like.d.ts +3 -2
  317. package/dist/regex/time-zone-like.js +3 -2
  318. package/dist/regex/utc-offset.d.ts +2 -1
  319. package/dist/regex/utc-offset.js +2 -1
  320. package/dist/transport/calculate/crossingTime.d.ts +6 -1
  321. package/dist/transport/calculate/crossingTime.js +6 -1
  322. package/dist/transport/calculate/cutoffAt.d.ts +6 -1
  323. package/dist/transport/calculate/cutoffAt.js +2 -0
  324. package/dist/transport/calculate/cutoffSchedule.d.ts +6 -1
  325. package/dist/transport/calculate/cutoffSchedule.js +2 -0
  326. package/dist/transport/calculate/dwellTime.d.ts +6 -1
  327. package/dist/transport/calculate/dwellTime.js +6 -1
  328. package/dist/transport/calculate/scheduleDelivery.d.ts +13 -5
  329. package/dist/transport/calculate/scheduleDelivery.js +6 -1
  330. package/dist/transport/convert/etaAtZone.d.ts +6 -1
  331. package/dist/transport/convert/etaAtZone.js +6 -1
  332. package/dist/types/business-calendar.d.ts +5 -4
  333. package/dist/types/duty.d.ts +777 -0
  334. package/dist/types/duty.js +0 -0
  335. package/dist/types/edi.d.ts +407 -0
  336. package/dist/types/edi.js +0 -0
  337. package/dist/types/epcis.d.ts +25 -0
  338. package/dist/types/epcis.js +0 -0
  339. package/dist/types/exact-duration-unit.d.ts +8 -0
  340. package/dist/types/exact-duration-unit.js +0 -0
  341. package/dist/types/free-time-options.d.ts +4 -3
  342. package/dist/types/index.d.ts +5 -0
  343. package/dist/types/index.js +5 -0
  344. package/dist/types/operating-schedule.d.ts +3 -2
  345. package/dist/types/two-digit-year.d.ts +23 -0
  346. package/dist/types/two-digit-year.js +0 -0
  347. package/dist/types/zone-bucket-unit.d.ts +1 -1
  348. package/dist/unix/calculate/addUnix.d.ts +11 -2
  349. package/dist/unix/calculate/addUnix.js +14 -11
  350. package/dist/unix/calculate/diffUnix.d.ts +10 -2
  351. package/dist/unix/calculate/diffUnix.js +11 -6
  352. package/dist/unix/calculate/diffUnixAsDuration.d.ts +10 -2
  353. package/dist/unix/calculate/diffUnixAsDuration.js +10 -5
  354. package/dist/unix/calculate/endOfQuarterForUnix.d.ts +10 -2
  355. package/dist/unix/calculate/endOfQuarterForUnix.js +6 -0
  356. package/dist/unix/calculate/endOfUnix.d.ts +11 -2
  357. package/dist/unix/calculate/endOfUnix.js +7 -0
  358. package/dist/unix/calculate/isBetweenUnix.d.ts +3 -2
  359. package/dist/unix/calculate/isBetweenUnix.js +3 -3
  360. package/dist/unix/calculate/roundUnix.d.ts +10 -2
  361. package/dist/unix/calculate/roundUnix.js +13 -6
  362. package/dist/unix/calculate/setUnix.d.ts +25 -5
  363. package/dist/unix/calculate/setUnix.js +33 -12
  364. package/dist/unix/calculate/startOfQuarterForUnix.d.ts +10 -2
  365. package/dist/unix/calculate/startOfQuarterForUnix.js +6 -0
  366. package/dist/unix/calculate/startOfUnix.d.ts +11 -2
  367. package/dist/unix/calculate/startOfUnix.js +7 -0
  368. package/dist/unix/calculate/subtractUnix.d.ts +10 -2
  369. package/dist/unix/calculate/subtractUnix.js +13 -11
  370. package/dist/unix/compare/areUnixEqualBy.d.ts +11 -2
  371. package/dist/unix/compare/areUnixEqualBy.js +7 -0
  372. package/dist/unix/convert/convertUnixToPlainDate.d.ts +5 -2
  373. package/dist/unix/convert/convertUnixToPlainDate.js +5 -4
  374. package/dist/unix/convert/convertUnixToPlainDateTime.d.ts +5 -2
  375. package/dist/unix/convert/convertUnixToPlainDateTime.js +5 -4
  376. package/dist/unix/convert/convertUnixToPlainTime.d.ts +5 -2
  377. package/dist/unix/convert/convertUnixToPlainTime.js +5 -4
  378. package/dist/unix/convert/convertUnixToZoned.d.ts +5 -1
  379. package/dist/unix/convert/convertUnixToZoned.js +5 -1
  380. package/dist/unix/format/formatCalendarUnix.d.ts +6 -2
  381. package/dist/unix/format/formatCalendarUnix.js +6 -1
  382. package/dist/unix/format/formatRelativeUnix.d.ts +11 -3
  383. package/dist/unix/format/formatRelativeUnix.js +13 -5
  384. package/dist/unix/format/formatUnix.d.ts +7 -2
  385. package/dist/unix/format/formatUnix.js +4 -0
  386. package/dist/unix/get/getUnixDay.d.ts +1 -3
  387. package/dist/unix/get/getUnixDay.js +5 -12
  388. package/dist/unix/get/getUnixHour.d.ts +1 -3
  389. package/dist/unix/get/getUnixHour.js +5 -12
  390. package/dist/unix/get/getUnixMicrosecond.d.ts +1 -3
  391. package/dist/unix/get/getUnixMicrosecond.js +4 -11
  392. package/dist/unix/get/getUnixMillisecond.d.ts +1 -3
  393. package/dist/unix/get/getUnixMillisecond.js +5 -12
  394. package/dist/unix/get/getUnixMinute.d.ts +1 -3
  395. package/dist/unix/get/getUnixMinute.js +5 -12
  396. package/dist/unix/get/getUnixMonth.d.ts +1 -3
  397. package/dist/unix/get/getUnixMonth.js +5 -12
  398. package/dist/unix/get/getUnixNanosecond.d.ts +1 -3
  399. package/dist/unix/get/getUnixNanosecond.js +4 -11
  400. package/dist/unix/get/getUnixNowUnit.d.ts +2 -0
  401. package/dist/unix/get/getUnixNowUnit.js +2 -0
  402. package/dist/unix/get/getUnixSecond.d.ts +1 -3
  403. package/dist/unix/get/getUnixSecond.js +5 -12
  404. package/dist/unix/get/getUnixYear.d.ts +3 -3
  405. package/dist/unix/get/getUnixYear.js +5 -12
  406. package/dist/unix/interval/intervalCountUnix.d.ts +11 -2
  407. package/dist/unix/interval/intervalCountUnix.js +7 -0
  408. package/dist/unix/interval/intervalFromDurationUnix.d.ts +10 -2
  409. package/dist/unix/interval/intervalFromDurationUnix.js +12 -7
  410. package/dist/unix/interval/intervalLengthUnix.d.ts +10 -2
  411. package/dist/unix/interval/intervalLengthUnix.js +6 -0
  412. package/dist/unix/interval/intervalOverlappingDaysUnix.d.ts +10 -2
  413. package/dist/unix/interval/intervalOverlappingDaysUnix.js +10 -5
  414. package/dist/unix/interval/splitIntervalByUnitUnix.d.ts +10 -2
  415. package/dist/unix/interval/splitIntervalByUnitUnix.js +12 -6
  416. package/dist/unix/parse/parseDateFromUnix.d.ts +4 -2
  417. package/dist/unix/parse/parseDateFromUnix.js +3 -3
  418. package/dist/unix/parse/parseDayFromUnix.d.ts +4 -2
  419. package/dist/unix/parse/parseDayOfWeekFromUnix.d.ts +4 -2
  420. package/dist/unix/parse/parseDayOfWeekFromUnix.js +2 -2
  421. package/dist/unix/parse/parseHourFromUnix.d.ts +4 -2
  422. package/dist/unix/parse/parseMicrosecondFromUnix.d.ts +4 -2
  423. package/dist/unix/parse/parseMillisecondFromUnix.d.ts +4 -2
  424. package/dist/unix/parse/parseMinuteFromUnix.d.ts +4 -2
  425. package/dist/unix/parse/parseMonthFromUnix.d.ts +4 -2
  426. package/dist/unix/parse/parseNanosecondFromUnix.d.ts +4 -2
  427. package/dist/unix/parse/parseSecondFromUnix.d.ts +4 -2
  428. package/dist/unix/parse/parseTimeFromUnix.d.ts +5 -2
  429. package/dist/unix/parse/parseTimeFromUnix.js +4 -3
  430. package/dist/unix/parse/parseUnitFromUnix.d.ts +9 -3
  431. package/dist/unix/parse/parseUnitFromUnix.js +21 -14
  432. package/dist/unix/parse/parseWeekFromUnix.d.ts +3 -2
  433. package/dist/unix/parse/parseWeekFromUnix.js +4 -4
  434. package/dist/unix/parse/parseYearFromUnix.d.ts +8 -3
  435. package/dist/unix/parse/parseYearFromUnix.js +4 -1
  436. package/dist/unix/validate/isValidUnixUnit.d.ts +1 -1
  437. package/dist/utc/calculate/addUtc.js +4 -7
  438. package/dist/utc/calculate/setUtc.d.ts +16 -3
  439. package/dist/utc/calculate/setUtc.js +22 -8
  440. package/dist/utc/calculate/subtractUtc.js +4 -7
  441. package/dist/utc/convert/convertUtcToPlainDate.d.ts +8 -2
  442. package/dist/utc/convert/convertUtcToPlainDate.js +10 -7
  443. package/dist/utc/convert/convertUtcToPlainDateTime.d.ts +9 -2
  444. package/dist/utc/convert/convertUtcToPlainDateTime.js +12 -13
  445. package/dist/utc/convert/convertUtcToPlainTime.d.ts +5 -2
  446. package/dist/utc/convert/convertUtcToPlainTime.js +7 -6
  447. package/dist/utc/convert/convertUtcToZoned.d.ts +5 -1
  448. package/dist/utc/convert/convertUtcToZoned.js +5 -1
  449. package/dist/utc/format/formatCalendarUtc.d.ts +6 -2
  450. package/dist/utc/format/formatCalendarUtc.js +6 -1
  451. package/dist/utc/format/{formatHttp.d.ts → formatHttpDate.d.ts} +7 -7
  452. package/dist/utc/format/{formatHttp.js → formatHttpDate.js} +7 -7
  453. package/dist/utc/format/formatRelativeUtc.d.ts +11 -3
  454. package/dist/utc/format/formatRelativeUtc.js +13 -5
  455. package/dist/utc/format/formatUtc.d.ts +7 -2
  456. package/dist/utc/format/formatUtc.js +4 -0
  457. package/dist/utc/format/index.d.ts +1 -1
  458. package/dist/utc/format/index.js +1 -1
  459. package/dist/utc/get/getUtcDay.d.ts +1 -3
  460. package/dist/utc/get/getUtcDay.js +5 -12
  461. package/dist/utc/get/getUtcHour.d.ts +1 -3
  462. package/dist/utc/get/getUtcHour.js +5 -12
  463. package/dist/utc/get/getUtcMicrosecond.d.ts +1 -3
  464. package/dist/utc/get/getUtcMicrosecond.js +4 -11
  465. package/dist/utc/get/getUtcMillisecond.d.ts +1 -3
  466. package/dist/utc/get/getUtcMillisecond.js +5 -12
  467. package/dist/utc/get/getUtcMinute.d.ts +1 -3
  468. package/dist/utc/get/getUtcMinute.js +5 -12
  469. package/dist/utc/get/getUtcMonth.d.ts +1 -3
  470. package/dist/utc/get/getUtcMonth.js +5 -12
  471. package/dist/utc/get/getUtcNanosecond.d.ts +1 -3
  472. package/dist/utc/get/getUtcNanosecond.js +4 -11
  473. package/dist/utc/get/getUtcNow.d.ts +1 -3
  474. package/dist/utc/get/getUtcNow.js +2 -9
  475. package/dist/utc/get/getUtcNowUnit.d.ts +2 -0
  476. package/dist/utc/get/getUtcNowUnit.js +2 -0
  477. package/dist/utc/get/getUtcSecond.d.ts +1 -3
  478. package/dist/utc/get/getUtcSecond.js +5 -12
  479. package/dist/utc/get/getUtcYear.d.ts +3 -3
  480. package/dist/utc/get/getUtcYear.js +5 -12
  481. package/dist/utc/interval/intervalXorAllUtc.js +2 -2
  482. package/dist/utc/interval/mergeIntervalsUtc.js +2 -2
  483. package/dist/utc/parse/index.d.ts +1 -1
  484. package/dist/utc/parse/index.js +1 -1
  485. package/dist/utc/parse/parseDateFromUtc.d.ts +4 -2
  486. package/dist/utc/parse/parseDateFromUtc.js +2 -2
  487. package/dist/utc/parse/parseDayFromUtc.d.ts +4 -2
  488. package/dist/utc/parse/parseDayFromUtc.js +2 -2
  489. package/dist/utc/parse/parseDayOfWeekFromUtc.d.ts +4 -2
  490. package/dist/utc/parse/parseDayOfWeekFromUtc.js +4 -4
  491. package/dist/utc/parse/parseHourFromUtc.d.ts +4 -2
  492. package/dist/utc/parse/parseHourFromUtc.js +2 -2
  493. package/dist/utc/parse/{parseHttp.d.ts → parseHttpDate.d.ts} +12 -12
  494. package/dist/utc/parse/{parseHttp.js → parseHttpDate.js} +12 -12
  495. package/dist/utc/parse/parseMicrosecondFromUtc.d.ts +4 -2
  496. package/dist/utc/parse/parseMicrosecondFromUtc.js +2 -2
  497. package/dist/utc/parse/parseMillisecondFromUtc.d.ts +4 -2
  498. package/dist/utc/parse/parseMillisecondFromUtc.js +2 -2
  499. package/dist/utc/parse/parseMinuteFromUtc.d.ts +4 -2
  500. package/dist/utc/parse/parseMinuteFromUtc.js +2 -2
  501. package/dist/utc/parse/parseMonthFromUtc.d.ts +4 -2
  502. package/dist/utc/parse/parseMonthFromUtc.js +2 -2
  503. package/dist/utc/parse/parseNanosecondFromUtc.d.ts +4 -2
  504. package/dist/utc/parse/parseNanosecondFromUtc.js +2 -2
  505. package/dist/utc/parse/parseSecondFromUtc.d.ts +4 -2
  506. package/dist/utc/parse/parseSecondFromUtc.js +2 -2
  507. package/dist/utc/parse/parseTimeFromUtc.d.ts +5 -2
  508. package/dist/utc/parse/parseTimeFromUtc.js +5 -4
  509. package/dist/utc/parse/parseUnitFromUtc.d.ts +10 -3
  510. package/dist/utc/parse/parseUnitFromUtc.js +11 -5
  511. package/dist/utc/parse/parseWeekFromUtc.d.ts +4 -2
  512. package/dist/utc/parse/parseWeekFromUtc.js +4 -4
  513. package/dist/utc/parse/parseYearFromUtc.d.ts +8 -4
  514. package/dist/utc/parse/parseYearFromUtc.js +8 -5
  515. package/dist/utc/validate/index.d.ts +2 -0
  516. package/dist/utc/validate/index.js +2 -0
  517. package/dist/utc/validate/isValidHttpDate.d.ts +35 -0
  518. package/dist/utc/validate/isValidHttpDate.js +38 -0
  519. package/dist/utc/validate/isValidUtc.d.ts +4 -0
  520. package/dist/utc/validate/isValidUtc.js +4 -0
  521. package/dist/utc/validate/isValidUtcTimestamp.d.ts +43 -0
  522. package/dist/utc/validate/isValidUtcTimestamp.js +48 -0
  523. package/dist/zoned/calculate/addZoned.js +4 -7
  524. package/dist/zoned/calculate/setZoned.d.ts +19 -5
  525. package/dist/zoned/calculate/setZoned.js +26 -9
  526. package/dist/zoned/calculate/subtractZoned.js +4 -7
  527. package/dist/zoned/convert/convertPlainDateTimeToZoned.d.ts +4 -1
  528. package/dist/zoned/convert/convertPlainDateTimeToZoned.js +4 -1
  529. package/dist/zoned/convert/convertZonedToZoned.d.ts +5 -1
  530. package/dist/zoned/convert/convertZonedToZoned.js +5 -1
  531. package/dist/zoned/format/formatCalendarZoned.js +2 -1
  532. package/dist/zoned/format/formatRelativeZoned.js +2 -1
  533. package/dist/zoned/format/formatTimeZoneName.d.ts +5 -1
  534. package/dist/zoned/format/formatTimeZoneName.js +5 -1
  535. package/dist/zoned/get/getDstTransitions.d.ts +5 -3
  536. package/dist/zoned/get/getDstTransitions.js +12 -7
  537. package/dist/zoned/get/getSystemTimeZone.d.ts +3 -1
  538. package/dist/zoned/get/getSystemTimeZone.js +5 -2
  539. package/dist/zoned/get/getTimeZoneOffset.d.ts +10 -2
  540. package/dist/zoned/get/getTimeZoneOffset.js +18 -5
  541. package/dist/zoned/get/getZonedDay.d.ts +2 -1
  542. package/dist/zoned/get/getZonedDay.js +6 -7
  543. package/dist/zoned/get/getZonedDayOfWeek.d.ts +2 -1
  544. package/dist/zoned/get/getZonedDayOfWeek.js +6 -5
  545. package/dist/zoned/get/getZonedHour.d.ts +2 -1
  546. package/dist/zoned/get/getZonedHour.js +6 -7
  547. package/dist/zoned/get/getZonedMicrosecond.d.ts +2 -1
  548. package/dist/zoned/get/getZonedMicrosecond.js +6 -5
  549. package/dist/zoned/get/getZonedMillisecond.d.ts +2 -1
  550. package/dist/zoned/get/getZonedMillisecond.js +6 -7
  551. package/dist/zoned/get/getZonedMinute.d.ts +2 -1
  552. package/dist/zoned/get/getZonedMinute.js +6 -7
  553. package/dist/zoned/get/getZonedMonth.d.ts +2 -1
  554. package/dist/zoned/get/getZonedMonth.js +6 -7
  555. package/dist/zoned/get/getZonedNanosecond.d.ts +2 -1
  556. package/dist/zoned/get/getZonedNanosecond.js +6 -5
  557. package/dist/zoned/get/getZonedNow.d.ts +5 -1
  558. package/dist/zoned/get/getZonedNow.js +5 -1
  559. package/dist/zoned/get/getZonedNowUnit.d.ts +6 -3
  560. package/dist/zoned/get/getZonedNowUnit.js +10 -7
  561. package/dist/zoned/get/getZonedSecond.d.ts +2 -1
  562. package/dist/zoned/get/getZonedSecond.js +6 -7
  563. package/dist/zoned/get/getZonedToday.d.ts +2 -1
  564. package/dist/zoned/get/getZonedToday.js +6 -5
  565. package/dist/zoned/get/getZonedWeekOfYear.d.ts +2 -1
  566. package/dist/zoned/get/getZonedWeekOfYear.js +6 -5
  567. package/dist/zoned/get/getZonedYear.d.ts +4 -1
  568. package/dist/zoned/get/getZonedYear.js +9 -7
  569. package/dist/zoned/interval/intervalXorAllZoned.js +2 -2
  570. package/dist/zoned/interval/mergeIntervalsZoned.js +2 -2
  571. package/dist/zoned/parse/parseUnitFromZoned.d.ts +4 -1
  572. package/dist/zoned/parse/parseUnitFromZoned.js +5 -1
  573. package/dist/zoned/parse/parseYearFromZoned.d.ts +4 -2
  574. package/dist/zoned/parse/parseYearFromZoned.js +6 -3
  575. package/dist/zoned/validate/hasDaylightSaving.d.ts +4 -2
  576. package/dist/zoned/validate/hasDaylightSaving.js +13 -7
  577. package/dist/zoned/validate/index.d.ts +2 -0
  578. package/dist/zoned/validate/index.js +2 -0
  579. package/dist/zoned/validate/isValidRfc2822DateTime.d.ts +32 -0
  580. package/dist/zoned/validate/isValidRfc2822DateTime.js +35 -0
  581. package/dist/zoned/validate/isValidRfc3339DateTime.d.ts +31 -0
  582. package/dist/zoned/validate/isValidRfc3339DateTime.js +34 -0
  583. package/dist/zoned/validate/isValidTimeZone.d.ts +5 -1
  584. package/dist/zoned/validate/isValidTimeZone.js +6 -2
  585. package/package.json +50 -1
  586. package/skills/gmt-arithmetic/SKILL.md +99 -12
  587. package/skills/gmt-basics/SKILL.md +57 -15
  588. package/skills/gmt-integration/SKILL.md +24 -9
  589. package/skills/gmt-timezone/SKILL.md +134 -29
  590. package/dist/internal/unixZonedDateTime.d.ts +0 -22
  591. package/dist/internal/unixZonedDateTime.js +0 -32
  592. package/dist/internal/utcZonedDateTime.d.ts +0 -19
  593. package/dist/internal/utcZonedDateTime.js +0 -31
package/README.md CHANGED
@@ -1,28 +1,25 @@
1
- # @northguild/gmt
1
+ # GMT: Give Me Temporal
2
2
 
3
- Give Me Temporal.
3
+ **The most tested date and time library for JavaScript.** `@northguild/gmt` is one library in place of Luxon, date-fns, Moment.js, Day.js, Spacetime and `@internationalized/date`. It is built on the TC39 Temporal standard and never touches `Date`.
4
4
 
5
- `@northguild/gmt` is a Temporal-first date and time library with a simple rule set:
5
+ [Docs](https://gmt-dox.northguild.workers.dev/) · [API reference](https://gmt-dox.northguild.workers.dev/reference/) · [Tools](https://gmt-dox.northguild.workers.dev/tools/) · [Discord](https://discord.gg/TdvQdP3t5a)
6
6
 
7
- - ISO 8601 strings in
8
- - ISO 8601 strings, numbers, booleans, or arrays out
9
- - no `Date`
10
- - plain and zoned operations kept separate
7
+ - **57,562 tests, run 1,726,860 times in CI.** Every test runs in 10 time zones on 3 Node versions.
8
+ - **52× the CI test runs** of the six libraries below, combined.
9
+ - **644 functions, to the nanosecond**, tested in 17 locales.
10
+ - **Never throws.** ISO 8601 strings in. Invalid input returns `""`, `null`, `false` or `[]`.
11
11
 
12
- It wraps `@js-temporal/polyfill` behind a smaller, more opinionated API aimed at the cases application code actually hits: arithmetic, comparison, parsing, formatting, unix conversions, timezone conversion, and validation.
12
+ | Library | Tests | CI test runs | Built on |
13
+ | ------------------------- | ---------- | ------------- | --------- |
14
+ | **`@northguild/gmt`** | **57,562** | **1,726,860** | Temporal |
15
+ | Spacetime | 6,086 | 12,172 | `Date` |
16
+ | Moment.js | 3,901 | 11,703 | `Date` |
17
+ | date-fns | 3,213 | 3,213 | `Date` |
18
+ | Luxon | 1,222 | 4,888 | `Date` |
19
+ | Day.js | 794 | 1,034 | `Date` |
20
+ | `@internationalized/date` | 386 | 386 | own types |
13
21
 
14
- Read the [docs](https://gmt-dox.northguild.workers.dev/), or ask us on [Discord](https://discord.gg/TdvQdP3t5a).
15
-
16
- **Why GMT:**
17
-
18
- - **100% Temporal, Temporal-first.** GMT is built directly on the TC39 `Temporal` standard (via `@js-temporal/polyfill`) — not a custom, homegrown date/time type system like `@internationalized/date`'s own `CalendarDate`/`ZonedDateTime` classes. No `Date` object anywhere, enforced by 3 dedicated lint packages.
19
- - **A full replacement for any and all of them.** Luxon, date-fns, Moment.js, and react-aria's `@internationalized/date` don't have parity with each other — GMT covers the combined capabilities of all four in one library, plus what none of them do alone.
20
- - **~59× more CI test executions than all four competitors combined**: 1,200,990 from 40,033 tests run in all 10 timezones × 3 Node versions, vs. their combined 20,190.
21
- - **~104× more test cases than `@internationalized/date`**: 40,033 vs. 386 — Adobe's own library, run at its own commit.
22
- - **The only one of the five that tests systematically across locales in CI at all.** Zero of the four comparison libraries run a locale-test matrix; GMT mandates all 17 locales on every locale-aware function.
23
- - **The only one that runs its entire suite under a real `TZ` env var across real-world zones.** Luxon and `@internationalized/date` have no CI timezone matrix; date-fns's zone scope is unclear; Moment.js covers 6 zones but not its full suite.
24
- - **Explicit DST disambiguation control on both construction _and_ arithmetic** — a control none of the others expose.
25
- - **The only actively-maintained one that's Temporal-native.** Moment.js is officially in maintenance mode; Luxon, date-fns, and `@internationalized/date` are still active but all still depend on `Date` internally.
22
+ The other libraries were measured from 2026-08-22 to 2026-09-09, each by running its own test suite. [The full comparison and the method](https://gmt-dox.northguild.workers.dev/compare/).
26
23
 
27
24
  ## Install
28
25
 
@@ -30,2928 +27,66 @@ Read the [docs](https://gmt-dox.northguild.workers.dev/), or ask us on [Discord]
30
27
  npm install @northguild/gmt
31
28
  ```
32
29
 
33
- ```bash
34
- pnpm add @northguild/gmt
35
- ```
36
-
37
- ## Design Philosophy
38
-
39
- GMT enforces a strict input/output contract to keep behavior predictable and auditable:
40
-
41
- - **Explicit inputs only**: Public APIs accept clearly defined shapes — ISO 8601 date/time strings, IANA timezone identifiers, or Unix epochs as safe integers or digit strings, in seconds or milliseconds (`{ epochUnit }`). We do not attempt to parse arbitrary or ambiguous date formats.
42
- - **Predictable outputs**: Helpers return normalized values (ISO strings, numbers, booleans, or arrays). Invalid input yields typed fallbacks (`""`, `null`, `false` or `[]`) instead of throwing.
43
- - **No fuzzy parsing**: Avoid "throw everything at the wall" patterns found in permissive libraries. If you need permissive parsing, perform it outside of `@northguild/gmt` and then canonicalize to the strict shapes before calling into gmt.
44
- - **Developer comfort with standards**: The library's goal is to make developers comfortable and deliberate with ISO 8601, IANA timezones, UTC instants, and Unix epochs by keeping APIs small and explicit.
45
-
46
- ## Core Rules
47
-
48
- | Rule | Current behavior |
49
- | ----------------------- | ---------------------------------------------------------------------------------- |
50
- | String-first API | Public helpers consume ISO strings and return normalized strings where appropriate |
51
- | Temporal-only internals | `Temporal` does the parsing and timezone math |
52
- | Plain/zoned separation | `plain/*` is timezone-free, `zoned/*` is timezone-aware |
53
- | No-throw public helpers | Invalid input returns a typed fallback instead of throwing |
54
-
55
- Invalid input fallbacks are consistent across the library:
56
-
57
- - string-returning helpers return `""`
58
- - number-returning helpers return `null`
59
- - boolean-returning helpers return `false`
60
- - array-returning helpers return `[]`
61
-
62
- ## Testing
63
-
64
- Every function is exercised across **17 locales** and a full IANA timezone matrix. The CI pipeline runs the complete suite in **30 environments** — 3 Node versions (22, 24, 26) × 10 timezones spanning every UTC offset band from Pacific/Niue (−11:00) to Pacific/Apia (+14:00):
65
-
66
- | Timezone | UTC Offset |
67
- | ------------------- | --------------- |
68
- | Pacific/Niue | −11:00 |
69
- | America/New_York | −05:00 / −04:00 |
70
- | UTC | ±00:00 |
71
- | Europe/London | ±00:00 / +01:00 |
72
- | Asia/Kolkata | +05:30 |
73
- | Asia/Kathmandu | +05:45 |
74
- | Asia/Shanghai | +08:00 |
75
- | Australia/Lord_Howe | +10:00 / +11:00 |
76
- | Pacific/Chatham | +12:45 / +13:45 |
77
- | Pacific/Apia | +13:00 / +14:00 |
78
-
79
- This guarantees that DST transitions, leap seconds, half-hour offsets, and locale-specific weekend boundaries are all covered — not just the happy path.
80
-
81
- ### Testing strategy
82
-
83
- GMT's test suite balances **thoroughness** against **maintenance burden** by testing behavior, not permutations.
84
-
85
- **What we test exhaustively:**
86
-
87
- - **17-locale matrix** — every locale-aware function is exercised across all 17 `MustTestLocales` (en-US, en-GB, de-DE, fr-FR, es-ES, it-IT, pt-PT, sv-SE, zh-CN, zh-TW, ja-JP, ko-KR, ar-SA, he-IL, ru-RU, tr-TR, is-IS). This covers script direction, first-day-of-week differences, and calendar metadata.
88
- - **Timezone battle matrix** — every zoned function is exercised across 10 IANA timezones spanning every UTC offset band from Pacific/Niue (−11:00) to Pacific/Apia (+14:00), including DST-transition and half-hour-offset zones.
89
- - **Zero-length and identity cases** — every interval and arithmetic function is tested with zero-length inputs, identity operations, and boundary-adjacent values.
90
- - **Invalid-input sentinels** — every public function is tested for the documented fallback behavior (`""`, `null`, `false`, `[]`) on malformed strings, wrong types, leap seconds, and inverted intervals.
91
-
92
- **What we collapse:**
93
-
94
- - **Non-string input tables** — functions that guard with `typeof x !== "string"` return the same sentinel for `null`, `undefined`, `123`, `true`, `[]`, and `{}`. We test one representative non-string per argument position rather than all six types × N positions. The collapse is safe because all non-string types hit the identical early-return code path.
95
- - **Redundant permutations** — adjacent/disjoint/reversed interval cases that produce identical results are not duplicated across every function variant. The `plain/`, `zoned/`, `utc/`, and `unix/` families share the same mathematical behavior; each family gets the minimum set of cases needed to prove correctness.
96
-
97
- **Result:** 40,033 tests across 706 files that exercise real behavior differences without redundant permutations. They run in CI as 1,200,990 executions — every one of them × 3 Node versions × 10 timezones.
98
-
99
- ## How GMT is tested, vs. the libraries it targets
100
-
101
- GMT is measured directly against react-aria's **`@internationalized/date`**, **Luxon**, **date-fns**, and **Moment.js** — the same four libraries compared below. All numbers were verified **2026-08-22** against the exact package versions/commits below — nothing is estimated. Re-verify before citing these numbers elsewhere; library surfaces and CI configs move.
102
-
103
- | Library | Version tested |
104
- | ------------------------- | --------------------------------------- |
105
- | GMT (`@northguild/gmt`) | 1.14.2 |
106
- | `@internationalized/date` | 3.12.3 (`adobe/react-spectrum@5d191ab`) |
107
- | Luxon | 3.7.2 (`moment/luxon@f427515`) |
108
- | date-fns | 4.4.0 (`date-fns/date-fns@a0a3922`) |
109
- | Moment.js | 2.30.1 (`moment/moment@cf524af`) |
110
-
111
- | Metric | GMT | `@internationalized/date` | Luxon | date-fns | Moment.js |
112
- | ------------------------------- | -------------------------------------------------- | ------------------------------ | ------------------------------------ | ----------------------------------------- | -------------------------------- |
113
- | Test files | 706 | 6 | 58 / 60<br>(2 didn't run<br>locally) | 256 | 191<br>(52 core +<br>139 locale) |
114
- | Individual test cases | **40,033** | 386 | 1,222 | 3,213 | 3,901 |
115
- | Effective CI test<br>executions | **1,200,990**<br>(40,033 × 3 Node<br>× 10 timezones) | 386<br>(×1 Node) | 4,888<br>(1,222 × 4 Node) | 3,213<br>(×1 Node) | 11,703<br>(3,901 × 3 Node) |
116
- | CI Node.js matrix | 22, 24, 26 | n/a — tests<br>React 16–canary | 20, 22, 24, 25 | not explicit<br>(`node = "latest"`) | LTS, LTS-1,<br>latest |
117
- | CI timezone matrix | **10 zones × 3**<br>**Node, full suite** | none found | none found | dedicated workflow,<br>zone scope unclear | 6 zones,<br>partial suite only |
118
- | Locale test matrix | **17 locales**,<br>every locale fn | none found | none found | none found | none found |
119
- | Real-browser CI | not yet | yes (Playwright) | not found | yes (Playwright) | not found |
120
- | Maintenance | active | active | active | active | **maintenance<br>mode** |
121
-
122
- <sub>Methodology: "Test files" and the CI/maintenance rows come from each project's public CI configuration and repository file listing. "Individual test cases" for GMT, Luxon, date-fns, and Moment.js were obtained by actually cloning the repo at the commit above, installing dependencies, running the project's own test command (`vitest run` / `jest` / `node scripts/test.js`), and reading that runner's own final summary — not grepped from source. `@internationalized/date` was run by cloning `adobe/react-spectrum` at `5d191ab`, installing dependencies, and executing `npx jest packages/@internationalized/date/tests/`, yielding 386 passing tests. Luxon (39 failures) and date-fns (46 failures) had environment-dependent local failures that don't affect the total count: Luxon's suite assumes its CI container's local time zone is `America/New_York`; date-fns's experimental native-`Temporal` code path needs a global `Temporal` Node doesn't yet provide natively. Moment.js passed cleanly (0 failed) on Node 24. Sources: [GMT](https://github.com/northguild/gmt/blob/main/.github/workflows/ci.yml) · [`@internationalized/date`](https://github.com/adobe/react-spectrum/blob/main/.circleci/config.yml) · [Luxon](https://github.com/moment/luxon/blob/master/.github/workflows/test.yml) · [date-fns](https://github.com/date-fns/date-fns/tree/main/.github/workflows) · [Moment.js](https://github.com/moment/moment/tree/develop/.github/workflows).</sub>
123
-
124
- ### Feature parity
125
-
126
- GMT has **full functional parity** with all four comparison libraries, capability for capability — with several areas where GMT goes further than any of them.
127
-
128
- | Capability | Status | Also has it |
129
- | -------------------------------------------------------------------------------------------- | ---------------------------- | ------------------------------------------------------------------------ |
130
- | Duration type<br>(ISO 8601 parse/format/arithmetic) | ✅ Done | Luxon `Duration` |
131
- | Interval/range math<br>(contains, overlap, union,<br>intersection, split, set ops) | ✅ Done | Luxon `Interval`,<br>date-fns `areIntervalsOverlapping` |
132
- | DST disambiguation control<br>on construction _and_ arithmetic | ✅ Done — **differentiator** | None of the others expose<br>this on arithmetic |
133
- | Locale-aware calendar helpers<br>(weekend, week start/end, day-of-week) | ✅ Done | `@internationalized/date` |
134
- | Business-day arithmetic with<br>holiday calendars and roll conventions,<br>clamp/closest, time rounding | ✅ Done | `temporal-kit` (arithmetic only) |
135
- | Interval rounding-out<br>(boundary count, from-duration) | ✅ Done | Luxon |
136
- | Locale calendar metadata<br>(names, `hasDST`) | ✅ Done | Luxon `Info` |
137
- | Overlap-day count, relative<br>rounding, DST transitions, hours-in-day | ✅ Done | date-fns, `@internationalized/date` |
138
- | Field setters, token-pattern<br>parsing, named machine formats,<br>calendar-style formatting | ✅ Done | Luxon `.set()`,<br>`toRFC2822`/`toHTTP`/`toSQL`,<br>Moment `.calendar()` |
139
- | Non-Gregorian calendar systems<br>(conversion + calendar-aware<br>interval/duration math) | ✅ Done | `@internationalized/date`'s<br>`toCalendar` |
140
-
141
- ### Where GMT stands alone
142
-
143
- Specific, sourced claims — not a repeat of the metrics above.
144
-
145
- | Claim | The others |
146
- | --------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
147
- | Only GMT runs its **entire** suite in CI<br>under a real `TZ` env var across 10<br>real-world zones × 3 Node versions<br>(30 full-suite runs) | Luxon/`@internationalized/date`: no<br>CI timezone matrix. date-fns: zone<br>scope unclear. Moment.js: 6 zones,<br>partial suite only |
148
- | Only GMT enforces a mandatory<br>17-locale test matrix on every<br>locale-aware function | No CI-level or systematic<br>locale-matrix testing found<br>in any of the four |
149
- | Only GMT exposes explicit DST<br>disambiguation control on both<br>construction _and_ arithmetic | Luxon's docs call this explicitly<br>undefined; `@internationalized/date`<br>only covers construction, not arithmetic |
150
- | Only GMT is Temporal-native with<br>zero `Date` usage, enforced by<br>3 dedicated lint packages | Luxon, date-fns, and Moment.js all<br>still wrap or depend on `Date` internally |
151
- | GMT's effective CI test<br>executions exceed all four<br>competitors **combined**<br>by ~59× | 1,200,990 vs. 386 + 4,888 + 3,213<br>+ 11,703 = 20,190 |
152
-
153
- ## Package Layout
154
-
155
- Every public function, type and regex is a flat named export of the package root, beside
156
- `Temporal`, `Intl` and `toTemporalInstant` re-exported from `@js-temporal/polyfill`. There are no
157
- namespace objects: a namespace is a subpath.
158
-
159
- ```typescript
160
- import { addDate, getNow, formatRelativeZoned, Temporal } from "@northguild/gmt";
161
- ```
162
-
163
- The fourteen namespace subpaths:
164
-
165
- - `@northguild/gmt/calendar`: ISO week and ordinal dates, quarter and fiscal periods, zone-aware bucketing, business calendars with holiday sets and roll conventions, and operating hours with open-time SLA arithmetic
166
- - `@northguild/gmt/duration`: ISO 8601 duration string parsing, validation, and arithmetic
167
- - `@northguild/gmt/instant`: the instant-plus-offset pair, and explicit resolution of zoneless local wall times
168
- - `@northguild/gmt/interval`: half-open `[start, end)` interval algebra over instants — overlap, intersect, clamp, merge, subtract, split, sum
169
- - `@northguild/gmt/plain`: timezone-free helpers
170
- - `@northguild/gmt/precision`: nanosecond (`bigint`) instants, their JSON bridge, storage truncation, and foreign epoch bridges
171
- - `@northguild/gmt/span`: elapsed and wall-clock durations between two timestamps, as raw numbers
172
- - `@northguild/gmt/transport`: transit legs as exact elapsed time, arrivals rendered where they land, dwell measured in local calendar days, crossings read in the zone that administers them, multi-leg schedules with missed connections flagged, cut-offs counted back from an anchor event, punctuality against a stated tolerance, planned, estimated, requested and actual timestamps kept apart, and the next departure a connection can make
173
- - `@northguild/gmt/intermodal`: free time, demurrage and detention counted in the terminal's local days, with the charged dates behind every count, and the invoice, dispute and resolution deadline chain with every window a caller parameter
174
- - `@northguild/gmt/zoned`: timezone-aware helpers
175
- - `@northguild/gmt/unix`: Unix epoch (seconds or milliseconds) helpers
176
- - `@northguild/gmt/utc`: UTC instant helpers
177
- - `@northguild/gmt/regex`: low-level regex building blocks
178
- - `@northguild/gmt/types`: the shared option and unit types
179
-
180
- Every namespace subpath except `types` also re-exports `Temporal`, `Intl` and `toTemporalInstant`, so `import { Temporal, addZoned } from "@northguild/gmt/zoned"` needs no second import.
181
-
182
- Every namespace except `regex` and `types` also exposes its modules as subpaths,
183
- `@northguild/gmt/<namespace>/<module>`:
184
-
185
- ```typescript
186
- import { getZonedNow } from "@northguild/gmt/zoned";
187
- import { addDateTime, diffDate } from "@northguild/gmt/plain/calculate";
188
- import type { BusinessCalendar } from "@northguild/gmt/types";
189
- ```
190
-
191
- ## Quick Start
192
-
193
- ### Plain arithmetic and comparisons
194
-
195
- ```typescript
196
- import {
197
- addDate,
198
- addBusinessDays,
199
- subtractBusinessDays,
200
- areDatesEqual,
201
- diffDateTime,
202
- isBeforeDateTime,
203
- } from "@northguild/gmt";
204
-
205
- addDate("2026-01-01", { days: 90 });
206
- // "2026-04-01"
207
-
208
- addBusinessDays("2024-03-15", 1);
209
- // "2024-03-18" (skips weekend)
210
-
211
- subtractBusinessDays("2024-03-18", 1);
212
- // "2024-03-15" (skips weekend)
213
-
214
- diffDateTime("2024-03-17T12:00:00", "2024-03-17T12:30:00", "minutes");
215
- // 30
216
-
217
- areDatesEqual("2026-03-17", "2026-03-17T09:00:00");
218
- // true
219
-
220
- isBeforeDateTime("2026-03-17T09:00:00", "2026-03-17T10:00:00");
221
- // true
222
- ```
223
-
224
- `add*`/`subtract*` accept an optional `overflow` (`"constrain"` (default) | `"reject"`) to control out-of-range results (e.g. adding a month to Jan 31) — except `addTime`/`subtractTime`, which take no options argument because a clock time wraps (`addTime("23:00:00", { hours: 2 })` is `"01:00:00"`), as Temporal's `PlainTime#add` does — and `diff*` accept optional `smallestUnit`/`roundingIncrement`/`roundingMode` to round the computed difference:
225
-
226
- ```typescript
227
- import { addDate, diffDate } from "@northguild/gmt";
228
-
229
- addDate("2024-01-31", { months: 1 }, { overflow: "reject" });
230
- // "" — Feb 31 doesn't exist and overflow: "reject" refuses to clamp it
231
-
232
- addDate("2024-02-29", { years: 1 });
233
- // "2025-02-28" — "constrain" (the default) clamps to the last valid day, as Temporal does
234
-
235
- diffDate("2023-01-01", "2023-01-10", "weeks", {
236
- smallestUnit: "week",
237
- roundingMode: "halfExpand",
238
- });
239
- // 1
240
- ```
241
-
242
- `setDate`/`setDateTime`/`setTime`/`setZoned`/`setUnix`/`setUtc` set one or more fields on a value in a single atomic `.with()`-based call — the safe alternative to composing `add*` calls field-by-field, which resolves each field's overflow independently and can silently diverge on multi-field updates:
243
-
244
- ```typescript
245
- import { setDate, setZoned } from "@northguild/gmt";
246
-
247
- setDate("2024-01-31", { month: 2 });
248
- // "2024-02-29" (constrain, the default, clamps to the last valid day)
249
-
250
- setZoned(
251
- "2024-11-03T01:45:00-05:00[America/New_York]",
252
- { minute: 0 },
253
- { disambiguation: "reject" },
254
- );
255
- // "2024-11-03T01:00:00-05:00[America/New_York]" — offset defaults to "prefer", as Temporal's
256
- // ZonedDateTime#with does: the source's -05:00 is still valid, so it is kept
257
-
258
- setZoned(
259
- "2024-11-03T01:45:00-05:00[America/New_York]",
260
- { minute: 0 },
261
- { disambiguation: "reject", offset: "ignore" },
262
- );
263
- // "" — offset "ignore" re-resolves the repeated 01:00, and "reject" fires on the fall-back overlap
264
- ```
265
-
266
- `setZoned`/`setUnix` also accept `disambiguation` and `offset` for DST gap/overlap control (`setUtc` takes only `overflow`: a UTC wall clock is never ambiguous) — see [DST Disambiguation](../../docs/dst-disambiguation.md).
267
-
268
- `cycleDate`/`cycleDateTime`/`cycleTime`/`cycleZoned` adjust a single field and **wrap** at that field's own min/max instead of carrying into the next larger field — the datepicker-segment-editing primitive `add*` can't express, since overflowing into the next field is exactly what `add*` is for:
269
-
270
- ```typescript
271
- import { addDate, cycleDate, cycleZoned } from "@northguild/gmt";
272
-
273
- cycleDate("2024-12-15", "month", 1);
274
- // "2024-01-15" — stays in the same year
275
-
276
- addDate("2024-12-15", { months: 1 });
277
- // "2025-01-15" — addDate correctly overflows into the next year instead
278
-
279
- cycleZoned("2024-03-10T01:30:00-06:00[America/Chicago]", "hour", 1);
280
- // "2024-03-10T03:30:00-05:00[America/Chicago]" — the cycled hour lands in a spring-forward
281
- // gap; disambiguation ("compatible" by default) resolves it the same way setZoned does
282
- ```
283
-
284
- `cycleZoned` also accepts `disambiguation` and `offset` (default `offset: "prefer"`) for the same DST gap/overlap control as `setZoned` — see [DST Disambiguation](../../docs/dst-disambiguation.md). `options.round` on any of the four steps to the next multiple of `amount` rather than rounding to the nearest one, matching `@internationalized/date`'s `CycleOptions.round`.
285
-
286
- `isWeekend`/`isZonedWeekend` check locale-specific weekend days (via `Intl.Locale#getWeekInfo()`, falling back to `weekInfo`) rather than assuming Saturday/Sunday:
287
-
288
- ```typescript
289
- import { isWeekend, isZonedWeekend } from "@northguild/gmt";
290
-
291
- isWeekend("2024-02-03", "en-US");
292
- // true (Saturday, en-US weekend is Sat/Sun)
293
-
294
- isWeekend("2024-02-03", "he-IL");
295
- // true (Saturday is also part of he-IL's Fri/Sat weekend)
296
-
297
- isZonedWeekend("2024-02-04T10:00:00+02:00[Asia/Jerusalem]", "he-IL");
298
- // false (Sunday isn't part of he-IL's weekend)
299
- ```
300
-
301
- `isBusinessDay` is the complement to locale-aware `isWeekend`, and shares its weekend rule with `addBusinessDays`/`subtractBusinessDays`. Called with one argument it uses fixed ISO Monday–Friday business days (Mon=1 … Fri=5), locale-agnostic and with no holidays; pass a `BusinessCalendar` to state the weekend and holidays yourself:
302
-
303
- ```typescript
304
- import { isBusinessDay } from "@northguild/gmt";
305
-
306
- isBusinessDay("2024-02-05");
307
- // true (Monday)
308
-
309
- isBusinessDay("2024-02-10");
310
- // false (Saturday)
311
-
312
- isBusinessDay("2024-07-04", {
313
- weekend: [6, 7],
314
- holidays: ["2024-07-04"],
315
- timeZone: "America/New_York",
316
- });
317
- // false (a holiday, though it's a Thursday)
318
- ```
319
-
320
- ### Business calendars and roll conventions
321
-
322
- A `BusinessCalendar` is `{ weekend: number[], holidays: string[], timeZone: string }`. The weekend is explicit ISO weekday numbers because Saturday–Sunday is not universal — much of the Middle East is Friday–Saturday, and some markets keep a one-day weekend. Holidays are yours to supply: GMT bundles no holiday table on the default import path, because holiday data is jurisdictional and changes annually, sometimes with days of notice. `timeZone` records which locality the calendar describes; the business-day functions take and return local dates and never read it, so a caller holding an instant reduces it with `floorToZone` first.
323
-
324
- `isBusinessDay`, `addBusinessDays` and `subtractBusinessDays` all take a calendar as an optional trailing argument. The rest of the family requires one — there is no default weekend to fall back on:
325
-
326
- ```typescript
327
- import {
328
- businessDaysBetween,
329
- nextBusinessDay,
330
- previousBusinessDay,
331
- rollDate,
332
- mergeCalendars,
333
- isValidBusinessCalendar,
334
- } from "@northguild/gmt";
335
-
336
- const nyse = {
337
- weekend: [6, 7],
338
- holidays: ["2024-05-31", "2024-07-04"],
339
- timeZone: "America/New_York",
340
- };
341
-
342
- businessDaysBetween("2024-07-01", "2024-07-05", nyse);
343
- // 3 — start exclusive, end inclusive, and 4 July is a holiday
344
-
345
- nextBusinessDay("2024-07-03", nyse);
346
- // "2024-07-05" — strictly after, skipping the holiday
347
-
348
- previousBusinessDay("2024-07-05", nyse);
349
- // "2024-07-03"
350
- ```
351
-
352
- `rollDate` moves a date onto a working day by an explicit convention — `following`, `modifiedFollowing`, `preceding`, `modifiedPreceding`, `endOfMonth` or `none`. `following` and `preceding` are on-or-after and on-or-before, so they leave a working day alone; `nextBusinessDay`/`previousBusinessDay` are the strict neighbours:
353
-
354
- ```typescript
355
- rollDate("2024-05-31", "following", nyse);
356
- // "2024-06-03" — forward past the weekend
357
-
358
- rollDate("2024-05-31", "modifiedFollowing", nyse);
359
- // "2024-05-30" — backward instead, because forward leaves May
360
-
361
- rollDate("2024-03-15", "endOfMonth", nyse);
362
- // "2024-03-29" — March's last working day; March ends on a Sunday
363
-
364
- rollDate("2024-06-01", "none", nyse);
365
- // "2024-06-01" — unadjusted, Saturday or not
366
- ```
367
-
368
- `mergeCalendars` composes jurisdictions: weekend rules and holidays union, so a date survives only if it is a working day in **every** input. That is the two-currency intersection FX settlement needs, and the two-port one an intermodal move needs:
369
-
370
- ```typescript
371
- const london = { weekend: [6, 7], holidays: ["2024-05-06"], timeZone: "Europe/London" };
372
-
373
- const both = mergeCalendars([nyse, london]);
374
- // { weekend: [6, 7], holidays: ["2024-05-06", "2024-05-31", "2024-07-04"], timeZone: "America/New_York" }
375
- // timeZone is the first calendar's; the merged set spans localities that may disagree
376
-
377
- isBusinessDay("2024-05-06", both);
378
- // false — a UK holiday closes the merged calendar too
379
-
380
- mergeCalendars([]);
381
- // null
382
- ```
383
-
384
- `isValidBusinessCalendar` and `isValidRollConvention` narrow a candidate, so a misconfigured calendar or contract term can be told apart from bad date input when a function returns its sentinel.
385
-
386
- `isRelativeDay`/`isThisUnit`/`isPast`/`isFuture` are now-relative predicates — `isRelativeDay` subsumes `isToday`/`isYesterday`/`isTomorrow`, `isThisUnit` subsumes `isThisWeek`/`isThisMonth`/`isThisYear`. They compare against `getToday()`, so they depend on the **system clock and system timeZone**; the zoned variants (`isZonedRelativeDay`, `isZonedThisUnit`, `isZonedPast`, `isZonedFuture`) resolve "today"/"now" in the value's own timeZone instead, for deterministic results regardless of the host machine's timeZone:
387
-
388
- ```typescript
389
- import { isRelativeDay, isThisUnit, isPast, isFuture } from "@northguild/gmt";
390
-
391
- isRelativeDay("2024-03-15", 0);
392
- // true, if today is 2024-03-15 ("isToday")
393
-
394
- isThisUnit("2024-02-26", "week", "fr-FR");
395
- // locale-aware week boundary — fr-FR weeks start Monday
396
-
397
- isPast("2024-03-14");
398
- // true, if today is 2024-03-15 (strictly before, not on-or-before)
399
-
400
- isFuture("2024-03-16");
401
- // true, if today is 2024-03-15 (strictly after, not on-or-before)
402
- ```
403
-
404
- `nextWeekday`/`previousWeekday` find the next/previous occurrence of a given ISO day of week (1 = Monday … 7 = Sunday, matching `getDayOfWeek`) on or after/before a date, replacing date-fns's sixteen `next*`/`previous*` functions with two parameterized calls. `options.inclusive` (default `false`) controls what happens when the input already falls on the target day — `false` advances/retreats a full week, matching date-fns:
405
-
406
- ```typescript
407
- import { nextWeekday, previousWeekday } from "@northguild/gmt";
408
-
409
- nextWeekday("2024-03-13", 5);
410
- // "2024-03-15" (Wednesday -> next Friday)
411
-
412
- nextWeekday("2024-03-15", 5);
413
- // "2024-03-22" (already a Friday -> advances a full week by default)
414
-
415
- nextWeekday("2024-03-15", 5, { inclusive: true });
416
- // "2024-03-15" (already a Friday -> returned as-is)
417
-
418
- previousWeekday("2024-03-13", 5);
419
- // "2024-03-08" (Wednesday -> previous Friday)
420
- ```
421
-
422
- ```typescript
423
- import { isZonedRelativeDay, isZonedPast } from "@northguild/gmt";
424
-
425
- isZonedRelativeDay("2024-03-15T10:00:00-04:00[America/New_York]", 0);
426
- // "today" resolved in America/New_York, not the host's system timeZone
427
-
428
- isZonedPast("2020-01-01T00:00:00Z[UTC]");
429
- // true — compares the exact instant, not just the calendar day
430
- ```
431
-
432
- `clampDate` restricts a date to a range, and `closestDateTo` finds the nearest candidate by calendar distance:
433
-
434
- ```typescript
435
- import { clampDate, closestDateTo } from "@northguild/gmt";
436
-
437
- clampDate("2024-02-01", "2024-03-01", "2024-03-31");
438
- // "2024-03-01"
439
-
440
- closestDateTo("2024-03-15", ["2024-03-01", "2024-03-20", "2024-03-18"]);
441
- // "2024-03-18"
442
- ```
443
-
444
- `getLocaleStartOfWeek`/`getLocaleEndOfWeek` (and their zoned equivalents) compute week boundaries from the locale's first day of week, instead of an ISO-Monday default:
445
-
446
- ```typescript
447
- import { getLocaleStartOfWeek, getLocaleEndOfWeek } from "@northguild/gmt";
448
-
449
- getLocaleStartOfWeek("2024-02-29", "en-US");
450
- // "2024-02-25" (Sunday, en-US weeks start Sunday)
451
-
452
- getLocaleStartOfWeek("2024-02-29", "fr-FR");
453
- // "2024-02-26" (Monday, fr-FR weeks start Monday)
454
-
455
- getLocaleEndOfWeek("2024-02-29", "en-US");
456
- // "2024-03-02" (Saturday)
457
- ```
458
-
459
- `getLocaleDayOfWeek`/`getLocaleZonedDayOfWeek` return a locale-relative day-of-week index (0 = first day of the locale's week):
460
-
461
- ```typescript
462
- import { getLocaleDayOfWeek, getLocaleZonedDayOfWeek } from "@northguild/gmt";
463
-
464
- getLocaleDayOfWeek("2024-02-25", "en-US");
465
- // 0 (Sunday = first day of en-US week)
466
-
467
- getLocaleDayOfWeek("2024-02-26", "fr-FR");
468
- // 0 (Monday = first day of fr-FR week)
469
-
470
- getLocaleDayOfWeek("2024-02-24", "ar-EG");
471
- // 0 (Saturday = first day of ar-EG week)
472
-
473
- getLocaleZonedDayOfWeek("2024-02-25T12:00:00+00:00[UTC]", "en-US");
474
- // 0
475
- ```
476
-
477
- `getLocaleEraNames`/`getLocaleMonthNames`/`getLocaleWeekdayNames`/`getLocaleMeridiems` return standalone, locale-aware calendar names with no date value required — the GMT equivalents of Luxon's `Info.eras`/`Info.months`/`Info.weekdays`/`Info.meridiems`:
478
-
479
- ```typescript
480
- import {
481
- getLocaleEraNames,
482
- getLocaleMonthNames,
483
- getLocaleWeekdayNames,
484
- getLocaleMeridiems,
485
- } from "@northguild/gmt";
486
-
487
- getLocaleEraNames("en-US");
488
- // ["Before Christ", "Anno Domini"]
489
-
490
- getLocaleEraNames("ja-JP", "short");
491
- // ["紀元前", "西暦"]
492
-
493
- getLocaleMonthNames("en-US");
494
- // ["January", "February", ... "December"]
495
-
496
- getLocaleMonthNames("de-DE", "short");
497
- // ["Jan", "Feb", "Mär", ... "Dez"]
498
-
499
- getLocaleWeekdayNames("en-US");
500
- // ["Sunday", "Monday", ... "Saturday"] (locale-first-day order)
501
-
502
- getLocaleWeekdayNames("fr-FR");
503
- // ["lundi", "mardi", ... "dimanche"]
504
-
505
- getLocaleMeridiems("en-US");
506
- // ["AM", "PM"]
507
-
508
- getLocaleMeridiems("zh-CN");
509
- // ["上午", "下午"]
510
- ```
511
-
512
- `getLocaleWeekdayNames` returns names in the locale's first-day order, consistent with `getLocaleDayOfWeek` (index 0 is the locale's first day of the week). All four delegate to the host runtime's `Intl` data, so their output depends on the runtime's ICU build.
513
-
514
- ### Parsing
515
-
516
- `parseDateWithPattern`/`parseDateTimeWithPattern`/`parseTimeWithPattern` decode a known, fixed producer format — a CSV column, a legacy API field, or a partially-typed form value — against a caller-supplied token pattern. This is for **decoding**, not display: the pattern hard-codes field order, so `formatDate`/`formatDateToParts` remain the correct choice for locale-correct output.
517
-
518
- ```typescript
519
- import {
520
- parseDateWithPattern,
521
- parseDateTimeWithPattern,
522
- parseTimeWithPattern,
523
- } from "@northguild/gmt";
524
-
525
- parseDateWithPattern("03/15/2024", "MM/dd/yyyy");
526
- // "2024-03-15"
527
-
528
- parseDateTimeWithPattern("15-Mar-2024 14:30", "dd-MMM-yyyy HH:mm");
529
- // "2024-03-15T14:30:00"
530
-
531
- parseTimeWithPattern("02:30:45 PM", "hh:mm:ss a");
532
- // "14:30:45"
533
-
534
- parseDateWithPattern("02/31/2024", "MM/dd/yyyy");
535
- // "" — shape-valid but not a real date; Temporal validates after the regex match
536
- ```
537
-
538
- Supported tokens include `yyyy`/`MM`/`dd`/`HH`/`mm`/`ss`/`SSS` for fixed-width fields, `M`/`d`/`H`/`h`/`m`/`s` for variable-width, `MMMM`/`MMM`/`EEEE`/`EEE`/`a`/`GGGG`/`GG` for locale-aware names, and `'single quotes'` for literal text. Two single quotes in a row are one literal quote, inside or outside quoted text, as UTS #35 reads them: `"MMM d, ''yy"` reads `"Mar 15, '24"`. A `locale` parameter (default `"en-US"`) controls name-token matching. Invalid input, no-match, and malformed patterns all return `""`.
539
-
540
- ### Calendar systems
541
-
542
- `convertDateToCalendar` expresses a date in another calendar system. GMT writes the standard form, exactly what `Temporal.PlainDate.prototype.toString()` writes: the ISO 8601 date, then an RFC 9557 `[u-ca=<id>]` annotation naming the calendar. The digits stay ISO. The annotation says which calendar the date is presented and computed in (RFC 9557 §3.3), so the string means the same date to GMT, to Temporal and to any other RFC 9557 parser.
543
-
544
- ```typescript
545
- import { convertDateToCalendar } from "@northguild/gmt";
546
-
547
- convertDateToCalendar("2024-10-03", "hebrew");
548
- // "2024-10-03[u-ca=hebrew]" — Rosh Hashanah 5785, written as its ISO date
549
-
550
- convertDateToCalendar("2024-10-03[u-ca=hebrew]", "iso8601");
551
- // "2024-10-03" — iso8601 writes no annotation
552
-
553
- convertDateToCalendar("2024-10-03", "gregory");
554
- // "2024-10-03[u-ca=gregory]" — gregory is its own calendar, so it is annotated
555
-
556
- convertDateToCalendar("invalid", "hebrew");
557
- // ""
558
- ```
559
-
560
- `CalendarSystem` is `"iso8601" | "gregory" | "hebrew" | "islamic-civil" | "islamic-tbla" | "islamic-umalqura" | "japanese" | "buddhist" | "roc" | "persian" | "indian" | "ethiopic" | "ethioaa" | "coptic"`: the canonical BCP 47 / CLDR `calendar.xml` ids Temporal uses, in the annotation and as function arguments. An alias or another letter case is canonicalized the way Temporal's `withCalendar` does it (`"ethiopic-amete-alem"` writes `[u-ca=ethioaa]`, `[u-ca=HEBREW]` reads as `hebrew`), and the canonical id is always written. `gregorian`, `taiwan` and `islamic-tabular` are not calendar ids, so they return the sentinel. `chinese`, `dangi`, `islamic` and `islamic-rgsa` are not supported.
561
-
562
- Every calendar-accepting function reads annotations the way `Temporal.PlainDate.from` / `Temporal.ZonedDateTime.from` read them. The critical flag is accepted and not written back (`"2024-10-03[!u-ca=hebrew]"` converts to `"2024-10-03[u-ca=hebrew]"`), and an unknown critical annotation is rejected. `;era=` is not RFC 9557 syntax and is rejected. A year is four digits, or a sign and six (`"+275760-09-13[u-ca=hebrew]"`), across Temporal's whole range, `-271821-04-19` to `+275760-09-13`.
563
-
564
- **Calendar fields are read values, never string content.** The Hebrew year 5785, the Japanese era `reiwa` and year 6, and the Umm al-Qura day number are all properties of the date, so GMT never puts them in a string. No standard defines a machine-readable date string with calendar-native digits, and such a string would be ambiguous: `5785-01-01[u-ca=hebrew]` is also a valid RFC 9557 string for ISO year 5785. When you need a field for display, format the date with `Intl.DateTimeFormat` and its `calendar` option, or read it from Temporal, which every GMT entry point re-exports:
565
-
566
- ```typescript
567
- import { Temporal } from "@northguild/gmt";
568
-
569
- Temporal.PlainDate.from("2024-10-03[u-ca=hebrew]").year; // 5785
570
- Temporal.PlainDate.from("2024-10-03[u-ca=japanese]").eraYear; // 6
571
- ```
572
-
573
- The three Islamic variants are different calendars, not spellings of one. On ISO 2020-02-24, `islamic-civil` (Friday epoch) reads day 29 of month 6 of 1441, `islamic-tbla` (the same arithmetic cycle with a Thursday epoch) reads day 1 of month 7, and `islamic-umalqura` (the Saudi civil calendar, from Umm al-Qura University's published tables) reads day 30 of month 6. Month arithmetic follows each calendar's own months, so the same `+1 month` lands on different ISO dates:
574
-
575
- ```typescript
576
- import { addDate } from "@northguild/gmt";
577
-
578
- addDate("2020-02-24[u-ca=islamic-umalqura]", { months: 1 });
579
- // "2020-03-24[u-ca=islamic-umalqura]"
580
-
581
- addDate("2020-02-24[u-ca=islamic-tbla]", { months: 1 });
582
- // "2020-03-25[u-ca=islamic-tbla]"
583
- ```
584
-
585
- `ethiopic` and `coptic` compute in `ethioaa`. The three share months, days and arithmetic and differ only by a constant year offset, and `@js-temporal/polyfill` 0.5.1 throws reading `ethiopic` and `coptic` fields under ICU 78 or later (`Temporal.PlainDate.from("2024-10-03[u-ca=ethiopic]").year` throws there). GMT's results are the same either way, and only the written id differs. Where the polyfill or the runtime's ICU computes a calendar wrongly (Buddhist before 1582, Hebrew years ≤ 0, Indian dates before ISO year 1, dates near either range limit), GMT's arithmetic computes the specified answer instead. Each correction probes the runtime once and stays inactive where the runtime is already right.
586
-
587
- #### Calendar-aware interval and duration arithmetic
588
-
589
- A calendar-annotated date feeds `addDate`/`subtractDate`/`diffDate`/`diffDateAsDuration` and every `Date`-suffixed `plain/interval/*` function (`intervalContainsDate`, `intervalCountDate`, `splitIntervalByUnitDate`, and the rest). Calendar units ("add 1 month") resolve in the value's own calendar:
590
-
591
- ```typescript
592
- import { addDate, diffDate, diffDateAsDuration, durationAs, intervalCountDate } from "@northguild/gmt";
593
-
594
- addDate("2024-02-24[u-ca=hebrew]", { months: 1 });
595
- // "2024-03-25[u-ca=hebrew]" — 15 Adar I 5784 to 15 Adar II; addDate("2024-02-24", { months: 1 }) is "2024-03-24"
596
-
597
- intervalCountDate("2023-09-16[u-ca=hebrew]", "2024-10-03[u-ca=hebrew]", "month");
598
- // 13 — Hebrew leap year 5784 has 13 months; the same ISO span touches 14 ISO months
599
-
600
- diffDate("2024-03-11[u-ca=hebrew]", "2024-04-10[u-ca=hebrew]", "months");
601
- // 1 — 1 Adar II to 2 Nisan; the bare ISO dates are 0 whole months apart
602
-
603
- diffDateAsDuration("2024-08-31[u-ca=buddhist]", "2024-09-30[u-ca=buddhist]", "months");
604
- // "P30D" — not "P1M": a month counts only once the end reaches the same day of the next month
605
-
606
- durationAs("P1Y", "days", { relativeTo: "2023-09-16[u-ca=hebrew]" });
607
- // 383 — Hebrew leap year 5784; relativeTo "2023-09-16" gives 366
608
- ```
609
-
610
- Month and year differences follow Temporal's `NonISODateSurpasses` in every calendar, so a span from a month's last day into a shorter month is days, not a month. This matches the ISO calendar (`diffDateAsDuration("2024-08-31", "2024-09-30", "months")` is `"P30D"`), and applies to `diffDate*`, `intervalLength*`, `intervalCount*` and `diffZoned*` alike.
611
-
612
- `utc/` reads a UTC string as `Temporal.Instant.from` does, so a `[u-ca=...]` annotation is ignored there: `isValidUtc("2024-01-01T00:00:00Z[u-ca=hebrew]")` is `true`. `duration/`'s `relativeTo` reads a calendar-annotated string as Temporal's `ParseTemporalRelativeToString` does: zoned when it carries a time zone annotation, otherwise a date.
30
+ To ban `Date` in your own code, add a lint plugin: [ESLint](https://github.com/northguild/gmt/tree/main/packages/gmt-eslint) · [Oxlint](https://github.com/northguild/gmt/tree/main/packages/gmt-oxlint) · [Biome](https://github.com/northguild/gmt/tree/main/packages/gmt-biome).
613
31
 
614
- Two values that name different calendars follow Temporal's `CalendarEquals`. A bare ISO string names `iso8601`.
615
-
616
- - **Differences return the sentinel** on a mismatch, as Temporal's `until` throws: `diffDate`, `diffDateAsDuration`, `intervalCountDate`, `intervalLengthDate`, `splitIntervalByUnitDate` and `intervalOverlappingDaysDate`.
617
- - **Ordering accepts mixed calendars**, as `Temporal.PlainDate.compare` has no calendar check: `intervalContainsDate`, `intervalsOverlapDate`, `intervalAbutsDate`, `intervalEngulfsDate` and `isValidDateInterval`.
618
- - **Functions that return a date value** (`intervalUnionDate`, `intervalIntersectionDate`, `intervalDifferenceDate`, `intervalXorDate`, `intervalXorAllDate`, `mergeIntervalsDate`, `intervalDivideEquallyDate`, `intervalSplitAtDate`) require one shared calendar and return `null`/`[]` on a mismatch, because no calendar can be chosen for the output.
32
+ ## Use
619
33
 
620
34
  ```typescript
621
- intervalCountDate("2023-09-16[u-ca=hebrew]", "2024-10-03", "month");
622
- // null — hebrew and iso8601
623
-
624
- intervalContainsDate("2023-09-16[u-ca=hebrew]", "2024-10-03", "2024-01-01[u-ca=roc]");
625
- // true — ordering compares the ISO dates
626
- ```
627
-
628
- #### Calendar-aware zoned datetimes
35
+ import { addBusinessDays, addDate, addZoned, convertUtcToZoned, diffDate, formatDate, isValidDate, resolveLocal } from "@northguild/gmt";
629
36
 
630
- A calendar-annotated `ZonedDateTime` string is the RFC 9557 form `Temporal.ZonedDateTime.prototype.toString()` writes: the time zone annotation first, then the calendar (RFC 9557 §4.1):
37
+ addDate("2024-01-31", { months: 1 }); // "2024-02-29"
38
+ diffDate("2024-01-01", "2024-03-01", "day"); // 60
39
+ formatDate("2024-03-15", "en-GB", { dateStyle: "long" }); // "15 March 2024"
40
+ isValidDate("2023-02-29"); // false
41
+ addBusinessDays("2024-03-15", 1); // "2024-03-18"
631
42
 
43
+ // Time zones, and the night the clocks change
44
+ convertUtcToZoned("2024-06-15T12:00:00Z", "Asia/Tokyo"); // "2024-06-15T21:00:00+09:00[Asia/Tokyo]"
45
+ addZoned("2024-03-10T01:30:00-05:00[America/New_York]", { hours: 1 }); // "2024-03-10T03:30:00-04:00[America/New_York]"
46
+ resolveLocal("2024-03-10T02:30:00", "America/New_York", { disambiguation: "reject" }); // "" (02:30 did not happen that day)
632
47
  ```
633
- <date>T<time><offset>[<timeZone>][u-ca=<id>]
634
48
 
635
- 2024-02-24T14:30:00-05:00[America/New_York][u-ca=hebrew]
636
- 2019-04-30T12:00:00+09:00[Asia/Tokyo][u-ca=japanese]
637
- ```
49
+ Every function has a reference page with a live playground:
50
+ [plain](https://gmt-dox.northguild.workers.dev/reference/plain/) · [zoned](https://gmt-dox.northguild.workers.dev/reference/zoned/) · [utc](https://gmt-dox.northguild.workers.dev/reference/utc/) · [unix](https://gmt-dox.northguild.workers.dev/reference/unix/) · [instant](https://gmt-dox.northguild.workers.dev/reference/instant/) · [calendar](https://gmt-dox.northguild.workers.dev/reference/calendar/) · [duration](https://gmt-dox.northguild.workers.dev/reference/duration/) · [interval](https://gmt-dox.northguild.workers.dev/reference/interval/) · [duty](https://gmt-dox.northguild.workers.dev/reference/duty/) · [span](https://gmt-dox.northguild.workers.dev/reference/span/) · [precision](https://gmt-dox.northguild.workers.dev/reference/precision/)
638
51
 
639
- `convertZonedToCalendar` produces it, and `isValidCalendarZonedDateTime` validates it. A calendar annotation before the zone is not RFC 9557 and returns the sentinel:
52
+ By industry: [transport](https://gmt-dox.northguild.workers.dev/reference/transport/) · [intermodal](https://gmt-dox.northguild.workers.dev/reference/intermodal/)
640
53
 
641
- ```typescript
642
- import { addZoned, convertZonedToCalendar } from "@northguild/gmt";
54
+ Then the [guides](https://gmt-dox.northguild.workers.dev/guides/) show how to do each job, and the [tools](https://gmt-dox.northguild.workers.dev/tools/) let you try it on your own values.
643
55
 
644
- convertZonedToCalendar("2024-10-03T14:30:45-04:00[America/New_York]", "hebrew");
645
- // "2024-10-03T14:30:45-04:00[America/New_York][u-ca=hebrew]"
56
+ ## Agent prompt
646
57
 
647
- convertZonedToCalendar("2024-10-03T14:30:45-04:00[u-ca=hebrew][America/New_York]", "iso8601");
648
- // "" — the calendar annotation must follow the zone
58
+ Paste this into your AI coding agent:
649
59
 
650
- addZoned("2024-02-24T14:30:00-05:00[America/New_York][u-ca=hebrew]", { months: 1 });
651
- // "2024-03-25T14:30:00-04:00[America/New_York][u-ca=hebrew]"
652
- // Adar I -> Adar II AND EST -> EDT, resolved in one call. No ordering of a plain/ calendar
653
- // operation and a zoned/ conversion produces this: do the calendar step first and DST is
654
- // applied to an already-resolved wall time; do the zoned step first and there is no calendar
655
- // left to step in.
656
60
  ```
61
+ You are a coding assistant using @northguild/gmt — a Temporal-first date/time library.
657
62
 
658
- Scope: `addZoned`, `subtractZoned`, `diffZoned`, `diffZonedAsDuration`, `convertZonedToCalendar`, and the `zoned/interval/*` family. Everything else in `zoned/`, and `isValidZonedDateTime`, accepts `[u-ca=iso8601]` and rejects any other calendar, so a function that has not opted in fails closed rather than silently answering in the wrong calendar. `addZonedBusinessDays`/`subtractZonedBusinessDays` stay out by design: day-of-week is ISO-fixed in every supported calendar, so a tag would change nothing while implying it might.
63
+ SETUP (do this once per project):
659
64
 
660
- Mixed-calendar endpoints follow the same rules as `plain/`. Ordering functions (`intervalContainsZoned`, `intervalsOverlapZoned`, `intervalAbutsZoned`, `intervalEngulfsZoned`, `isValidCalendarZonedInterval`) accept them. The value-returning set operations require one shared calendar. Differences (`diffZoned`, `diffZonedAsDuration`, `intervalCountZoned`, `intervalLengthZoned`, `splitIntervalByUnitZoned`, `intervalOverlappingDaysZoned`) return their sentinel on a mismatch for every unit, including hours, as `Temporal.ZonedDateTime.prototype.until` throws across calendars.
65
+ 1. Install the runtime:
66
+ npm install @northguild/gmt
661
67
 
662
- **Migrating strings written before 1.16.0.** Earlier releases wrote the calendar's own year, month and day (`"5785-01-01[u-ca=hebrew]"` for ISO 2024-10-03), a `;era=` suffix, the zoned calendar annotation before the zone, and the ids `gregorian`, `taiwan`, `islamic-tabular` and `ethiopic-amete-alem` (now `iso8601`, `roc`, `islamic-tbla` and `ethioaa`). No converter exists: most old strings are also valid RFC 9557 strings for a different ISO date, and the two readings cannot be told apart. `convertDateToCalendar("5785-01-01[u-ca=hebrew]", "iso8601")` returns `"5785-01-01"`. Regenerate each stored value from its ISO date with `convertDateToCalendar` or `convertZonedToCalendar`.
68
+ 2. (Optional) Install a linter plugin for Date-ban enforcement:
69
+ npm install -D @northguild/gmt-eslint # ESLint
70
+ npm install -D @northguild/gmt-oxlint # Oxlint
71
+ npm install -D @northguild/gmt-biome # Biome
663
72
 
664
- ### Durations
73
+ 3. Wire TanStack Intent so skill guidance is discoverable in AGENTS.md:
74
+ npx @tanstack/intent@latest install
665
75
 
666
- ```typescript
667
- import {
668
- absDuration,
669
- addDuration,
670
- compareDurations,
671
- diffDateAsDuration,
672
- durationAs,
673
- formatDuration,
674
- getDurationSign,
675
- getDurationUnit,
676
- isValidDuration,
677
- negateDuration,
678
- normalizeDuration,
679
- parseDuration,
680
- subtractDuration,
681
- } from "@northguild/gmt";
682
-
683
- isValidDuration("P1DT2H30M");
684
- // true
685
-
686
- parseDuration("P1DT2H30M");
687
- // "P1DT2H30M"
688
-
689
- parseDuration("PT1.5S", { smallestUnit: "second", roundingMode: "trunc" });
690
- // "PT1S"
691
-
692
- parseDuration("not a duration");
693
- // ""
694
-
695
- addDuration("P1D", "PT2H");
696
- // "P1DT2H"
697
-
698
- subtractDuration("P1D", "PT2H");
699
- // "PT22H"
700
-
701
- normalizeDuration("PT90M", { largestUnit: "hour" });
702
- // "PT1H30M"
703
-
704
- normalizeDuration("P45D", { largestUnit: "month", relativeTo: "2024-01-01" });
705
- // "P1M14D"
706
-
707
- getDurationUnit("P1DT2H30M", "hours");
708
- // 2 — the hours component as stored
709
-
710
- durationAs("P1DT2H30M", "hours");
711
- // 26.5 — the whole duration totalled into hours
712
-
713
- durationAs("P1M", "days");
714
- // null — a calendar unit needs a relativeTo anchor
715
-
716
- durationAs("P1M", "days", { relativeTo: "2024-02-01" });
717
- // 29
718
-
719
- negateDuration("P1DT2H");
720
- // "-P1DT2H"
721
-
722
- absDuration("-P1DT2H");
723
- // "P1DT2H"
724
-
725
- getDurationSign("-P1DT2H");
726
- // -1
76
+ WHEN HELPING THE USER:
727
77
 
728
- compareDurations("PT60M", "PT1H");
729
- // 0 — equal by length, not by spelling
78
+ 1. Ask what difficulties they are having with JavaScript dates — this helps match
79
+ them to the right task area (basics, arithmetic, timezone, integration).
730
80
 
731
- compareDurations("P1M", "P30D", { relativeTo: "2024-01-01" });
732
- // 1 — January is 31 days; relativeTo "2024-02-01" gives -1
81
+ 2. Generate code using GMT's string-in/string-out API. NEVER use new Date().
82
+ For API details, read https://gmt-dox.northguild.workers.dev/llms.txt and the
83
+ JSDoc in the installed package.
733
84
 
734
- formatDuration("P1DT2H30M", "en-US");
735
- // "1 day, 2 hours, and 30 minutes"
736
-
737
- formatDuration("P1DT2H30M", "en-US", { style: "short" });
738
- // "1 day, 2 hr, & 30 min"
739
-
740
- formatDuration("P1DT0H30M", "en-US");
741
- // "1 day and 30 minutes"
742
-
743
- diffDateAsDuration("2024-03-10", "2024-04-05", "days");
744
- // "P26D" — bridges diffDate to an ISO duration string instead of a single-unit number
85
+ 3. For specialized tasks, use TanStack Intent to discover and load the relevant skill:
86
+ npx @tanstack/intent@latest list
87
+ npx @tanstack/intent@latest load @northguild/gmt#<skill-name>
745
88
  ```
746
89
 
747
- `getDurationUnit` reads a component as stored, while `durationAs` converts the whole duration — `getDurationUnit("PT90M", "hours")` is `0` but `durationAs("PT90M", "hours")` is `1.5`. `durationAs` and `compareDurations` return `null` when a calendar unit (year/month/week) is involved without a `relativeTo` anchor, the same documented constraint `normalizeDuration` carries; `negateDuration`, `absDuration`, `getDurationSign`, and `getDurationUnit` are sign/field reads and never need one.
748
-
749
- `diffDateAsDuration`/`diffDateTimeAsDuration`/`diffZonedAsDuration`/`diffUnixAsDuration`/`diffUtcAsDuration` are sibling functions to `diffDate`/`diffDateTime`/`diffZoned`/`diffUnix`/`diffUtc`, returning an ISO 8601 duration string (sentinel `""`) instead of a single-unit number (sentinel `null`). They take a single `unit` (not an array) to set the duration's `largestUnit`.
750
-
751
- ### Intervals
752
-
753
- > **One boundary model.** Every interval function in this section (`intervalsOverlapDate`, `intervalDifferenceUtc`, …) reads an interval as half-open, `[start, end)`: `start` is inside and `end` is not, as in the `interval/` namespace described under [Interval algebra](#interval-algebra) (SQL:2011 closed-open `PERIOD`, RFC 5545's non-inclusive `DTEND`, EWD831). Touching intervals share no instant, so they abut rather than overlap, and no function steps an endpoint by one unit. For a `Date` interval that means `end` is the first day **after** the period: pass `"2024-07-01"`, not `"2024-06-30"`, for the first half of 2024 (`addDate(lastDay, { days: 1 })`). Before 1.16.0 most positional families read intervals as closed `[start, end]`.
754
-
755
- Interval and range validators are available in two API shapes — **range validators** (matching `isValidDateRange`'s `{ value1, value2, options? }` object-param shape) and **interval validators** (`(start, end)` positional args, `start <= end` always):
756
-
757
- ```typescript
758
- import {
759
- isValidDateInterval,
760
- isValidTimeInterval,
761
- isValidDateTimeInterval,
762
- isValidDateRange,
763
- isValidTimeRange,
764
- isValidDateTimeRange,
765
- isValidUtcRange,
766
- isValidUnixRange,
767
- isValidZonedRange,
768
- isValidUtcInterval,
769
- isValidUnixInterval,
770
- isValidZonedInterval,
771
- } from "@northguild/gmt";
772
-
773
- // Interval validators — positional args, start <= end
774
- isValidDateInterval("2024-01-01", "2024-12-31");
775
- // true
776
-
777
- isValidTimeInterval("09:00:00", "17:00:00");
778
- // true
779
-
780
- isValidDateTimeInterval("2024-01-01T10:00:00", "2024-12-31T23:59:59");
781
- // true
782
-
783
- isValidZonedInterval(
784
- "2024-01-01T10:00:00+00:00[UTC]",
785
- "2024-12-31T23:59:59+00:00[UTC]",
786
- );
787
- // true
788
-
789
- // Range validators — object params, with optional allowEqual
790
- isValidDateRange({ value1: "2024-01-01", value2: "2024-12-31" });
791
- // true
792
-
793
- isValidTimeRange({ value1: "09:00:00", value2: "17:00:00" });
794
- // true
795
-
796
- isValidZonedRange({
797
- value1: "2024-01-01T10:00:00+00:00[UTC]",
798
- value2: "2024-12-31T23:59:59+00:00[UTC]",
799
- });
800
- // true
801
- ```
802
-
803
- Interval containment checks (`intervalContains*`) test whether a point or inner interval falls within an outer interval. Each supports two modes via an optional fourth argument:
804
-
805
- - 3-arg: `intervalContains(start, end, point)` — true when `start <= point < end`
806
- - 4-arg: `intervalContains(start, end, innerStart, innerEnd)` — true when the inner interval lies within the outer one and overlaps it, so an inner interval may share the outer `end`, but an empty inner interval at `end` is not contained
807
-
808
- ```typescript
809
- import {
810
- intervalContainsDate,
811
- intervalContainsTime,
812
- intervalContainsDateTime,
813
- intervalContainsUtc,
814
- intervalContainsUnix,
815
- intervalContainsZoned,
816
- } from "@northguild/gmt";
817
-
818
- // Point-in-interval (3-arg)
819
- intervalContainsDate("2024-01-01", "2024-12-31", "2024-06-15");
820
- // true
821
-
822
- intervalContainsDate("2024-01-01", "2024-12-31", "2024-12-31");
823
- // false (the end is excluded)
824
-
825
- intervalContainsTime("09:00:00", "17:00:00", "12:00:00");
826
- // true
827
-
828
- intervalContainsUtc(
829
- "2024-01-01T00:00:00Z",
830
- "2024-12-31T23:59:59Z",
831
- "2024-06-15T12:00:00Z",
832
- );
833
- // true
834
-
835
- intervalContainsUnix(0, 1700000000, 170000000);
836
- // true
837
-
838
- intervalContainsZoned(
839
- "2024-01-01T00:00:00+00:00[UTC]",
840
- "2024-12-31T23:59:59+00:00[UTC]",
841
- "2024-06-15T12:00:00+00:00[UTC]",
842
- );
843
- // true
844
-
845
- // Interval-in-interval (4-arg)
846
- intervalContainsDate("2024-01-01", "2024-12-31", "2024-03-01", "2024-09-01");
847
- // true
848
-
849
- intervalContainsTime("09:00:00", "17:00:00", "10:00:00", "16:00:00");
850
- // true
851
- ```
852
-
853
- All interval containment checks return `false` on invalid input (wrong type, malformed strings, leap seconds, inverted intervals, or an epoch outside the `unix/` grammar: not a safe integer or a digit string).
854
-
855
- `intervalsOverlap*` checks whether two intervals share any instant. Returns `false` when they are disjoint. Intervals that touch (one's end equals the other's start) share no instant, so they do **not** overlap, exactly as `intervalsOverlap` in `interval/`:
856
-
857
- ```typescript
858
- import {
859
- intervalsOverlapDate,
860
- intervalsOverlapTime,
861
- intervalsOverlapDateTime,
862
- intervalsOverlapUtc,
863
- intervalsOverlapUnix,
864
- intervalsOverlapZoned,
865
- } from "@northguild/gmt";
866
-
867
- intervalsOverlapDate("2024-01-01", "2024-06-30", "2024-04-01", "2024-12-31");
868
- // true
869
-
870
- intervalsOverlapDate("2024-01-01", "2024-06-30", "2024-06-30", "2024-12-31");
871
- // false (touching — 2024-06-30 is outside the first interval)
872
-
873
- intervalsOverlapUnix(0, 1700000000, 1000000, 2000000);
874
- // true
875
-
876
- intervalsOverlapUtc(
877
- "2024-01-01T09:00:00Z",
878
- "2024-01-01T17:00:00Z",
879
- "2024-01-01T17:00:00Z",
880
- "2024-01-01T18:00:00Z",
881
- );
882
- // false (touching at 17:00)
883
- ```
884
-
885
- All overlap checks return `false` on invalid input (wrong type, malformed strings, leap seconds, inverted intervals, or an epoch outside the `unix/` grammar: not a safe integer or a digit string).
886
-
887
- `intervalIntersection*` returns the overlapping span of two intervals, or `null` when they do not overlap. Touching intervals share no instant, so their intersection is `null`:
888
-
889
- ```typescript
890
- import {
891
- intervalIntersectionDate,
892
- intervalIntersectionTime,
893
- intervalIntersectionDateTime,
894
- intervalIntersectionUtc,
895
- intervalIntersectionUnix,
896
- intervalIntersectionZoned,
897
- } from "@northguild/gmt";
898
-
899
- intervalIntersectionDate(
900
- "2024-01-01",
901
- "2024-07-01",
902
- "2024-04-01",
903
- "2025-01-01",
904
- );
905
- // { start: "2024-04-01", end: "2024-07-01" }
906
-
907
- intervalIntersectionDate(
908
- "2024-01-01",
909
- "2024-07-01",
910
- "2024-07-01",
911
- "2025-01-01",
912
- );
913
- // null (touching, no shared day)
914
-
915
- intervalIntersectionUnix(0, 1700000000, 1000000, 2000000);
916
- // { start: 1000000, end: 2000000 } (B lies inside A)
917
-
918
- intervalIntersectionUtc(
919
- "2024-01-01T00:00:00Z",
920
- "2024-07-01T00:00:00Z",
921
- "2024-04-01T00:00:00Z",
922
- "2025-01-01T00:00:00Z",
923
- );
924
- // { start: "2024-04-01T00:00:00Z", end: "2024-07-01T00:00:00Z" }
925
- ```
926
-
927
- All intersection functions return `null` on invalid input (wrong type, malformed strings, leap seconds, inverted intervals, or an epoch outside the `unix/` grammar: not a safe integer or a digit string).
928
-
929
- `intervalOverlappingDays*` returns how many distinct calendar dates two intervals share — the numeric counterpart to `intervalIntersection*`'s span. It counts the calendar dates that hold at least one instant of the half-open intersection `[max(aStart, bStart), min(aEnd, bEnd))`, so an empty intersection counts `0`: `intervalOverlappingDaysDate("2024-01-01", "2024-01-02", "2024-01-01", "2024-01-02")` is `1`, and touching intervals are `0`. There is no `Time` variant — `PlainTime` has no calendar, so a day count is undefined for it:
930
-
931
- ```typescript
932
- import {
933
- intervalOverlappingDaysDate,
934
- intervalOverlappingDaysDateTime,
935
- intervalOverlappingDaysUtc,
936
- intervalOverlappingDaysUnix,
937
- intervalOverlappingDaysZoned,
938
- } from "@northguild/gmt";
939
-
940
- intervalOverlappingDaysDate(
941
- "2024-01-01",
942
- "2024-07-01",
943
- "2024-04-01",
944
- "2025-01-01",
945
- );
946
- // 91 (2024-04-01 through 2024-06-30)
947
-
948
- intervalOverlappingDaysDate(
949
- "2024-01-01",
950
- "2024-07-01",
951
- "2024-07-01",
952
- "2025-01-01",
953
- );
954
- // 0 (touching, no shared date)
955
-
956
- intervalOverlappingDaysUtc(
957
- "2024-01-17T12:00:00Z",
958
- "2024-01-19T00:00:00Z",
959
- "2024-01-10T00:00:00Z",
960
- "2024-01-18T06:00:00Z",
961
- );
962
- // 2 (18 hours of overlap touch 17 and 18 January)
963
-
964
- intervalOverlappingDaysUnix(0, 172800000, 86400000, 259200000);
965
- // 1 (the intersection [86400000, 172800000) is 1970-01-02 in UTC)
966
- ```
967
-
968
- Returns `0` when the intervals do not overlap (a well-defined answer, not invalid input) and `null` on invalid input, including an inverted interval (`start > end`). `intervalOverlappingDaysZoned` and `intervalOverlappingDaysUnix` count days in `aStart`'s time zone (`intervalOverlappingDaysUnix` defaults to UTC; pass `{ timeZone }`, or `"local"` for the system zone) — the same rule `intervalCountZoned`/`intervalCountUnix` use — so `intervalOverlappingDaysZoned` is **not commutative** when the two intervals carry different zones: swapping the arguments can change the answer. Both count the distinct local dates the overlap touches, so a date the zone skipped (`Pacific/Apia`, 2011-12-30) is not counted, and a fall-back that sends the clock back into the previous date counts that date too.
969
-
970
- It counts calendar dates, not elapsed days: the 18-hour overlap above touches two dates. For the elapsed length of the overlap, compose `intervalIntersection*` with `intervalLength*`:
971
-
972
- ```typescript
973
- const span = intervalIntersectionUtc(aStart, aEnd, bStart, bEnd);
974
- span ? intervalLengthUtc(span.start, span.end, "day") : 0; // 0.75 for the overlap above
975
- ```
976
-
977
- `intervalUnion*` returns the combined span of two overlapping or touching intervals, or `null` when a gap separates them. Touching intervals (one's end equals the other's start) join:
978
-
979
- ```typescript
980
- import {
981
- intervalUnionDate,
982
- intervalUnionTime,
983
- intervalUnionDateTime,
984
- intervalUnionUtc,
985
- intervalUnionUnix,
986
- intervalUnionZoned,
987
- } from "@northguild/gmt";
988
-
989
- intervalUnionDate("2024-01-01", "2024-07-01", "2024-04-01", "2025-01-01");
990
- // { start: "2024-01-01", end: "2025-01-01" }
991
-
992
- intervalUnionDate("2024-01-01", "2024-07-01", "2024-07-01", "2025-01-01");
993
- // { start: "2024-01-01", end: "2025-01-01" } (touching, joined)
994
-
995
- intervalUnionDate("2024-01-01", "2024-06-30", "2024-07-01", "2024-12-31");
996
- // null (2024-06-30 lies in neither interval)
997
-
998
- intervalUnionUnix(0, 1700000000, 1000000, 2000000);
999
- // { start: 0, end: 1700000000 }
1000
-
1001
- intervalUnionUtc(
1002
- "2024-01-01T00:00:00Z",
1003
- "2024-07-01T00:00:00Z",
1004
- "2024-04-01T00:00:00Z",
1005
- "2025-01-01T00:00:00Z",
1006
- );
1007
- // { start: "2024-01-01T00:00:00Z", end: "2025-01-01T00:00:00Z" }
1008
- ```
1009
-
1010
- All union functions return `null` on invalid input (wrong type, malformed strings, leap seconds, inverted intervals, or an epoch outside the `unix/` grammar: not a safe integer or a digit string).
1011
-
1012
- `intervalDifference*` returns the portion(s) of interval A not covered by interval B, as an array of `{ start, end }` records:
1013
-
1014
- ```typescript
1015
- import {
1016
- intervalDifferenceDate,
1017
- intervalDifferenceTime,
1018
- intervalDifferenceDateTime,
1019
- intervalDifferenceUtc,
1020
- intervalDifferenceUnix,
1021
- intervalDifferenceZoned,
1022
- } from "@northguild/gmt";
1023
-
1024
- intervalDifferenceDate("2024-01-01", "2024-12-31", "2024-06-01", "2024-07-01");
1025
- // [{ start: "2024-01-01", end: "2024-06-01" }, { start: "2024-07-01", end: "2024-12-31" }]
1026
-
1027
- intervalDifferenceDate("2024-01-01", "2024-12-31", "2024-01-01", "2024-12-31");
1028
- // [] (B fully covers A)
1029
-
1030
- intervalDifferenceUnix(0, 1700000000, 1000000, 2000000);
1031
- // [{ start: 0, end: 1000000 }, { start: 2000000, end: 1700000000 }]
1032
-
1033
- intervalDifferenceUtc(
1034
- "2024-01-01T09:00:00Z",
1035
- "2024-01-01T17:00:00Z",
1036
- "2024-01-01T12:00:00Z",
1037
- "2024-01-01T13:00:00Z",
1038
- );
1039
- // [{ start: "2024-01-01T09:00:00Z", end: "2024-01-01T12:00:00Z" }, { start: "2024-01-01T13:00:00Z", end: "2024-01-01T17:00:00Z" }]
1040
- ```
1041
-
1042
- Each piece ends exactly where B starts and resumes exactly where B ends, with no one-unit step, so the pieces and B together cover A once. `intervalDifferenceUtc` returns the same pieces as `subtractIntervals`.
1043
-
1044
- All difference functions return `[]` on invalid input (wrong type, malformed strings, leap seconds, inverted intervals, or an epoch outside the `unix/` grammar: not a safe integer or a digit string).
1045
-
1046
- `intervalXor*` returns the symmetric difference of two intervals — the portions covered by exactly one of them, not both — as an array of `{ start, end }` records:
1047
-
1048
- ```typescript
1049
- import {
1050
- intervalXorDate,
1051
- intervalXorTime,
1052
- intervalXorDateTime,
1053
- intervalXorUtc,
1054
- intervalXorUnix,
1055
- intervalXorZoned,
1056
- } from "@northguild/gmt";
1057
-
1058
- intervalXorDate("2024-01-01", "2024-07-01", "2024-04-01", "2025-01-01");
1059
- // [{ start: "2024-01-01", end: "2024-04-01" }, { start: "2024-07-01", end: "2025-01-01" }]
1060
-
1061
- intervalXorDate("2024-01-01", "2024-07-01", "2024-07-01", "2025-01-01");
1062
- // [{ start: "2024-01-01", end: "2025-01-01" }] (touching intervals share no date, so the runs join)
1063
-
1064
- intervalXorUnix(0, 1700000000, 1000000, 2000000);
1065
- // [{ start: 0, end: 1000000 }, { start: 2000000, end: 1700000000 }]
1066
-
1067
- intervalXorUtc(
1068
- "2024-01-01T09:00:00Z",
1069
- "2024-01-01T13:00:00Z",
1070
- "2024-01-01T12:00:00Z",
1071
- "2024-01-01T17:00:00Z",
1072
- );
1073
- // [{ start: "2024-01-01T09:00:00Z", end: "2024-01-01T12:00:00Z" }, { start: "2024-01-01T13:00:00Z", end: "2024-01-01T17:00:00Z" }]
1074
- ```
1075
-
1076
- All xor functions return `[]` on invalid input (wrong type, malformed strings, leap seconds, inverted intervals, or an epoch outside the `unix/` grammar: not a safe integer or a digit string).
1077
-
1078
- `intervalAbuts*` checks whether two intervals are exactly adjacent, in either order: one interval's `end` equals the other's `start` (Allen's "meets"). The two then share no instant and leave no gap. Intervals separated by any gap, even one nanosecond or one epoch unit, do not abut, and an empty interval abuts nothing:
1079
-
1080
- ```typescript
1081
- import {
1082
- intervalAbutsDate,
1083
- intervalAbutsTime,
1084
- intervalAbutsDateTime,
1085
- intervalAbutsUtc,
1086
- intervalAbutsUnix,
1087
- intervalAbutsZoned,
1088
- } from "@northguild/gmt";
1089
-
1090
- intervalAbutsDate("2024-01-01", "2024-07-01", "2024-07-01", "2025-01-01");
1091
- // true (the first interval ends where the second starts)
1092
-
1093
- intervalAbutsDate("2024-01-01", "2024-06-30", "2024-07-01", "2024-12-31");
1094
- // false (2024-06-30 lies in neither interval)
1095
-
1096
- intervalAbutsDate("2024-01-01", "2024-07-01", "2024-04-01", "2025-01-01");
1097
- // false (overlap)
1098
-
1099
- intervalAbutsUnix(0, 1000000, 1000000, 2000000);
1100
- // true
1101
-
1102
- intervalAbutsUtc(
1103
- "2024-01-01T09:00:00Z",
1104
- "2024-01-01T12:00:00Z",
1105
- "2024-01-01T12:00:00.000000001Z",
1106
- "2024-01-01T17:00:00Z",
1107
- );
1108
- // false (1 ns apart)
1109
- ```
1110
-
1111
- All abuts checks return `false` on invalid input (wrong type, malformed strings, leap seconds, inverted intervals, or an epoch outside the `unix/` grammar: not a safe integer or a digit string).
1112
-
1113
- `intervalEngulfs*` checks whether interval B lies within interval A and overlaps it — B may share A's `start` or `end`, but an empty B at A's `end` is not engulfed. Equivalent to the 4-argument `intervalContains*` mode:
1114
-
1115
- ```typescript
1116
- import {
1117
- intervalEngulfsDate,
1118
- intervalEngulfsTime,
1119
- intervalEngulfsDateTime,
1120
- intervalEngulfsUtc,
1121
- intervalEngulfsUnix,
1122
- intervalEngulfsZoned,
1123
- } from "@northguild/gmt";
1124
-
1125
- intervalEngulfsDate("2024-01-01", "2024-12-31", "2024-06-01", "2024-07-01");
1126
- // true
1127
-
1128
- intervalEngulfsDate("2024-01-01", "2024-12-31", "2024-01-01", "2024-12-31");
1129
- // true (equal intervals)
1130
-
1131
- intervalEngulfsDate("2024-06-01", "2024-07-01", "2024-01-01", "2024-12-31");
1132
- // false
1133
-
1134
- intervalEngulfsUnix(0, 1700000000, 1000000, 2000000);
1135
- // true
1136
-
1137
- intervalEngulfsUtc(
1138
- "2024-01-01T00:00:00Z",
1139
- "2024-12-31T23:59:59Z",
1140
- "2024-06-01T00:00:00Z",
1141
- "2024-07-01T00:00:00Z",
1142
- );
1143
- // true
1144
- ```
1145
-
1146
- All engulfs checks return `false` on invalid input (wrong type, malformed strings, leap seconds, inverted intervals, or an epoch outside the `unix/` grammar: not a safe integer or a digit string).
1147
-
1148
- `splitIntervalByUnit*` splits an interval into sub-intervals of `amount × unit`, returning an array of `{ start, end }` records. The final sub-interval is trimmed so its `end` never exceeds the original `end`:
1149
-
1150
- ```typescript
1151
- import {
1152
- splitIntervalByUnitDate,
1153
- splitIntervalByUnitTime,
1154
- splitIntervalByUnitDateTime,
1155
- splitIntervalByUnitUtc,
1156
- splitIntervalByUnitUnix,
1157
- splitIntervalByUnitZoned,
1158
- } from "@northguild/gmt";
1159
-
1160
- splitIntervalByUnitDate("2024-01-01", "2024-01-10", "day", 2);
1161
- // [{ start: "2024-01-01", end: "2024-01-03" }, { start: "2024-01-03", end: "2024-01-05" }, { start: "2024-01-05", end: "2024-01-07" }, { start: "2024-01-07", end: "2024-01-09" }, { start: "2024-01-09", end: "2024-01-10" }]
1162
-
1163
- splitIntervalByUnitUtc(
1164
- "2024-01-01T00:00:00Z",
1165
- "2024-01-02T00:00:00Z",
1166
- "hour",
1167
- 6,
1168
- );
1169
- // [{ start: "2024-01-01T00:00:00Z", end: "2024-01-01T06:00:00Z" }, { start: "2024-01-01T06:00:00Z", end: "2024-01-01T12:00:00Z" }, { start: "2024-01-01T12:00:00Z", end: "2024-01-01T18:00:00Z" }, { start: "2024-01-01T18:00:00Z", end: "2024-01-02T00:00:00Z" }]
1170
-
1171
- splitIntervalByUnitUnix(0, 86400000, "hour", 6);
1172
- // [{ start: 0, end: 21600000 }, { start: 21600000, end: 43200000 }, { start: 43200000, end: 64800000 }, { start: 64800000, end: 86400000 }]
1173
- ```
1174
-
1175
- All split functions return `[]` on invalid input (wrong type, malformed strings, leap seconds, inverted intervals, non-positive amount, unsupported unit, or a unit that has no effect on the target type). A zero-length interval returns itself as the one piece (`[{ start, end }]`) for a supported unit, and `[]` for an unsupported one: `splitIntervalByUnitDate("2024-01-01", "2024-01-01", "hour", 1)` is `[]`.
1176
-
1177
- `splitIntervalByUnit*`, `intervalDivideEqually*`, `mapDatesInRange` and `mapZonedDatesInRange` build one array element per piece, so each takes an optional `{ maxPieces }` (a positive safe integer, default `1_000_000`) and returns `[]` when the result would be longer. A split stops as soon as the piece past the limit is due, so a huge range answers in bounded time instead of exhausting the heap. Pass a larger `maxPieces` when a long range legitimately needs it:
1178
-
1179
- ```typescript
1180
- import { mapDatesInRange, splitIntervalByUnitDate } from "@northguild/gmt";
1181
-
1182
- splitIntervalByUnitDate("2024-01-01", "2024-01-10", "day", 2, { maxPieces: 4 });
1183
- // [] — 5 slices exceed the limit
1184
-
1185
- mapDatesInRange("0001-01-01", "9999-12-31", 1);
1186
- // [] — 3,652,059 dates exceed the default
1187
- ```
1188
-
1189
- For a calendar unit (day, week, month, year) each boundary is computed from `start` (`start + k × amount`), never by stepping from the previous boundary, so month-end starts don't drift. A yearly split from February 29 likewise returns to February 29 in leap years. An exact unit (hour and smaller) steps from the previous boundary: adding an exact unit never clamps, so the two ways give the same boundaries.
1190
-
1191
- ```typescript
1192
- splitIntervalByUnitDate("2024-01-31", "2024-05-01", "month", 1);
1193
- // [{ start: "2024-01-31", end: "2024-02-29" }, { start: "2024-02-29", end: "2024-03-31" }, { start: "2024-03-31", end: "2024-04-30" }, { start: "2024-04-30", end: "2024-05-01" }]
1194
- ```
1195
-
1196
- `intervalCount*` returns how many calendar-unit boundaries an interval crosses — the number of units the half-open interval `[start, end)` touches. This is distinct from `diff*`, which measures exact elapsed duration: an interval from 23:59 to 00:01 is two minutes long but touches two days:
1197
-
1198
- ```typescript
1199
- import {
1200
- intervalCountDate,
1201
- intervalCountTime,
1202
- intervalCountDateTime,
1203
- intervalCountUtc,
1204
- intervalCountUnix,
1205
- intervalCountZoned,
1206
- } from "@northguild/gmt";
1207
-
1208
- intervalCountDateTime("2024-01-01T23:59:00", "2024-01-02T00:01:00", "day");
1209
- // 2 (two minutes long, but two days touched)
1210
-
1211
- intervalCountDate("2024-01-01", "2024-01-03", "day");
1212
- // 2 (the end boundary is excluded)
1213
-
1214
- intervalCountDate("2024-01-15", "2024-03-10", "month");
1215
- // 3
1216
-
1217
- intervalCountTime("12:30:00", "13:00:00", "hour");
1218
- // 1
1219
-
1220
- intervalCountZoned(
1221
- "2024-03-10T00:00:00-05:00[America/New_York]",
1222
- "2024-03-11T00:00:00-04:00[America/New_York]",
1223
- "hour",
1224
- );
1225
- // 23 (the spring-forward local day is 23 hours long)
1226
-
1227
- intervalCountUnix(0, 86400000, "hour");
1228
- // 24
1229
- ```
1230
-
1231
- A zero-length interval counts `0` in every unit — the empty `[start, start)` holds no instant, so `intervalCountDate("2024-01-15", "2024-01-15", "month")` is `0`. Weeks start on Monday (ISO 8601), singular and plural units are interchangeable (`"day"` and `"days"`), and `intervalCountUnix` counts calendar boundaries in `options.timeZone`, UTC by default (`"local"` for the system zone), like `addUnix`. All count functions return `null` on invalid input (wrong type, malformed strings, leap seconds, inverted intervals, unsupported unit, or a unit that has no effect on the target type).
1232
-
1233
- `intervalCountZoned`, `intervalCountUnix` and `intervalCountUtc` count the same local buckets `bucketRange` returns. A bucket shorter than its unit still counts once — a 20-minute range straddling 04:00 on `Pacific/Chatham`'s spring-forward day counts 2 hours, because its 03:00 hour lasts only 15 minutes — and a day the zone deleted counts not at all (`Pacific/Apia`'s 30 December 2011).
1234
-
1235
- `intervalLength*` is `intervalCount*`'s exact-duration counterpart — it answers "how long is this interval" as a real, possibly fractional number, rather than "how many boundaries does it touch":
1236
-
1237
- ```typescript
1238
- import {
1239
- intervalLengthDate,
1240
- intervalLengthTime,
1241
- intervalLengthDateTime,
1242
- intervalLengthUtc,
1243
- intervalLengthUnix,
1244
- intervalLengthZoned,
1245
- } from "@northguild/gmt";
1246
-
1247
- intervalLengthDateTime("2024-01-01T23:59:00", "2024-01-02T00:01:00", "day");
1248
- // 0.001388888888888889 (the same interval intervalCount* reports as 2 day boundaries)
1249
-
1250
- intervalLengthDate("2024-01-01", "2024-01-16", "month");
1251
- // 0.4838709677419355 (15 of January's 31 days)
1252
-
1253
- intervalLengthZoned(
1254
- "2024-03-10T00:00:00-05:00[America/New_York]",
1255
- "2024-03-11T00:00:00-04:00[America/New_York]",
1256
- "hour",
1257
- );
1258
- // 23 (spring-forward local day is 23 real hours)
1259
- ```
1260
-
1261
- `intervalLength*` uses `Temporal.Duration.prototype.total`, so calendar units (month, year) resolve against the interval's own `start` rather than truncating, and zoned/unix/utc variants are DST-aware the same way `intervalCount*` is. Returns `0` for a zero-length interval, and `null` on invalid input (wrong type, malformed strings, leap seconds, inverted intervals, unsupported unit). `intervalLengthUnix` and `splitIntervalByUnitUnix` take `{ epochUnit, timeZone }` like `intervalCountUnix` (milliseconds and UTC by default): `intervalLengthUnix(0, 86400, "day", { epochUnit: "seconds" })` is `1`.
1262
-
1263
- `intervalDivideEqually*` splits an interval into `n` equal-length sub-intervals, and `intervalSplitAt*` splits an interval at arbitrary points instead of by count:
1264
-
1265
- ```typescript
1266
- import {
1267
- intervalDivideEquallyDate,
1268
- intervalSplitAtDate,
1269
- } from "@northguild/gmt";
1270
-
1271
- intervalDivideEquallyDate("2024-01-01", "2024-01-05", 4);
1272
- // [{ start: "2024-01-01", end: "2024-01-02" }, { start: "2024-01-02", end: "2024-01-03" }, { start: "2024-01-03", end: "2024-01-04" }, { start: "2024-01-04", end: "2024-01-05" }]
1273
-
1274
- intervalSplitAtDate("2024-01-01", "2024-01-10", ["2024-01-07", "2024-01-03"]);
1275
- // [{ start: "2024-01-01", end: "2024-01-03" }, { start: "2024-01-03", end: "2024-01-07" }, { start: "2024-01-07", end: "2024-01-10" }]
1276
- ```
1277
-
1278
- `n` must be a positive integer (`[]` otherwise), and `n` above `maxPieces` also returns `[]`; `n === 1` returns the original interval unchanged, and a zero-length interval returns `n` identical zero-length sub-intervals. Each internal boundary is `start + round((end − start) · i / n)`, an exact half rounding up — in whole days for `PlainDate`, nanoseconds for the other string variants and the arguments' own epoch unit for `intervalDivideEquallyUnix` — so the split is exact when the span divides evenly by `n`, within half a unit otherwise, and the pieces tile the range with no gap or overshoot (`intervalDivideEquallyZoned` splits DST-crossing intervals by real elapsed time, not local clock time). `intervalSplitAt*` sorts its `points` internally — they need not be pre-sorted — and drops points outside the interval or exactly on `start` or `end`, since those cannot introduce a new sub-interval. Each piece's `end` is the next piece's `start`, so under `[start, end)` the pieces partition the interval; an empty or all-dropped `points` array returns `[{ start, end }]` unsplit.
1279
-
1280
- `mergeIntervals*` and `intervalXorAll*` are the list-form generalizations of `intervalUnion*` and `intervalXor*`, which are pairwise only — each takes a single array of `{ start, end }` records instead of two flat intervals:
1281
-
1282
- ```typescript
1283
- import { mergeIntervalsDate, intervalXorAllDate } from "@northguild/gmt";
1284
-
1285
- mergeIntervalsDate([
1286
- { start: "2024-01-01", end: "2024-01-10" },
1287
- { start: "2024-01-05", end: "2024-01-15" },
1288
- ]);
1289
- // [{ start: "2024-01-01", end: "2024-01-15" }]
1290
-
1291
- intervalXorAllDate([
1292
- { start: "2024-01-01", end: "2024-01-10" },
1293
- { start: "2024-01-05", end: "2024-01-15" },
1294
- { start: "2024-01-08", end: "2024-01-20" },
1295
- ]);
1296
- // [{ start: "2024-01-01", end: "2024-01-05" }, { start: "2024-01-08", end: "2024-01-10" }, { start: "2024-01-15", end: "2024-01-20" }]
1297
- ```
1298
-
1299
- `mergeIntervals*` collapses overlapping or touching intervals (one's end equals the next one's start) into the minimum sorted set that does not overlap. `intervalXorAll*` returns the maximal runs covered by an odd number of the input intervals, sorted by start — for exactly two intervals the result is identical to the pairwise `intervalXor*`, and two identical intervals cancel out to `[]`. All four return `[]` for an empty list or on invalid input.
1300
-
1301
- `intervalFromDuration*` constructs an interval from a single point plus an ISO 8601 duration, anchored at either end — Luxon's `Interval.after`/`Interval.before` as one function with an `anchor` param instead of two:
1302
-
1303
- ```typescript
1304
- import {
1305
- intervalFromDurationDate,
1306
- intervalFromDurationTime,
1307
- intervalFromDurationDateTime,
1308
- intervalFromDurationUtc,
1309
- intervalFromDurationUnix,
1310
- intervalFromDurationZoned,
1311
- } from "@northguild/gmt";
1312
-
1313
- intervalFromDurationDate("2024-01-01", "P1M", "start");
1314
- // { start: "2024-01-01", end: "2024-02-01" }
1315
-
1316
- intervalFromDurationDate("2024-02-01", "P1M", "end");
1317
- // { start: "2024-01-01", end: "2024-02-01" }
1318
-
1319
- intervalFromDurationZoned(
1320
- "2024-03-09T02:30:00-05:00[America/New_York]",
1321
- "P1D",
1322
- "start",
1323
- );
1324
- // { start: "2024-03-09T02:30:00-05:00[America/New_York]", end: "2024-03-10T03:30:00-04:00[America/New_York]" } (spring-forward day is 23 hours long)
1325
-
1326
- intervalFromDurationTime("12:00:00", "P1D", "start");
1327
- // null (PlainTime has no calendar — a date-unit duration needs a relativeTo it can't supply)
1328
- ```
1329
-
1330
- Calendar units (years/months/weeks) resolve against `value` itself, so no separate `relativeTo` is needed — except for `intervalFromDurationTime`, which returns `null` for a `duration` with a nonzero years/months/weeks/days component, since `PlainTime` has no calendar to resolve it against. `intervalFromDurationZoned` accepts the same `disambiguation`/`overflow` options as `addZoned`; `intervalFromDurationUnix` accepts `addUnix`'s `epochUnit`/`timeZone`/`overflow` options; `intervalFromDurationTime` takes no options argument, like `addTime`. A negative `duration` that inverts the computed span, or an `overflow: "reject"` result, returns `null` — same sentinel as any other invalid input.
1331
-
1332
- All validators return `false` on invalid input (wrong type, malformed strings, leap seconds, mixed kinds for plain interval validators, or an epoch outside the `unix/` grammar: not a safe integer or a digit string).
1333
-
1334
- ### Zoned operations
1335
-
1336
- ```typescript
1337
- import { addZoned, formatZonedDateTime } from "@northguild/gmt";
1338
-
1339
- addZoned("2026-03-07T23:00:00-05:00[America/New_York]", { hours: 2 });
1340
- // "2026-03-08T01:00:00-05:00[America/New_York]"
1341
-
1342
- formatZonedDateTime("2024-03-17T14:30:45+00:00[UTC]", "en-US", {
1343
- dateStyle: "full",
1344
- timeStyle: "short",
1345
- });
1346
- // locale-dependent non-empty formatted string
1347
- ```
1348
-
1349
- Twice a year, DST creates local times that don't exist (spring-forward gap) or happen twice (fall-back overlap). Functions that attach a timezone to a plain/local value accept an optional `disambiguation` (`"compatible"` (default) | `"earlier"` | `"later"` | `"reject"`) to control how that's resolved instead of silently guessing:
1350
-
1351
- ```typescript
1352
- import { convertPlainDateTimeToZoned } from "@northguild/gmt";
1353
-
1354
- // 2024-03-10T02:30:00 doesn't exist in America/New_York (clocks jump 2am -> 3am).
1355
- convertPlainDateTimeToZoned("2024-03-10T02:30:00", "America/New_York", {
1356
- disambiguation: "reject",
1357
- });
1358
- // "" — no such local time exists
1359
- ```
1360
-
1361
- The `startOfZoned`/`endOfZoned`/`startOfQuarterForZoned`/`endOfQuarterForZoned`/`getLocaleZonedStartOfWeek`/`getLocaleZonedEndOfWeek` family (and their `unix/` counterparts) always return the real boundary of the unit that contains the input, in its zone: a start is never after the input and an end never before it, even when the wall-clock boundary was skipped or repeated. `Pacific/Chatham`'s 03:00 hour on its spring-forward starts at 03:45, New York's repeated 1 a.m. is its own hour, and a midnight repeated on the same date (America/Havana, 2024-11-03) stays one 25-hour day:
1362
-
1363
- ```typescript
1364
- import { startOfZoned } from "@northguild/gmt";
1365
-
1366
- // 2024-11-03T01:45:00-05:00 is the SECOND, repeated 1am of the fall-back overlap in America/New_York.
1367
- startOfZoned("2024-11-03T01:45:00-05:00[America/New_York]", "hour");
1368
- // "2024-11-03T01:00:00-05:00[America/New_York]" — the real start of the repeated hour
1369
- ```
1370
-
1371
- These boundary functions follow TC39's `startOfDay()`, which takes no resolution options: they take no `disambiguation` or `offset` options, and neither does `mapZonedHoursInDay`. Resolution options belong on functions that set wall-clock fields. `getHoursInZonedDay` and `mapZonedHoursInDay` measure the input's calendar date exactly as TC39's `hoursInDay` does, which differs from `startOfZoned(…, "day")` only where a fall-back re-enters the previous date (America/Goose_Bay, 2010-11-07).
1372
-
1373
- `convertPlainDateTimeToZoned`, `addZoned`, `subtractZoned` and `intervalFromDurationZoned` take no `offset` option: they resolve a plain date-time that has no UTC offset for it to act on.
1374
-
1375
- `addZoned`, `subtractZoned` and `intervalFromDurationZoned` follow TC39's AddZonedDateTime: the date part of the duration (years to days) moves the wall clock, and the time part (hours and smaller) is added in exact time. `disambiguation` resolves the wall clock the date part lands on — in a spring-forward gap `"compatible"` and `"later"` move it forward, `"earlier"` back, and `"reject"` returns the sentinel; in a fall-back overlap it picks the occurrence — and never re-resolves the time part, so adding 10 minutes is always 10 real minutes. Before 1.16.0 a gap landing always moved forward. `diffZoned`, `diffZonedAsDuration` and `intervalLengthZoned` follow DifferenceZonedDateTime: days, weeks, months and years are counted on the zone's wall clock, and hours and smaller in exact time. Two values in different zones share no wall clock, so a calendar unit returns the sentinel; convert one end with `convertZonedToZoned` first:
1376
-
1377
- ```typescript
1378
- import { diffZoned } from "@northguild/gmt";
1379
-
1380
- diffZoned("2024-03-09T12:00:00-05:00[America/New_York]", "2024-03-10T12:00:00-04:00[America/New_York]", "days");
1381
- // 1 — noon to noon across a 23-hour day
1382
-
1383
- diffZoned("2024-01-01T00:00:00-05:00[America/New_York]", "2024-01-03T00:00:00+01:00[Europe/Paris]", "days");
1384
- // null — calendar unit across two time zones
1385
- ```
1386
-
1387
- `hasDaylightSaving(timeZone, { at })` reports whether an IANA timezone observes daylight saving time at a reference instant. It is `true` when the zone is in daylight time at `at`, or a daylight period begins less than 365 days after it. So a zone in its winter is `true`, and a zone that has stopped changing its clocks is `false`. `at` is an ISO 8601 instant string. With it, the answer does not depend on the day the code runs; without it, the reference is the current instant. An invalid zone, an invalid `at` and a fixed offset return `false`:
1388
-
1389
- ```typescript
1390
- import { hasDaylightSaving } from "@northguild/gmt";
1391
-
1392
- hasDaylightSaving("America/New_York", { at: "2024-01-15T12:00:00Z" });
1393
- // true
1394
-
1395
- hasDaylightSaving("Asia/Tokyo", { at: "2024-01-15T12:00:00Z" });
1396
- // false
1397
-
1398
- hasDaylightSaving("Invalid/Zone", { at: "2024-01-15T12:00:00Z" });
1399
- // false
1400
- ```
1401
-
1402
- `getDstTransitions` enumerates the exact transition instants for a timezone in a given year:
1403
-
1404
- ```typescript
1405
- import { getDstTransitions } from "@northguild/gmt/zoned";
1406
-
1407
- getDstTransitions("America/New_York", 2024);
1408
- // [
1409
- // { instant: "2024-03-10T07:00:00Z", offsetBefore: "-05:00", offsetAfter: "-04:00" },
1410
- // { instant: "2024-11-03T06:00:00Z", offsetBefore: "-04:00", offsetAfter: "-05:00" }
1411
- // ]
1412
-
1413
- getDstTransitions("Asia/Tokyo", 2024);
1414
- // []
1415
-
1416
- getDstTransitions("Invalid/Zone", 2024);
1417
- // []
1418
- ```
1419
-
1420
- Each object's `instant` is a UTC ISO 8601 string; `offsetBefore`/`offsetAfter` are `±HH:MM` offset strings. Returns `[]` for zones with no transitions in the requested year and on invalid input.
1421
-
1422
- **Four DST-related questions, four different functions.** The names are close enough to be misread, so here's the map:
1423
-
1424
- | Question | Function | Scope |
1425
- | ------------------------------------------------------------------------------- | --------------------------------------------- | ---------------------------------------------------- |
1426
- | Does this zone observe DST at this instant, or now? | `hasDaylightSaving(timeZone, { at })` | Zone-level, judged from a reference instant |
1427
- | Where do this zone's transitions fall? | `getDstTransitions(timeZone, year)` | Enumerates instants |
1428
- | Is _this particular instant_ currently in DST? | `isInDaylightSaving(value)` | A single zoned value |
1429
- | What should happen when construction lands on an ambiguous/nonexistent instant? | `disambiguation` / `offset` options (Group C) | Orthogonal — a construction-time choice, not a query |
1430
-
1431
- `isInDaylightSaving` answers the third question. It and `hasDaylightSaving` apply one rule, read from the zone's own clock changes in the runtime's time zone data. The rule is GMT's own definition, not the tz database's daylight flag: no JavaScript API exposes that flag, and it cannot be worked out from offsets.
1432
-
1433
- - **The rule.** Daylight time runs from a forward change of the zone's clocks to the backward change of the same size that undoes it, less than 365 days later.
1434
- - **A forward change never undone, or undone 365 days or more later, is a change of standard time.** `Europe/Istanbul` has been on standard time since its last advance in March 2016.
1435
- - **The higher of two alternating offsets is the daylight one.** `Europe/Dublin` is in daylight time in summer, and `Africa/Casablanca` at `+01:00`.
1436
-
1437
- The full list of what the rule reads as standard time is in [DST Disambiguation](../../docs/dst-disambiguation.md) and in the function's JSDoc.
1438
-
1439
- ```typescript
1440
- import { isInDaylightSaving } from "@northguild/gmt/zoned";
1441
-
1442
- isInDaylightSaving("2024-07-15T12:00:00-04:00[America/New_York]");
1443
- // true
1444
-
1445
- isInDaylightSaving("2024-01-15T12:00:00-05:00[America/New_York]");
1446
- // false
1447
-
1448
- // Southern-hemisphere DST spans the new year.
1449
- isInDaylightSaving("2024-01-15T12:00:00+11:00[Australia/Sydney]");
1450
- // true
1451
-
1452
- isInDaylightSaving("2024-07-15T12:00:00+09:00[Asia/Tokyo]");
1453
- // false — Asia/Tokyo has had no DST since 1951
1454
-
1455
- isInDaylightSaving("2015-07-01T12:00:00+03:00[Europe/Istanbul]");
1456
- // true — the clocks went back on 2015-11-08
1457
-
1458
- isInDaylightSaving("2016-12-01T12:00:00+03:00[Europe/Istanbul]");
1459
- // false — the advance of March 2016 was never undone
1460
-
1461
- isInDaylightSaving("invalid");
1462
- // false
1463
- ```
1464
-
1465
- `getZonedOffset` and `getZonedOffsetAs` read a zoned value's UTC offset — the former as a `±HH:MM` string, the latter as a number in minutes or nanoseconds:
1466
-
1467
- ```typescript
1468
- import { getZonedOffset, getZonedOffsetAs } from "@northguild/gmt/zoned";
1469
-
1470
- getZonedOffset("2024-07-15T12:00:00-04:00[America/New_York]");
1471
- // "-04:00"
1472
-
1473
- getZonedOffsetAs("2024-07-15T12:00:00-04:00[America/New_York]", "minutes");
1474
- // -240
1475
-
1476
- getZonedOffsetAs("2024-05-15T12:00:00+05:45[Asia/Kathmandu]", "minutes");
1477
- // 345 — GMT has half- and quarter-hour offsets too, not just whole hours
1478
-
1479
- getZonedOffset("invalid");
1480
- // ""
1481
- ```
1482
-
1483
- `getTimeZoneOffset` looks up a timezone's offset at a given instant without needing an existing zoned value in hand:
1484
-
1485
- ```typescript
1486
- import { getTimeZoneOffset } from "@northguild/gmt/zoned";
1487
-
1488
- getTimeZoneOffset("America/New_York", "2024-07-15T12:00:00Z");
1489
- // "-04:00"
1490
-
1491
- getTimeZoneOffset("America/New_York", "2024-01-15T12:00:00Z");
1492
- // "-05:00"
1493
-
1494
- getTimeZoneOffset("Invalid/Zone", "2024-07-15T12:00:00Z");
1495
- // ""
1496
- ```
1497
-
1498
- `formatTimeZoneName` returns a timezone's localized display name. `options.style` covers every `Intl.DateTimeFormatOptions` `timeZoneName` value:
1499
-
1500
- ```typescript
1501
- import { formatTimeZoneName } from "@northguild/gmt/zoned";
1502
-
1503
- formatTimeZoneName("America/New_York", "en-US", { style: "shortGeneric" });
1504
- // "ET" — season-independent
1505
-
1506
- formatTimeZoneName("America/New_York", "en-US", { style: "longGeneric" });
1507
- // "Eastern Time" — season-independent
1508
-
1509
- formatTimeZoneName("America/New_York", "en-US", { style: "long" });
1510
- // "Eastern Standard Time" or "Eastern Daylight Time", depending on the current date
1511
-
1512
- formatTimeZoneName("Invalid/Zone", "en-US");
1513
- // ""
1514
- ```
1515
-
1516
- The `"short"`/`"long"`/`"shortOffset"`/`"longOffset"` styles name the zone's _current_ offset — for a DST-observing zone the label flips between standard and daylight names depending on when this is called, since there's no instant parameter to pin it to (this matches how `Intl.DateTimeFormat.prototype.format()` itself defaults to "now" with no argument). The `"shortGeneric"`/`"longGeneric"` styles are season-independent and don't have this issue — prefer them for a name that won't change twice a year.
1517
-
1518
- `clampZoned` restricts a zoned datetime to a range, and `closestZonedTo` finds the nearest candidate by temporal distance:
1519
-
1520
- ```typescript
1521
- import { clampZoned, closestZonedTo } from "@northguild/gmt";
1522
-
1523
- clampZoned(
1524
- "2024-02-01T12:00:00[America/New_York]",
1525
- "2024-03-01T00:00:00[America/New_York]",
1526
- "2024-03-31T23:59:59[America/New_York]",
1527
- );
1528
- // "2024-03-01T00:00:00-05:00[America/New_York]"
1529
-
1530
- closestZonedTo("2024-03-15T12:00:00[America/New_York]", [
1531
- "2024-03-01T00:00:00[America/New_York]",
1532
- "2024-03-20T00:00:00[America/New_York]",
1533
- "2024-03-18T00:00:00[America/New_York]",
1534
- ]);
1535
- // "2024-03-18T00:00:00-04:00[America/New_York]"
1536
- ```
1537
-
1538
- See [`docs/dst-disambiguation.md`](../../docs/dst-disambiguation.md) for the full explanation, including where `overflow` is and is not exposed.
1539
-
1540
- ### Formatting
1541
-
1542
- ```typescript
1543
- import {
1544
- formatDate,
1545
- formatRelativeDate,
1546
- formatTime,
1547
- formatRelativeTime,
1548
- formatDateTime,
1549
- formatRelativeDateTime,
1550
- formatCalendar,
1551
- formatDateRange,
1552
- formatDateTimeRange,
1553
- formatDateToParts,
1554
- formatDateTimeToParts,
1555
- formatZonedDateTime,
1556
- formatZonedRange,
1557
- formatZonedToParts,
1558
- formatRelativeZoned,
1559
- formatCalendarZoned,
1560
- formatUtc,
1561
- formatRelativeUtc,
1562
- formatCalendarUtc,
1563
- formatUnix,
1564
- formatRelativeUnix,
1565
- formatCalendarUnix,
1566
- } from "@northguild/gmt";
1567
-
1568
- // Relative to "now" — auto-picks the best unit.
1569
- formatRelativeDate("2026-01-15");
1570
- // e.g. "3 months ago"
1571
-
1572
- formatRelativeTime("14:30:00", "en-US", { style: "short" });
1573
- // e.g. "2 hr. ago"
1574
-
1575
- formatRelativeDateTime("2026-03-17T09:00:00", "en-GB", {
1576
- style: "long",
1577
- numeric: "always",
1578
- });
1579
- // e.g. "in 3 hours"
1580
-
1581
- // Zoned relative formatting — reference can be a ZonedDateTime, UTC string, or unix epoch.
1582
- formatRelativeZoned("2026-03-08T01:00:00-05:00[America/New_York]", "en-US");
1583
- // e.g. "tomorrow"
1584
-
1585
- // Auto-picked units run from second through year, with formatRelativeDate's thresholds;
1586
- // pass largestUnit to cap them. It takes a singular or plural name: "day" or "days".
1587
- // Each function accepts the units its values have: formatRelativeDate day to year,
1588
- // formatRelativeTime second to hour, the others second to year. Any other unit returns "".
1589
- formatRelativeUtc("2024-03-17T14:30:45Z", "en-US", {
1590
- reference: "2026-03-17T14:30:45Z",
1591
- });
1592
- // "2 years ago"
1593
- formatRelativeUtc("2024-03-17T14:30:45Z", "en-US", {
1594
- reference: "2026-03-17T14:30:45Z",
1595
- largestUnit: "day",
1596
- });
1597
- // "730 days ago"
1598
-
1599
- // Unix epoch relative formatting.
1600
- formatRelativeUnix(1710685845000, "en-US", {
1601
- epochUnit: "milliseconds",
1602
- reference: 1805358645000,
1603
- });
1604
- // "3 years ago"
1605
-
1606
- // roundingMethod ("floor" | "ceil" | "round", default "round") controls how the
1607
- // computed distance rounds to the display unit — every formatRelative* function accepts it.
1608
- // Any other value returns "".
1609
- formatRelativeUtc(value, "en-US", { roundingMethod: "floor" });
1610
-
1611
- // formatCalendar* — a relative day label + time-of-day, joined with the
1612
- // locale's own connector (never a hardcoded "at"). Distinct from
1613
- // formatRelative*'s elapsed-time phrasing ("in 1 day"): this is Moment's
1614
- // `.calendar()` — for user-facing schedules, not elapsed-time displays.
1615
- // Within ±6 days of "now" (or an explicit `reference`) it stays relative;
1616
- // beyond that it falls back to an absolute date + time, no relative wording.
1617
- formatCalendar("2026-03-16T14:30:00", "en-US", {
1618
- reference: "2026-03-15T09:00:00",
1619
- });
1620
- // "tomorrow at 2:30 PM"
1621
-
1622
- formatCalendar("2026-03-08T14:30:00", "en-US", {
1623
- reference: "2026-03-15T09:00:00",
1624
- });
1625
- // "March 8, 2026 at 2:30 PM" — 7 days out, beyond the threshold
1626
-
1627
- formatCalendarZoned("2026-03-16T14:30:00-04:00[America/New_York]", "de-DE", {
1628
- reference: "2026-03-15T09:00:00-04:00[America/New_York]",
1629
- });
1630
- // "morgen um 14:30" — locale's own connector, not "at"
1631
-
1632
- // formatDateRange / formatDateTimeRange — plain counterparts of
1633
- // formatZonedRange (same parameter order and option shape), for a
1634
- // locale-elided range between two timezone-free values.
1635
- formatDateRange("2024-02-03", "2024-02-05", "en-US", { dateStyle: "long" });
1636
- // "February 3 - 5, 2024"
1637
-
1638
- formatDateTimeRange("2024-02-03T09:00:00", "2024-02-03T17:00:00", "en-US", {
1639
- dateStyle: "long",
1640
- timeStyle: "short",
1641
- });
1642
- // "February 3, 2024, 9:00 AM - 5:00 PM"
1643
-
1644
- // formatDateToParts / formatDateTimeToParts / formatZonedToParts return the
1645
- // locale-ordered Array<{ type, value }> parts behind the strings above,
1646
- // instead of a finished string — GMT's substitute for a token formatter.
1647
- // Iterate the array as returned; reassembling parts in a fixed order
1648
- // reintroduces the locale-ordering bug formatToParts exists to avoid.
1649
- formatDateToParts("2024-03-15", "en-US");
1650
- // [{ type: "month", value: "3" }, { type: "literal", value: "/" },
1651
- // { type: "day", value: "15" }, { type: "literal", value: "/" },
1652
- // { type: "year", value: "2024" }]
1653
-
1654
- formatDateToParts("2024-03-15", "fr-FR");
1655
- // day comes before month, same locale-order guarantee as formatDate:
1656
- // [{ type: "day", value: "15" }, { type: "literal", value: "/" },
1657
- // { type: "month", value: "3" }, { type: "literal", value: "/" },
1658
- // { type: "year", value: "2024" }]
1659
-
1660
- formatZonedToParts("2024-03-15T14:30:00-04:00[America/New_York]", "en-US", {
1661
- timeZoneName: "longOffset",
1662
- });
1663
- // includes { type: "timeZoneName", value: "GMT-04:00" }
1664
- ```
1665
-
1666
- ### Named machine formats
1667
-
1668
- Fixed, non-locale-adaptive grammars for interchange with email, HTTP, SQL, and
1669
- RFC 3339 consumers — none of these take a `locale` argument, since the
1670
- grammar itself is constant (English weekday/month names where the format
1671
- mandates them).
1672
-
1673
- ```typescript
1674
- import {
1675
- formatRfc2822,
1676
- parseRfc2822,
1677
- formatHttp,
1678
- parseHttp,
1679
- formatSql,
1680
- parseSql,
1681
- formatRfc3339,
1682
- parseRfc3339,
1683
- } from "@northguild/gmt";
1684
-
1685
- // Email `Date:` headers (RFC 5322 / RFC 2822).
1686
- formatRfc2822("2024-03-15T14:30:00-04:00[America/New_York]");
1687
- // "Fri, 15 Mar 2024 14:30:00 -0400"
1688
- parseRfc2822("Fri, 15 Mar 2024 14:30:00 -0400");
1689
- // "2024-03-15T14:30:00-04:00[-04:00]"
1690
-
1691
- // A receiver reads RFC 5322's full and obsolete syntax: comments, any case,
1692
- // two-digit years and zone names. A day name that contradicts the date is "".
1693
- parseRfc2822("fri,15 Mar 24 14:30 -0400 (EDT)");
1694
- // "2024-03-15T14:30:00-04:00[-04:00]"
1695
- parseRfc2822("Sat, 15 Mar 2024 14:30:00 -0400");
1696
- // "" — 15 March 2024 was a Friday
1697
-
1698
- // HTTP headers (RFC 9110 IMF-fixdate) — Last-Modified, Date, Expires.
1699
- formatHttp("2024-03-15T14:30:00Z");
1700
- // "Fri, 15 Mar 2024 14:30:00 GMT"
1701
- parseHttp("Fri, 15 Mar 2024 14:30:00 GMT");
1702
- // "2024-03-15T14:30:00Z"
1703
- // parseHttp also reads the obsolete rfc850-date and asctime-date forms.
1704
- parseHttp("Sun Nov 6 08:49:37 1994");
1705
- // "1994-11-06T08:49:37Z"
1706
-
1707
- // ANSI SQL / ODBC datetime literals (DATETIME/TIMESTAMP columns, no tz).
1708
- formatSql("2024-03-15T14:30:00");
1709
- // "2024-03-15 14:30:00"
1710
- parseSql("2024-03-15 14:30:00");
1711
- // "2024-03-15T14:30:00"
1712
- parseSql("2024-03-15 14:30");
1713
- // "" — the SQL literal grammar requires seconds, and a year of 0001–9999
1714
-
1715
- // Strict RFC 3339 — strips the bracketed IANA zone GMT's own zoned strings
1716
- // carry, which RFC 3339 does not permit.
1717
- formatRfc3339("2024-03-15T14:30:00-04:00[America/New_York]");
1718
- // "2024-03-15T14:30:00-04:00"
1719
- parseRfc3339("2024-03-15T14:30:00-04:00");
1720
- // "2024-03-15T14:30:00-04:00[-04:00]"
1721
- ```
1722
-
1723
- The formatters return `""` for a value their grammar cannot express, such as a year outside `0000`–`9999` for RFC 3339 and HTTP-date or outside `0001`–`9999` for SQL, rather than writing a string no conforming parser accepts. Use Temporal's own `toString()` when you need any year.
1724
-
1725
- ### Unix and UTC helpers
1726
-
1727
- ```typescript
1728
- import { getUnixNow, getUtcNow, convertUnixToPlainDate } from "@northguild/gmt";
1729
-
1730
- getUnixNow();
1731
- // 1710685845000 (milliseconds; getUnixNow({ epochUnit: "seconds" }) is 1710685845)
1732
-
1733
- getUtcNow();
1734
- // "2026-03-18T11:42:33.123Z"
1735
-
1736
- convertUnixToPlainDate(1710685845000, { timeZone: "UTC" });
1737
- // "2024-03-17"
1738
- ```
1739
-
1740
- ### Nanosecond precision
1741
-
1742
- `convertZonedToUnix` and friends return `number` milliseconds. Systems that record
1743
- telemetry, trades or sensor readings need nanoseconds, and a `number` cannot hold them:
1744
- integers are exact only to `2^53 − 1` ≈ 9.0 × 10^15, and nanoseconds since the epoch
1745
- passed that in April 1970. (`Date.now() * 1e6` is not a nanosecond timestamp — it is a
1746
- millisecond timestamp with three zeroes appended.) The `precision/` namespace works in
1747
- `bigint`:
1748
-
1749
- ```typescript
1750
- import {
1751
- toNanoseconds,
1752
- fromNanoseconds,
1753
- formatNanoseconds,
1754
- parseNanoseconds,
1755
- truncateNanoseconds,
1756
- } from "@northguild/gmt";
1757
-
1758
- toNanoseconds("2024-03-10T12:00:00.123456789Z");
1759
- // 1710072000123456789n
1760
-
1761
- fromNanoseconds(1710072000123456789n);
1762
- // "2024-03-10T12:00:00.123456789Z"
1763
-
1764
- fromNanoseconds(1710072000123456789n, "America/New_York");
1765
- // "2024-03-10T08:00:00.123456789-04:00[America/New_York]"
1766
- ```
1767
-
1768
- `JSON.stringify` throws a `TypeError` on a `bigint`, so the value crosses a wire as a
1769
- canonical decimal string:
1770
-
1771
- ```typescript
1772
- JSON.stringify({ observedAt: formatNanoseconds(1710072000123456789n) });
1773
- // '{"observedAt":"1710072000123456789"}'
1774
-
1775
- parseNanoseconds("1710072000123456789");
1776
- // 1710072000123456789n
1777
- ```
1778
-
1779
- Most storage engines cannot hold nanoseconds — PostgreSQL `timestamptz` and MySQL
1780
- `DATETIME(6)` hold microseconds — so a naive write-then-read loses the last digits
1781
- silently. Truncate to the target precision first, and the round-trip is exact:
1782
-
1783
- ```typescript
1784
- truncateNanoseconds(1710072000123456789n, "us");
1785
- // 1710072000123456000n — safe to write to a microsecond column
1786
-
1787
- truncateNanoseconds(1710072000123456789n, "ms");
1788
- // 1710072000123000000n
1789
- ```
1790
-
1791
- Truncation floors toward negative infinity, so pre-1970 values truncate the same way
1792
- post-1970 ones do — `truncateNanoseconds(-1500n, "us")` is `-2000n`, not `-1000n`.
1793
- Rounding toward zero would make the result jump direction either side of the epoch.
1794
-
1795
- `0n` is both the epoch and the invalid-input sentinel, so `precision/validate` exists to
1796
- tell them apart. Each predicate accepts exactly what its partner parses:
1797
-
1798
- ```typescript
1799
- import {
1800
- isValidInstant,
1801
- isValidNanoseconds,
1802
- isValidNanoPattern,
1803
- } from "@northguild/gmt";
1804
-
1805
- isValidInstant("1970-01-01T00:00:00Z"); // true — toNanoseconds returns 0n, the epoch
1806
- isValidInstant("garbage"); // false — toNanoseconds returns 0n, the sentinel
1807
-
1808
- isValidNanoPattern("0"); // true — parseNanoseconds returns 0n, the epoch
1809
- isValidNanoPattern("1e18"); // false
1810
-
1811
- isValidNanoseconds(0n); // true — in range for truncateNanoseconds / fromNanoseconds
1812
- isValidNanoseconds(0); // false — a number cannot carry a nanosecond timestamp
1813
- ```
1814
-
1815
- Reach for `isValidInstant` rather than `isValidUtc`: the latter gates on GMT's stricter
1816
- `<date>T<time>Z` shape and rejects the offsets and bracketed zones `toNanoseconds`
1817
- accepts, so validating with it discards valid input.
1818
-
1819
- Every `precision/` function returns a sentinel (`""` for strings, `0n` for bigints) on
1820
- invalid input, and accepts only values inside the range `Temporal.Instant` can represent
1821
- (±8_640_000_000_000_000_000_000n). `0n` is also the epoch itself, so validate the input
1822
- first when the two must be told apart. Leap-second-aware time scales (TAI, GPS) are not
1823
- part of this namespace — these are plain instant conversions.
1824
-
1825
- **A zoned string names one instant in every reader.** Temporal writes a zone's offset rounded
1826
- to the minute, so a zone with a sub-minute offset is written with an offset up to 30 seconds
1827
- from the real one: `Africa/Monrovia` stood at −00:44:30 until 1972 and is written `-00:45`.
1828
- Every GMT function that reads a moment reads such a string as `Temporal.ZonedDateTime.from`
1829
- does (TC39 `ToTemporalZonedDateTime` matches a minute-precision offset by minutes), so a zoned
1830
- string GMT wrote reads back as the instant it was written for:
1831
-
1832
- ```typescript
1833
- import { isValidZonedDateTime, toNanoseconds } from "@northguild/gmt";
1834
-
1835
- toNanoseconds("1960-01-01T00:20:00-00:45[Africa/Monrovia]"); // -315615330000000000n (01:04:30Z)
1836
- toNanoseconds("1960-01-01T00:20:00-00:45"); // -315615300000000000n (01:05:00Z; no zone, so the offset is exact)
1837
- toNanoseconds("2024-06-15T10:00:00Z[Not/AZone]"); // 1718445600000000000n (an instant reader does not validate the bracket)
1838
-
1839
- isValidZonedDateTime("1960-01-01T00:20:00-00:45[Africa/Monrovia]"); // true (the rounded offset)
1840
- isValidZonedDateTime("1960-01-01T00:20:00-00:44:30[Africa/Monrovia]"); // true (the exact offset)
1841
- isValidZonedDateTime("1960-01-01T00:20:00-00:45:00[Africa/Monrovia]"); // false (an offset with seconds must be exact)
1842
- ```
1843
-
1844
- - The rule applies to `precision/`, `span/`, `interval/`, `calendar/`, `intermodal/`,
1845
- `transport/` and `getTimeZoneOffset` alike. Any other string keeps its written offset: one
1846
- without a bracket, a `Z` instant, an offset written with seconds, and a bracket whose zone
1847
- does not exist or does not fit the offset.
1848
- - Every string Temporal writes for an instant in range is valid, the first and last instants
1849
- included: `-271821-04-19T23:58:45-00:01[Europe/London]` is the first instant.
1850
- - A function that keeps the zone (`isValidZonedDateTime`, `transitTime`, `toOffsetInstant`,
1851
- every `zoned/` function, a zoned `relativeTo`) rejects a bracket that contradicts the
1852
- offset, and requires an offset written with seconds to be the zone's offset exactly.
1853
- - Two limits are Temporal's own. A wall time repeated inside a sub-minute offset change,
1854
- written to the minute, reads as its first pass (`1952-10-15T23:59:59-11:20[Pacific/Niue]`);
1855
- write the offset with seconds (`-11:20:00`) to name the second. A zoned read refuses a local
1856
- date of −271821-04-19, which a zone west of Greenwich shows for the first hours of the
1857
- instant range; pass that instant in `Z` form.
1858
-
1859
- ### Foreign epoch bridges
1860
-
1861
- Other systems do not count from 1970. NTP counts from 1900 and rolls over in 2036, Windows
1862
- `FILETIME` counts 100-nanosecond intervals from 1601, .NET ticks count the same interval
1863
- from year 1, Excel uses a day serial with a deliberate 1900 leap-year bug, and PostgreSQL
1864
- stores microseconds from 2000-01-01. Every integration re-derives these constants, usually
1865
- wrongly:
1866
-
1867
- ```typescript
1868
- import {
1869
- toNtpTimestamp,
1870
- fromNtpTimestamp,
1871
- toFileTime,
1872
- fromFileTime,
1873
- toDotNetTicks,
1874
- fromDotNetTicks,
1875
- toExcelSerial,
1876
- fromExcelSerial,
1877
- toPgMicroseconds,
1878
- fromPgMicroseconds,
1879
- } from "@northguild/gmt";
1880
-
1881
- toFileTime("1970-01-01T00:00:00Z");
1882
- // 116444736000000000n — 100 ns intervals since 1601-01-01
1883
-
1884
- toDotNetTicks("1970-01-01T00:00:00Z");
1885
- // 621355968000000000n — same unit, counted from 0001-01-01
1886
-
1887
- toPgMicroseconds("2024-03-10T12:00:00Z");
1888
- // 763387200000000n — microseconds since 2000-01-01
1889
- ```
1890
-
1891
- **NTP timestamps are era-ambiguous by design.** The seconds field is unsigned 32-bit, so it
1892
- wraps every ~136.19 years and the era is not part of the value. `fromNtpTimestamp` takes it
1893
- as an explicit argument:
1894
-
1895
- ```typescript
1896
- toNtpTimestamp("2036-02-07T06:28:15Z");
1897
- // 18446744069414584320n — the last second of era 0
1898
-
1899
- toNtpTimestamp("2036-02-07T06:28:16Z");
1900
- // 0n — era 1 begins, and the seconds field wraps
1901
-
1902
- fromNtpTimestamp(0n);
1903
- // "1900-01-01T00:00:00Z"
1904
-
1905
- fromNtpTimestamp(0n, 1);
1906
- // "2036-02-07T06:28:16Z" — the same value, one era on
1907
- ```
1908
-
1909
- **Excel's serial 60 is the phantom 29 February 1900**, a date that never existed — 1900 was
1910
- not a leap year. Lotus 1-2-3 got it wrong and Excel keeps the bug for compatibility, so the
1911
- serial day `[60, 61)` is a hole rather than a date. "Fixing" it would put GMT one day out
1912
- from Excel for every date before March 1900:
1913
-
1914
- ```typescript
1915
- fromExcelSerial(59);
1916
- // "1900-02-28T00:00:00Z"
1917
-
1918
- fromExcelSerial(60);
1919
- // "" — the phantom 1900-02-29
1920
-
1921
- fromExcelSerial(61);
1922
- // "1900-03-01T00:00:00Z"
1923
-
1924
- toExcelSerial("2024-03-10T12:00:00Z");
1925
- // 45361.5 — days, with the time of day as the fraction
1926
-
1927
- toExcelSerial("2024-03-10T12:00:00Z", { system: "1904" });
1928
- // 43899.5 — the legacy Mac system, exactly 1462 days lower
1929
- ```
1930
-
1931
- Each bridge accepts only what its target format can actually hold, and returns the
1932
- namespace's sentinel otherwise — `toFileTime` on a pre-1601 instant is `0n`, not a negative
1933
- tick count, and `toExcelSerial` past 9999-12-31 is `null`, not an out-of-range serial.
1934
- PostgreSQL's range is the one that surprises: its timestamp domain starts at 4713 BC, which
1935
- is 40× later than the earliest instant Temporal can represent, so an in-range Temporal
1936
- instant is not automatically an in-range PostgreSQL one. Where
1937
- the target unit is coarser than a nanosecond the conversion floors toward negative infinity,
1938
- so a round trip is exact to that unit: 100 ns for `FILETIME` and .NET ticks, 1 µs for
1939
- PostgreSQL, 1 ms for Excel. NTP is the exception in the other direction — its 2^-32 s unit is
1940
- finer than a nanosecond, so `fromNtpTimestamp` rounds to the nearest nanosecond and the round
1941
- trip is exact.
1942
- ### Spans
1943
-
1944
- `diffZoned` measures in calendar units and returns a `Duration`. Profiling, tracing and
1945
- telemetry want a raw number, and the `span/` namespace gives one:
1946
-
1947
- ```typescript
1948
- import { spanMs, spanNs, spanWallClock } from "@northguild/gmt";
1949
-
1950
- spanMs("2024-03-10T12:00:00Z", "2024-03-10T12:00:01Z");
1951
- // 1000
1952
-
1953
- spanMs("2024-03-10T12:00:00Z", "2024-03-10T12:00:00.123456789Z");
1954
- // 123.456789 — fractional, like performance.now()
1955
-
1956
- spanNs("2024-03-10T12:00:00.123456789Z", "2024-03-10T12:00:00.123456790Z");
1957
- // 1n
1958
- ```
1959
-
1960
- Both are signed — `spanMs(b, a)` is exactly `-spanMs(a, b)` — and both measure **exact
1961
- elapsed time**. That is not the same question as calendar distance, and conflating the two
1962
- is the most common span bug there is. A wall-clock day containing a DST transition is 23 or
1963
- 25 hours long (24.5 in `Australia/Lord_Howe`), so:
1964
-
1965
- ```typescript
1966
- const start = "2024-03-09T12:00:00-05:00[America/New_York]";
1967
- const end = "2024-03-10T12:00:00-04:00[America/New_York]";
1968
-
1969
- spanMs(start, end);
1970
- // 82800000 — 23 hours actually elapsed
1971
-
1972
- spanWallClock(start, end, "hours");
1973
- // 24 — the clock face advanced a full day
1974
-
1975
- spanWallClock(start, end, "days");
1976
- // 1
1977
- ```
1978
-
1979
- `spanWallClock` reads each endpoint's own local wall clock — straight off the string, never
1980
- via an instant, so DST disambiguation cannot distort it and a local time that never occurred
1981
- is measured as written. The two endpoints need not share a zone: a flight leaving New York at 23:00 and landing in Berlin at 11:00 the next local day
1982
- is 12 wall-clock hours and 7 elapsed hours. It truncates toward zero, and it is not a count
1983
- of midnights crossed.
1984
-
1985
- `isValidSpan(start, end)` answers "will these two produce a span?" — true exactly when
1986
- `spanMs` and `spanNs` will return a value. It is symmetric, since a reversed pair is a
1987
- negative span rather than an invalid one. `spanWallClock` has a different input grammar and
1988
- is covered by `isValidZonedDateTime`.
1989
-
1990
- Three limits, all deliberate:
1991
-
1992
- - **`0` and `0n` are valid spans**, so invalid input returns `null` from all three rather
1993
- than the zero the rest of the library uses for numbers. `null` and not `NaN`: it is the
1994
- sentinel every other number-returning function in GMT uses, and the only one
1995
- `strictNullChecks` forces a caller to handle — a `NaN` types as plain `number` and
1996
- propagates silently through arithmetic.
1997
- - **`spanMs` returns `null` past `Number.MAX_SAFE_INTEGER` milliseconds** (±285,000 years,
1998
- which two instants at opposite ends of `Temporal.Instant`'s range exceed). A
1999
- sub-millisecond fraction counts toward that ceiling, so what comes back is always a safe
2000
- integer or below. Use `spanNs` there; its result is a duration, not an instant, and can be
2001
- twice the epoch-nanosecond range.
2002
- - **Leap seconds are not counted.** UTC repeats a second rather than numbering a 61st one,
2003
- so a span across one is a second short of the physical elapsed time; against a smeared
2004
- clock (Google, AWS, Meta) the error is up to a second spread over the smear window.
2005
-
2006
- ### Offset-preserving instants and local resolution
2007
-
2008
- An instant orders events globally; the UTC offset in force where the event happened renders
2009
- it as the human on the ground saw it. Neither derives from the other, which is why GS1 EPCIS
2010
- 2.0 requires both fields (`eventTime` plus `eventTimeZoneOffset`), UN/EDIFACT DTM has
2011
- qualifiers `303`/`304` for the pair, and DICOM appends `&ZZXX` to a `DT` value. The `instant/`
2012
- namespace is that pair:
2013
-
2014
- ```typescript
2015
- import { fromOffsetInstant, toOffsetInstant } from "@northguild/gmt";
2016
-
2017
- toOffsetInstant("2024-07-15T12:00:00-04:00[America/New_York]");
2018
- // { instant: "2024-07-15T16:00:00Z", offset: "-04:00", timeZone: "America/New_York" }
2019
-
2020
- // Most feeds send no zone at all — the offset alone is what happened.
2021
- toOffsetInstant("2024-07-15T12:00:00-04:00");
2022
- // { instant: "2024-07-15T16:00:00Z", offset: "-04:00" }
2023
-
2024
- // A UTC-only feed plus a zone you know from somewhere else.
2025
- toOffsetInstant("2024-07-15T16:00:00Z", "America/New_York");
2026
- // { instant: "2024-07-15T16:00:00Z", offset: "-04:00", timeZone: "America/New_York" }
2027
-
2028
- fromOffsetInstant({ instant: "2024-07-15T16:00:00Z", offset: "-04:00" });
2029
- // "2024-07-15T12:00:00-04:00"
2030
- ```
2031
-
2032
- **An offset is not a zone.** `-05:00` does not identify `America/New_York` — it is every zone
2033
- sitting at `-05:00` that day, and it says nothing about what that zone will do next spring.
2034
- Store the zone for anything still to be scheduled; store the offset for anything that already
2035
- happened. `timeZone` is optional because most feeds do not send one, and a string whose
2036
- offset contradicts its own bracketed zone returns `null` rather than a guess.
2037
-
2038
- The other half of the namespace is the reverse problem: a wall time that arrives with no
2039
- offset at all ("gate-out 08:00"). Resolving one needs a zone the sender did not send, plus a
2040
- policy for the two days a year the mapping is not one-to-one:
2041
-
2042
- ```typescript
2043
- import { classifyLocal, resolveLocal } from "@northguild/gmt";
2044
-
2045
- classifyLocal("2024-11-03T01:30:00", "America/New_York"); // "ambiguous"
2046
- classifyLocal("2024-03-10T02:30:00", "America/New_York"); // "nonexistent"
2047
- classifyLocal("2024-07-15T12:00:00", "America/New_York"); // "unique"
2048
-
2049
- resolveLocal("2024-11-03T01:30:00", "America/New_York");
2050
- // "2024-11-03T05:30:00Z" — the default "compatible" takes the first 01:30
2051
-
2052
- resolveLocal("2024-11-03T01:30:00", "America/New_York", { disambiguation: "later" });
2053
- // "2024-11-03T06:30:00Z" — the same wall clock, an hour of real time later
2054
-
2055
- resolveLocal("2024-11-03T01:30:00", "America/New_York", { disambiguation: "reject" });
2056
- // "" — ambiguous, and not guessed
2057
- ```
2058
-
2059
- `classifyLocal` exists so code can branch **before** a policy is applied. A demurrage clock, a
2060
- medication window or a duty limit should refuse an ambiguous wall time, or route it to a
2061
- human — not silently accept whichever of two instants an hour apart a default handed it. See
2062
- [DST Disambiguation](../../docs/dst-disambiguation.md) for the four strategies in full.
2063
-
2064
- Two notes on the boundaries of this namespace:
2065
-
2066
- - **`resolveLocal` returns an instant; `convertPlainDateTimeToZoned` returns a zoned string.**
2067
- Same underlying resolution, different output and different precision — `resolveLocal` is
2068
- exact to the nanosecond, while `convertPlainDateTimeToZoned` defaults to milliseconds.
2069
- Neither takes an `offset` option. Reach for whichever shape you need next.
2070
- - **`offset` is `±HH:MM`, except where it is not.** A handful of zones did not run on a whole
2071
- minute before 1972 — `Africa/Monrovia` really was `-00:44:30` — and those report
2072
- `±HH:MM:SS`. Rounding them would put the pair thirty seconds from the event it describes.
2073
-
2074
- ### Calendar boundaries and zone-aware buckets
2075
-
2076
- Two absences with outsized consequences, and they turn out to be the same absence twice: a
2077
- calendar boundary is not a fixed number of hours, and it is not in UTC.
2078
-
2079
- **Week and period identifiers.** Vessel schedules are published by week number and retail runs
2080
- on 52/53-week fiscal calendars. Both come back as whole identifiers, not as fields to combine
2081
- by hand — a week number without its week-numbering year is ambiguous at both ends of a year:
2082
-
2083
- ```typescript
2084
- import { getFiscalPeriod, getIsoWeekDate, getOrdinalDate, getQuarter } from "@northguild/gmt";
2085
-
2086
- getIsoWeekDate("2027-01-01"); // { year: 2026, week: 53, weekday: 5 } — week-year 2026, not 2027
2087
- getOrdinalDate("2024-12-31"); // { year: 2024, dayOfYear: 366 }
2088
- getQuarter("2024-03-31", { fiscalYearStartMonth: 4 }); // { year: 2023, quarter: 4 }
2089
-
2090
- // The NRF retail calendar, stated as its published rule: "the Saturday nearest to January 31".
2091
- const nrf = { pattern: "4-5-4", yearEndsOn: "2026-01-31" } as const;
2092
-
2093
- getFiscalPeriod("2024-06-15", nrf); // { year: 2024, period: 5, week: 19 }
2094
- getFiscalPeriod("2024-01-28", nrf); // { year: 2023, period: 12, week: 53 } — a 53-week year
2095
- ```
2096
-
2097
- 52 × 7 is 364 days, so a 53rd week is inserted every five or six years and lands in the final
2098
- period. `yearEndsOn` states the *rule*, by example — years end on that date's weekday, nearest
2099
- that date's month and day — not one year's end, because GMT bundles no fiscal calendar. There
2100
- is no single correct retail calendar to bundle.
2101
-
2102
- **Zone-aware bucketing.** "Group by day in `America/New_York`" over UTC timestamps is the most
2103
- common observability bug there is, and the same operation decides how many chargeable days a
2104
- container accrued, because free time is counted in terminal-local calendar days:
2105
-
2106
- ```typescript
2107
- import { bucketRange, floorToZone } from "@northguild/gmt";
2108
-
2109
- floorToZone("2024-06-15T03:00:00Z", "day", "America/New_York"); // "2024-06-14T04:00:00Z"
2110
- floorToZone("2024-06-15T03:00:00Z", "day", "UTC"); // "2024-06-15T00:00:00Z"
2111
- ```
2112
-
2113
- Same instant, different calendar day — 03:00 UTC on 15 June is still 14 June in New York, and
2114
- flooring it to the UTC day is wrong for most of the world for most of the day. The zone is an
2115
- argument because GMT has no ambient one and must not acquire one.
2116
-
2117
- `bucketRange` returns the boundaries spanning a range, and **the buckets are deliberately not
2118
- uniform in length**:
2119
-
2120
- ```typescript
2121
- bucketRange("2024-03-09T05:00:00Z", "2024-03-12T04:00:00Z", "day", "America/New_York");
2122
- // ["2024-03-09T05:00:00Z", "2024-03-10T05:00:00Z", "2024-03-11T04:00:00Z"]
2123
- // 24h, then 23h — the middle day springs forward
2124
-
2125
- bucketRange("2024-11-02T04:00:00Z", "2024-11-05T05:00:00Z", "day", "America/New_York");
2126
- // ["2024-11-02T04:00:00Z", "2024-11-03T04:00:00Z", "2024-11-04T05:00:00Z"]
2127
- // 24h, then 25h — the middle day falls back
2128
- ```
2129
-
2130
- Forcing 24 hours here is what makes a daily aggregate drift an hour twice a year. The same
2131
- honesty applies to boundaries that do not exist: the calendar day Samoa deleted crossing the
2132
- date line is absent from the list, a local day whose midnight is skipped by a spring-forward
2133
- starts at 01:00, and in a zone that falls back by half an hour (`Australia/Lord_Howe`) the
2134
- local 01:00 hour bucket is genuinely 90 minutes long.
2135
-
2136
- For a zoned or epoch value, `startOfZoned`/`startOfUnix` with no options return the same real
2137
- boundary. Do not floor with `roundZoned`/`roundUnix` and `roundingMode: "trunc"`: they follow
2138
- TC39 `ZonedDateTime.round`, which rounds the wall clock and re-resolves it in the zone, so
2139
- `"2024-09-29T03:50:00+13:45[Pacific/Chatham]"` truncated to the hour gives 04:00 — after the
2140
- input.
2141
-
2142
- A pattern or a bucket unit the namespace does not recognise returns the same sentinel an
2143
- unusable date or instant does, so both unions have a type guard to narrow one that arrived as
2144
- a bare string — from config, an env var, a form:
2145
-
2146
- ```typescript
2147
- import { isValidFiscalPattern, isValidZoneBucketUnit } from "@northguild/gmt";
2148
-
2149
- isValidFiscalPattern("4-5-4"); // true — the NRF retail calendar
2150
- isValidFiscalPattern("4-5-5"); // false — the shape a 53-week year's last quarter takes
2151
- isValidZoneBucketUnit("day"); // true
2152
- isValidZoneBucketUnit("days"); // true — singular or plural, as floorToZone accepts
2153
- isValidZoneBucketUnit("year"); // false — the year question is getQuarter's
2154
- ```
2155
-
2156
- ### Interval algebra
2157
-
2158
- Four realms need the same operation and none of them could express it: sum the parts of an
2159
- interval that fall inside a set of allowed windows. Laytime counts only the hours a charter
2160
- clause allows, driver hours split a duty period around mandatory rest, demurrage free time
2161
- counts only working days, and a trading window counts only continuous-session time. The
2162
- `interval/` namespace is that one shared primitive, with one boundary rule:
2163
-
2164
- **Half-open `[start, end)`.** An instant `t` is inside when `start ≤ t < end`. That is SQL:2011's
2165
- closed-open `PERIOD`, RFC 5545's non-inclusive `DTEND` and Dijkstra's EWD831. A container gated
2166
- out at exactly 17:00 has not used another day, and two consecutive shifts never double-count the
2167
- instant they meet.
2168
-
2169
- ```typescript
2170
- import {
2171
- clampInterval,
2172
- intersectIntervals,
2173
- intervalContains,
2174
- intervalsOverlap,
2175
- isValidInterval,
2176
- mergeIntervals,
2177
- splitIntervalAt,
2178
- subtractIntervals,
2179
- sumIntervals,
2180
- } from "@northguild/gmt";
2181
-
2182
- const shift = { start: "2024-01-01T09:00:00Z", end: "2024-01-01T17:00:00Z" };
2183
-
2184
- intervalContains(shift, "2024-01-01T09:00:00Z"); // true — start is inside
2185
- intervalContains(shift, "2024-01-01T17:00:00Z"); // false — end is not
2186
-
2187
- intervalsOverlap(shift, { start: "2024-01-01T17:00:00Z", end: "2024-01-01T18:00:00Z" });
2188
- // false — touching intervals share no instant
2189
- intersectIntervals(shift, { start: "2024-01-01T12:00:00Z", end: "2024-01-01T18:00:00Z" });
2190
- // { start: "2024-01-01T12:00:00Z", end: "2024-01-01T17:00:00Z" }
2191
- clampInterval({ start: "2024-01-01T08:00:00Z", end: "2024-01-01T12:00:00Z" }, shift);
2192
- // { start: "2024-01-01T09:00:00Z", end: "2024-01-01T12:00:00Z" }
2193
-
2194
- mergeIntervals([
2195
- { start: "2024-01-01T13:00:00Z", end: "2024-01-01T17:00:00Z" },
2196
- { start: "2024-01-01T09:00:00Z", end: "2024-01-01T12:00:00Z" },
2197
- { start: "2024-01-01T12:00:00Z", end: "2024-01-01T13:00:00Z" },
2198
- ]);
2199
- // [{ start: "2024-01-01T09:00:00Z", end: "2024-01-01T17:00:00Z" }] — sorted, touching runs joined
2200
-
2201
- const worked = subtractIntervals(shift, [{ start: "2024-01-01T12:00:00Z", end: "2024-01-01T13:00:00Z" }]);
2202
- // [{ start: "2024-01-01T09:00:00Z", end: "2024-01-01T12:00:00Z" },
2203
- // { start: "2024-01-01T13:00:00Z", end: "2024-01-01T17:00:00Z" }]
2204
- sumIntervals(worked); // "PT7H"
2205
-
2206
- splitIntervalAt(shift, ["2024-01-01T15:00:00Z", "2024-01-01T11:00:00Z"]);
2207
- // [{ start: "2024-01-01T09:00:00Z", end: "2024-01-01T11:00:00Z" },
2208
- // { start: "2024-01-01T11:00:00Z", end: "2024-01-01T15:00:00Z" },
2209
- // { start: "2024-01-01T15:00:00Z", end: "2024-01-01T17:00:00Z" }] — pieces share no instant
2210
-
2211
- isValidInterval({ start: "2024-01-01T17:00:00Z", end: "2024-01-01T09:00:00Z" }); // false — inverted
2212
- ```
2213
-
2214
- - **Endpoints are instants.** An offset (`Z` or `±HH:MM`) is required and a `[Zone]` annotation is
2215
- optional. As with a TC39 `Temporal.Instant`, endpoints compare by epoch nanoseconds, so two of
2216
- them may name different zones. Leap seconds are rejected, and a `[u-ca=…]` annotation is ignored, as `Temporal.Instant.from` ignores it.
2217
- The offset fixes the instant; an offset written to the minute that is the bracketed zone's
2218
- sub-minute offset rounded (`-00:45[Africa/Monrovia]`, for −00:44:30) names the instant the
2219
- zone gives, so a zoned string GMT wrote is read back as the same moment. That holds
2220
- from the first instant of the range to the last. The one exception is Temporal's own: a wall
2221
- time repeated inside a sub-minute offset change, written with a minute-rounded offset, reads
2222
- as its first pass (for example `1952-10-15T23:59:59-11:20[Pacific/Niue]`; several zones have
2223
- such a change in their history).
2224
- For local-calendar
2225
- edges, build them with `floorToZone` or `bucketRange` first.
2226
- - **Outputs are the caller's own strings**, never re-serialised. When two strings spell the same
2227
- instant, the first argument's spelling wins. No spec covers the tie, so this is a GMT rule.
2228
- - **`start === end` is a valid empty interval.** It contains no instant, overlaps only an interval
2229
- it lies strictly inside, and adds nothing to a sum. An inverted interval returns the sentinel:
2230
- `false`, `null`, `[]` or `""`.
2231
- - **`sumIntervals` is covered time.** It returns the length of the union, with overlaps counted
2232
- once, as an exact duration with hours as the largest unit. That is exactly what TC39
2233
- `Instant.prototype.until(…, { largestUnit: "hour" })` produces, because an instant has no
2234
- calendar that says how long a day is. So `PT49H30M` is never written `P2DT1H30M`, a New York
2235
- spring-forward day is `PT23H`, and `sumIntervals([])` is `"PT0S"`. The sum is exact past
2236
- 2^53 nanoseconds.
2237
- - **`[]` means two things.** From `mergeIntervals`, `subtractIntervals` and `splitIntervalAt` it
2238
- is both a legitimate result (a fully covered subtraction) and the invalid-input sentinel.
2239
- `isValidInterval` tells them apart.
2240
-
2241
- ### Operating hours and open-time SLAs
2242
-
2243
- A business day answers "is Thursday a working day". A terminal gate, a support desk, a customs
2244
- office and a night curfew ask a finer question: is it open right now, how many open hours have
2245
- passed, and when does an SLA measured in open hours fall due. `calendar/hours` answers them from
2246
- one `OperatingSchedule`: a zone, windows of local wall time for each ISO weekday, holiday dates,
2247
- and dated overrides.
2248
-
2249
- ```typescript
2250
- import {
2251
- addOperatingTime,
2252
- isOpenAt,
2253
- nextCloseAt,
2254
- nextOpenAt,
2255
- operatingIntervals,
2256
- operatingTimeBetween,
2257
- recurringWindows,
2258
- } from "@northguild/gmt";
2259
- import type { OperatingSchedule } from "@northguild/gmt/types";
2260
-
2261
- const nineToFive = [{ from: "09:00", to: "17:00" }];
2262
- const desk: OperatingSchedule = {
2263
- timeZone: "America/New_York",
2264
- weekly: { 1: nineToFive, 2: nineToFive, 3: nineToFive, 4: nineToFive, 5: nineToFive },
2265
- holidays: ["2024-07-04"],
2266
- overrides: [{ date: "2024-07-05", windows: [{ from: "10:00", to: "12:00" }] }],
2267
- };
2268
-
2269
- operatingIntervals(desk, { start: "2024-07-04T04:00:00Z", end: "2024-07-06T04:00:00Z" });
2270
- // [{ start: "2024-07-05T14:00:00Z", end: "2024-07-05T16:00:00Z" }] — the holiday is closed, the override replaces Friday
2271
-
2272
- isOpenAt("2024-06-17T14:00:00Z", desk); // true — Monday 10:00 local
2273
- nextOpenAt("2024-06-15T16:00:00Z", desk); // "2024-06-17T13:00:00Z" — Saturday noon to Monday 09:00 local
2274
- nextCloseAt("2024-06-17T14:00:00Z", desk); // "2024-06-17T21:00:00Z" — 17:00 local
2275
-
2276
- operatingTimeBetween("2024-06-14T20:00:00Z", "2024-06-17T14:00:00Z", desk); // "PT2H" — Friday 16:00 to Monday 10:00
2277
- addOperatingTime("2024-06-14T20:00:00Z", "PT8H", desk); // "2024-06-17T20:00:00Z" — an 8-hour SLA from Friday 16:00: Monday 16:00
2278
- addOperatingTime("2024-06-14T20:00:00Z", "PT8H", desk, { within: "P1D" }); // "" — not due within a day
2279
-
2280
- // A curfew wraps midnight and belongs to the night it starts on.
2281
- recurringWindows(
2282
- { 6: [{ from: "23:00", to: "06:00" }] },
2283
- { start: "2024-11-02T00:00:00Z", end: "2024-11-04T00:00:00Z" },
2284
- "America/New_York",
2285
- );
2286
- // [{ start: "2024-11-03T03:00:00Z", end: "2024-11-03T11:00:00Z" }] — 8 hours across the fall-back night
2287
- ```
2288
-
2289
- - **Windows are half-open local wall time.** `{ from: "09:00", to: "17:00" }` is open at 09:00
2290
- and closed at 17:00. A `to` at or before its `from` wraps past midnight: `23:00`–`06:00` is a
2291
- night and `00:00`–`00:00` a whole day. A window belongs to the date it starts on, so a holiday
2292
- or override on Friday removes or replaces a Friday-night window, and one on Saturday does not.
2293
- Windows that overlap or touch merge, so a run of whole days is one interval.
2294
- - **Every edge goes through `resolveLocal`.** The default `disambiguation` is `"compatible"`: an
2295
- edge in a repeated fall-back hour takes the earlier instant, and one in a skipped
2296
- spring-forward hour moves forward by the gap. So a 23:00–06:00 window is 8 real hours across
2297
- New York's fall-back night and 6 across its spring-forward night. Under `"earlier"`, a skipped
2298
- midnight can move a date's first window onto the previous date. `"earlier"` and `"later"`
2299
- pick the other instant. `"reject"` guesses nothing: it returns the sentinel when a window with
2300
- an ambiguous or nonexistent edge could change the answer, and ignores one that cannot.
2301
- - **Dates are the zone's real local dates.** Holidays and overrides are ISO dates in the
2302
- schedule's zone. A `BusinessCalendar` can be passed as `holidays`; its `holidays` are read, and
2303
- its `weekend` is not, because `weekly` already says which weekdays open. A date the zone
2304
- deleted (`Pacific/Apia`, 2011-12-30) has no windows. An override wins over a holiday on the
2305
- same date, and `windows: []` closes a date.
2306
- - **Open time is elapsed time.** `operatingTimeBetween` is the length of the open intervals
2307
- inside `[start, end)`, the same number `sumIntervals` gives for their intersections with the
2308
- range, with hours as the largest unit. `addOperatingTime` is its inverse: the earliest instant
2309
- at which that much open time has passed, so a deadline that lands on a closing time is that
2310
- closing time. Its duration is hours and smaller units: `P1D` of open time could mean 24 open
2311
- hours or one working day, so it returns `""`.
2312
- - **Searches stop at a horizon.** `nextOpenAt`, `nextCloseAt` and `addOperatingTime` search up to
2313
- `within` after the input (default `"P1Y"`), added in the schedule's zone so `"P1D"` is one local
2314
- day. An answer exactly at the horizon counts; one past it returns `""`. A 24/7 schedule never
2315
- closes, so `nextCloseAt` returns `""` for it.
2316
- - **Bounded walks.** A range spanning more than 10,000 local dates (about 27 years), counted from
2317
- its start's date, or a search that would go that far past its input, returns the sentinel
2318
- rather than a truncated answer. A search answers as soon as the answer is certain, so a long
2319
- `within` costs nothing when the answer is near. A horizon past Temporal's last instant stops
2320
- there.
2321
- - **Not a recurrence engine.** A weekly pattern with dated exceptions covers gate hours, office
2322
- hours, trading sessions and curfews. Monthly and yearly RFC 5545 rules are out of scope.
2323
-
2324
- ### Transport legs and dwell
2325
-
2326
- Every transport mode shares three operations: add a leg's duration to a departure, show the
2327
- arrival in the zone where it lands, and measure how long something sat at a terminal, berth,
2328
- gate or yard. Every mode also gets the same two things wrong: a leg across a DST transition is a
2329
- fixed number of *elapsed* hours, and dwell is charged in *local calendar days*, not hours.
2330
-
2331
- ```typescript
2332
- import { dwellTime, etaAtZone, transitTime } from "@northguild/gmt";
2333
-
2334
- // A leg is exact time in the departure's own zone. Four elapsed hours across the
2335
- // spring-forward: the wall clock reads 04:00, not 03:00.
2336
- transitTime("2024-03-09T23:00:00-05:00[America/New_York]", "PT4H");
2337
- // "2024-03-10T04:00:00-04:00[America/New_York]"
2338
-
2339
- // A day is 24 exact hours, not "the same wall time tomorrow".
2340
- transitTime("2024-03-09T12:00:00-05:00[America/New_York]", "P1D");
2341
- // "2024-03-10T13:00:00-04:00[America/New_York]"
2342
-
2343
- // The departure's form is preserved: Z stays Z, an offset stays an offset.
2344
- transitTime("2024-06-15T10:00:00Z", "PT36H"); // "2024-06-16T22:00:00Z"
2345
- transitTime("2024-06-15T10:00:00+09:00", "PT1H"); // "2024-06-15T11:00:00+09:00"
2346
- transitTime("2024-06-15T10:00:00+05:30:15", "PT1H"); // "2024-06-15T11:00:00+05:30:15"
2347
-
2348
- // Calendar units need a reference point; a zone that contradicts its offset is rejected.
2349
- transitTime("2024-06-15T10:00:00Z", "P1M"); // ""
2350
- transitTime("2024-06-15T10:00:00-05:00[America/New_York]", "PT2H"); // "" (June is -04:00)
2351
-
2352
- // Render a moment where it will be read. No disambiguation arises: on a fall-back night
2353
- // two arrivals an hour apart print the same wall time, and the offset tells them apart.
2354
- etaAtZone("2024-06-15T12:30:00Z", "Asia/Tokyo"); // "2024-06-15T21:30:00+09:00[Asia/Tokyo]"
2355
- etaAtZone("2024-11-03T05:30:00Z", "America/New_York"); // "2024-11-03T01:30:00-04:00[America/New_York]"
2356
- etaAtZone("2024-11-03T06:30:00Z", "America/New_York"); // "2024-11-03T01:30:00-05:00[America/New_York]"
2357
-
2358
- // Dwell: exact duration, zone-local entry and exit, and the local calendar days touched.
2359
- dwellTime("2024-06-15T22:30:00Z", "2024-06-16T01:00:00Z", "Europe/London");
2360
- // { duration: "PT2H30M",
2361
- // enter: "2024-06-15T23:30:00+01:00[Europe/London]",
2362
- // exit: "2024-06-16T02:00:00+01:00[Europe/London]",
2363
- // calendarDays: 2 }
2364
-
2365
- // The same two instants, one fewer day: the zone decides.
2366
- dwellTime("2024-06-15T22:30:00Z", "2024-06-16T01:00:00Z", "Europe/Amsterdam");
2367
- // { duration: "PT2H30M",
2368
- // enter: "2024-06-16T00:30:00+02:00[Europe/Amsterdam]",
2369
- // exit: "2024-06-16T03:00:00+02:00[Europe/Amsterdam]",
2370
- // calendarDays: 1 }
2371
-
2372
- // The zone comes from targetZone or from the entry's bracketed zone. Bare instants with
2373
- // neither return null: an offset is not a place, and a day count needs one.
2374
- dwellTime("2024-06-15T22:30:00+01:00[Europe/London]", "2024-06-16T02:00:00+01:00[Europe/London]");
2375
- // { duration: "PT3H30M",
2376
- // enter: "2024-06-15T22:30:00+01:00[Europe/London]",
2377
- // exit: "2024-06-16T02:00:00+01:00[Europe/London]",
2378
- // calendarDays: 2 }
2379
- dwellTime("2024-06-15T22:30:00Z", "2024-06-16T01:00:00Z"); // null
2380
-
2381
- // Inverted intervals return null.
2382
- dwellTime("2024-06-16T01:00:00Z", "2024-06-15T22:30:00Z", "Europe/London"); // null
2383
- ```
2384
-
2385
- - **`transitTime` adds exact time.** Hours, minutes, seconds and fractions are elapsed time and a
2386
- `D` component is 24 hours exactly, so the arrival wall clock reflects any DST shift in between.
2387
- Years, months and weeks return `""`: no leg takes "a month" without a reference point. A
2388
- negative duration moves backwards, which is how a departure is recovered from an arrival.
2389
- - **`etaAtZone` renders a moment.** The input is an instant (`Z`, an offset, or an offset with a
2390
- bracketed zone), and only the instant is read; `targetZone` is where it is rendered. The zone
2391
- is the caller's fact: GMT does not resolve a port, airport or station code to a timezone. A
2392
- fixed offset such as `"+09:00"` is accepted as a zone, as `isValidTimeZone` accepts it.
2393
- `dwellTime` accepts one as `targetZone` too, and counts days in that offset, which observes
2394
- no DST.
2395
- - **`dwellTime.calendarDays` counts local dates, not hours.** It is the number of distinct
2396
- local dates the half-open interval `[entry, exit)` touches: same date is `1`, across one
2397
- local midnight is `2`, and an exit exactly at local midnight does not touch the new day. It
2398
- is counted by walking the zone's real transitions, so a 23- or 25-hour local day is one day,
2399
- a date the zone deleted (`Pacific/Apia`, 2011-12-30) is never touched, and a date the clock
2400
- falls back into (`America/Goose_Bay`, 2010-11-07) is counted once. `duration` is the exact elapsed time with hours as the largest unit, as
2401
- `sumIntervals` reports it.
2402
-
2403
- ### Crossings and multi-leg schedules
2404
-
2405
- A crossing (a canal transit, a strait passage, a border queue) is logged as two instants and
2406
- read on the clock of whoever administers it. A multi-leg move (truck, ship, rail) is one
2407
- departure, a duration per leg and a handling time at each handoff, and each handoff is where a
2408
- naive implementation loses an hour. Every leg boundary here is an exact instant; local time is
2409
- rendered only at the edges, with `etaAtZone`.
2410
-
2411
- ```typescript
2412
- import { crossingTime, scheduleDelivery } from "@northguild/gmt";
2413
-
2414
- // A crossing: exact elapsed time, both ends read in the zone that runs the crossing.
2415
- crossingTime("2024-03-10T05:00:00Z", "2024-03-10T12:00:00Z", "America/New_York");
2416
- // { duration: "PT7H",
2417
- // enter: "2024-03-10T00:00:00-05:00[America/New_York]",
2418
- // exit: "2024-03-10T08:00:00-04:00[America/New_York]" } (seven hours across the spring-forward)
2419
-
2420
- // targetZone is always the rendering zone; a bracketed zone on the input never is.
2421
- crossingTime("2024-06-15T10:00:00+02:00[Europe/Berlin]", "2024-06-15T12:00:00+02:00[Europe/Berlin]", "Africa/Cairo");
2422
- // { duration: "PT2H",
2423
- // enter: "2024-06-15T11:00:00+03:00[Africa/Cairo]",
2424
- // exit: "2024-06-15T13:00:00+03:00[Africa/Cairo]" }
2425
- crossingTime("2024-06-16T01:00:00Z", "2024-06-15T22:30:00Z", "Europe/London"); // null (exit before entry)
2426
-
2427
- // Truck, ship, rail. The first departure is a published local wall time, read in startTimeZone.
2428
- // The ship waits for its scheduled sailing; the rail leg leaves when the 48-hour dwell ends.
2429
- const legs = [
2430
- { departure: "2024-06-14T08:00:00", duration: "PT6H", timeZone: "Asia/Shanghai",
2431
- dwellAfter: "PT12H", mode: "truck", origin: "CNSZX-DC", destination: "CNYTN" },
2432
- { departure: "2024-06-15T10:00:00+08:00[Asia/Shanghai]", duration: "PT336H",
2433
- timeZone: "America/Los_Angeles", dwellAfter: "PT48H", mode: "ship", origin: "CNYTN", destination: "USLAX" },
2434
- { duration: "PT70H", timeZone: "America/Chicago", mode: "rail", origin: "USLAX", destination: "USCHI" },
2435
- ];
2436
- scheduleDelivery(legs, { startTimeZone: "Asia/Shanghai" });
2437
- // { eta: "2024-07-03T19:00:00-05:00[America/Chicago]",
2438
- // legTimes: [
2439
- // { arrival: "2024-06-14T06:00:00Z", localArrival: "2024-06-14T14:00:00+08:00[Asia/Shanghai]",
2440
- // dwellAfter: "PT12H", mode: "truck", origin: "CNSZX-DC", destination: "CNYTN" },
2441
- // { arrival: "2024-06-29T02:00:00Z", localArrival: "2024-06-28T19:00:00-07:00[America/Los_Angeles]",
2442
- // dwellAfter: "PT48H", mode: "ship", origin: "CNYTN", destination: "USLAX" },
2443
- // { arrival: "2024-07-04T00:00:00Z", localArrival: "2024-07-03T19:00:00-05:00[America/Chicago]",
2444
- // dwellAfter: "PT0S", mode: "rail", origin: "USLAX", destination: "USCHI" } ] }
2445
-
2446
- // A sailing at 20:00 local on the 14th leaves inside the truck leg's 12-hour dwell: missed.
2447
- scheduleDelivery([legs[0], { ...legs[1], departure: "2024-06-14T20:00:00+08:00[Asia/Shanghai]" }],
2448
- { startTimeZone: "Asia/Shanghai" }); // null
2449
-
2450
- scheduleDelivery(legs); // null (a zoneless first departure needs startTimeZone)
2451
- scheduleDelivery([]); // { eta: "", legTimes: [] }
2452
- ```
2453
-
2454
- - **`crossingTime` counts no days.** It returns the exact duration, with hours as the largest
2455
- unit, and the entry and exit rendered in `targetZone`, an IANA identifier or a fixed offset.
2456
- A crossing that needs a day count is a dwell: use `dwellTime`. An exit before the entry
2457
- returns `null`; a zero-length crossing is `PT0S`.
2458
- - **`dwellAfter` is the minimum connect time.** Each leg leaves at its own `departure`, or, when
2459
- it has none, at the previous arrival plus the previous leg's `dwellAfter`. A scheduled
2460
- departure earlier than that (inside the dwell, or before the arrival) is a missed connection
2461
- and returns `null`. Equal passes: a zero-slack connection is feasible. `dwellAfter` is echoed
2462
- as written (`"PT90M"` stays `"PT90M"`), `"PT0S"` when omitted. The last leg's `dwellAfter` is
2463
- validated and echoed but never added to an instant, because no leg follows it.
2464
- - **Departures must be exact, with two wall-time exceptions.** A departure is an instant (`Z` or
2465
- an offset) or a zoned string, whose bracketed zone must be real and agree with its offset.
2466
- Schedules are published as local wall times, so a zoneless first departure is read in
2467
- `startTimeZone`, and a zoneless later-leg departure is read in the _previous_ leg's own
2468
- `timeZone` (leg N−1's destination is exactly leg N's departure zone). Zoneless without one of
2469
- these returns `null`.
2470
- - **Wall times resolve as `"compatible"`.** A zoneless first departure, a zoneless later-leg
2471
- departure, or a zoned departure written without an offset, is a wall time. An ambiguous one (a
2472
- fall-back hour the clock ran through twice) resolves to the earlier instant; a skipped one (a
2473
- spring-forward hour) resolves to the later instant. Write the offset to pick the other pass of
2474
- a repeated hour. At a hub, a printed timetable never says which pass it means, so on any leg
2475
- but the first a repeated hour takes the earliest pass at or after the previous arrival plus its
2476
- dwell; when neither is that late, the connection is missed.
2477
- - **Durations follow `transitTime`.** Time units are elapsed time and a day is 24 hours;
2478
- years, months and weeks return `null`. A negative leg returns `null`, because a leg cannot
2479
- arrive before it departs. A single-leg schedule is exactly `transitTime` composed with
2480
- `etaAtZone`.
2481
- - **Tags are opaque.** `mode`, `origin` and `destination` are echoed onto each `LegTime` and are
2482
- absent when not supplied. GMT never resolves them to a zone: `timeZone` is the caller's fact.
2483
- - Also exported: the `Crossing`, `Leg`, `LegTime`, `ScheduleDeliveryOptions` and
2484
- `DeliverySchedule` types.
2485
-
2486
- ### Cut-offs and deadlines
2487
-
2488
- A logistics deadline is not a fixed timestamp. It counts back from an event (a vessel's
2489
- departure, a container's loading, an arrival) in the local time of the place that enforces it,
2490
- and it moves when that event moves. An ocean booking carries a stack of them: documentation,
2491
- VGM, gate-in and customs. `cutoffAt` finds one, `cutoffSchedule` finds the whole stack, and
2492
- `isPastCutoff` and `timeToCutoff` read the clock against one.
2493
-
2494
- ```typescript
2495
- import { cutoffAt, cutoffSchedule, isPastCutoff, timeToCutoff } from "@northguild/gmt";
2496
-
2497
- // A vessel sails Friday 14 June 2024 at 18:00 in Amsterdam (16:00Z).
2498
- // "Two days before, 17:00" is 17:00 local on Wednesday: not 48 hours, which keeps 18:00.
2499
- cutoffAt("2024-06-14T16:00:00Z", "P2D", { timeZone: "Europe/Amsterdam", atLocalTime: "17:00" });
2500
- // "2024-06-12T17:00:00+02:00[Europe/Amsterdam]"
2501
- cutoffAt("2024-06-14T16:00:00Z", "PT48H", { timeZone: "Europe/Amsterdam" });
2502
- // "2024-06-12T18:00:00+02:00[Europe/Amsterdam]"
2503
-
2504
- // The whole stack against one departure, earliest first. Reschedule the vessel, call it again.
2505
- cutoffSchedule("2024-06-14T16:00:00Z", [
2506
- { name: "gate-in", offset: "P1D" },
2507
- { name: "document", offset: "P2D", atLocalTime: "17:00" },
2508
- { name: "customs", offset: "PT30H" },
2509
- { name: "VGM", offset: "P1D", atLocalTime: "10:00" },
2510
- ], { timeZone: "Europe/Amsterdam" });
2511
- // [ { name: "document", at: "2024-06-12T17:00:00+02:00[Europe/Amsterdam]" },
2512
- // { name: "VGM", at: "2024-06-13T10:00:00+02:00[Europe/Amsterdam]" },
2513
- // { name: "customs", at: "2024-06-13T12:00:00+02:00[Europe/Amsterdam]" },
2514
- // { name: "gate-in", at: "2024-06-13T18:00:00+02:00[Europe/Amsterdam]" } ]
2515
-
2516
- // A cut-off on a weekend or holiday moves by the roll convention you name. There is no default.
2517
- cutoffAt("2024-06-18T16:00:00Z", "P2D", {
2518
- timeZone: "Europe/Amsterdam",
2519
- atLocalTime: "17:00",
2520
- calendar: { weekend: [6, 7], holidays: [], timeZone: "Europe/Amsterdam" },
2521
- roll: "preceding",
2522
- }); // "2024-06-14T17:00:00+02:00[Europe/Amsterdam]" (Sunday rolls back to Friday)
2523
-
2524
- // The anchor is the caller's event. The same 24 hours from loading and from departure:
2525
- cutoffAt("2024-06-10T08:00:00Z", "PT24H", { timeZone: "Asia/Shanghai" });
2526
- // "2024-06-09T16:00:00+08:00[Asia/Shanghai]"
2527
- cutoffAt("2024-06-12T20:00:00Z", "PT24H", { timeZone: "Asia/Shanghai" });
2528
- // "2024-06-12T04:00:00+08:00[Asia/Shanghai]"
2529
-
2530
- // 96 hours before an arrival is exact elapsed time, across New York's fall-back.
2531
- cutoffAt("2024-11-05T17:00:00Z", "PT96H", { timeZone: "America/New_York" });
2532
- // "2024-11-01T13:00:00-04:00[America/New_York]"
2533
-
2534
- // A cut-off in an hour the clock skipped is refused, not moved.
2535
- cutoffAt("2024-03-11T22:00:00Z", "P1D", { timeZone: "America/New_York", atLocalTime: "02:30" }); // ""
2536
-
2537
- isPastCutoff("2024-06-12T16:00:00Z", "2024-06-12T17:00:00+02:00[Europe/Amsterdam]"); // true
2538
- timeToCutoff("2024-06-12T16:00:00Z", "2024-06-12T17:00:00+02:00[Europe/Amsterdam]"); // "-PT1H"
2539
- timeToCutoff("2024-06-10T15:00:00Z", "2024-06-12T17:00:00+02:00[Europe/Amsterdam]"); // "PT48H"
2540
- ```
2541
-
2542
- - **`atLocalTime` pins the time of day.** With it, only the day the offset lands on matters: the
2543
- offset's exact part (hours and smaller) comes off the anchor's instant, its calendar part
2544
- (years, months, weeks, days) off that local date, and the cut-off is `atLocalTime` on that day.
2545
- The exact part comes off first, so it can change the day: from midnight, `P2DT0.000000001S`
2546
- lands a day before `P2D`. Without it, the offset is taken off as Temporal's `ZonedDateTime#subtract` takes it: `P2D`
2547
- keeps the anchor's wall clock, and `PT96H` is 96 elapsed hours.
2548
- - **The anchor is exact and is the caller's event.** An instant (`Z` or an offset) or a zoned
2549
- string; its bracket never supplies the zone, and `timeZone` (an IANA identifier or a fixed
2550
- offset) is the local frame. `cutoffAt` never guesses whether a rule counts from loading, departure or arrival.
2551
- The functions are pure, so a rescheduled departure is a new call, not a stored cut-off to fix.
2552
- - **Non-business days roll as you say.** With `calendar` (a `BusinessCalendar`), a cut-off on a
2553
- weekend or holiday moves by `roll`, as `rollDate` moves it, and keeps its local time of day.
2554
- Every industry answers "what if it lands on a non-working day" differently, so the convention
2555
- is always explicit and there is no default: `calendar` without `roll`, or `roll` without
2556
- `calendar`, returns `""`. `calendar.timeZone` is not read. `"endOfMonth"` is `rollDate`'s
2557
- schedule tool, not a cut-off convention.
2558
- - **The order is not checked.** `cutoffAt` does not check that the cut-off is before the anchor.
2559
- A negative offset, `P0D` with a late `atLocalTime`, `"following"` or `"endOfMonth"` can each
2560
- put it after.
2561
- - **Local wall times resolve for a deadline.** `atLocalTime`, the calendar part of an unpinned
2562
- offset and a rolled day are wall times. An ambiguous one (a fall-back hour the clock ran
2563
- through twice) takes the earlier instant, the first occurrence, as RFC 5545 §3.3.5 reads a
2564
- repeated local time. A nonexistent one (a spring-forward hour, a deleted day) returns `""`
2565
- rather than shifting the deadline.
2566
- - **`cutoffSchedule` is all or nothing.** Each entry is `cutoffAt` with its own `atLocalTime`;
2567
- the result is sorted by instant, and entries on the same instant keep their order. One invalid
2568
- entry returns `[]`, never a stack with a deadline missing.
2569
- - **The window closes at the cut-off.** `isPastCutoff` is `true` from the cut-off instant on, and
2570
- `timeToCutoff` is `PT0S` there, exact hours before it and negative after it. Both compare
2571
- instants, and `now` is always the caller's. Both return their sentinel (`false`, `""`) for
2572
- `cutoffAt`'s `""`, so check the cut-off first.
2573
- - Also exported: the `CutoffOptions`, `Cutoff`, `CutoffTime` and `CutoffScheduleOptions` types.
2574
-
2575
- ### Punctuality, timestamp classes and the next departure
2576
-
2577
- Every mode publishes a plan and records what happened, and measures the gap the same way. Only
2578
- the tolerance differs, and it is the caller's. `scheduleDeviation` measures the gap,
2579
- `classifyPunctuality` judges it against a tolerance, and `punctualityRate` counts a list of
2580
- arrivals under one. One field often holds four kinds of time (planned, estimated, requested and
2581
- actual), so `bestAvailable` returns the class with the value, and `estimateDrift` measures how far
2582
- an estimate moved. `nextDeparture` finds the departure a connection can make.
2583
-
2584
- ```typescript
2585
- import {
2586
- bestAvailable,
2587
- classifyPunctuality,
2588
- estimateDrift,
2589
- nextDeparture,
2590
- punctualityRate,
2591
- scheduleDeviation,
2592
- type TimestampEvent,
2593
- } from "@northguild/gmt";
2594
-
2595
- scheduleDeviation("2024-06-15T10:00:00Z", "2024-06-15T10:14:00Z"); // "PT14M"
2596
- scheduleDeviation("2024-06-15T10:00:00Z", "2024-06-15T09:55:00Z"); // "-PT5M" (early)
2597
- scheduleDeviation("2024-06-15T10:00:00Z", "2024-06-17T11:30:00Z"); // "PT49H30M" (hours, never days)
2598
- // The same wall time, an hour later across New York's fall-back: 60 minutes, not 0.
2599
- scheduleDeviation("2024-11-03T01:30:00-04:00[America/New_York]", "2024-11-03T01:30:00-05:00[America/New_York]");
2600
- // "PT1H"
2601
-
2602
- // A 15-minute tolerance. Exactly 15 minutes is late: on time is strictly inside.
2603
- classifyPunctuality("2024-06-15T10:00:00Z", "2024-06-15T10:14:59Z", { late: "PT15M" }); // "onTime"
2604
- classifyPunctuality("2024-06-15T10:00:00Z", "2024-06-15T10:15:00Z", { late: "PT15M" }); // "late"
2605
- // Without `early`, an early arrival is on time. With it, 11 minutes early is early.
2606
- classifyPunctuality("2024-06-15T10:00:00Z", "2024-06-15T09:49:00Z", { late: "PT15M" }); // "onTime"
2607
- classifyPunctuality("2024-06-15T10:00:00Z", "2024-06-15T09:49:00Z", { late: "PT15M", early: "PT10M" }); // "early"
2608
- // The same 90-minute delay under a 60-minute and a 120-minute tolerance.
2609
- classifyPunctuality("2024-06-15T10:00:00Z", "2024-06-15T11:30:00Z", { late: "PT60M" }); // "late"
2610
- classifyPunctuality("2024-06-15T10:00:00Z", "2024-06-15T11:30:00Z", { late: "PT120M" }); // "onTime"
2611
- classifyPunctuality("2024-06-15T10:00:00Z", "2024-06-15T10:14:00Z", { late: "P1W" }); // null (weeks have no exact length)
2612
-
2613
- punctualityRate([
2614
- { planned: "2024-06-15T10:00:00Z", actual: "2024-06-15T10:05:00Z" },
2615
- { planned: "2024-06-15T10:00:00Z", actual: "2024-06-15T10:14:00Z" },
2616
- { planned: "2024-06-15T10:00:00Z", actual: "2024-06-15T10:16:00Z" },
2617
- { planned: "2024-06-15T10:00:00Z", actual: "2024-06-15T09:57:00Z" },
2618
- ], { late: "PT15M" }); // { onTime: 3, total: 4, rate: 0.75 }
2619
- punctualityRate([], { late: "PT15M" }); // null
2620
-
2621
- // An estimate recorded after the actual is still only an estimate.
2622
- bestAvailable([
2623
- { classifier: "ACT", at: "2024-06-15T12:52:00Z", recordedAt: "2024-06-15T12:53:00Z" },
2624
- { classifier: "EST", at: "2024-06-15T13:05:00Z", recordedAt: "2024-06-15T14:00:00Z" },
2625
- ]); // { at: "2024-06-15T12:52:00Z", classifier: "ACT" }
2626
- // Never an estimate when a request exists.
2627
- bestAvailable([
2628
- { classifier: "EST", at: "2024-06-15T12:40:00Z", recordedAt: "2024-06-14T00:00:00Z" },
2629
- { classifier: "REQ", at: "2024-06-15T12:30:00Z", recordedAt: "2024-06-12T00:00:00Z" },
2630
- ]); // { at: "2024-06-15T12:30:00Z", classifier: "REQ" }
2631
-
2632
- // Three revisions of one arrival estimate: it slid nine hours.
2633
- const estimates: TimestampEvent[] = [
2634
- { classifier: "EST", at: "2024-06-20T08:00:00Z", recordedAt: "2024-06-01T00:00:00Z" },
2635
- { classifier: "EST", at: "2024-06-20T12:00:00Z", recordedAt: "2024-06-05T00:00:00Z" },
2636
- { classifier: "EST", at: "2024-06-20T17:00:00Z", recordedAt: "2024-06-10T00:00:00Z" },
2637
- ];
2638
- estimateDrift(estimates, { tolerance: "PT8H" });
2639
- // { first: "2024-06-20T08:00:00Z", last: "2024-06-20T17:00:00Z", drift: "PT9H", revisions: 3, exceedsTolerance: true }
2640
- estimateDrift(estimates.slice(0, 1)); // null (no drift from one estimate)
2641
-
2642
- const timetable = ["2024-06-15T08:00:00Z", "2024-06-15T09:30:00Z", "2024-06-15T11:00:00Z"];
2643
- nextDeparture("2024-06-15T09:00:00Z", timetable); // "2024-06-15T09:30:00Z"
2644
- nextDeparture("2024-06-15T09:00:00Z", timetable, { minimumConnection: "PT45M" }); // "2024-06-15T11:00:00Z"
2645
- nextDeparture("2024-06-15T09:00:00Z", ["2024-06-15T09:30:00"]); // "" (a timetable entry needs an offset)
2646
-
2647
- // Every 20 minutes from 06:00, up to but never at 09:00.
2648
- const shuttle = { headway: "PT20M", from: "2024-06-15T06:00:00+02:00", to: "2024-06-15T09:00:00+02:00" };
2649
- nextDeparture("2024-06-15T06:05:00+02:00", shuttle); // "2024-06-15T06:20:00+02:00"
2650
- nextDeparture("2024-06-15T09:00:00+02:00", shuttle); // ""
2651
- ```
2652
-
2653
- - **A deviation is exact elapsed time.** `scheduleDeviation` is `actual` minus `planned` with
2654
- hours as the largest unit: positive when late, negative when early, `"PT0S"` on the plan. A
2655
- delay across a DST transition is measured in real minutes, never wall-clock ones. Both times
2656
- are an instant (`Z` or an offset) or a zoned string; the zone each is written in does not
2657
- matter. The offset fixes the instant, and a bracketed zone resolves only a rounded one: an
2658
- offset written to the minute that is the zone's sub-minute offset rounded
2659
- (`-00:45[Africa/Monrovia]`, for −00:44:30) names the instant the zone gives, so a zoned string
2660
- GMT wrote is read back as the same moment, except a wall time repeated inside a sub-minute
2661
- offset change, which reads as its first pass. To show a schedule's local time, use `etaAtZone`.
2662
- - **"On time" needs a stated tolerance.** GMT holds no default. A `PunctualityTolerance` is
2663
- `{ late, early? }` as ISO 8601 durations of exact time: a day is 24 hours, and years, months,
2664
- weeks or a negative value return `null`. Tolerances differ by mode (15 minutes, a 60/120-minute
2665
- pair, a day), and each is the same call with different numbers.
2666
- - **Both edges belong to the outside.** A deviation of `late` or more is late; with `early`, one
2667
- of `-early` or less is early. Without `early`, every early arrival is on time. `late: "PT0S"`
2668
- makes an arrival exactly on the plan late. This half-open convention is GMT's own, stated here.
2669
- `classifyPunctuality` returns `null` on invalid input.
2670
- - **A rate is a count under one tolerance.** `punctualityRate` reads the tolerance once and
2671
- judges every pair under it. Only `"onTime"` counts, so with an `early` tolerance an early
2672
- arrival is not on time. `rate` is `onTime / total`, not rounded. An empty list, or any invalid
2673
- pair, returns `null`, never a rate over the rest. Percentiles and causes are the consumer's.
2674
- - **The class travels with the value.** A `TimestampEvent` is `{ classifier, at, recordedAt }`,
2675
- with `classifier` one of DCSA's `PLN`, `EST`, `REQ` and `ACT`. `bestAvailable` picks an `ACT`
2676
- whenever one exists, else `PLN`, else `REQ`, else `EST`, and within that class the latest
2677
- `recordedAt`. This order is GMT's and is not configurable. It reads, from its most settled
2678
- end, the estimated, requested, planned, then actual pattern that
2679
- [DCSA's Port Call standard](https://reference.dcsa.org/content/standards/releases/port-call/v2-0-0/port-call-v2-0-0-use-cases)
2680
- describes for some port call services; DCSA defines the classes and that pattern, not a
2681
- selection rule. `at` is echoed exactly as written. An empty list or any invalid event returns
2682
- `null`.
2683
- - **Drift needs two estimates.** `estimateDrift` reads only the `EST` records, in `recordedAt`
2684
- order, and reports the last `at` minus the first, negative when the estimate moved earlier. It
2685
- returns `null` with fewer than two, since one estimate has no drift. With `tolerance`,
2686
- `exceedsTolerance` is `true` when the drift in either direction is greater than it; exactly the
2687
- tolerance is `false`. Without it, `null`.
2688
- - **`nextDeparture` is timetable lookup, not routing.** It returns the first departure at or
2689
- after `after` plus `minimumConnection` (default `"PT0S"`, exact time, not negative); one exactly
2690
- at that threshold is made. A list entry is returned exactly as written, so it can go straight
2691
- into `scheduleDelivery` as a leg's `departure`. Every moment (`after`, each entry, `from` and
2692
- `to`) must carry its offset, as `nextOpenAt` requires: a wall time without one returns `""`,
2693
- because in a repeated fall-back hour it names two departures. One invalid entry returns `""`.
2694
- - **A headway window is half-open.** `{ headway, from, to }` has the shape of a GTFS
2695
- [`frequencies.txt`](https://github.com/google/transit/blob/master/gtfs/spec/en/reference.md#frequenciestxt) row. Departures are `from` plus whole headways in exact time, up to but never
2696
- at `to`, and are computed from `from`, not stepped, so a long window costs nothing. Across a DST
2697
- transition they keep their spacing and the wall clock shifts. The result is written the way
2698
- `from` was written.
2699
- - Also exported: the `TimestampClass`, `TimestampEvent`, `PunctualityTolerance`, `Punctuality`,
2700
- `PlannedActual`, `OnTimeRate`, `ClassifiedTimestamp`, `EstimateDriftOptions`, `DriftReport`,
2701
- `Headway` and `NextDepartureOptions` types.
2702
-
2703
- ### Free time and demurrage
2704
-
2705
- Free time is the money calculation in container logistics, and none of its terms is a fact about
2706
- the port or fixed by a world standard. How many days are free, whether the day of discharge is
2707
- free day one, how free days and charged days are counted, and which clock a charge runs on are set
2708
- by the carrier's tariff and the service contract. DCSA defines what demurrage, detention and
2709
- storage are, but not how their days are counted. So every term is a parameter, no counting term has a default, and what comes back is the free-time window and the specific dates charged.
2710
-
2711
- ```typescript
2712
- import { chargeableDays, demurrageClock, freeTimeExpiry } from "@northguild/gmt";
2713
-
2714
- const tariff = { basis: "calendar", chargeBasis: "calendar", timeZone: "America/New_York", firstDay: "eventDay" };
2715
-
2716
- // A Friday afternoon discharge in New York with three free calendar days: the weekend is
2717
- // consumed, and free time ends at Monday 00:00 local.
2718
- freeTimeExpiry("2024-06-14T19:00:00Z", 3, tariff);
2719
- // { freeTimeStart: "2024-06-14", lastFreeDay: "2024-06-16", expiresAt: "2024-06-17T04:00:00Z" }
2720
-
2721
- // The same tariff read the other way: one more day. firstDay has no default.
2722
- freeTimeExpiry("2024-06-14T19:00:00Z", 3, { ...tariff, firstDay: "nextDay" });
2723
- // { freeTimeStart: "2024-06-15", lastFreeDay: "2024-06-17", expiresAt: "2024-06-18T04:00:00Z" }
2724
- freeTimeExpiry("2024-06-14T19:00:00Z", 3, { basis: "calendar", timeZone: "America/New_York" }); // null
2725
-
2726
- // Working days skip the weekend, and a terminal holiday moves expiry a day.
2727
- const terminal = { weekend: [6, 7], holidays: [], timeZone: "America/New_York" };
2728
- freeTimeExpiry("2024-06-14T19:00:00Z", 3, { ...tariff, basis: "working", calendar: terminal });
2729
- // { freeTimeStart: "2024-06-14", lastFreeDay: "2024-06-18", expiresAt: "2024-06-19T04:00:00Z" }
2730
- freeTimeExpiry("2024-06-14T19:00:00Z", 3, { ...tariff, basis: "working", calendar: { ...terminal, holidays: ["2024-06-17"] } });
2731
- // { freeTimeStart: "2024-06-14", lastFreeDay: "2024-06-19", expiresAt: "2024-06-20T04:00:00Z" }
2732
-
2733
- // Expiry is half-open: out exactly as free time ends is free, one second later is a charged day,
2734
- // and the date is listed.
2735
- chargeableDays("2024-06-14T19:00:00Z", "2024-06-17T04:00:00Z", 3, tariff);
2736
- // { freeDaysUsed: 3, chargeableDays: 0, expiresAt: "2024-06-17T04:00:00Z",
2737
- // chargedDates: [], byTier: [{ from: 1, to: null, days: 0 }] }
2738
- chargeableDays("2024-06-14T19:00:00Z", "2024-06-17T04:00:01Z", 3, tariff);
2739
- // { freeDaysUsed: 3, chargeableDays: 1, expiresAt: "2024-06-17T04:00:00Z",
2740
- // chargedDates: ["2024-06-17"], byTier: [{ from: 1, to: null, days: 1 }] }
2741
-
2742
- // Charged days are counted on their own basis. Where a tariff grants free time in working days,
2743
- // the days after it are mostly charged as calendar days; some tariffs charge working days only.
2744
- const juneteenth = { ...terminal, holidays: ["2024-06-19"] };
2745
- chargeableDays("2024-06-14T19:00:00Z", "2024-06-24T15:00:00Z", 3, { ...tariff, basis: "working", calendar: juneteenth });
2746
- // { freeDaysUsed: 3, chargeableDays: 6, expiresAt: "2024-06-19T04:00:00Z",
2747
- // chargedDates: ["2024-06-19", "2024-06-20", "2024-06-21", "2024-06-22", "2024-06-23", "2024-06-24"],
2748
- // byTier: [{ from: 1, to: null, days: 6 }] }
2749
- chargeableDays("2024-06-14T19:00:00Z", "2024-06-24T15:00:00Z", 3, { ...tariff, basis: "working", chargeBasis: "working", calendar: juneteenth });
2750
- // { freeDaysUsed: 3, chargeableDays: 3, expiresAt: "2024-06-19T04:00:00Z",
2751
- // chargedDates: ["2024-06-20", "2024-06-21", "2024-06-24"],
2752
- // byTier: [{ from: 1, to: null, days: 3 }] }
2753
-
2754
- // Tiers are day bands, not rates: twelve charged days in bands of 5, 5 and the rest.
2755
- chargeableDays("2024-06-14T19:00:00Z", "2024-06-28T15:00:00Z", 3, { ...tariff, tiers: [5, 10] });
2756
- // { freeDaysUsed: 3, chargeableDays: 12, expiresAt: "2024-06-17T04:00:00Z",
2757
- // chargedDates: ["2024-06-17", "2024-06-18", "2024-06-19", "2024-06-20", "2024-06-21",
2758
- // "2024-06-22", "2024-06-23", "2024-06-24", "2024-06-25", "2024-06-26",
2759
- // "2024-06-27", "2024-06-28"],
2760
- // byTier: [{ from: 1, to: 5, days: 5 }, { from: 6, to: 10, days: 5 }, { from: 11, to: null, days: 2 }] }
2761
-
2762
- // Which two events a charge runs between, on the import or the export leg.
2763
- const imported = [
2764
- { type: "discharged", at: "2024-06-14T19:00:00Z" },
2765
- { type: "available", at: "2024-06-15T12:00:00Z" },
2766
- { type: "gatedOut", at: "2024-06-20T14:30:00Z" },
2767
- { type: "emptyReturned", at: "2024-06-27T09:00:00Z" },
2768
- ];
2769
- demurrageClock(imported, "demurrage", { direction: "import" }); // { start: "2024-06-14T19:00:00Z", end: "2024-06-20T14:30:00Z" }
2770
- demurrageClock(imported, "detention", { direction: "import" }); // { start: "2024-06-20T14:30:00Z", end: "2024-06-27T09:00:00Z" }
2771
- demurrageClock(imported, "combined", { direction: "import" }); // { start: "2024-06-14T19:00:00Z", end: "2024-06-27T09:00:00Z" }
2772
- demurrageClock(imported, "demurrage", { direction: "import", startEvent: "available" }); // { start: "2024-06-15T12:00:00Z", end: "2024-06-20T14:30:00Z" }
2773
- const exported = [
2774
- { type: "emptyReleased", at: "2024-07-01T08:00:00Z" },
2775
- { type: "gatedIn", at: "2024-07-05T16:00:00Z" },
2776
- { type: "loaded", at: "2024-07-09T03:00:00Z" },
2777
- ];
2778
- demurrageClock(exported, "demurrage", { direction: "export" }); // { start: "2024-07-05T16:00:00Z", end: "2024-07-09T03:00:00Z" }
2779
- demurrageClock(exported, "detention", { direction: "export" }); // { start: "2024-07-01T08:00:00Z", end: "2024-07-05T16:00:00Z" }
2780
- demurrageClock([{ type: "discharged", at: "2024-06-14T19:00:00Z" }], "demurrage", { direction: "import" }); // null (no gate-out yet)
2781
- ```
2782
-
2783
- - **Days are the terminal's local days.** `clockStart` and `clockEnd` are instants; their local
2784
- dates in `options.timeZone` are what is counted, over the zone's real day boundaries, the same
2785
- ones `dwellTime` and `floorToZone` find. A 23- or 25-hour day is one day, a date the zone deleted
2786
- is never a free or charged day, and a date the clock falls back into counts once. On the calendar
2787
- basis with `firstDay: "eventDay"`, `freeDaysUsed + chargeableDays` is exactly
2788
- `dwellTime(...).calendarDays` for the same dwell, except that a fall-back re-entering the day
2789
- before the event day (Goose Bay, 7 November 2010) is a date `dwellTime` counts and a tariff
2790
- never does.
2791
- - **`firstDay` has no default.** `"eventDay"` makes the event day free day one; `"nextDay"` starts
2792
- free time the following counted day. The two differ by a full day of charges, so omitting it
2793
- returns `null`.
2794
- - **`basis` counts free days; `chargeBasis` counts charged days.** `"calendar"` counts every local
2795
- day; `"working"` counts only the working days of `options.calendar`, a `BusinessCalendar` (its
2796
- weekend and holidays; its `timeZone` is not read), and returns `null` without one. Many tariffs
2797
- count both in calendar days. Where a tariff grants free time in working days, the days after it
2798
- are mostly charged as calendar days; some tariffs charge working days only. Neither has a default.
2799
- "Calendar days excluding bank holidays" is `"working"` with an empty `weekend`.
2800
- - **Expiry is half-open.** `expiresAt` is the first instant of the local day after `lastFreeDay`, as
2801
- a UTC instant. A gate-out at exactly `expiresAt` is not a chargeable day; one nanosecond later is.
2802
- - **`chargedDates` makes the count auditable.** It is the list a carrier's day-numbered tariff grid
2803
- is applied to, and the dates an itemised invoice can list.
2804
- - **Tiers are day bands, not rates.** `tiers: [5, 10]` names days 1–5, 6–10 and 11 onward; `byTier`
2805
- says how many charged days fell in each band, empty bands included, so a rate table applies by
2806
- index. Omitted, it is the single open band. GMT computes days, never money.
2807
- - **`freeDays: 0`** is a tariff with no free time: `chargeableDays` charges every counted day from day
2808
- one, and `freeTimeExpiry` returns `null` because there is no last free day to name.
2809
- - **`demurrageClock` selects the pair, per leg.** DCSA's glossary puts demurrage inside the terminal
2810
- or depot and detention outside it; the carriers' published tariffs agree on the events. Import: demurrage and
2811
- storage run from `startEvent` (`"discharged"` by default, or `"available"`) to `gatedOut`, detention
2812
- from `gatedOut` to `emptyReturned`, and the combined clock from `startEvent` to `emptyReturned`.
2813
- Export: demurrage and storage run from `gatedIn` to `loaded`, detention from `emptyReleased` to
2814
- `gatedIn`, and the combined clock from `emptyReleased` to `loaded`. `direction` has no default. The
2815
- event names follow DCSA's Track & Trace equipment events (DISC, GTOT, GTIN, LOAD). A required event
2816
- that is missing or present twice, or an end before its start, returns `null`, and the result is an
2817
- `Interval` in the caller's own strings, ready for `chargeableDays(start, end, …)`.
2818
-
2819
- ### Billing deadlines
2820
-
2821
- A billing regime or service contract can set up to three windows around a demurrage or
2822
- detention invoice: one to issue it, counted from an anchor date; one to dispute it, counted from
2823
- issuance; and one to resolve the dispute, counted from the request, unless the parties
2824
- agree a date instead. The number of days in each is the caller's fact; GMT carries none of them. The arithmetic
2825
- is the function's: each deadline is a date, counted in calendar days from a date, with the anchor
2826
- as day zero and the deadline day itself inside the window.
2827
-
2828
- ```typescript
2829
- import { billingTimeline, convertUtcToPlainDate } from "@northguild/gmt";
2830
-
2831
- const windows = { issueDays: 30, disputeDays: 30, resolutionDays: 30 };
2832
-
2833
- // Charges last accrued on 1 March. Day 30 is the last day by the deadline; day 31 is not.
2834
- billingTimeline({ anchorOn: "2026-03-01", invoiceIssuedOn: "2026-03-31" }, windows);
2835
- // { invoiceDeadline: "2026-03-31", issuedByDeadline: true, disputeDeadline: "2026-04-30",
2836
- // requestedByDeadline: null, resolutionDeadline: null }
2837
- billingTimeline({ anchorOn: "2026-03-01", invoiceIssuedOn: "2026-04-01" }, windows);
2838
- // { invoiceDeadline: "2026-03-31", issuedByDeadline: false, disputeDeadline: "2026-05-01",
2839
- // requestedByDeadline: null, resolutionDeadline: null }
2840
-
2841
- // No invoice yet: a forecast. Only the invoice deadline is known.
2842
- billingTimeline({ anchorOn: "2026-03-01" }, windows);
2843
- // { invoiceDeadline: "2026-03-31", issuedByDeadline: null, disputeDeadline: null,
2844
- // requestedByDeadline: null, resolutionDeadline: null }
2845
-
2846
- // A dispute received on the dispute deadline, and one received a day later with a resolution
2847
- // date the parties agreed, which replaces the computed one.
2848
- billingTimeline({ anchorOn: "2026-03-01", invoiceIssuedOn: "2026-03-20", requestReceivedOn: "2026-04-19" }, windows);
2849
- // { invoiceDeadline: "2026-03-31", issuedByDeadline: true, disputeDeadline: "2026-04-19",
2850
- // requestedByDeadline: true, resolutionDeadline: "2026-05-19" }
2851
- billingTimeline({ anchorOn: "2026-03-01", invoiceIssuedOn: "2026-03-20", requestReceivedOn: "2026-04-20" }, { ...windows, agreedResolutionOn: "2026-06-01" });
2852
- // { invoiceDeadline: "2026-03-31", issuedByDeadline: true, disputeDeadline: "2026-04-19",
2853
- // requestedByDeadline: false, resolutionDeadline: "2026-06-01" }
2854
-
2855
- // A re-bill is anchored on the invoice it received (issued 10 March), not on the charge.
2856
- billingTimeline({ anchorOn: "2026-03-10", invoiceIssuedOn: "2026-04-05" }, windows);
2857
- // { invoiceDeadline: "2026-04-09", issuedByDeadline: true, disputeDeadline: "2026-05-05",
2858
- // requestedByDeadline: null, resolutionDeadline: null }
2859
-
2860
- // A 14/14/45 service contract through the same chain: the numbers are the caller's.
2861
- billingTimeline({ anchorOn: "2026-03-01", invoiceIssuedOn: "2026-03-05", requestReceivedOn: "2026-03-18" }, { issueDays: 14, disputeDays: 14, resolutionDays: 45 });
2862
- // { invoiceDeadline: "2026-03-15", issuedByDeadline: true, disputeDeadline: "2026-03-19",
2863
- // requestedByDeadline: true, resolutionDeadline: "2026-05-02" }
2864
-
2865
- // Windows have no defaults.
2866
- billingTimeline({ anchorOn: "2026-03-01", invoiceIssuedOn: "2026-03-31" }, { disputeDays: 30, resolutionDays: 30 }); // null
2867
-
2868
- // Deadlines are dates. Reduce an instant to the billing party's local date first.
2869
- convertUtcToPlainDate("2024-06-17T04:00:01Z", { timeZone: "America/New_York" }); // "2024-06-17"
2870
- ```
2871
-
2872
- - **Day zero is the anchor and the deadline is `anchor + days`** on the ISO calendar
2873
- (`Temporal.PlainDate.add`); a date is by the deadline when it is on or before it
2874
- (`Temporal.PlainDate.compare(date, deadline) <= 0`). That is the function's stated contract, not
2875
- a reading of any rule; a regime that counts differently passes a different number. Thirty days
2876
- across a leap day, a month end, a year end or a DST change is thirty dates, never thirty times
2877
- 24 hours: `2028-01-30` plus 30 is `2028-02-29`.
2878
- - **Deadlines are dates, never instants.** Reduce an instant to the billing party's local date
2879
- first with `convertUtcToPlainDate(instant, { timeZone })`; the function does not guess a zone,
2880
- and a date-time or an instant passed as a date returns `null`.
2881
- - **Windows have no defaults.** Each is a safe integer of at least `0`; a missing, negative or
2882
- non-integer window returns `null`, because a silently defaulted window is a wrong deadline. The
2883
- same rule as `firstDay`.
2884
- - **The anchor is whatever date the caller counts from.** The last date a charge accrued is
2885
- `chargeableDays(...).chargedDates.at(-1)`. A party re-billing a charge it was itself billed
2886
- passes the issuance date of the invoice it received; the chain is the same.
2887
- - **The chain fills in as its dates exist.** With only `anchorOn` the result is a forecast:
2888
- `invoiceDeadline` is set and every other field is `null`. An invoice date sets
2889
- `issuedByDeadline` and `disputeDeadline`; a request date sets `requestedByDeadline` and
2890
- `resolutionDeadline`. A request without an invoice, or dated before it, is a data error and
2891
- returns `null`; an invoice dated before the anchor is allowed, because an invoice may be issued
2892
- while charges still accrue.
2893
- - **An agreed date replaces the computed resolution deadline.** `agreedResolutionOn` must be a
2894
- valid date on or after `requestReceivedOn`, else `null`; it is validated even when no request
2895
- exists, but cannot act without one. Every emitted date is bare ISO, so an input annotation such
2896
- as `[u-ca=iso8601]` does not reach the output.
2897
- - **GMT computes dates, not liability.** `issuedByDeadline` and `requestedByDeadline` compare
2898
- dates and say nothing else. Whether a charge is payable, whether a dispute must be heard, and
2899
- any consequence are the consumer's, the sibling of "GMT computes days, never money".
2900
-
2901
- ## API Surface
2902
-
2903
- For the complete API listing, see the namespace documentation on GitHub:
2904
-
2905
- - [Duration API](https://github.com/northguild/gmt/tree/main/packages/gmt/src/duration) — ISO 8601 duration parsing, validation, arithmetic, and formatting
2906
- - [Plain API](https://github.com/northguild/gmt/tree/main/packages/gmt/src/plain) — timezone-free operations
2907
- - [Zoned API](https://github.com/northguild/gmt/tree/main/packages/gmt/src/zoned) — IANA timezone-aware operations
2908
- - [Unix API](https://github.com/northguild/gmt/tree/main/packages/gmt/src/unix) — Unix epoch utilities
2909
- - [Precision API](https://github.com/northguild/gmt/tree/main/packages/gmt/src/precision) — nanosecond instants, JSON transport, storage truncation, NTP / FILETIME / .NET ticks / Excel / PostgreSQL epoch bridges
2910
- - [Span API](https://github.com/northguild/gmt/tree/main/packages/gmt/src/span) — elapsed and wall-clock durations as raw numbers
2911
- - [Calendar API](https://github.com/northguild/gmt/tree/main/packages/gmt/src/calendar) — ISO week and ordinal dates, quarter and fiscal periods, zone-aware bucketing, business calendars with holiday sets and roll conventions, and operating hours: open intervals, open or closed now, open time elapsed and SLA deadlines
2912
- - [Interval API](https://github.com/northguild/gmt/tree/main/packages/gmt/src/interval) — half-open interval algebra over instants: overlap, intersect, clamp, merge, subtract, split, sum
2913
- - [Transport API](https://github.com/northguild/gmt/tree/main/packages/gmt/src/transport) — transit legs as exact elapsed time, arrivals rendered in the zone where they land, dwell in local calendar days, crossings, multi-leg delivery schedules, cut-offs, schedule deviation and punctuality, PLN/EST/REQ/ACT timestamp classes, and timetable and headway departures
2914
- - [Intermodal API](https://github.com/northguild/gmt/tree/main/packages/gmt/src/intermodal) — free time, demurrage and detention: which clock, which start day, calendar or working days, and the charged dates an itemised invoice can list, plus the deadline chain around the invoice with every window a caller parameter
2915
- - [Instant API](https://github.com/northguild/gmt/tree/main/packages/gmt/src/instant) — the instant-plus-offset pair, and explicit local-time resolution
2916
- - [UTC API](https://github.com/northguild/gmt/tree/main/packages/gmt/src/utc) — UTC instant utilities
2917
- - [Regex API](https://github.com/northguild/gmt/tree/main/packages/gmt/src/regex) — composable regex patterns
2918
-
2919
- ## AI Agent Skills
2920
-
2921
- `@northguild/gmt` ships four lightweight TanStack Intent skill files
2922
- (`skills/gmt-basics/`, `gmt-arithmetic/`, `gmt-timezone/`,
2923
- `gmt-integration/`). Each is a ~30–60 line routing pointer that carries core
2924
- rules and common pitfalls, then directs the agent to **this README** and the
2925
- source JSDoc for full API signatures and code examples.
2926
-
2927
- | Skill | Task area | Load with |
2928
- | --- | --- | --- |
2929
- | `gmt-basics` | Get current values, parse, format, relative time, compare, validate | `intent load @northguild/gmt#gmt-basics` |
2930
- | `gmt-arithmetic` | Add/subtract, diff, durations, interval range math | `intent load @northguild/gmt#gmt-arithmetic` |
2931
- | `gmt-timezone` | Zoned arithmetic, formatting, DST disambiguation | `intent load @northguild/gmt#gmt-timezone` |
2932
- | `gmt-integration` | Cache keys, router/query params, lint package selection | `intent load @northguild/gmt#gmt-integration` |
2933
-
2934
- Contributor skills (issue-creation, pr-contribution, new-method-implementation,
2935
- unit-test-generation, api-expansion-workflow) live under
2936
- `skills/contributor/` and are excluded from the npm tarball via
2937
- `.npmignore` — load them only when contributing to the library itself.
2938
-
2939
- See the [dox site's Skills guide](https://gmt-dox.northguild.workers.dev/guides/integration/skills/)
2940
- for full install and discovery instructions.
2941
-
2942
- ## Agent Prompt
2943
-
2944
- When working with `@northguild/gmt`, follow these rules:
2945
-
2946
- 1. **No `Date` object.** Use `Temporal` exclusively.
2947
- 2. **String-in, string-out.** Public APIs accept ISO 8601 strings; return strings, numbers, booleans, or arrays.
2948
- 3. **Invalid input returns a sentinel, never throws.** `""` for strings, `null` for numbers, `false` for booleans, `[]` for arrays.
2949
- 4. **Wrap all Temporal calls in `try-catch`.** `.from()`, `.add()`, `.since()`, etc. throw `RangeError` on bad input.
2950
- 5. **Keep `plain/` and `zoned/` strictly separate.** Never mix `PlainDateTime` and `ZonedDateTime`.
2951
- 6. **Full locale matrix for any locale-aware function.** 17 locales, explicit rows, `expectOneOfIcu`/`expectDateTimeEqual` (from `src/test/icuVariants.ts`) where CLDR wording differs.
2952
- 7. **Use pre-built mocks for error-path tests.** See `packages/gmt/src/test/mocks`.
2953
- 8. **JSDoc with `@example` on every public function.** Cover valid, invalid, and edge-case inputs.
2954
-
2955
90
  ## License
2956
91
 
2957
- MIT — See [LICENSE](../../LICENSE) for details.
92
+ MIT. See [LICENSE](https://github.com/northguild/gmt/blob/main/LICENSE).