Skip to content

feature(intermodal) INT-25 billingTimeLine #260 - #282

Merged
craig-o-curtis merged 5 commits into
mainfrom
260-int-58-fmc-demurrage-and-detention-billing-timelines-46-cfr-541
Sep 25, 2026
Merged

craig-o-curtis merged 5 commits into
mainfrom
260-int-58-fmc-demurrage-and-detention-billing-timelines-46-cfr-541

Conversation

@craig-o-curtis

Copy link
Copy Markdown
Contributor

Summary

Adds billingTimeline, which works out the deadline chain around a demurrage or detention invoice: the last date to issue it, the last date to dispute it and the last date to resolve the dispute. Every window length is a caller parameter with no default. The PR also makes GMT track no law. It cleans the INT-12 text to match, adds the docs pages and the Billing Deadlines tool, and brings all accent text on the site up to 7:1 contrast.

Closes #260.


What Changed

1. billingTimeline (intermodal/calculate)

  • billingTimeline(dates, windows) returns invoiceDeadline, issuedByDeadline, disputeDeadline, requestedByDeadline and resolutionDeadline.
  • Day zero is the anchor, and the deadline is anchor plus days on the ISO calendar. A date is by the deadline when it is on or before it.
  • Windows are safe integers of 0 or more. A missing, negative or non-integer window returns null.
  • Fields whose input does not exist yet are null. A request without an invoice, or dated before it, returns null.
  • agreedResolutionOn, when on or after the request, replaces the computed resolution deadline.
  • Every emitted date is bare ISO, with no calendar annotation.
  • Exported types: BillingDates, BillingWindows and BillingDeadlines.

2. GMT tracks no law, and the INT-12 text is cleaned up

  • No statute, regulation number, agency or court decision appears in the new code, JSDoc, READMEs, skills or docs site. Examples are labelled by their numbers, such as a 14/14/45 contract.
  • The same rule now applies to INT-12's shipped text. It covers the JSDoc of freeTimeExpiry, chargeableDays, demurrageClock and internal/freeTimeLedger, the test labels, the guide, scenario and mistakes pages, the Free Time Ledger tool, both READMEs, the INT-12 changeset and packages/gmt/skills. No behaviour changes, and no @example changes beyond one label.
  • Tariff facts stay, labelled as tariff terms.

3. Docs site

  • New guide: guides/industries/intermodal-billing-deadlines.mdx.
  • Three new scenarios: invoice-a-day-late, re-bill-anchored-on-the-wrong-date and dispute-window-from-a-contract.
  • Seven new mistakes on mistakes/intermodal.mdx.
  • New entries on the three index pages. The INT-12 guide's last section now links forward to the new guide.
  • The Billing Deadlines tool (tools/billing-deadlines.mdx): a day strip with three shaded windows, no default windows, and presets labelled by their numbers. It is also the chat tool showBillingDeadlines, and its permalink carries every number as a string.

4. Site-wide 7:1 text contrast

  • Every accent used as text now uses its -ink theme-role token.
  • --gmt-ice-dim was retuned to at least 7:1 in both themes. --gmt-ice-dim-fill keeps the old value for non-text fills.
  • The Starlight badges and the severity and DST badges were fixed, and forced-colours rules were added for the new widget.

5. Context

  • The INT-58 issue file, tracker status, docs-site checklist and design-system rule 6 are updated.

Why

  • Hand-built deadline code goes wrong in three ways. It adds 30 x 24 hours across a DST change. It compares an instant to a date. It hard-codes a window that the contract or the rule later changes.
  • Carrying one jurisdiction's numbers in a date library ships wrong deadlines everywhere else, and ships a wrong deadline on the day that rule changes.
  • The new widget showed accent text below the site's 7:1 text rule.

Validation

  • pnpm run validate exits 0. It runs deps check, test-markers, build, stats check, temporal-compat, upstream, api-surface, lint, typecheck and every test.
  • Tests: packages/gmt has 36,975 tests in 674 files. apps/dox has 4,617 tests in 83 files. gmt-oxlint has 97 tests in 15 files. All pass.
  • api-surface:
    • 608 public source files, 0 unreachable.
    • 2,875 of 3,039 examples checked, 0 failing.
    • 666 of 730 documented results checked, 0 failing.
    • 0 unresolved imports, 0 broken reference links, 0 broken site links.
  • html-diff reports the five new pages as having no baseline: the billing guide, the Billing Deadlines tool and the three scenarios.
  • Every other visual:diff change is on a page this story edits:
    • the new sidebar entry on every page
    • the contrast fixes
    • the Free Time Ledger tool
    • the INT-12 guide, its scenarios and its mistakes page
    • the three index pages
  • A keyboard-only pass and a prefers-reduced-motion check pass.
  • gmt-reviewer ran 3 passes, with no open blocking finding.
  • dox-tester's final verdict is PASS.

Risk / Impact

