feat(intermodal) INT-12 freeTimeExpiry chargeableDays demurrageClock #193 - #281
Conversation
…INT-12, #193) Adds the `intermodal/` namespace, the first consumer of `dwellTime`'s local-day count. Free time is contractual, not per-port, so every term is a parameter and none has a default. - `freeTimeExpiry(clockStart, freeDays, { basis, timeZone, firstDay, calendar? })` lays free time out in the terminal's local days and returns the half-open expiry: the first instant of the day after the last free day. `firstDay` (`eventDay` | `nextDay`) has no default because the two differ by a day of charges; the working basis needs a `BusinessCalendar` and returns `null` without one. - `chargeableDays(clockStart, clockEnd, freeDays, options)` counts the days on or after expiry that the half-open dwell touched, lists them as `chargedDates` (46 CFR 541.6) and splits them into `tiers` bands. A gate-out exactly at `expiresAt` is free; one nanosecond later is a charged day. `freeDays: 0` is a tariff with no free time. - `demurrageClock(events, scope, { startEvent? })` selects discharge (or availability) to gate-out for demurrage and storage, and gate-out to empty return for detention. A required event missing or present twice returns `null`; the result is an `Interval` in the caller's own strings. - One walker, `internal/freeTimeLedger.ts`, serves both bases over the zone's real local-day buckets from `zonedBucket.ts`. A deleted date (Apia) is never a free or charged day, a re-entered date (Goose Bay) is folded into its day, and the walk gives up after 10,000 days. On the calendar basis `freeDaysUsed + chargeableDays` equals `dwellTime(...).calendarDays`, and the tests assert it across the battle-test zones. Subpaths `@northguild/gmt/intermodal` and `intermodal/calculate` join the exports, with the `FreeTime`, `FreeTimeCharges`, `TierBand`, `ClockEvent` and `FreeTimeOptions` types. Minor changeset.
…ence (INT-12, #193) - `packages/gmt/README.md` gains the fourteenth subpath and a Free time and demurrage section whose every result was run against dist; the root README lists `intermodal` among the subpaths. - The `gmt-timezone` skill routes free-time questions: description clause, the intermodal source, a new core rule and the API index line. `domain_map.yaml` and `skill_tree.yaml` carry the three functions and their tasks. - The dox module registry loads `intermodal/calculate`; the reference corpus, route manifest and published test stats are regenerated.
- New `/tools/free-time-ledger/` page and `showFreeTimeLedger` chat tool with a starter pill. It draws a container's clock on the terminal's real local-day grid, reusing the Dwell Ledger's grid helpers, and colours each day as the tariff reads it: uncounted event day, free, closed, chargeable with tier boundaries and the expiry line. - Controls for `firstDay`, `basis`, free days and tiers, plus a weekend picker and holiday list on the working basis. The outputs are the real `freeTimeExpiry` and `chargeableDays` calls; every preset is a JSDoc example and the mount test asserts the printed literal and that the coloured cells equal `freeDaysUsed` and `chargedDates`. - A zoneless wall time from the chat is read in the named zone with `disambiguation: reject`. The permalink carries every list and number as a string, because `seedFromLocation` passes only strings. - Registered in the widget registry, permalink kinds, Worker tools, the html-diff and visual gates, forced-colours CSS, and `context/dox/built.md` (six tools).
- Guide: the three clocks, which day is day one, calendar or working days, the half-open expiry, charged dates and tiers, and what 46 CFR 541.6 makes an invoice print. Ported from the README section. - Scenarios: free time start day (`freeTimeExpiry`), demurrage across a weekend (`chargeableDays`), detention is not demurrage (`demurrageClock`), each ending with what to edit in the widget. - Mistakes: a defaulted `firstDay`, a per-port free-time table, counting in UTC, charging the gate-out at `expiresAt`, working days without a calendar, detention from discharge, hours divided by 24, rates inside the day count. - Index entries in the industries, guides and mistakes indexes. Every result shown was run against dist.
- The INT-12 spec lists its docs-site pages and the Free Time Ledger, renames `expiredAt` to `expiresAt`, and records the decisions of record: `freeDays: 0` handling, one walker for both bases, half-open used and charged days, every tier band listed, and type names that avoid a case-only clash. - Tracker row 10 is Done; `pnpm deps:sync` clears INT-58's blocker.
The Dox chat was slow and gave no clue where the wait went. - Each answer now records how long each stage took: ledger read, corpus fetch, search, prompt, each brain tried, first token and total. It also records the widget tool called, if any. This shows in the retrieval trace and as a `dox-timing` line in the Worker log. - Gemini brains run with `thinkingLevel: low`, set per brain. - `maxRetries` is 0. A spent brain now fails over at once, without the SDK's 2 s backoff first. - The MiniSearch index is built once per corpus array, not on every request. The parsed corpus is kept in memory for the cache TTL, so a warm request no longer re-parses 750 KB of JSON. - A widget whose library fails to load now throws `WidgetLoadError`. It no longer mounts controls that look live and do nothing. - The chat's clocks import gmt by module, not from the root barrel, which re-exports the whole Temporal polyfill.
…ts loudly The Dox chat was slow and gave no clue where the wait went. Widgets whose library failed to load showed controls that did nothing. Chat speed: - Each answer now records how long each stage took: ledger read, corpus fetch, search, prompt, each brain tried, first token and total. It also records the widget tool called, if any. This shows in the retrieval trace and as a `dox-timing` line in the Worker log. - Gemini brains run with `thinkingLevel: low`, set per brain. - `maxRetries` is 0. A spent brain now fails over at once, without the SDK's 2 s backoff first. - The MiniSearch index is built once per corpus array, not on every request. The parsed corpus is kept in memory for the cache TTL, so a warm request no longer re-parses 750 KB of JSON. - The chat's clocks import gmt by module, not from the root barrel. Widget loading: - A mount whose library fails to load throws `WidgetLoadError` instead of returning an inert handle. - Every page shell catches it and calls `showUnavailable`. The widget is dimmed and disabled, with a notice that offers a reload. - In the chat rail, a widget shows as busy until it is wired. A load failure offers Try again. A bad argument does not. context/dox/built.md records all of the above. It also records that the widget registry still bundles every mount and the Temporal polyfill into the chat chunk.
…tabs stats.mjs derives the industry layers from guides/industries/ (one guide per layer, named for its namespace) and writes them to gmt-stats.json. The API surface chart toggles between the core namespaces and the industry layers. Each tab carries its own caption and follow-on paragraph through a new after-<view> slot on ChartContainer, which the text surfaces unwrap.
…tariffs (INT-12, #193) Research against the regulations and published tariffs (record in context/domination/research/int-12-demurrage-conventions.md) showed that no world standard counts demurrage days, and that the first draft modelled two things wrongly. - `chargeableDays` takes a required `chargeBasis`. Tariffs mostly count free time in working days and then charge every calendar day (CMA CGM US, Hapag-Lloyd US, ACL); California law charges working days only. The first draft let `basis: working` also suspend charging, which under-bills the common shape. - `demurrageClock` takes a required `direction` and covers the export leg (`emptyReleased`, `gatedIn`, `loaded`) and a `combined` scope, the merged clock Maersk, CMA CGM and MSC sell. Event names map to DCSA Track & Trace equipment events. - Review fixes: the walker only advances to later dates (Goose Bay first pass of 7 November no longer frees or charges the 6th), `tiers` and `freeDays` reject holes and unsafe integers, an event list with a hole is rejected, and probe rows for Manila, Lord Howe, Casey and the range limit are pinned. - JSDoc labels 46 CFR 541.6 as the US invoice rule, not a world standard.
…cks on the site (INT-12, #193) - Guide, scenarios, mistakes page, README and skill show `chargeBasis` and the export and combined clocks, every result checked against dist. A new mistake covers assuming working-day free time means working-day charges. - The US invoice rule (46 CFR 541.6) is labelled as US-only everywhere; the guide says no world regulation exists and notes the US and Indian wording differences. - Free Time Ledger: a charged-days basis control, presets for the common working-then-calendar shape and the California shape, permalinks and the `showFreeTimeLedger` chat schema carry `chargeBasis`. - Spec, painpoints and a cited research record carry the decisions and their jurisdiction.
…ed starters A single busy model failed the whole Dox answer, the reader saw nothing while Dox walked its brains, and in dev a late dependency re-bundle broke widget imports mid-load. Worker: - A 503 or 529 from a model now moves to the next brain without marking it in the ledger. If every brain was out or busy, the reader gets a retryable refusal, not `allowance used`. A 500 still stops at once. - The stream opens before a brain is chosen. Each step goes out as a transient `data-status` part, and a refusal as a transient `data-refusal` part carrying the old HTTP status and payload. Chat: - The waiting card shows the Worker's progress. A refusal goes through the same `classifyChatError` and leaves no empty turn. - Starter pills open their widget on click, with seeded arguments that are checked against each tool's schema. The model's own call replaces the widget unless its tool and arguments match. - The widget registry loads template and mount together through one `import()`. It imports only types from the mount modules, so no mount is in the first download. `widget-graph.test.ts` guards this. - The panel shows a loading placeholder until the widget is wired. - Chat helpers import gmt by module path, not the root barrel. Dev: - Vite scans `src/lib` at startup, so widget and chart packages are found before any page imports them. No mid-load re-bundle or reload. context/dox/built.md records all of the above, with the measurements.
…INT-12, #193) Fixes from the second gmt-reviewer pass. - `freeTimeExpiry` and `chargeableDays` no longer return `null` for a clock start at the minimum instant west of UTC. The event day began before Temporal's first instant, so `localDayOf` now anchors that day at the clock start. `null` is returned only when that unrepresentable start would itself be the expiry (`freeDays: 0`). Pinned with Etc/GMT+12, America/New_York and Etc/GMT-14 rows. - Working-day free time with calendar-day charges is now called the usual US shape, not a universal one. Outside the US both are mostly calendar days. 46 CFR 541.6 is labelled the US invoice rule. - `no counting term has a default` replaces `none has a default`. The DCSA glossary is now the storage citation. The `freeDays: 0` wording is corrected, and the export clocks name the tariffs that quote them. - Shorten the gmt-timezone skill description to Intent's 1,024-character limit, and name the combined clock and both legs in the skill tree. - Sync published test counts.
…-12, #193) Apply the second review's wording fixes to the site. - The guide, mistakes page, weekend scenario, Free Time Ledger page and preset, and the chat tool hint now say that working-day free time with calendar-day charges is the usual US shape. Outside the US both are mostly calendar days, and California charges working days for free time and charges alike. - Drop `the dates an invoice has to print` as a universal claim. Only a US invoice must print them. - Say which tariffs quote the export clocks, and that export storage follows from the DCSA definition of storage. - Reflow the Free Time Ledger mount and its test.
Line wrapping only, from the repo formatter. No code changes: each file matches its previous version once whitespace and trailing commas are ignored.
ReviewI ran One blocking finding, and it is not in the walker. Blocking: all three new functions throw on a hostile options bagCore Rule 3 says invalid input returns the sentinel and never throws. These three throw: These are the exact garbage values in The cause is that the reads happen before the
I prototyped the fix locally and it clears every row above: wrap the destructure in One more of the same class that the harness does not reach: Why CI did not catch this
Adding Minor: the corpus memo can pin itself for the life of the isolate
Checked and fine
Everything but the blocking section is take-it-or-leave-it. |
…193) Review of PR #281: all three intermodal functions and `transitTime` threw on values from the no-throw harness, breaking Core Rule 3. - `freeTimeExpiry`, `chargeableDays` and `demurrageClock` read their arguments only inside a never-throw guard. A Proxy that throws on any access, a revoked Proxy, or a throwing `tiers`, `chargeBasis`, `direction` or event `at` getter now returns `null`. - `transitTime` refuses a non-string before its regex checks coerce it, so a throwing `toString`, `Object.create(null)`, a symbol and both Proxies return an empty string. `etaAtZone` and `dwellTime` now run their checks inside their guard too. The pending TRAN-8 changeset says all three never throw. - The no-throw harness derives its namespaces from the corpus instead of a hand-typed list, which had missed `intermodal` and `transport`. A guard test fails if any namespace has no cases. - Hostile-input rows in each intermodal test file and the transport annotation matrix.
Review of PR #281: when the clock returned its null sentinel at write time, the memo stored an expiry of `Infinity` and kept that corpus for the life of the isolate. It now stores an already-expired entry, so the next request re-reads once the clock recovers. Also note in context/dox/built.md that a pool spent or every brain busy during an answer is now HTTP 200 with the refusal in the stream.
|
@baldurpan thanks for the differential probe. The fixes are in:
|
Title:
feat(intermodal): free time, demurrage and detention (INT-12, #193)Summary
Adds the
intermodal/namespace:freeTimeExpiry,chargeableDaysanddemurrageClock. They lay out a container's free time and count the charged days with their dates. They also pick which two events a demurrage, detention, storage or combined clock runs between, on the import or export leg. No world standard fixes how these days are counted, so every counting term is a parameter with no default, because each is worth days of charges. The story ships a guide, scenarios, a mistakes page and a teaching widget, the Free Time Ledger. The branch also carries separate Dox chat speed, reliability and formatting work, described in its own section.What Changed
1. The intermodal namespace (packages/gmt)
Three functions, one shared day walker, and their types.
freeTimeExpiry(clockStart, freeDays, { basis, timeZone, firstDay, calendar? })returns the first and last free day as local dates, andexpiresAt, the instant free time ends.firstDay(eventDayornextDay) has no default, because the two conventions differ by a full day of charges.chargeableDays(clockStart, clockEnd, freeDays, options)returns the free days used, the chargeable days, the charged dates and the days in eachtiersband.chargeBasiscounts charged days separately frombasis, which counts free days. Outside the US both are mostly calendar days. The usual US shape is working-day free time followed by calendar-day charges, and California law charges working days only. Expiry is half-open: a gate-out exactly atexpiresAtis free, and one nanosecond later is a charged day.demurrageClock(events, scope, { direction, startEvent? })selects the events fordemurrage,detention,storageorcombined, per leg:internal/freeTimeLedger.tswalks the zone's real local days, using the same boundaries asfloorToZone:FreeTimeOptions,FreeTimeBasisandFreeTimeFirstDayintypes/, with result and argument types beside each function.intermodalandintermodal/calculatejoin thepackage.jsonexports.2. Tests
basis × firstDayandbasis × chargeBasispair, and both legs and all four scopes of the clock.dwellTime.nullfor an option.3. Docs site (apps/dox)
guides/industries/intermodal-free-time-and-demurrage.mdx.free-time-start-day,demurrage-across-a-weekendanddetention-is-not-demurrage.mistakes/intermodal.mdx, including assuming that working-day free time means working-day charges./tools/free-time-ledger/). It draws a container's clock on the terminal's local-day grid and colours each day as the tariff reads it. Its controls set the start day, the free-day basis and the charged-day basis. It is also theshowFreeTimeLedgerchat tool, and it reuses the Dwell Ledger's grid helpers.free-time-ledger-mount.ts) landed in55b0018(section 6), not in an INT-12 commit.4. World standards, and what is US-only
context/domination/research/int-12-demurrage-conventions.mdrecords every source, tagged by jurisdiction:availableevent and the invoice-field wording rest on US law alone.painpoints.md, both READMEs, thegmt-timezoneskill and its artifacts, and the changeset. The tracker marks INT-12 Done, which unblocks INT-58.5. Review fixes
tiers,freeDaysand the event list now reject holes and unsafe integers. A holed tier array used to produceNaNbands.null. It now gets the right answer.6. Other work on this branch (not INT-12)
1a2a1fa,55b0018: the Dox chat times every stage, fails over between brains without backoff, and builds its search index once per corpus. A widget whose library fails to load now shows as unavailable, instead of mounting controls that do nothing.f462d44: a busy model (503 or 529) fails over without being marked as spent. The stream opens early and shows progress. Starter pills open a seeded widget. Widget mounts are lazy, so none is in the first download. Vite pre-scanssrc/libso dev reloads do not break widget imports.5b9d9c3: the why-gmt API surface chart splits into core-namespace and industry-layer tabs, derived bystats.mjs.b6255bb,2cd5bae: formatter line wrapping only. No code changes: each file matches its previous version once whitespace is ignored.Why
Validation
pnpm run validateexits 0: deps, test markers, build, stats, temporal-compat, upstream, api-surface, lint, typecheck and every test.pnpm exec intent validate packages/gmt/skills: all 9 skill files pass.pnpm changeset:status:@northguild/gmtminor.astro buildemits every new page.NaNbands.chargeBasis,directionand the export clocks were added. One blocking finding, fixed and pinned:nullat the minimum instant west of UTC. The should-fix items are fixed too: the US-shape wording, the US invoice rule label, the DCSA storage citation, and the zero-free-day wording.html-diffandvisual:difffor/tools/free-time-ledger/. They need amainbaseline.Risk / Impact
Medium. The three functions are new API, so no shipped behaviour changes. The day walker is the part with edge cases, and the probe-zone, range-limit and
dwellTimeagreement tests guard it. The Dox chat and widget-loading changes in section 6 touch every widget page and the chat Worker, and should be reviewed on their own.Release
.changeset/patient-gates-free-time.md:minor, for the newintermodal/namespace..changeset/quiet-harbours-dwell.md(TRAN-8) is still pending from the previous PR.Checklist
Closes #193