Skip to content

feat(transport) TRAN-8 transitTime etaAtZone dwellTime #189 #192 - #279

Merged
craig-o-curtis merged 10 commits into
mainfrom
feature/189-tran-8-transittime-etaatzone-dwelltime
Sep 24, 2026
Merged

craig-o-curtis merged 10 commits into
mainfrom
feature/189-tran-8-transittime-etaatzone-dwelltime

Conversation

@craig-o-curtis

@craig-o-curtis craig-o-curtis commented Sep 24, 2026 •

Copy link
Copy Markdown
Contributor

TRAN-8: transitTime, etaAtZone, dwellTime, and the realm gap spike

Closes #189.

Two commits:

  1. docs(domination) — a research pass over every remaining realm story, plus the tracker put
    into true build order.
  2. feat(transport) — the transport/ namespace and its pages on the docs site.

The transport/ namespace

Every transport mode shares three operations, and every mode gets the same two things wrong. A
leg that crosses a DST change takes a fixed number of elapsed hours. Dwell is charged in
local calendar days, not hours.

  • transitTime(departure, duration) adds a leg as exact elapsed time, in the departure's
    own zone. P1D is 24 hours. Years, months and weeks return "", because they need a
    reference point. Z stays Z and an offset stays an offset. A zone whose offset contradicts
    it, such as -05:00[America/New_York] in June, is rejected, not reinterpreted.
  • etaAtZone(instant, zone) shows an arrival in the zone where it lands. No
    disambiguation is needed: a moment has one wall time in a zone. On the night clocks go back,
    the offset in the result tells the two 01:30 arrivals apart. The caller supplies the zone.
    GMT does not look up port, airport or station codes.
  • dwellTime(entry, exit, targetZone?) returns { duration, enter, exit, calendarDays }.
    calendarDays counts the local dates that [entry, exit) touches, using the zone's real day
    boundaries from floorToZone. So a 23 or 25 hour day counts as one day, and an exit exactly
    at midnight does not touch the next day. Two bare instants with no zone return null,
    because a day count needs a place. INT-12, MAR-19 and HLTH-69 will all count days with this
    function.

The tests cover the battleTestTimeZones matrix, DST transition rows in the probe zones, exits
exactly at midnight, invalid inputs, and Temporal throwing. The package now exports
@northguild/gmt/transport, …/transport/calculate and …/transport/convert, plus the Dwell
result type. This PR also includes a minor changeset, a Transport section in both READMEs, and
updated gmt-timezone skill coverage, with every skill moved to 1.17.0.

Docs site

  • A new Industries guide group, with Transport: Legs and Dwell ported from the README
    section.
  • Three scenarios, each showing the naive approach failing beside a live widget:
    • a container that dwells two hours but is charged two days;
    • a 26-hour rail leg across the spring-forward night;
    • two arrivals that show the same wall time on the fall-back night.
  • A transport mistakes page with seven entries.
  • Every result on these pages was run against the built package.
  • Generator guard. The result type was first named DwellTime. Its reference page and the
    dwellTime page differ only in letter case, so on macOS they are the same file: one page
    overwrote the other, and the sidebar linked to a page that did not exist. The type is now
    Dwell. build-reference.ts refuses such a pair before writing anything, and
    reference-corpus.test.ts checks the route list.

Realm gap spike