Low for the library. The new function is additive, and the INT-12 changes are to text only. Medium for the site: the contrast changes touch shared tokens on every page, and visual:diff reviewed them.


Release

.changeset/steady-ledgers-billing-deadlines.md is a minor bump for @northguild/gmt. This PR also edits the text of the pending INT-12 changeset, .changeset/patient-gates-free-time.md. Merging publishes nothing; a release PR ships it.


Staging note

  • Every uncommitted change in the working tree belongs to INT-58. That includes the hunks in gmt-tokens.css, gmt-globe.css and gmt-a11y.css: those are contrast and forced-colours fixes, not globe shading.
  • The globe shading work is in commit a05ddb0, which replaced the earlier 0a8c4a4 on this branch. It also carries the globe glow and haze tokens and INT-58's context/dox/built.md lines.
  • a05ddb0 is not on main, so this PR includes it unless it is split out.

Checklist

  • Each What Changed section has a corresponding entry
  • Type-check passed
  • No secrets committed
  • Changeset present with the correct bump

@craig-o-curtis craig-o-curtis added this to the Domination milestone Sep 25, 2026
@craig-o-curtis craig-o-curtis added documentation Improvements or additions to documentation enhancement New feature or request gmt gmt monorepo labels Sep 25, 2026
@craig-o-curtis craig-o-curtis changed the title feature(intermodal) INT-25 demurrage and detention billing timelines #260 feature(intermodal) INT-25 billingTimeLine #260 Sep 25, 2026
@craig-o-curtis
craig-o-curtis enabled auto-merge (squash) September 25, 2026 10:28
@baldurpan

Copy link
Copy Markdown
Contributor

Review

The library side is clean. Two confirmed bugs in the Billing Deadlines widget, both from one root cause.

I read billingTimeline and its test file, ran that suite (94 tests pass), and checked the INT-12 changes are text-only — they are, across all six source files. The function itself holds up: readDates and readWindows validate into sanitised copies, so a hostile getter is never read twice; the outer try/catch covers Core Rule 3; and I worked through the nine @example values by hand, including 2028-01-30 + 30 = 2028-02-29 and 2026-03-18 + 45 = 2026-05-02.

Both findings are in apps/dox/src/lib/billing-deadlines.ts. datesOf trims the input but never canonicalises it, so raw user strings reach the layout and the labels while the library's own output is canonical bare ISO. The two then get compared to each other.

1. An annotated anchor loses its marker and a lane cell

billingTimeline accepts 2026-03-01[u-ca=iso8601] — isValidDate reads RFC 9557 annotations, and the new test file covers them explicitly. The widget's date field is type="text", so a person can paste one, and a permalink or a showBillingDeadlines call can carry one.

Anchor plus three 30-day windows, through dayStrip:

anchor input anchor marker lane cells summary text
2026-03-01 anchor 31 31 days from 2026-03-01 to 2026-03-31.
2026-03-01[u-ca=iso8601] none 30 31 days from 2026-03-01[u-ca=iso8601] to 2026-03-31.
+002026-03-01 none 37 31 days from +002026-03-01 to 2026-03-31.

The deadlines the widget prints are correct. Only the drawing is wrong, in three places:

  • marksOf tests date === dates.anchorOn. The left side is a strip date Temporal generated, the right side is the raw string, so day zero never matches and the anchor mark never draws.
  • lanesOf tests date < lane.start as strings. "2026-03-01" < "2026-03-01[u-ca=iso8601]" is true, so the anchor day is dropped from its own lane — 30 cells instead of 31.
  • The summary keeps the annotation. That string is the accessible summary, so a screen reader reads 2026-03-01[u-ca=iso8601] aloud.

An expanded year goes the other way, because + sorts before every digit. With +002026-03-01, date < lane.start is never true, so the lane swallows the padded days before the anchor: 37 cells instead of 31.

2. A null result can render with no reason

explainNull compares dates with < where the library uses Temporal.PlainDate.compare:

if (request < invoice) return { reason: "request-before-invoice" };

With anchorOn: "2026-03-01", invoiceIssuedOn: "+002026-03-20", requestReceivedOn: "2026-03-10" and three 30-day windows, billingTimeline returns null because the request precedes its invoice. explainNull also returns null, because "2026-03-10" < "+002026-03-20" is false. The widget shows NO SIGNAL and no explanation. The agreed < request test on the line below has the same gap.

Suggested fix

Both go away if datesOf canonicalises after validation — Temporal.PlainDate.from(text.trim()).toString() — so the widget compares and labels the strings the library actually emits. The file header already says the widget never computes a deadline itself; comparison order seems worth holding to the same rule. One catch: callSource needs the raw text so the printed call stays the real call, so those two uses probably have to part company. Does that split sound right to you, or is there a reason the raw string should reach the strip?

