Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
55006d4
feat(intermodal): freeTimeExpiry, chargeableDays and demurrageClock (…
craig-o-curtis Sep 24, 2026
7c4cbf1
docs(intermodal): README section, skill routing and regenerated refer…
craig-o-curtis Sep 24, 2026
36c9a2a
feat(dox): Free Time Ledger tool and chat widget (INT-12, #193)
craig-o-curtis Sep 24, 2026
35fce5c
docs(dox): intermodal guide, scenarios and mistakes (INT-12, #193)
craig-o-curtis Sep 24, 2026
88120e5
docs(domination): INT-12 done, INT-58 unblocked (#193)
craig-o-curtis Sep 24, 2026
b6255bb
Formatter churn
craig-o-curtis Sep 24, 2026
1a2a1fa
perf(dox): time every chat stage and cut time to first token
craig-o-curtis Sep 24, 2026
55b0018
perf(dox): time every chat stage, cut time to first token, fail widge…
craig-o-curtis Sep 24, 2026
5b9d9c3
feat(dox): split the why-gmt API surface into namespace and industry …
craig-o-curtis Sep 24, 2026
deea0d2
feat(intermodal): charge basis, export and combined clocks, from the …
craig-o-curtis Sep 24, 2026
42ad1a4
docs(intermodal): world-standard wording, charge basis and export clo…
craig-o-curtis Sep 24, 2026
f462d44
perf(dox): fail over busy models, stream progress, lazy widgets, seed…
craig-o-curtis Sep 24, 2026
36c0dde
fix(intermodal): minimum-instant free time, US-shape tariff wording (…
craig-o-curtis Sep 24, 2026
6bf9617
docs(dox): US-shape charge-basis wording on the intermodal pages (INT…
craig-o-curtis Sep 24, 2026
2cd5bae
style(dox): apply oxfmt to the chat and widget files
craig-o-curtis Sep 24, 2026
c91b185
fix(intermodal,transport): never throw on a hostile argument (INT-12,…
craig-o-curtis Sep 24, 2026
0a22977
fix(dox): expire the corpus memo written on an unreadable clock
craig-o-curtis Sep 24, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions .changeset/patient-gates-free-time.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
---
"@northguild/gmt": minor
---

Add the `intermodal/` namespace: `freeTimeExpiry`, `chargeableDays` and `demurrageClock` (Story INT-12).

Free time is the money calculation in container logistics, and none of its terms is a fact about the port or fixed by a world standard. How many days are free, whether the day of discharge is free day one, how free days and charged days are counted, and which clock a charge runs on are set by the carrier's tariff and the service contract. DCSA defines what demurrage, detention and 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.

```typescript
import { chargeableDays, demurrageClock, freeTimeExpiry } from "@northguild/gmt";

const tariff = { basis: "calendar", chargeBasis: "calendar", timeZone: "America/New_York", firstDay: "eventDay" };

// A Friday afternoon discharge in New York with three free calendar days.
freeTimeExpiry("2024-06-14T19:00:00Z", 3, tariff);
// { freeTimeStart: "2024-06-14", lastFreeDay: "2024-06-16", expiresAt: "2024-06-17T04:00:00Z" }
freeTimeExpiry("2024-06-14T19:00:00Z", 3, { ...tariff, firstDay: "nextDay" });
// { freeTimeStart: "2024-06-15", lastFreeDay: "2024-06-17", expiresAt: "2024-06-18T04:00:00Z" }
// — the same tariff read the other way: one more day

// Out exactly as free time ends, and one second later.
chargeableDays("2024-06-14T19:00:00Z", "2024-06-17T04:00:00Z", 3, tariff);
// { freeDaysUsed: 3, chargeableDays: 0, expiresAt: "2024-06-17T04:00:00Z",
// chargedDates: [], byTier: [{ from: 1, to: null, days: 0 }] }
chargeableDays("2024-06-14T19:00:00Z", "2024-06-17T04:00:01Z", 3, tariff);
// { freeDaysUsed: 3, chargeableDays: 1, expiresAt: "2024-06-17T04:00:00Z",
// chargedDates: ["2024-06-17"], byTier: [{ from: 1, to: null, days: 1 }] }

// Which two events a charge runs between, per leg.
demurrageClock(
[
{ type: "discharged", at: "2024-06-14T19:00:00Z" },
{ type: "gatedOut", at: "2024-06-20T14:30:00Z" },
{ type: "emptyReturned", at: "2024-06-27T09:00:00Z" },
],
"detention",
{ direction: "import" },
);
// { start: "2024-06-20T14:30:00Z", end: "2024-06-27T09:00:00Z" } — gate-out to empty return
```

- **Days are the terminal's local days.** `clockStart` and `clockEnd` are instants; their local dates in `options.timeZone` are what is counted, over the zone's real day boundaries, the same ones `dwellTime` and `floorToZone` find. A 23- or 25-hour day is one day, a date the zone deleted is never a free or charged day, and a date the clock falls back into counts once. On the calendar basis with `firstDay: "eventDay"`, `freeDaysUsed + chargeableDays` is exactly `dwellTime(...).calendarDays` for the same dwell, except that a fall-back re-entering the day before the event day (Goose Bay, 7 November 2010) is a date `dwellTime` counts and a tariff never does.
- **`firstDay` has no default.** `"eventDay"` makes the event day free day one; `"nextDay"` starts free time the following counted day. The two differ by a full day of charges, so omitting it returns `null`.
- **`basis` counts free days; `chargeBasis` counts charged days.** `"calendar"` counts every local day; `"working"` counts only the working days of `options.calendar`, a `BusinessCalendar`, and returns `null` without one. Outside the US both are mostly calendar days; where free time is in working days (the usual US shape), the days after it are mostly charged as calendar days, and California law and some tariffs charge working days only. Neither term has a default, because each is worth days of charges.
- **Expiry is half-open.** `expiresAt` is the first instant of the local day after `lastFreeDay`, as a UTC instant. A gate-out at exactly `expiresAt` is not a chargeable day; one nanosecond later is.
- **`chargedDates` makes the count auditable.** It is the list a carrier's day-numbered tariff grid is applied to; on US trades the invoice must also print it (46 CFR 541.6, the US invoice rule).
- **Tiers are day bands, not rates.** `tiers: [5, 10]` names days 1–5, 6–10 and 11 onward; `byTier` says how many charged days fell in each band, empty bands included, so a rate table applies by index. GMT computes days, never money.
- **`freeDays: 0`** is a tariff with no free time: `chargeableDays` charges every counted day from day one, and `freeTimeExpiry` returns `null` because there is no last free day to name.
- **`demurrageClock` selects the pair for a leg.** Import demurrage and storage run from `startEvent` (`"discharged"` by default, or `"available"`) to `gatedOut`, detention from `gatedOut` to `emptyReturned`, and the combined clock from `startEvent` to `emptyReturned`. Export demurrage and storage run from `gatedIn` to `loaded`, detention from `emptyReleased` to `gatedIn`, and the combined clock from `emptyReleased` to `loaded`. `direction` has no default. The event names follow DCSA's Track & Trace equipment events. A required event that is missing or present twice, or an end before its start, returns `null`; the result is an `Interval` in the caller's own strings.
- Also exported: the `FreeTime`, `FreeTimeCharges`, `FreeTimeChargeOptions`, `TierBand`, `ClockEvent`, `ClockEventType`, `ClockScope`, `ClockDirection`, `ClockStartEvent` and `ClockOptions` result and argument types, and `FreeTimeOptions`, `FreeTimeBasis` and `FreeTimeFirstDay` under `/types`. Subpaths `@northguild/gmt/intermodal` and `…/intermodal/calculate` join the package exports.
1 change: 1 addition & 0 deletions .changeset/quiet-harbours-dwell.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,4 +28,5 @@ dwellTime("2024-06-15T22:30:00Z", "2024-06-16T01:00:00Z", "Europe/Amsterdam");
- **`etaAtZone` renders a moment in a zone**, so no disambiguation arises: on a fall-back night two arrivals an hour apart print the same wall time with different offsets, and the offset in the result tells them apart. The zone is the caller's fact — GMT does not resolve a port, airport or station code to a timezone.
- **`dwellTime.calendarDays` is the library's one "local days crossed" count.** It is the number of distinct local dates the half-open interval `[entry, exit)` touches: same date is `1`, across one local midnight is `2`, an exit exactly at local midnight does not touch the new day. It is counted across the zone's real transitions, so a 23- or 25-hour local day is one day, a date the zone skipped is not counted, and a date it re-entered counts once. Free time and demurrage, laytime and hospital length of stay will all count from here rather than each deciding what a midnight is.
- **A day count needs a place.** `dwellTime` takes the zone from `targetZone`, or from the entry's bracketed IANA zone. Bare instants (`Z` or an offset) with no `targetZone` return `null`: an offset is not a zone, and a day count in an unstated locality would be a guess.
- **All three never throw.** A non-string, a throwing `toString`, a symbol or a hostile Proxy returns the sentinel (`""` or `null`), as every GMT function does.
- Also exported: the `Dwell` result type. Subpaths `@northguild/gmt/transport`, `…/transport/calculate` and `…/transport/convert` join the package exports.
16 changes: 8 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@ Questions, or want to help? Join us on [Discord](https://discord.gg/TdvQdP3t5a).

- **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.
- **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.
- **~54× more CI test executions than all four competitors combined**: 1,096,980 from 36,566 tests run in all 10 timezones × 3 Node versions, vs. their combined 20,190.
- **~95× more test cases than `@internationalized/date`**: 36,566 vs. 386 — Adobe's own library, run at its own commit.
- **~55× more CI test executions than all four competitors combined**: 1,106,400 from 36,880 tests run in all 10 timezones × 3 Node versions, vs. their combined 20,190.
- **~96× more test cases than `@internationalized/date`**: 36,880 vs. 386 — Adobe's own library, run at its own commit.
- **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.
- **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.
- **Explicit DST disambiguation control on both construction _and_ arithmetic** — a control none of the others expose.
Expand Down Expand Up @@ -98,7 +98,7 @@ If you see a Date API in code, replace it with a GMT helper.
| ----------------------------------- | ----------------------------- | ---------------------------------------------------- |
| [`@northguild/gmt`](./packages/gmt) | `npm install @northguild/gmt` | Give Me Temporal — string-in/string-out date library |

`@northguild/gmt` exports every public function, type and regex as a flat named export of the package root, beside `Temporal` re-exported from `@js-temporal/polyfill`. The same exports are grouped into subpaths — `@northguild/gmt/calendar`, `duration`, `instant`, `interval`, `plain`, `precision`, `regex`, `span`, `transport`, `types`, `unix`, `utc` and `zoned` — and, except for `regex` and `types`, into module subpaths such as `@northguild/gmt/plain/calculate`.
`@northguild/gmt` exports every public function, type and regex as a flat named export of the package root, beside `Temporal` re-exported from `@js-temporal/polyfill`. The same exports are grouped into subpaths — `@northguild/gmt/calendar`, `duration`, `instant`, `intermodal`, `interval`, `plain`, `precision`, `regex`, `span`, `transport`, `types`, `unix`, `utc` and `zoned` — and, except for `regex` and `types`, into module subpaths such as `@northguild/gmt/plain/calculate`.

### How GMT is tested, vs. the libraries it targets

Expand All @@ -114,9 +114,9 @@ GMT is measured directly against react-aria's **`@internationalized/date`**, **L

| Metric | GMT | `@internationalized/date` | Luxon | date-fns | Moment.js |
| ------------------------------- | -------------------------------------------------- | ------------------------------ | ------------------------------------ | ----------------------------------------- | -------------------------------- |
| Test files | 668 | 6 | 58 / 60<br>(2 didn't run<br>locally) | 256 | 191<br>(52 core +<br>139 locale) |
| Individual test cases | **36,566** | 386 | 1,222 | 3,213 | 3,901 |
| Effective CI test<br>executions | **1,096,980**<br>(36,566 × 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) |
| Test files | 673 | 6 | 58 / 60<br>(2 didn't run<br>locally) | 256 | 191<br>(52 core +<br>139 locale) |
| Individual test cases | **36,880** | 386 | 1,222 | 3,213 | 3,901 |
| Effective CI test<br>executions | **1,106,400**<br>(36,880 × 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) |
| 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 |
| 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 |
| Locale test matrix | **17 locales**,<br>every locale fn | none found | none found | none found | none found |
Expand All @@ -141,7 +141,7 @@ GMT's test suite balances **thoroughness** against **maintenance burden** by tes
- **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.
- **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.

**Result:** 36,566 tests across 668 files that exercise real behavior differences without redundant permutations. They run in CI as 1,096,980 executions — every one of them × 3 Node versions × 10 timezones.
**Result:** 36,880 tests across 673 files that exercise real behavior differences without redundant permutations. They run in CI as 1,106,400 executions — every one of them × 3 Node versions × 10 timezones.

### Feature parity

Expand Down Expand Up @@ -170,7 +170,7 @@ Specific, sourced claims — not a repeat of the metrics above.
| 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 |
| 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 |
| 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 |
| GMT's effective CI test<br>executions exceed all four<br>competitors **combined**<br>by ~54× | 1,096,980 vs. 386 + 4,888 + 3,213<br>+ 11,703 = 20,190 |
| GMT's effective CI test<br>executions exceed all four<br>competitors **combined**<br>by ~55× | 1,106,400 vs. 386 + 4,888 + 3,213<br>+ 11,703 = 20,190 |

## Optional: Add Linting for Date API Bans

Expand Down
21 changes: 20 additions & 1 deletion apps/dox/astro.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,22 @@ export default defineConfig({
},
},
plugins: [tailwindcss()],
optimizeDeps: {
// Astro points Vite's startup dependency scan at .jsx/.tsx/.vue/.svelte/
// .html only. Every widget, chart and globe is an .astro <script> into a
// plain .ts module under src/lib, so their packages (@tanstack/charts,
// d3-geo, topojson-client, …) were discovered only when a page first
// imported them. Vite then re-bundled and force-reloaded every open page,
// and any dynamic import in flight — a widget's chunk — failed with
// "Failed to fetch dynamically imported module" (seen 2026-09-24). Scanning
// src/lib finds them at startup instead. Tests and the server-only
// retrieval modules are left out; they never reach a browser.
entries: [
"src/lib/**/*.ts",
"!src/lib/**/*.test.ts",
"!src/lib/retrieval/**",
],
},
build: {
cssTarget: ["chrome107", "edge107", "firefox104", "safari16"],
cssMinify: "esbuild",
Expand Down Expand Up @@ -189,7 +205,9 @@ export default defineConfig({
{
label: "Scenarios",
collapsed: true,
items: [{ autogenerate: { directory: "scenarios", collapsed: true } }],
items: [
{ autogenerate: { directory: "scenarios", collapsed: true } },
],
},
{
label: "Mistakes",
Expand Down Expand Up @@ -220,6 +238,7 @@ export default defineConfig({
"./src/styles/gmt-dst-inspector.css", // DST Transition Inspector widget (DOX-B2b)
"./src/styles/gmt-interval-visualizer.css", // Interval Algebra Visualizer widget (DOX-B2c)
"./src/styles/gmt-dwell-ledger.css", // Dwell Ledger widget (TRAN-8)
"./src/styles/gmt-free-time-ledger.css", // Free Time Ledger widget (INT-12)
"./src/styles/gmt-converter-bench.css", // Converter + format bench + regex tester widget
"./src/styles/gmt-playground-form.css", // form-control playground (POC, chore/136)
"./src/styles/gmt-charts.css", // chart theme variables + container styles
Expand Down
8 changes: 6 additions & 2 deletions apps/dox/scripts/build-reference.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1636,7 +1636,9 @@ interface SymbolEntry {
* - Ordering inside a namespace: multi-symbol module groups first (alpha),
* then hoisted single-symbol items (alpha by symbol name).
*/
export function buildSidebar(moduleSymbols: Map<string, SymbolEntry[]>): string {
export function buildSidebar(
moduleSymbols: Map<string, SymbolEntry[]>,
): string {
const item = (sym: SymbolEntry) =>
sym.unreleased
? `{ slug: "${sym.slug}", badge: ${UNRELEASED_BADGE} }`
Expand Down Expand Up @@ -1747,7 +1749,9 @@ function main() {
still regenerates. */
const baseline = releasedBaseline(repoRoot);
if ("none" in baseline) {
console.log(`[reference] no published gmt tag (${baseline.none}); no Unreleased badges`);
console.log(
`[reference] no published gmt tag (${baseline.none}); no Unreleased badges`,
);
}
const hash = `${hashFiles(referenceInputs())}|${baselineKey(baseline)}`;
// The MDX tree is gitignored, so a fresh checkout (or a manual `rm -rf`) can
Expand Down
Loading
Loading