Before building the remaining realm stories on Core, we checked every spec against its primary
source. The record, with citations, is context/domination/research/realm-gap-spike-2026-09.md.
Each realm's verification notes are under research/spike-2026-09/.

  • 22 new stories. Each has a spec, a tracker row, evidence in painpoints.md and a GitHub
    issue (CORE-54: Holiday rule engine: nth weekday, Easter, observance shifts #256 to SPA-75: Solar events: sunrise, sunset, twilight, solar position #277):
    • holiday rules (CORE-54), operating hours (CORE-55), and identifiers and data-platform
      timestamps (CORE-56);
    • schedule deviation (TRAN-57) and FMC billing timelines (INT-58);
    • NMEA and seafarer rest hours (MAR-59, MAR-60);
    • FMCSA exceptions and Canadian hours of service (ROAD-61, ROAD-62);
    • FRA hours (RAI-63);
    • AIRAC cycles and cumulative crew limits (AV-64, AV-65);
    • ordering under clock uncertainty (IOT-66);
    • four clinical calculations (HLTH-67 to HLTH-70);
    • FpML schedules, RFR conventions and financial message timestamps (FIN-71 to FIN-73);
    • TDB/TCB and solar events (SPA-74, SPA-75).
  • 33 existing specs refined. For example:
    • FIN-42 adopts the FpML day-count codes.
    • ROAD-20 starts the duty day in home-terminal time, per 49 CFR 395.2.
    • ROAD-21 uses the fixed Monday-to-Sunday week of Regulation 561/2006, Art 4(i).
    • MAR-19 restates every laytime term against the numbered BIMCO 2013 definitions.
    • MAR-16 becomes GNSS time scales.
  • The port-dwell draft is folded into TRAN-8 and TRAN-9, with a Corrections note. Free time
    is set by contract, not by port. Its old issue, INT-11: containerLeg + terminalDwell #192, can be closed.
  • Closes INT-11: containerLeg + terminalDwell #192 (absorbed into TRAN-8 and TRAN-9; see the Corrections note in TRAN-8)
  • SPA-47 and SPA-49 no longer depend on each other. TT and TCG stay in SPA-47, and TDB and
    TCB move to SPA-74.
  • Every realm story now ships its docs-site pages in the same PR. The Definition of Done
    says a story that adds public functions also ships a guide, scenarios and a mistakes page,
    with every result checked against the built package. The rules are in
    context/domination/docs-site.md. TRAN-8 is the first story under this rule.
  • The tracker is in build order. Done stories sit at the top in the order they shipped, so
    the first row not marked Done is the next story. That is INT-12, then INT-58. Each Core
    primitive sits just before the first story that uses it: CORE-55 before Maritime and
    Aviation, CORE-54 before Aviation, and CORE-56 before IoT, since no story needs it.
  • scripts/deps.mjs takes build order from the tracker row, not the ID number. That keeps
    primitives added later showing as blockers.

Checks

  • pnpm run validate passes
  • node scripts/deps.mjs check: 75 Domination rows and 15 Dox rows OK
  • pnpm exec intent validate packages/gmt/skills: 9 of 9 skills pass

Checked every remaining realm spec against its primary source before
building on Core. Record with citations: research/realm-gap-spike-2026-09.md,
per-realm verification under research/spike-2026-09/.

- 22 new stories with specs, tracker rows, painpoint evidence and GitHub
  issues #256..#277: CORE-54..56, TRAN-57, INT-58, MAR-59..60, ROAD-61..62,
  RAI-63, AV-64..65, IOT-66, HLTH-67..70, FIN-71..73, SPA-74..75.
- 33 existing specs refined, e.g. FIN-42 adopts the FpML day-count codes,
  ROAD-20 anchors the duty day on home-terminal time (49 CFR 395.2), ROAD-21
  uses the fixed week of Reg 561/2006 Art 4(i), MAR-19 restates every term
  against BIMCO 2013, MAR-16 becomes GNSS time scales.
- The port-dwell draft is absorbed into TRAN-8 and TRAN-9 with a
  Corrections note: free time is contractual, not per-port.
- SPA-47 and SPA-49 no longer depend on each other: TDB and TCB move to
  SPA-74.
- Definition of Done: a story that adds public functions ships its
  docs-site guide, scenarios and mistakes page in the same PR
  (docs-site.md). TRAN-8 scope lists its pages; its June example is
  corrected to -04:00; the tracker marks it done.
- `scripts/deps.mjs` orders shared primitives by tracker row, not ID
  number, so stories inserted earlier in build order render as blockers.
Adds the `transport/` namespace, the first Phase 2 story of the Realms epic.

- `transitTime(departure, duration)` adds a leg as exact elapsed time in the
  departure's own zone. A `D` component is 24 hours; years, months and weeks
  return the sentinel. `Z` stays `Z`, an offset stays an offset, and a
  bracketed zone that contradicts its offset is rejected, not reinterpreted.
- `etaAtZone(instant, zone)` renders an arrival in the zone where it lands.
  No disambiguation arises; the offset in the result tells the two passes of
  a fall-back hour apart.
- `dwellTime(entry, exit, targetZone?)` returns the exact duration,
  zone-local entry and exit, and `calendarDays`: the distinct local dates
  the half-open interval touches, from `floorToZone`. Bare instants without
  a zone return `null`. This is the one local-days-crossed primitive INT-12,
  MAR-19 and HLTH-69 will consume.

Subpaths `@northguild/gmt/transport`, `transport/calculate` and
`transport/convert` join the package exports, with the `Dwell` result type.
Minor changeset; both READMEs gain the transport subpath and a Transport legs
and dwell section; `gmt-timezone` skill covers the three functions and every
skill moves to 1.17.0; dox module registry, reference corpus and published
test stats regenerated.

Docs site: an industries guide group with the transport guide, three
scenarios (container dwell counted in days, a 26-hour rail leg across
spring-forward, two arrivals on the fall-back night) and a transport
mistakes page, every result checked against dist. The reference generator
refuses two exports whose page paths differ only by case, which on macOS
overwrote one page with the other; a manifest test guards it.
@craig-o-curtis craig-o-curtis added this to the Domination milestone Sep 24, 2026
@craig-o-curtis craig-o-curtis added enhancement New feature or request gmt labels Sep 24, 2026
- New `/tools/dwell-ledger/` page and `showDwellLedger` chat tool with
  a starter pill. It draws a dwell on a zone's real local-day grid and
  shows the real `dwellTime` result, with an optional compare zone.
- Playground: `object | null` returns get their own kind, so an
  invalid-input `null` shows NO SIGNAL instead of a live value.
  Interval functions whose `null` means no overlap show the empty state.
- Gate scripts report a page with no baseline instead of crashing.
- Docs-site rule, TRAN-8 spec and Dox notes cover the widget; the
  changeset spells out the elided Amsterdam result.
…TRAN-8, #189)

- Reference pages whose export is not in the newest `@northguild/gmt`
  tag get an Unreleased caution note and a sidebar badge. With no tags,
  nothing is badged.
- `deploy-dox.yml` runs after the Release workflow, so a publishing
  push has its tag before the site builds and shows no badge.
- The tracker gains an owner-maintained `Release` column, plain issue
  numbers and wrapped deliverables. PUBLISHING.md, the finalizer and
  the docs-site rules agree on it.
- Restores the TRAN-8 changeset (minor); the package stays at 1.16.0.
- `dwellTime.calendarDays` counts distinct local dates across the
  zone's real transitions. It returned 3 for Apia's deleted day, 0 or 1
  across Goose Bay's midnight fall-back, and 4 across Manila 1844.
- `transitTime` and `dwellTime` parse through `zonedDateTimeFrom`, so
  zoned values at the range maximum no longer fail.
- `transitTime` accepts elective and critical `u-ca` and unknown key
  annotations as RFC 9557 does; a new matrix keeps all three consistent.
- `transitTime` keeps a sub-minute offset such as `+05:30:15` as written.
- JSDoc cites Temporal for days as 24 hours and documents bracket
  reading and fixed-offset zones; docs, skill and changeset no longer
  say the count comes from `floorToZone`. Tracker lists plain issue
  numbers and wraps long deliverables.
@craig-o-curtis craig-o-curtis changed the title feat(transport) TRAN-8 transittime etaatzone dwelltime 189 feat(transport) TRAN-8 transitTime etaAtZone dwellTime 189 Sep 24, 2026
Every top-level group and every autogenerated subgroup starts collapsed.
Starlight still opens any group that contains the current page, so a
link lands with only its own path expanded.
@craig-o-curtis
craig-o-curtis enabled auto-merge (squash) September 24, 2026 08:58

@baldurpan baldurpan left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed against context/code-review-checklist.md and context/coding-standards.md, in a clean worktree at 7753381. One defect, then suggestions. The library work here is unusually careful — the elapsed-vs-wall-clock and half-open day-count decisions are both right, documented, and tested at the range limits.

Verified, not just read

  • vitest packages/gmt/src/transport — 211 pass. tsc -p tsconfig.build.json clean. node scripts/test-markers.mjs check clean.
  • All 30 JSDoc @example results, and 55 documented results across packages/gmt/README.md, the changeset, the industries guide, the three scenarios and the mistakes page, reproduce exactly against this branch. The "every result was run against the built package" claim holds.
  • Dox: dwell-ledger, dwell-ledger-mount, released-exports and build-reference tests pass (48 + 51) with the workspace package built from this branch.
  • The crux is right: round({ largestUnit: "hours" }) before ZonedDateTime.add is what makes P1D 24 exact hours rather than calendar-day arithmetic, and it is covered in both DST directions.
  • calendarDays agrees with coding-standards § 8. Probed independently on Apia (deleted date), Goose Bay (re-entered date), Havana, Santiago, Lord Howe and Chatham, and the widget's own day grid agrees with dwellTime on all of them.
  • Sentinels per the table, try-catch on every Temporal call, bounded walk returning the sentinel, minor changeset, skills pre-bumped to 1.17.0 (matching the precedent set in 7846ecf for 1.16.0), and "thirteen namespace subpaths" is the right count.

Defect: transitTime drops the departure's offset for a comma fraction

packages/gmt/src/transport/calculate/transitTime.ts:7

const trailingOffset = /(Z|[+-]\d{2}:\d{2}(?::\d{2}(?:\.\d{1,9})?)?)(?:\[[^\]]*\])*$/;