Smaller things

  • BillingWindows's doc says "The three windows the caller supplies, in calendar days. None has a default." The interface has a fourth member, agreedResolutionOn, which is a date rather than a day count and is optional. Worth rewording while the type is still unreleased.
  • This PR carries three independent pieces: the billing story, commit a05ddb08 (globe sun-lit shading — about 640 lines with its own tests and tokens), and the backlog re-scope that deletes 11 files under context/domination/. You flagged the globe one yourself in the staging note. Splitting it out would let it get its own visual review; the re-scope and the INT-12 text read as part of this story, but the globe does not. I checked the deletions and found no dangling references to the removed issue IDs.

Add , the invoice, dispute and resolution deadlines
around a demurrage or detention invoice. Every window length is the
caller's number, with no defaults.

- Day zero is the anchor and the deadline is anchor plus days on the
  ISO calendar. A date is by the deadline when it is on or before it.
  Thirty days across a DST change or a leap day is thirty dates.
- The chain fills in as its dates exist. With only an anchor it is a
  forecast. A request without an invoice, or dated before it,
  returns null.
- An agreed resolution date on or after the request replaces the
  computed one.
- The two booleans compare dates and say nothing about liability.

GMT tracks no law. The new code, docs and INT-12's shipped text now
name no statute, regulation, agency or jurisdiction. Tariff facts
stay, labelled as tariff terms.

Docs site: a billing deadlines guide, three scenarios, seven
mistakes, and the Billing Deadlines tool, which is also a Dox chat
tool. The INT-12 guide now ends by linking forward to the new guide.

Every accent colour used as text now uses its ink token, and the
muted text colour was retuned, so all text clears 7:1 in both themes.
Keep regulated numbers out of the plan. Eight hours-of-work stories
were one duty-log engine each carrying a regulator's numbers; they
become CORE-76 (#283), with every limit a caller parameter. Advance
filing folds into TRAN-10 and TRAN-57; length of stay into dwellTime
and HLTH-38's guide. Twelve stories lose a default, a table or a
regime enum; seven lose a citation or a jurisdiction label.
Professional-body and treaty numbers are treated like law; bundled
data is limited to standards-body data and operator calendars. The
tracker is 66 stories and states the plan without a decision log.
Remove the last references to the deleted spike summary. Link the
verification files under research/spike-2026-09/ instead, and state
the findings and the Core primitives in the present tense.
@craig-o-curtis
craig-o-curtis force-pushed the 260-int-58-fmc-demurrage-and-detention-billing-timelines-46-cfr-541 branch from 0bf67f7 to 0632081 Compare September 25, 2026 12:23
The Billing Deadlines widget compared the typed date text with < and >,
while billingTimeline compares real dates and returns plain ISO. An
annotated date such as 2026-03-01[u-ca=iso8601], or one with a leading
+, lost its anchor mark and got the wrong number of lane cells. Its
annotation reached the strip summary and the verdict line, which
screen readers read aloud. A request dated before an invoice written
with a leading + showed NO SIGNAL with no reason.

- The strip, the summary and the verdict lines show the plain ISO date
  the library returns. The printed call keeps the text as typed,
  because it is the real call.
- Every date comparison uses Temporal.PlainDate.compare. Plain ISO
  strings still sort wrongly past year 9999 and for negative years.
- Nine new widget tests run against the real library. They cover
  annotations, a leading +, both sides of year 9999, negative years
  and both null reasons.
- The BillingWindows doc now mentions the optional agreed resolution
  date.
@craig-o-curtis

Copy link
Copy Markdown
Contributor Author

@baldurpan Thanks. Both findings are confirmed and fixed.

The raw-string split: yes. We kept datesOf as typed, because it is the argument the widget actually passes to billingTimeline, so the printed call stays the real call. The strip and the verdict text now show the plain ISO form the library returns. That also fixes a case you didn't list: the verdict line read out Invoice issued 2026-03-31[u-ca=iso8601].

Plain ISO alone wasn't enough. Plain ISO strings still sort wrongly outside years 0000–9999. 9999-12-01 + 60 is +010000-01-30, which sorts before 9999-12-01 as text, and negative years sort backwards. So every date comparison in the widget now uses Temporal.PlainDate.compare. That covers the span, padding, lanes, the week loops and both explainNull checks. It also fixed another case: a request of +002026-04-10 was reported as "before the invoice".

New tests run against the real library for [u-ca=iso8601], [foo=bar], a time zone annotation, +002026-03-01, both sides of year 9999 and negative years, both null reasons, and a check that the printed call keeps the typed text. All of them failed before the fix.

Smaller things. The BillingWindows doc now mentions the optional agreed date. The globe commit is now its own PR, #284, and this branch has been rebased without it.

@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 c05dcf9 into main Sep 25, 2026
38 checks passed
@craig-o-curtis
craig-o-curtis deleted the 260-int-58-fmc-demurrage-and-detention-billing-timelines-46-cfr-541 branch September 25, 2026 12:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation enhancement New feature or request gmt monorepo gmt

Projects

None yet

Development

Successfully merging this pull request may close these issues.

INT-58: billingTimeline: invoice, dispute and resolution deadlines

2 participants