GMT's canonical instant grammar accepts either decimal separator — internal/isoStringBody.ts:34, where EXTENDED_UTC_OFFSET ends (?::[0-5]\d(?:[.,]\d{1,9})?)?. This regex allows only ., so the comma spelling misses and the ?? "Z" default takes over:

transitTime("2024-06-15T10:00:00+05:30:00,5", "PT1H"); // "2024-06-15T05:29:59.5Z"   <- offset dropped
transitTime("2024-06-15T10:00:00+05:30:00.5", "PT1H"); // "2024-06-15T11:00:00+05:30:00.5"

The instant is correct either way, so no moment is computed wrongly. But isValidInstant accepts both spellings, so both are inside the documented contract, and the JSDoc promises that an offset-only instant "keeps its offset, written exactly as the departure wrote it". Two equivalent departures returning two different forms breaks that. The offset-preserving it.each table has the +05:30:00.5 row but not the ,5 twin.

Suggested fix: accept [.,], or better, build the capture from the canonical EXTENDED_UTC_OFFSET instead of restating the grammar, and add the comma row beside the existing one.

Suggestions, none blocking

  1. The range-limit idiom is now duplicated. The offset branch (transitTime.ts:104-110) is the same three-call chain, with the same rationale, as the no-zone branch of instant/convert/fromOffsetInstant.ts. Have you considered one internal/ helper? The reasoning about PlainDateTime reaching a day past the instant range would then live in one place — and the defect above would have been impossible by construction.
  2. A third zone-annotation regex. transitTime.ts:14 has /\[!?[^\]=]+\]/, toOffsetInstant.ts has /^\[!?[^\]=]+\]/, and apps/dox/src/lib/dwell-ledger.ts:118 has /\[!?(?!u-ca=)([^\]]+)\]/. They agree on everything tested today, but three spellings of one RFC 9557 rule will drift.
  3. scripts/deps.mjs order trap. buildOrder is module-level state filled as a side effect of dominationTracker(), and orderOf silently degrades to 100000 + idNumber when a caller reads it first. All four commands are correctly ordered today, and the comments say why, but nothing enforces it. Passing the map, or memoising dominationTracker() inside declaredDeps(), would make it structural.
  4. The namespace lists still omit transport/. context/coding-standards.md:186 and context/code-review-checklist.md:18 enumerate the namespaces. Since this is the first realm namespace, the next reviewer applying the checklist literally would flag transport/ as not matching the existing structure.
  5. Scope. The deploy-dox.yml trigger change (push to workflow_run on Release) checks out sound — name: Release, it runs on every push to main, and fetch-depth: 0 brings the tags released-exports.ts needs. But a delivery-pipeline change whose failure mode is "the docs site quietly stops deploying" is doing a lot of work inside a feature PR, and might be easier to reason about on its own.

Happy to approve as soon as the comma-offset row is in. Requesting changes only because of Core Rule 12, not because anything about the design worries me.

Comment thread packages/gmt/src/transport/calculate/transitTime.ts Outdated
@craig-o-curtis

Copy link
Copy Markdown
Contributor Author

Thanks @baldurpan. Everything is addressed; the fixes are in the next push on this branch.

  • Defect (comma fraction): fixed. transitTime now keeps +05:30:00,5 and +05:30:15,25 exactly as written. See the inline reply.
  • 1. Duplicated range-limit idiom: now one helper, internal/wallClockAtOffset, used by transitTime and fromOffsetInstant. Each keeps its own offset text: fromOffsetInstant normalises it, transitTime keeps the input's. The helper has its own tests at both range limits.
  • 2. Zone-annotation regex: TIME_ZONE_ANNOTATION is exported from internal/isoStringBody, citing RFC 9557 §3.3 and §4.1. transitTime, zonedWallClockDifference and toOffsetInstant (anchored) all derive from it. The Dox widget's older spelling is gone; gridZone now relies on ZonedDateTime.from, with rows for [foo=bar] and [!u-ca=gregory]. A fifth spelling in internal/resolveDurationRelativeTo.ts checks only the first bracket; changing it could change behaviour, so it gets its own failing test first, separately.
  • 3. deps.mjs order trap: orderOf now fills the build order on first use, so no caller can read it early.
  • 4. Namespace lists: added transport/, and interval/, which was also missing, to both lists.
  • 5. Scope: the deploy change is split out into ci(dox): deploy after the Release workflow, not beside it #280, and this branch no longer touches deploy-dox.yml.

- `transitTime` keeps a comma-fraction offset (`+05:30:00,5`) as
  written; the capture is built from the canonical `EXTENDED_UTC_OFFSET`.
- New `internal/wallClockAtOffset`, shared by `transitTime` and
  `fromOffsetInstant`, holds the range-limit wall-clock shift once.
- One `TIME_ZONE_ANNOTATION` rule (RFC 9557) feeds `transitTime`,
  `zonedWallClockDifference` and `toOffsetInstant`; the Dox widget's
  stale copy is removed.
- `deps.mjs`: `orderOf` fills the build order on first use.
- Namespace lists gain `interval/` and `transport/`.
- The deploy workflow change moves to #280; this branch restores
  `deploy-dox.yml` to main and points its comments there.
…/189-tran-8-transittime-etaatzone-dwelltime
@craig-o-curtis craig-o-curtis changed the title feat(transport) TRAN-8 transitTime etaAtZone dwellTime 189 feat(transport) TRAN-8 transitTime etaAtZone dwellTime #189 Sep 24, 2026
@craig-o-curtis craig-o-curtis changed the title feat(transport) TRAN-8 transitTime etaAtZone dwellTime #189 feat(transport) TRAN-8 transitTime etaAtZone dwellTime #189 #192 Sep 24, 2026

@baldurpan baldurpan left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🏳️‍⚧️

@craig-o-curtis
craig-o-curtis merged commit df33b58 into main Sep 24, 2026
38 checks passed
@craig-o-curtis
craig-o-curtis deleted the feature/189-tran-8-transittime-etaatzone-dwelltime branch September 24, 2026 09:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request gmt

Projects

None yet

Development

Successfully merging this pull request may close these issues.

INT-11: containerLeg + terminalDwell TRAN-8: transitTime + etaAtZone + dwellTime

2 participants