From 3746ca2ae4109b3b526d8c43af2bde0313f60d0d Mon Sep 17 00:00:00 2001
From: Aymeric Rabot
Date: Wed, 29 Jul 2026 14:28:56 +0200
Subject: [PATCH 1/7] docs(plans): rewrite 032 around the = escape hatch and
LLM arithmetic
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Empirical probing turned up two things the plan had to answer before any
grammar work: `2+3 kg` is silently read as a range with full confidence
(no issue, contradicting D4's honest-ambiguity rule), and additive
compounds over affine units already delta-convert correctly but never say
so. Both are recorded as fixes that stand on their own.
The plan now takes the leading-`=` mode switch from spreadsheet formula
entry, which makes expressions purely additive: bare input keeps every
reading it has today, `=` opts into arithmetic. It also reframes the
value around LLM tool calls — models set arithmetic up well and execute
it badly, so having the model emit the expression and lingo evaluate it
deterministically is the safety story, not a calculator feature.
Co-authored-by: Cursor
---
plans/032-input-calculations.md | 318 ++++++++++++++++++++-----
plans/backlog.md | 24 ++
wiki/inspiration.md | 5 +-
wiki/research/utility-opportunities.md | 28 +++
4 files changed, 311 insertions(+), 64 deletions(-)
diff --git a/plans/032-input-calculations.md b/plans/032-input-calculations.md
index d6dd8c4..86cfc93 100644
--- a/plans/032-input-calculations.md
+++ b/plans/032-input-calculations.md
@@ -3,94 +3,286 @@ id: 032
title: Input calculations (quantity arithmetic)
status: draft
created: 2026-07-08
-updated: 2026-07-08
-goal: "Decide whether (and how) lingo evaluates arithmetic typed into fields — '2+3 kg', '10% off 50', '2 * 1h30' — without becoming a CAS or busting budgets."
+updated: 2026-07-29
+goal: "Decide whether (and how) lingo evaluates arithmetic typed into fields — '=2+3 kg', '10% off $50', '12 * 0.75 kg' — without becoming a CAS or busting budgets, and close the operator/range collision that already ships."
success_criteria:
- "Go/no-go decision recorded as a D-entry -> wiki/decisions.md"
- - "If go: expression grammar spec'd here with locked node union and corpus rows -> this plan + tests/corpus"
+ - "Bare-mode `2+3 kg` no longer silently returns a range -> packages/lingo/tests/corpus + parse tests"
+ - "If go: node union and operator table locked here, corpus rows added -> this plan + tests/corpus"
+ - "If go: `./calc` marginal budget assigned and green -> packages/lingo/scripts/size.mjs"
---
# Input calculations (quantity arithmetic)
Driver: owner request 2026-07-08 — users type light math into quantity fields
("2+3 kg", "half of 10 L", "10% off $50") and expect the field (and
-autocomplete) to resolve it. Prior art to evaluate:
-[mathjs expression trees](https://mathjs.org/docs/expressions/expression_trees.html).
+autocomplete) to resolve it. Re-scoped 2026-07-29 after a prior-art pass over
+[mathjs expressions](https://mathjs.org/docs/expressions/index.html) plus a
+probe of what the current parser actually does with arithmetic. Two things
+changed: the collision this plan worried about is already producing wrong
+answers, and the strongest argument for the feature turned out to be the LLM
+boundary, not the form field.
## Design principle
-**Calculator, not CAS.** Evaluate a closed, tiny arithmetic surface over
-already-parsed same-kind quantities. No symbols, no variables, no dimensional
-algebra (D2 stands; kg·m/s² stays out of scope), no assignment, no functions.
-If an expression needs a scope object, it's out of scope.
+**Calculator, not CAS — and the model shows its work, lingo does the math.**
-## Prior-art evaluation: mathjs expression trees (studied 2026-07-08)
+Evaluate a closed, tiny arithmetic surface over already-parsed same-kind
+quantities. No symbols, no variables, no dimensional algebra (D2 stands;
+kg·m/s² is out of scope), no assignment, no functions. If an expression needs a
+scope object, it's out of scope.
-What mathjs does: `math.parse(expr)` → typed node AST (`ConstantNode`,
-`OperatorNode`, `SymbolNode`, `FunctionNode`, …), each node supporting
-`evaluate(scope)`, `transform`, `traverse`, `toString`. Powerful and general —
-and exactly the shape we must NOT ship wholesale:
+The second half is the reason the feature is worth its bytes. An LLM asked for
+`weight_kg: number` must do the multiplication and the unit conversion in its
+head, and nothing downstream can tell a correct `0.907` from a hallucinated
+one — the failure mode plan 019's research pass documented as `value_error`.
+A field that accepts `"12 * 0.75 kg"` and evaluates it deterministically moves
+the arithmetic out of the model and into the library. Models are unreliable at
+computing and reliable at *setting up* a computation; this exploits exactly
+that split.
-- **Take:** the small typed node union as the internal representation
- (`value | quantity | op(+,-,*,/) | percent-of | paren`), the
+## What ships today (probed 2026-07-29)
+
+Not hypothetical future risk — current `lingo()` behavior on inputs users type:
+
+| Input | Today | Verdict |
+|---|---|---|
+| `2+3 kg` | range 2–3 kg, `confidence: 1`, **zero issues** | Silent wrong answer |
+| `2+4 kg` | `TRAILING_INPUT` | Inconsistent with the row above |
+| `5 kg - 2 kg` | range 2–5 kg + `RANGE_REVERSED` | Wrong, but warned |
+| `20°C + 5°C` | 25 °C, zero issues | Right math, unstated semantics |
+| `0°C + 100°F` | 55.56 °C, zero issues | Right math, unstated semantics |
+
+`2+3 kg` is the one that matters. It contradicts D4 more directly than anything
+else in the parser: a deterministic wrong reading at full confidence with no
+alternative and no warning.
+
+The cause is `tryAdjacentCjkRange` (`parse/range.ts`), the path behind D68's
+CJK adjacent-number implicit ranges (`七八天` = 7–8 days). Despite the name,
+nothing in it is CJK-specific: it checks only that the next token has no
+preceding space and that a re-parse from that token yields exactly `a + 1`. For
+`2+3 kg` the `+` is consumed as a compound-plus sign, the re-parse returns 3,
+and 3 === 2+1, so a range is built. That's why `2+3` and `3+4` become ranges
+while `2+4` and `9+10` fail — the guard is `value >= 1 && value < 9`, not
+anything about script. D68's own rule ("三三 stays rejected") was only ever
+meant to fire on CJK juxtaposition.
+
+The signal it should be consulting already exists one layer down:
+`parseCjkNumberText` (`number/cjk.ts`) returns an explicit `adjacentRange: true`
+flag on genuine CJK juxtaposition. The fix is to gate the range path on that
+flag instead of on a script-blind re-parse.
+
+**This gets fixed regardless of the go/no-go below.** Bare mode does no
+arithmetic, uniformly: `2+3 kg` joins `2+4 kg` as `TRAILING_INPUT`. Corpus drift
+here is a previously-`ok` row turning into a failure, so `corpus-diff.mjs` will
+class it BREAKING — it needs an explicit owner acknowledgement in the same
+change rather than a reclassification.
+
+## Prior-art evaluation
+
+### mathjs expression trees (studied 2026-07-08, revisited 2026-07-29)
+
+- **Take:** the small typed node union as the internal representation, the
parse-then-evaluate split (grammar produces a tree; evaluation is a separate
pure fold), and `toString` discipline (canonical emission that re-parses —
- maps to our two-way guarantee).
+ our hard rule 4). Also its **percentage operators**: `100 + 3%` → `103` is a
+ dedicated operator distinct from modulus, and that family is the highest-value
+ slice for forms (see phase 1).
- **Reject:** symbols/scopes, function nodes, matrices/objects/ranges as
- expression citizens, `compile()` codegen, implicit multiplication. mathjs is
- ~500 kB+; our entire full bundle budget is 33 kB. Zero-deps rule means this
- is a study, never a dependency.
-- **Hazards mathjs itself documents:** temperature arithmetic footguns
- (°C/°F sums are wrong under affine units — we already split
- `convert`/`convertDelta`; expression eval must refuse or delta-convert
- affine-unit arithmetic), case traps (`C`=coulomb), unit-symbol collisions
- with operator tokens (`in` reads as inches, `-` is both minus and a range
- separator).
-
-## Candidate scope (not locked)
-
-| Expression | Result | Notes |
+ expression citizens, `compile()` codegen, implicit multiplication (`2 pi`),
+ BigNumber/Fraction numeric types, symbolic simplify/derivative, physical
+ constants. mathjs is ~500 kB+; the zero-deps rule (D1) means this is a study,
+ never a dependency.
+- **Security is a design input, not an afterthought.** mathjs ships a whole
+ security page because it evaluates arbitrary code. At an LLM tool boundary
+ that is an attack surface. A closed node union has none — the grammar cannot
+ express a side effect, a property access, or a call. That guarantee is worth
+ stating in the docs, not just holding internally.
+- **Hazards mathjs documents:** case traps (`C` = coulomb — we already emit
+ `AMBIGUOUS_UNIT`), unit symbols colliding with operator tokens (`in` reads as
+ inches, `-` is also a range separator), and affine-unit arithmetic, on which
+ its own advice is "avoid calculations using celsius and fahrenheit."
+
+### The `=` escape hatch (Excel / Sheets / Notion / Airtable)
+
+The most-used form software on earth resolves exactly our `-`-means-range
+collision with a leading `=` mode switch. Soulver, Numi, Raycast and Spotlight
+do the same thing with a dedicated surface. `=2+3 kg` currently fails with
+`NO_VALUE`, so the prefix is free.
+
+### Pint (Python) `delta_degC`
+
+Prior art for making affine-delta semantics explicit in the type rather than
+warning users off the operation. We already have the `convert`/`convertDelta`
+split (from js-quantities, per `wiki/inspiration.md`); the compound path just
+never said which one it was using.
+
+## Design (proposed — not locked; gated on the go/no-go)
+
+### The compound/arithmetic discriminator
+
+The rule that keeps this additive:
+
+- **Both operands carry units** → compound accumulation. Existing behavior,
+ unchanged: `2 ft + 3 in`, `2 kg + 500 g`, `2 m minus 10 cm`.
+- **A bare operand, or any non-additive operator** → arithmetic, which lives in
+ `./calc` and never in `lingo()`.
+
+So `lingo('2+3 kg')` fails and `calc('2+3 kg')` gives 5 kg, and no input changes
+meaning based on which entries a consumer imported.
+
+### `=` is a field-level mode switch, not grammar
+
+`calc()` accepts an expression with or without the prefix. The prefix matters
+only where one text box feeds both parsers — `completions()` and the DOM
+controller — so that bare input keeps today's range-first semantics with zero
+corpus churn:
+
+```ts
+interface CalcOptions extends LingoOptions {
+ /** '=' (default): only treat input as an expression when prefixed. */
+ trigger?: '=' | 'always'
+}
+
+function calc(input: string, opts?: CalcOptions): CalcResult | CalcFail
+```
+
+### Node union and operators
+
+Closed union, five node types, no extension point:
+
+```ts
+type CalcNode =
+ | { type: 'number'; value: number; span: Span }
+ | { type: 'quantity'; value: Quantity; span: Span }
+ | { type: 'group'; node: CalcNode; span: Span }
+ | { type: 'percent'; of: CalcNode; percent: CalcNode; mode: 'of' | 'add' | 'off'; span: Span }
+ | { type: 'op'; op: '+' | '-' | '*' | '/'; left: CalcNode; right: CalcNode; span: Span }
+```
+
+Every node carries a span into the ORIGINAL input (hard rule 3), so issues point
+at the offending operand and a UI can highlight it.
+
+Operand rules, and the issue code when they're violated:
+
+| Form | Result | Rule |
|---|---|---|
-| `2+3 kg` | 5 kg | bare left side inherits unit (matches range policy) |
-| `2 kg + 500 g` | 2.5 kg | same-kind, unit-mixed sum |
-| `2 * 1h30` | 3 h | scalar × quantity |
-| `10 L / 4` | 2.5 L | quantity ÷ scalar |
-| `10% off $50` | $45.00 | percent-of family, incl. `off` / `of` |
-| `half of 10 L` | 5 L | word multipliers reuse the number-word lexicon |
-| `5 kg + 3 m` | KIND_MISMATCH | never cross-kind |
-| `20°C + 5°C` | refuse or delta (decide) | affine-unit hazard |
-
-Grammar collision to resolve first: `-` and `to` already mean *range*
-(`5-10 kg`), and `x` could mean multiplication or a typo. Proposal: ranges win
-every ambiguous read; arithmetic requires an unambiguous operator context
-(`+`, `*`, `/`, `% off`) — subtraction may need to be excluded or
-parenthesized-only, which is fine for form inputs.
-
-## Where it would land
-
-- New entry (`@pascal-app/lingo/calc` or folded into `./complete` fan-out as a
- `'calc'` completion source) — NOT the main entry; main budget stays flat.
-- Autocomplete integration: `completions('2+3 kg')` → `5 kg` ranked first with
- the expression tree attached for UI explanation.
-- Spans: every node carries `[start, end)` offsets like every other result
- (hard rule 3); issues point at the offending operand.
+| `q + q`, `q - q` | quantity | Same kind only, else `EXPRESSION_KIND_MISMATCH` |
+| `n + q`, `q + n` | quantity | Bare operand inherits the other side's unit |
+| `q * n`, `n * q` | quantity | Exactly one operand may be a quantity, else `SCALAR_EXPECTED` |
+| `q / n` | quantity | Divisor must be scalar |
+| `q / q` | number | Same-kind division cancels to a dimensionless ratio (phase 3) |
+| `q * q` | rejected | `SCALAR_EXPECTED` — this is dimensional algebra (D2) |
+| `x / 0` | rejected | `DIVISION_BY_ZERO` |
+
+`q * q` being refused is load-bearing: it's the line that keeps this a
+calculator instead of the start of a unit algebra.
+
+### Affine arithmetic — resolved, not open
+
+The plan previously listed "refuse or delta-convert" as an open question. The
+probe answers it: **lingo already delta-converts**, and correctly.
+`0°C + 100°F` → 55.56 °C is 100 Fahrenheit-*degrees* of rise, not 100 °F
+converted absolutely. That's the right reading and better than mathjs, which
+tells you not to try.
+
+The defect is silence, not math. Additive arithmetic on an affine unit emits a
+new warning: `AFFINE_DELTA_ASSUMED` (past-tense, per the applied-forgiveness
+naming convention), carrying the operand span and the delta reading in `data`.
+This applies to the **existing compound path too**, so it lands even if the
+go/no-go comes back no.
+
+### Phasing
+
+1. **Percent-of family.** `10% off $50`, `$60 + 20% tip`, `15% of 60 kg`,
+ `$100 + 8.875% tax`. No collision with ranges at all — `%` plus `of`/`off`/
+ `on` are unambiguous markers — and it's the arithmetic people actually type
+ into money forms. Shippable without the general expression grammar.
+2. **Operators + grouping.** `+ - * /`, parentheses, precedence.
+3. **Ratios and word multipliers.** `q / q` → number; `half of 10 L`, `twice
+ 3 kg`, `double`, reusing the existing number-word lexicon.
+
+### Where it lands
+
+- New entry `@pascal-app/lingo/calc`. The main entry does not grow — no import,
+ no re-export, budget stays flat. `./calc` gets its own marginal budget line in
+ `size.mjs` at implementation time.
+- `./complete` gains a `'calc'` `CompletionSource`, fed by an **injected**
+ evaluator exactly like plan 031 injects `date` — `./complete` never imports
+ `./calc` at runtime. Evaluated results surface as a ranked completion
+ (`= 45 USD`) with the tree attached for the UI to explain, rather than
+ silently committing into the field.
+- `./ai`: `quantityField` and `rangeField` accept an injected `calc` evaluator
+ under the same rule, so `./ai`'s budget doesn't move either:
+
+```ts
+import { calc } from '@pascal-app/lingo/calc'
+
+quantityField({ unit: 'kg', calc }) // accepts "12 * 0.75 kg" -> 9
+```
+
+ When `calc` is injected, the emitted JSON Schema `description` must tell the
+ model it may submit an expression — a capability the model can't use if it
+ doesn't know it has it. `CalcResult` carries the evaluated tree so a tool can
+ log or display the work; the field itself still returns a plain number
+ (or `QuantityJSON` under `output: 'quantity'`), so the wire shape at the tool
+ boundary is unchanged.
+
+### Vocabulary
+
+`expression`, `node`, and `calc` are new nouns. If this ships, `CONTEXT.md`
+gains entries for them in the same change, with the *Avoid* list naming
+"formula" and "AST", and an explicit note that a calc `node`'s `span` is a span
+(the range/span collision `CONTEXT.md` already flags as the one that bites).
+
+## Changes
+
+1. `packages/lingo/src/parse/range.ts` — gate `tryAdjacentCjkRange` on the
+ `adjacentRange` flag already returned by `parseCjkNumberText`
+ (`number/cjk.ts`) instead of a script-blind `a + 1` re-parse (the `2+3 kg`
+ fix). Keep D68's `七八天` corpus rows green.
+2. `packages/lingo/src/parse/quantity.ts` — emit `AFFINE_DELTA_ASSUMED` on
+ additive compounds over affine units.
+3. `packages/lingo/src/core/types.ts` — new issue codes (add-only, per
+ conventions): `AFFINE_DELTA_ASSUMED`, `EXPRESSION_KIND_MISMATCH`,
+ `SCALAR_EXPECTED`, `DIVISION_BY_ZERO`, each with a typed `IssueDataMap` entry.
+4. `packages/lingo/src/calc/` — grammar (tree), evaluator (pure fold),
+ `toString` canonical emission.
+5. `packages/lingo/package.json` + `tsup.config.ts` — `./calc` entry.
+6. `packages/lingo/src/complete/` — `'calc'` completion source, injected.
+7. `packages/lingo/src/ai/` — injected `calc` option + schema description.
+8. `packages/lingo/scripts/size.mjs` — `./calc` marginal budget.
+9. Tests (incl. two-way for every emitted result), corpus rows, `ai-eval.mjs`
+ category for expression-valued tool arguments, CHANGELOG, README, llms.txt,
+ `wiki/inspiration.md`, `CONTEXT.md`.
## Non-goals
-- Dimensional algebra / compound-dimension arithmetic (D2, vision plan).
-- Variables, scopes, functions, matrices, multi-statement blocks.
+- Dimensional algebra / compound-dimension arithmetic (D2, plan 000).
+- Variables, scopes, assignment, functions, matrices, multi-statement blocks,
+ constants, trig/log/statistics, arbitrary-precision numerics.
- Shipping or depending on mathjs.
+- Changing any bare-input reading other than the `2+3 kg` bug fix.
+- Rates with non-unit denominators (`£45 per night`, `50 kg per person`) — a
+ bigger form win, but a wire-schema change and a separate plan. Parked in
+ `backlog.md`.
## Open questions
-- Go/no-go at all — is a calculator inside a form field a feature or a trap?
- Needs owner call + a D-entry either way.
-- Affine-unit arithmetic policy (refuse vs delta-convert).
-- Subtraction vs range `-`: excluded, parenthesized-only, or space-sensitive.
-- Entry placement and budget (new `./calc` vs `./complete` growth).
+- **Go/no-go on phases 2–3.** Is a general calculator inside a form field a
+ feature or a trap? Owner call + a D-entry either way. Phase 1 (percent-of) and
+ the two defect fixes stand on their own and could land first.
+- **Corpus classification for the `2+3 kg` fix.** BREAKING by the script's
+ definition; needs owner acknowledgement, not a reclassification.
+- **Does `q / q` → number earn its bytes?** Useful ("how many 2 L bottles in
+ 10 L"), but it's the only rule that changes result *type*, which complicates
+ the `./ai` field contract.
## Acceptance
-Decision D-entry exists. If go: grammar locked here, corpus rows added
-(ADDITIVE), size budget assigned in `size.mjs`, two-way tests for every
-emitted result.
+Decision D-entry exists. The `2+3 kg` and `AFFINE_DELTA_ASSUMED` fixes ship with
+corpus coverage regardless of that decision. If go: node union and operator
+table locked here, corpus rows added, `./calc` budget assigned in `size.mjs` and
+green, two-way tests for every emitted result, and an `ai-eval.mjs` category
+showing expression-valued arguments beat naive number-valued ones on silent-wrong
+rate.
diff --git a/plans/backlog.md b/plans/backlog.md
index f75dd31..cb65350 100644
--- a/plans/backlog.md
+++ b/plans/backlog.md
@@ -27,6 +27,20 @@ surfaces mid-task, add it here and keep going — don't act on it.
- **Dimensional expressions** — arbitrary unit algebra (beyond the declared
flow_rate/concentration/torque/… kinds) needs a separate kind/model decision
rather than ad hoc aliases (algebra deferred per D2).
+- **Rates with non-unit denominators** — `£45 per night`, `50 kg per person`,
+ `$12/hour`, `3 per box` all fail with `TRAILING_INPUT` today. This is *not*
+ dimensional algebra (D2 stands): the denominator is a countable domain noun,
+ not a unit, so no unit algebra is involved — it wants a `per` slot on the
+ quantity. Covers pricing, dosing, capacity, and rate fields, and gives LLM
+ tools a clean shape to emit (`{amount: 45, currency: 'EUR', per: 'night'}`).
+ Ranked above the general expression grammar on form value, but it changes the
+ v3 wire shape, so it needs its own plan rather than folding into 032.
+ (Surfaced by the mathjs/plan-032 prior-art pass 2026-07-29.)
+- **Multiplier and count words** — `twice 3 kg`, `double 3 kg`, `3 boxes of
+ 2 kg`, `3 @ 2.5 kg`, `5 kg each`, `a dozen eggs` all fail today. Reuses the
+ existing number-word lexicon; overlaps plan 032 phase 3, but the unit-less
+ count forms (`a dozen eggs`) are a quantity-grammar question, not arithmetic.
+ (Same pass.)
- **Range span excludes the frame word** — `from 5 to 10 kg` and
`between 5 and 10 kg` spans start at the first value, not at `from`/`between`.
`buildRange` (`range.ts`) could extend the span to cover the leading frame.
@@ -324,3 +338,13 @@ suffix clock grammar. Still open:
`百`/`千` positional composition (`三百五十日` as a day-of-month is nonsense,
yet `二千二十六年` is a legal year spelling). Unify with the CJK number
walker in `number/` instead of growing a second reader.
+
+## Test-suite maintenance
+
+- **`suggest.test.ts` alias-probe timeout** — "matches an unpruned reference
+ across generated alias probes" builds a full registry and walks every alias in
+ `allKinds`, taking ~7 s against vitest's 5 s default. It fails in isolation on
+ a mid-range laptop and intermittently under a loaded pool, so `bun run check`
+ is flaky through no fault of the change under test. Either sample the alias
+ space, hoist the registry out of the timed body, or give the case its own
+ timeout — pick one deliberately rather than raising the global default.
diff --git a/wiki/inspiration.md b/wiki/inspiration.md
index 98a9583..89927cd 100644
--- a/wiki/inspiration.md
+++ b/wiki/inspiration.md
@@ -22,7 +22,8 @@ license obligation; we do not copy code without noting it here explicitly.
| [js-quantities](https://github.com/gentooboontoo/js-quantities) | MIT | Temperature absolute-vs-delta separation (tempC vs degC) — our `convert`/`convertDelta` split. |
| [unitmath](https://github.com/ericman314/UnitMath) | Apache-2.0 | Formatting options design; custom unit definition ergonomics. |
| mathjs unit system | Apache-2.0 | Prefix handling and parsing grammar cautionary study (powerful but heavyweight — we deliberately stay non-algebraic). |
-| [mathjs expression trees](https://mathjs.org/docs/expressions/expression_trees.html) | Apache-2.0 | Typed node AST + parse/evaluate split studied for plan 032 (input calculations); we'd take the tiny node union and canonical `toString`, reject scopes/functions/CAS. |
+| [mathjs expression trees](https://mathjs.org/docs/expressions/expression_trees.html) + [expression syntax](https://mathjs.org/docs/expressions/syntax.html) | Apache-2.0 | Typed node AST + parse/evaluate split studied for plan 032 (input calculations); we'd take the tiny node union and canonical `toString`, reject scopes/functions/CAS. Deeper pass 2026-07-29 added two things: its **percentage operators** (`100 + 3%` → `103` as a dedicated operator distinct from modulus) as the model for our phase-1 percent-of family, and its [security page](https://mathjs.org/docs/expressions/security.html) as the cautionary framing — an expression evaluator needs one only because it evaluates arbitrary code, so our closed node union's inability to express a call or a side effect is a guarantee to advertise at the LLM tool boundary, not just an internal constraint. |
+| [Pint](https://github.com/hgrecco/pint) (Python) | BSD-3-Clause | `delta_degC` / `delta_degF` as prior art for making affine-delta semantics *explicit* rather than warning users off the operation (mathjs's own advice is "avoid calculations using celsius and fahrenheit"). Motivated plan 032's `AFFINE_DELTA_ASSUMED` warning: lingo's compound path already delta-converts correctly, it just never said so. |
| ECMA-402 / Intl.NumberFormat unit style | spec | Sanctioned unit identifier list; free locale-aware formatting we lean on instead of shipping CLDR. |
## Dates domain
@@ -52,6 +53,8 @@ license obligation; we do not copy code without noting it here explicitly.
| [Radix Primitives](https://www.radix-ui.com/primitives) / [Headless UI](https://headlessui.com) | MIT | Zero shipped CSS; one `data-*` attribute per state (not combined strings); namespaced CSS custom properties. |
| [downshift](https://github.com/downshift-js/downshift) | MIT | Prop-getter pattern for the React hook adapter. |
| [flatpickr](https://flatpickr.js.org) | MIT | `altInput` pattern: pretty text visible, hidden canonical input submits — our `name` option. |
+| Excel / Google Sheets / Notion / Airtable formula entry (studied 2026-07-29) | convention | The leading-`=` mode switch as the answer to plan 032's hardest open question. One text box serves two grammars: bare input keeps its literal meaning, `=` opts into arithmetic. Lets lingo add expressions where `-` currently means *range* without changing a single existing reading — the feature becomes purely additive instead of a corpus-breaking reinterpretation. |
+| [Soulver](https://soulver.app) · [Numi](https://numi.app) · Raycast/Spotlight calculators (studied 2026-07-29) | commercial, studied not copied | The "calculator in a text field" UX bar for plan 032: unit-aware natural-language lines (`$50 with 20% off`, `5 hours in minutes`) resolved inline with the result shown beside the input rather than replacing it. Confirms our choice to surface evaluated expressions through `./complete` as a ranked completion instead of silently committing a computed value into the field. |
| WAI / W3C forms tutorials & WAI-ARIA APG | W3C | Polite-while-typing vs alert-on-commit announcement split; debounce guidance; error summary + focus management. |
| MDN Constraint Validation, `:user-invalid` | CC / spec | Post-interaction error timing semantics; `setCustomValidity` interop. |
diff --git a/wiki/research/utility-opportunities.md b/wiki/research/utility-opportunities.md
index 3f204b2..dd92eb6 100644
--- a/wiki/research/utility-opportunities.md
+++ b/wiki/research/utility-opportunities.md
@@ -47,6 +47,34 @@ cost and bundle risk run from 1 (small) to 5 (large).
No new backlog entries result from this pass: every deferred opportunity above
already appears in a numbered plan or `plans/backlog.md`.
+### Addendum 2026-07-29 — input calculations re-scored
+
+A prior-art pass over mathjs plus a probe of live parser behavior changed three
+of the inputs behind the `./calc` row, and plan 032 was rewritten accordingly.
+
+- **Audience is wrong, not just incomplete.** Scored as "Forms"; the stronger
+ case is the LLM tool boundary. A model asked for `weight_kg: number` must do
+ the arithmetic in its head and nothing downstream can audit it, whereas a
+ field accepting `"12 * 0.75 kg"` moves the computation into deterministic
+ library code. That is a `value_error` mitigation, which raises
+ differentiation from 3 — no other Standard Schema library offers it.
+- **Cost and bundle risk drop with the `=` escape hatch.** Borrowing the
+ Excel/Sheets mode switch makes the feature purely additive instead of a
+ reinterpretation of existing readings, and the injected-evaluator pattern
+ (already used for `./complete` → `./date`) keeps both the main entry and
+ `./ai` budgets flat. Phase 1 (percent-of only) is a much smaller first slice
+ than the full expression grammar that was costed here.
+- **"Resolve plan 032 first" now has a prerequisite of its own.** The probe
+ found that `2+3 kg` already returns a range at `confidence: 1` with zero
+ issues — a D4 violation shipping today, independent of any calc decision.
+ That fix and the `AFFINE_DELTA_ASSUMED` warning land regardless of the
+ go/no-go.
+
+Two adjacent opportunities surfaced by the same pass went to `plans/backlog.md`
+rather than this matrix: rates with non-unit denominators (`£45 per night`),
+which ranks above input calculations on form value, and multiplier/count words
+(`twice 3 kg`, `a dozen eggs`).
+
## Shipped first slice: React completions
`@pascal-app/lingo/complete` already returns ranked, fully parsed completions,
From 68c3eb17dd4af890ff1309a5880ee0a63805ccc6 Mon Sep 17 00:00:00 2001
From: Aymeric Rabot
Date: Wed, 29 Jul 2026 14:29:11 +0200
Subject: [PATCH 2/7] feat(date): read date-to-date spans and calendar periods
as ranges
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
parseDateRange only understood clock slots, so "July 1 to July 5" and
"next week" both came back UNSUPPORTED_DATE — which is most of what
people type into a date field. A second pass now reuses the single-date
parser on each side of the existing separators, and expands a coarse
single date (week, month, quarter, year, weekend) into its real first
and last day rather than a midnight instant.
Two details the implementation forced. The bare-dash split has to stay
off when the input contains an ISO date, or "2026-08-03" splits itself;
that is now a flag on the separator table instead of a special case at
the call site. And the result carries a `dated` flag, because
humanizeDateRange otherwise renders a day span as "12:00 AM to 12:00 AM"
and breaks the two-way guarantee.
The date entry and the marginal ai/date budgets move up ~450 B; D71
records the measurement and the case for spending it.
Co-authored-by: Cursor
---
packages/lingo/CHANGELOG.md | 12 +
packages/lingo/scripts/size.mjs | 14 +-
packages/lingo/src/date/humanize.ts | 25 +-
packages/lingo/src/date/parse.ts | 148 ++++
packages/lingo/src/date/range.test.ts | 110 +++
packages/lingo/src/date/range.ts | 24 +-
packages/lingo/src/date/relative.ts | 15 +-
packages/lingo/tests/corpus/contract-v1.json | 721 +++++++++++++++++++
packages/lingo/tests/corpus/source.mjs | 21 +
plans/005-dates-and-durations.md | 32 +-
wiki/decisions.md | 51 ++
11 files changed, 1145 insertions(+), 28 deletions(-)
diff --git a/packages/lingo/CHANGELOG.md b/packages/lingo/CHANGELOG.md
index 8661338..3597e4c 100644
--- a/packages/lingo/CHANGELOG.md
+++ b/packages/lingo/CHANGELOG.md
@@ -9,6 +9,18 @@ change**, even if the API is untouched.
### Added
+- Calendar ranges in `parseDateRange` (`@pascal-app/lingo/date`), which
+ previously covered only clock slots and anchored durations:
+ - Date to date — `July 1 to July 5`, `Aug 3 - Aug 9`,
+ `between Aug 3 and Aug 9`, `from tomorrow to friday`, `Mon-Fri`,
+ `2026-08-01 to 2026-08-05`, and open ends (`from monday`, `until august 9`).
+ The end resolves against the start, so a pair never reads backwards.
+ - Calendar periods — `next week` spans Mon–Sun, `next month` the 1st to the
+ last, `this year` and `2027` the whole year, `August` the whole month.
+ - Weekends — `this weekend`, `next weekend`, `last weekend` span Saturday
+ through Sunday. `parseDate` gained the `next`/`last` weekend modifiers too.
+ - `humanizeDateRange` renders these as dates rather than clock times, so they
+ round-trip. Time slots are unchanged.
- Chinese and Japanese calendar and clock grammar: numeric dates written with
suffixes (`2026年3月5日`, `3月5日`, and years spelled digit-by-digit as
`二〇二六年`), weekdays (`星期三`, `周三`, `水曜日`), clocks closed by
diff --git a/packages/lingo/scripts/size.mjs b/packages/lingo/scripts/size.mjs
index 254f838..433d480 100644
--- a/packages/lingo/scripts/size.mjs
+++ b/packages/lingo/scripts/size.mjs
@@ -320,7 +320,10 @@ if (has('src/date/index.ts')) {
// 43.3 (was 41.0): D70 — suffix-delimited date/clock grammar (date/suffix.ts,
// date/numeral.ts): 年月日 numeric dates, 点/時/分/秒 clocks, day periods,
// unspaced date+time splitting, glued affix matching. Measured 43.01.
- check('./date (standalone, incl. engine)', dateAlone, 43_300)
+ // 43.8 (was 43.3): D71 — calendar ranges. Date endpoints reuse the existing
+ // splitter and single-date parser, so the whole capability (date-to-date,
+ // period spans, weekends, the humanize date branch) is 450 B. Measured 43.46.
+ check('./date (standalone, incl. engine)', dateAlone, 43_800)
const withDate = await bundleStdin(
`export * from './src/index.ts'; export * from './src/date/index.ts'`,
)
@@ -344,7 +347,9 @@ if (has('src/date/index.ts')) {
// the date module (see standalone note). Measured 14.29 after golfing.
// 15.7 (was 14.4): D70 — the suffix date/clock grammar lands entirely in the
// date module (see standalone note). Measured 15.46.
- check('./date (marginal over full)', withDate - full, 15_700)
+ // 16.2 (was 15.7): D71 — calendar ranges land entirely in the date module
+ // (see standalone note). Measured 15.90.
+ check('./date (marginal over full)', withDate - full, 16_200)
}
if (has('src/dom/index.ts')) {
@@ -477,7 +482,10 @@ if (has('src/ai/index.ts')) {
// bundled date module (see ./date notes). Measured 17.27 after golfing.
// 18.6 (was 17.4): D70 — the suffix date/clock grammar cascades through the
// bundled date module; no /ai code changed. Measured 18.39.
- check('./ai (marginal over full)', withAi - full, 18_600) // D30: +notation in shared renderNumber
+ // 19.1 (was 18.6): D71 — calendar ranges cascade through the bundled date
+ // module; no /ai code changed. dateRangeField gains the capability for free.
+ // Measured 18.84.
+ check('./ai (marginal over full)', withAi - full, 19_100) // D30: +notation in shared renderNumber
if (has('src/mcp/index.ts')) {
const withMcp = await bundleStdin(
`export * from './src/index.ts'; export * from './src/ai/index.ts'; export * from './src/mcp/index.ts'`,
diff --git a/packages/lingo/src/date/humanize.ts b/packages/lingo/src/date/humanize.ts
index fdcf1f1..3440f58 100644
--- a/packages/lingo/src/date/humanize.ts
+++ b/packages/lingo/src/date/humanize.ts
@@ -309,7 +309,8 @@ export interface HumanizeDateRangeOptions {
/**
* Render a time slot (from `parseDateRange()`) as a clock-time phrase —
- * "2:00 PM to 4:00 PM", "from 9:00 AM", "until 5:00 PM". The inverse of
+ * "2:00 PM to 4:00 PM", "from 9:00 AM", "until 5:00 PM". Calendar ranges render
+ * as dates instead — "2026-07-01 to 2026-07-05". The inverse of
* `parseDateRange()`: the emitted string re-parses to the same civil times.
* @example
* ```ts
@@ -319,7 +320,12 @@ export interface HumanizeDateRangeOptions {
* ```
*/
export function humanizeDateRange(
- range: { anchored?: boolean; start?: { date: Date }; end?: { date: Date } },
+ range: {
+ anchored?: boolean
+ dated?: boolean
+ end?: { date: Date; grain?: DateGrain }
+ start?: { date: Date; grain?: DateGrain }
+ },
opts?: HumanizeDateRangeOptions,
): string {
if (range.anchored && range.start && range.end) {
@@ -329,8 +335,19 @@ export function humanizeDateRange(
}
}
const hour12 = opts?.hour12 ?? true
- const start = range.start ? formatClock(range.start.date, hour12) : undefined
- const end = range.end ? formatClock(range.end.date, hour12) : undefined
+ // A calendar range must carry its dates, or "July 1 to July 5" renders as
+ // "12:00 AM to 12:00 AM" and re-parses to today. Clock slots stay bare.
+ const render = (ep: { date: Date; grain?: DateGrain }): string => {
+ if (!range.dated) {
+ return formatClock(ep.date, hour12)
+ }
+ const day = formatMonthDayYear(ep.date)
+ return ep.grain === 'hour' || ep.grain === 'minute' || ep.grain === 'second'
+ ? `${day} ${formatClock(ep.date, hour12)}`
+ : day
+ }
+ const start = range.start ? render(range.start) : undefined
+ const end = range.end ? render(range.end) : undefined
if (start && end) {
return `${start} to ${end}`
}
diff --git a/packages/lingo/src/date/parse.ts b/packages/lingo/src/date/parse.ts
index 7de8f62..57fb3e4 100644
--- a/packages/lingo/src/date/parse.ts
+++ b/packages/lingo/src/date/parse.ts
@@ -387,6 +387,12 @@ export interface DateRange {
*/
anchored?: boolean
confidence: number
+ /**
+ * True when the endpoints came from the calendar grammar rather than the
+ * clock, so `humanizeDateRange()` renders dates instead of bare times.
+ * Runtime-only provenance; never serialized.
+ */
+ dated?: boolean
end?: DateRangeEndpoint
issues: LingoIssue[]
ok: true
@@ -534,9 +540,151 @@ function parseDateRangeImpl(text: string, opts?: DateOptions): DateRange | DateR
}
return finishRange(p, span, zoneSpan, issues, startEp, endEp)
}
+
+ // Clock endpoints did not take. Retry the same splits against the full date
+ // grammar — "July 1 to July 5", "from tomorrow to friday". Order matters:
+ // "2pm" parses as a date too (time-only), so the clock pass must win first.
+ const dated = parseDateToDateRange(p, source, spanStart, span, zoneSpan, issues, rangeZone)
+ if (dated) {
+ return dated
+ }
return fail()
}
+const SPACED_DASH = /^(.+?)\s+[-–—]\s+(.+)$/d
+
+/** Last day of the period a coarse-grained date names, or null if it names a single day. */
+function periodEnd(date: Date, grain: DateGrain): Date | null {
+ if (grain === 'week') {
+ return addCalendar(date, { days: 6 })
+ }
+ if (grain === 'month') {
+ return new Date(date.getFullYear(), date.getMonth() + 1, 0)
+ }
+ if (grain === 'year') {
+ return new Date(date.getFullYear(), 11, 31)
+ }
+ return null
+}
+
+/** Build a range endpoint from a parsed date, resolving the zone like the anchored path. */
+function dateEndpoint(core: CoreDate, rangeZone: DateZone | undefined): DateRangeEndpoint {
+ const zone = core.zone ?? rangeZone
+ const out: DateRangeEndpoint = {
+ date: zone?.applied ? applyZoneToCivil(core.date, zone) : core.date,
+ grain: core.grain,
+ known: [...new Set(core.known)],
+ }
+ if (zone) {
+ out.zone = zone
+ }
+ return out
+}
+
+function parseDateToDateRange(
+ p: P,
+ source: string,
+ normStart: number,
+ span: Span,
+ zoneSpan: Span,
+ issues: LingoIssue[],
+ rangeZone?: DateZone,
+): DateRange | DateRangeFail | null {
+ // A date coarser than a day already IS a period — "next week" resolves to its
+ // Monday, "August" to the 1st. Spanning it needs no separate grammar: parse
+ // the whole slot as one date and widen it to the period it names.
+ const whole = parseWithOptionalTime(p, normStart, normStart + source.length)
+ // A weekend is named by its Saturday at day grain, so it needs the explicit
+ // widening the coarse grains get for free.
+ const wholeEnd =
+ whole &&
+ (/weekend$/i.test(source)
+ ? addCalendar(whole.date, { days: 1 })
+ : periodEnd(whole.date, whole.grain))
+ if (whole && wholeEnd) {
+ return finishDateRange(p, span, zoneSpan, [...issues, ...whole.issues], whole.ref === true, {
+ end: dateEndpoint(
+ { ...whole, date: wholeEnd, grain: 'day', known: knownFor('day') },
+ rangeZone,
+ ),
+ start: dateEndpoint(whole, rangeZone),
+ })
+ }
+
+ // The lazy dash rule splits "2026-07-01 - 2026-07-05" at the ISO date's own
+ // dash. When the input carries one, demand a SPACED dash instead, which no
+ // ISO date contains. "2026-07-01-2026-07-05" stays unparseable, as it should.
+ const hasIsoDash = /\d{4}-\d{2}/.test(source)
+ for (const { re, open, dash } of RANGE_SPLITS) {
+ const m = (dash && hasIsoDash ? SPACED_DASH : re).exec(source)
+ if (!m?.indices) {
+ continue
+ }
+ const at = (group: number): [number, number] => {
+ const [s, e] = m.indices![group]!
+ return [normStart + s, normStart + e]
+ }
+ if (open) {
+ const only = parseWithOptionalTime(p, ...at(1))
+ if (!only) {
+ continue
+ }
+ const ep = dateEndpoint(only, rangeZone)
+ return finishDateRange(p, span, zoneSpan, [...issues, ...only.issues], only.ref === true, {
+ ...(open === 'end' ? { start: ep } : { end: ep }),
+ })
+ }
+ const startCore = parseWithOptionalTime(p, ...at(1))
+ if (!startCore) {
+ continue
+ }
+ // Each endpoint rolls forward off `now` independently, so a July-3 reference
+ // reads "July 1 to July 5" as 2027-07-01 → 2026-07-05 — descending. Anchor
+ // the end to the start instead, so the pair always reads left to right.
+ const endCore = parseWithOptionalTime({ ...p, now: startCore.date }, ...at(2))
+ if (!endCore) {
+ continue
+ }
+ return finishDateRange(
+ p,
+ span,
+ zoneSpan,
+ [...issues, ...startCore.issues, ...endCore.issues],
+ startCore.ref === true || endCore.ref === true,
+ { end: dateEndpoint(endCore, rangeZone), start: dateEndpoint(startCore, rangeZone) },
+ )
+ }
+ return null
+}
+
+/**
+ * Absolute date endpoints need no reference time, so they bypass `finishRange`'s
+ * blanket NOW_REQUIRED (which exists for clock endpoints). Reference-dependent
+ * ones — "tomorrow", "next friday" — still demand an explicit `now`.
+ */
+function finishDateRange(
+ p: P,
+ span: Span,
+ zoneSpan: Span,
+ issues: LingoIssue[],
+ needsNow: boolean,
+ ends: { end?: DateRangeEndpoint; start?: DateRangeEndpoint },
+): DateRange | DateRangeFail {
+ if (p.opts.now === undefined && needsNow) {
+ return {
+ ok: false,
+ type: 'date-range-failure',
+ text: p.src,
+ issues: [...issues, makeIssue('NOW_REQUIRED', {}, span, p.opts.messages)],
+ }
+ }
+ const range = finishRange(p, span, zoneSpan, issues, ends.start, ends.end, true)
+ if (range.ok) {
+ range.dated = true
+ }
+ return range
+}
+
type CalendarDelta = Parameters[1]
function parseAnchoredDurationRange(
diff --git a/packages/lingo/src/date/range.test.ts b/packages/lingo/src/date/range.test.ts
index 9cada5f..75abab4 100644
--- a/packages/lingo/src/date/range.test.ts
+++ b/packages/lingo/src/date/range.test.ts
@@ -402,3 +402,113 @@ describe('anchored duration range trailing zone (F3)', () => {
expect(r.issues.filter((i) => i.code === 'TZ_IGNORED')).toEqual([])
})
})
+
+/** Local calendar day as `YYYY-MM-DD`, so assertions read host-independently. */
+function ymd(d: Date): string {
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
+}
+
+function span(text: string, now: Date = NOW): [string, string] {
+ const r = parseDateRange(text, { now })
+ if (!(r.ok && r.start && r.end)) {
+ throw new Error(`expected a closed range for ${text}`)
+ }
+ return [ymd(r.start.date), ymd(r.end.date)]
+}
+
+describe('parseDateRange date-to-date', () => {
+ it('parses date endpoints across every word separator', () => {
+ expect(span('Aug 3 to Aug 9')).toEqual(['2026-08-03', '2026-08-09'])
+ expect(span('Aug 3 - Aug 9')).toEqual(['2026-08-03', '2026-08-09'])
+ expect(span('between Aug 3 and Aug 9')).toEqual(['2026-08-03', '2026-08-09'])
+ expect(span('from Aug 3 to Aug 9')).toEqual(['2026-08-03', '2026-08-09'])
+ expect(span('Aug 3 through Aug 9')).toEqual(['2026-08-03', '2026-08-09'])
+ })
+
+ it('anchors the end to the start so the pair never reads backwards', () => {
+ // Each endpoint rolling forward off `now` independently would give
+ // 2027-07-01 → 2026-07-05, because July 1 is already past on July 3.
+ expect(span('July 1 to July 5')).toEqual(['2027-07-01', '2027-07-05'])
+ })
+
+ it('splits ISO endpoints on a spaced dash and refuses an unspaced one', () => {
+ expect(span('2026-08-01 to 2026-08-05')).toEqual(['2026-08-01', '2026-08-05'])
+ expect(span('2026-08-01 - 2026-08-05')).toEqual(['2026-08-01', '2026-08-05'])
+ // Genuinely ambiguous — four dashes, no way to know which one splits.
+ expect(parseDateRange('2026-08-01-2026-08-05', { now: NOW }).ok).toBe(false)
+ })
+
+ it('keeps open ends open', () => {
+ const from = parseDateRange('from monday', { now: NOW })
+ expect(from.ok && [ymd(from.start!.date), from.end]).toEqual(['2026-07-06', undefined])
+ const until = parseDateRange('until august 9', { now: NOW })
+ expect(until.ok && [until.start, ymd(until.end!.date)]).toEqual([undefined, '2026-08-09'])
+ })
+
+ it('needs an explicit now only for reference-dependent endpoints', () => {
+ expect(parseDateRange('2026-08-01 to 2026-08-05').ok).toBe(true)
+ const relative = parseDateRange('tomorrow to friday')
+ expect(relative.ok).toBe(false)
+ expect(relative.issues.some((i) => i.code === 'NOW_REQUIRED')).toBe(true)
+ })
+
+ it('leaves clock slots to the clock grammar', () => {
+ const clock = parseDateRange('2pm to 4pm', { now: NOW })
+ expect(clock.ok && clock.dated).toBeUndefined()
+ // A lone date is not a range; only a period is.
+ expect(parseDateRange('tomorrow', { now: NOW }).ok).toBe(false)
+ expect(parseDateRange('2pm', { now: NOW }).ok).toBe(false)
+ })
+})
+
+describe('parseDateRange calendar periods', () => {
+ it('spans the period a coarse-grained date names', () => {
+ expect(span('next week')).toEqual(['2026-07-06', '2026-07-12'])
+ expect(span('this month')).toEqual(['2026-07-01', '2026-07-31'])
+ expect(span('next month')).toEqual(['2026-08-01', '2026-08-31'])
+ expect(span('this year')).toEqual(['2026-01-01', '2026-12-31'])
+ expect(span('August')).toEqual(['2026-08-01', '2026-08-31'])
+ expect(span('2027')).toEqual(['2027-01-01', '2027-12-31'])
+ })
+
+ it('spans a weekend Saturday through Sunday', () => {
+ expect(span('this weekend')).toEqual(['2026-07-04', '2026-07-05'])
+ expect(span('next weekend')).toEqual(['2026-07-11', '2026-07-12'])
+ expect(span('last weekend')).toEqual(['2026-06-27', '2026-06-28'])
+ })
+
+ it('handles a February in a leap year', () => {
+ expect(span('February', new Date(2028, 0, 15))).toEqual(['2028-02-01', '2028-02-29'])
+ })
+})
+
+describe('humanizeDateRange round-trips calendar ranges', () => {
+ const cases = [
+ 'July 1 to July 5',
+ 'next week',
+ 'this weekend',
+ 'next month',
+ 'August',
+ 'from monday',
+ 'until august 9',
+ '2026-08-01 to 2026-08-05',
+ 'July 1 3pm to July 2 5pm',
+ ]
+
+ it.each(cases)('%s', (text) => {
+ const first = parseDateRange(text, { now: NOW })
+ expect(first.ok).toBe(true)
+ if (!first.ok) {
+ return
+ }
+ const again = parseDateRange(humanizeDateRange(first), { now: NOW })
+ expect(again.ok).toBe(true)
+ if (!again.ok) {
+ return
+ }
+ expect([again.start?.date.getTime(), again.end?.date.getTime()]).toEqual([
+ first.start?.date.getTime(),
+ first.end?.date.getTime(),
+ ])
+ })
+})
diff --git a/packages/lingo/src/date/range.ts b/packages/lingo/src/date/range.ts
index 631bb10..1a62039 100644
--- a/packages/lingo/src/date/range.ts
+++ b/packages/lingo/src/date/range.ts
@@ -70,14 +70,22 @@ function isNamedTime(p: P, text: string): boolean {
return aliases[text.toLowerCase()] !== undefined
}
-export const RANGE_SPLITS: { re: RegExp; open?: 'start' | 'end' }[] = [
- { re: /^between\s+(.+?)\s+and\s+(.+)$/i },
- { re: /^from\s+(.+?)\s+(?:to|till|til|until|through|thru|[-–—])\s+(.+)$/i },
- { re: /^(.+?)\s+(?:to|till|til|until|through|thru)\s+(.+)$/i },
- { re: /^(.+?)\s*[-–—]\s*(.+)$/i },
- { re: /^from\s+(.+?)(?:\s+onwards?)?$/i, open: 'end' },
- { re: /^(.+?)\s+onwards?$/i, open: 'end' },
- { re: /^(?:until|till|til|before|by)\s+(.+)$/i, open: 'start' },
+/**
+ * Whole-string splitters, tried in order. The `d` flag is load-bearing: the
+ * date-endpoint pass needs exact group offsets to slice `p.text`, because a
+ * leading frame word ("between ", "from ") means the left group does not start
+ * at offset 0.
+ */
+export const RANGE_SPLITS: { dash?: true; open?: 'start' | 'end'; re: RegExp }[] = [
+ { re: /^between\s+(.+?)\s+and\s+(.+)$/di },
+ { re: /^from\s+(.+?)\s+(?:to|till|til|until|through|thru|[-–—])\s+(.+)$/di },
+ { re: /^(.+?)\s+(?:to|till|til|until|through|thru)\s+(.+)$/di },
+ // Lazy, so it splits at the FIRST dash. Harmless for clocks ("9-5"), but an
+ // ISO date is full of dashes — the date pass skips this via `dash`.
+ { re: /^(.+?)\s*[-–—]\s*(.+)$/di, dash: true },
+ { re: /^from\s+(.+?)(?:\s+onwards?)?$/di, open: 'end' },
+ { re: /^(.+?)\s+onwards?$/di, open: 'end' },
+ { re: /^(?:until|till|til|before|by)\s+(.+)$/di, open: 'start' },
]
export function endpointDate(p: P, ep: Endpoint, baseDay: Date): DateRangeEndpoint {
diff --git a/packages/lingo/src/date/relative.ts b/packages/lingo/src/date/relative.ts
index 3952aae..2f9f665 100644
--- a/packages/lingo/src/date/relative.ts
+++ b/packages/lingo/src/date/relative.ts
@@ -453,15 +453,14 @@ function parseCalendarPeriod(p: P, start: number, end: number): CoreDate | null
return monthPeriodCore(p, mod, month, start, end)
}
}
- if (source === 'this weekend') {
+ // The weekend is named by its Saturday, at day grain — `parseDateRange` widens
+ // it through Sunday. Bare "weekend" reads as "this weekend".
+ const weekend = /^(?:(this|next|last)\s+)?weekend$/.exec(source)
+ if (weekend) {
const today = startOfDay(p.now)
- return core(
- addCalendar(today, { days: forwardDiff(today.getDay(), 6) }),
- 'day',
- knownFor('day'),
- start,
- end,
- )
+ const saturday = addCalendar(today, { days: forwardDiff(today.getDay(), 6) })
+ const shift = weekend[1] === 'next' ? 7 : weekend[1] === 'last' ? -7 : 0
+ return core(addCalendar(saturday, { days: shift }), 'day', knownFor('day'), start, end)
}
const edge =
/^(?:the\s+)?(beginning|start|end|middle|mid)(?:\s+of)?(?:\s+the)?(?:\s+(this|next|last))?\s+(.+)$/.exec(
diff --git a/packages/lingo/tests/corpus/contract-v1.json b/packages/lingo/tests/corpus/contract-v1.json
index c516420..e7d32ba 100644
--- a/packages/lingo/tests/corpus/contract-v1.json
+++ b/packages/lingo/tests/corpus/contract-v1.json
@@ -5812,6 +5812,46 @@
},
"confidence": 1
},
+ "next weekend": {
+ "input": "next weekend",
+ "type": "date",
+ "kind": "date",
+ "base": {
+ "year": 2026,
+ "month": 7,
+ "day": 11,
+ "hour": 0,
+ "minute": 0,
+ "second": 0
+ },
+ "unit": "day",
+ "issues": [],
+ "span": {
+ "start": 0,
+ "end": 12
+ },
+ "confidence": 1
+ },
+ "last weekend": {
+ "input": "last weekend",
+ "type": "date",
+ "kind": "date",
+ "base": {
+ "year": 2026,
+ "month": 6,
+ "day": 27,
+ "hour": 0,
+ "minute": 0,
+ "second": 0
+ },
+ "unit": "day",
+ "issues": [],
+ "span": {
+ "start": 0,
+ "end": 12
+ },
+ "confidence": 1
+ },
"beginning of the week": {
"input": "beginning of the week",
"type": "date",
@@ -7346,6 +7386,687 @@
"end": 14
},
"confidence": 0.9
+ },
+ "July 1 to July 5 [now:2026,6,3,9,0,0]": {
+ "input": "July 1 to July 5",
+ "opts": {
+ "now": [
+ 2026,
+ 6,
+ 3,
+ 9,
+ 0,
+ 0
+ ]
+ },
+ "type": "date-range",
+ "kind": "date-range",
+ "base": {
+ "start": {
+ "year": 2027,
+ "month": 7,
+ "day": 1,
+ "hour": 0,
+ "minute": 0,
+ "second": 0
+ },
+ "end": {
+ "year": 2027,
+ "month": 7,
+ "day": 5,
+ "hour": 0,
+ "minute": 0,
+ "second": 0
+ }
+ },
+ "unit": {
+ "start": "day",
+ "end": "day"
+ },
+ "issues": [],
+ "span": {
+ "start": 0,
+ "end": 16
+ },
+ "confidence": 1
+ },
+ "Aug 3 - Aug 9 [now:2026,6,3,9,0,0]": {
+ "input": "Aug 3 - Aug 9",
+ "opts": {
+ "now": [
+ 2026,
+ 6,
+ 3,
+ 9,
+ 0,
+ 0
+ ]
+ },
+ "type": "date-range",
+ "kind": "date-range",
+ "base": {
+ "start": {
+ "year": 2026,
+ "month": 8,
+ "day": 3,
+ "hour": 0,
+ "minute": 0,
+ "second": 0
+ },
+ "end": {
+ "year": 2026,
+ "month": 8,
+ "day": 9,
+ "hour": 0,
+ "minute": 0,
+ "second": 0
+ }
+ },
+ "unit": {
+ "start": "day",
+ "end": "day"
+ },
+ "issues": [],
+ "span": {
+ "start": 0,
+ "end": 13
+ },
+ "confidence": 1
+ },
+ "from tomorrow to friday [now:2026,6,3,9,0,0]": {
+ "input": "from tomorrow to friday",
+ "opts": {
+ "now": [
+ 2026,
+ 6,
+ 3,
+ 9,
+ 0,
+ 0
+ ]
+ },
+ "type": "date-range",
+ "kind": "date-range",
+ "base": {
+ "start": {
+ "year": 2026,
+ "month": 7,
+ "day": 4,
+ "hour": 0,
+ "minute": 0,
+ "second": 0
+ },
+ "end": {
+ "year": 2026,
+ "month": 7,
+ "day": 10,
+ "hour": 0,
+ "minute": 0,
+ "second": 0
+ }
+ },
+ "unit": {
+ "start": "day",
+ "end": "day"
+ },
+ "issues": [
+ "WEEKDAY_ASSUMED_NEXT"
+ ],
+ "span": {
+ "start": 0,
+ "end": 23
+ },
+ "confidence": 0.9
+ },
+ "2026-08-01 to 2026-08-05 [now:2026,6,3,9,0,0]": {
+ "input": "2026-08-01 to 2026-08-05",
+ "opts": {
+ "now": [
+ 2026,
+ 6,
+ 3,
+ 9,
+ 0,
+ 0
+ ]
+ },
+ "type": "date-range",
+ "kind": "date-range",
+ "base": {
+ "start": {
+ "year": 2026,
+ "month": 8,
+ "day": 1,
+ "hour": 0,
+ "minute": 0,
+ "second": 0
+ },
+ "end": {
+ "year": 2026,
+ "month": 8,
+ "day": 5,
+ "hour": 0,
+ "minute": 0,
+ "second": 0
+ }
+ },
+ "unit": {
+ "start": "day",
+ "end": "day"
+ },
+ "issues": [],
+ "span": {
+ "start": 0,
+ "end": 24
+ },
+ "confidence": 1
+ },
+ "2026-08-01 - 2026-08-05 [now:2026,6,3,9,0,0]": {
+ "input": "2026-08-01 - 2026-08-05",
+ "opts": {
+ "now": [
+ 2026,
+ 6,
+ 3,
+ 9,
+ 0,
+ 0
+ ]
+ },
+ "type": "date-range",
+ "kind": "date-range",
+ "base": {
+ "start": {
+ "year": 2026,
+ "month": 8,
+ "day": 1,
+ "hour": 0,
+ "minute": 0,
+ "second": 0
+ },
+ "end": {
+ "year": 2026,
+ "month": 8,
+ "day": 5,
+ "hour": 0,
+ "minute": 0,
+ "second": 0
+ }
+ },
+ "unit": {
+ "start": "day",
+ "end": "day"
+ },
+ "issues": [],
+ "span": {
+ "start": 0,
+ "end": 23
+ },
+ "confidence": 1
+ },
+ "Mon-Fri [now:2026,6,3,9,0,0]": {
+ "input": "Mon-Fri",
+ "opts": {
+ "now": [
+ 2026,
+ 6,
+ 3,
+ 9,
+ 0,
+ 0
+ ]
+ },
+ "type": "date-range",
+ "kind": "date-range",
+ "base": {
+ "start": {
+ "year": 2026,
+ "month": 7,
+ "day": 6,
+ "hour": 0,
+ "minute": 0,
+ "second": 0
+ },
+ "end": {
+ "year": 2026,
+ "month": 7,
+ "day": 10,
+ "hour": 0,
+ "minute": 0,
+ "second": 0
+ }
+ },
+ "unit": {
+ "start": "day",
+ "end": "day"
+ },
+ "issues": [
+ "WEEKDAY_ASSUMED_NEXT",
+ "WEEKDAY_ASSUMED_NEXT"
+ ],
+ "span": {
+ "start": 0,
+ "end": 7
+ },
+ "confidence": 0.8
+ },
+ "from monday [now:2026,6,3,9,0,0]": {
+ "input": "from monday",
+ "opts": {
+ "now": [
+ 2026,
+ 6,
+ 3,
+ 9,
+ 0,
+ 0
+ ]
+ },
+ "type": "date-range",
+ "kind": "date-range",
+ "base": {
+ "start": {
+ "year": 2026,
+ "month": 7,
+ "day": 6,
+ "hour": 0,
+ "minute": 0,
+ "second": 0
+ },
+ "end": null
+ },
+ "unit": {
+ "start": "day",
+ "end": null
+ },
+ "issues": [
+ "WEEKDAY_ASSUMED_NEXT"
+ ],
+ "span": {
+ "start": 0,
+ "end": 11
+ },
+ "confidence": 0.9
+ },
+ "until august 9 [now:2026,6,3,9,0,0]": {
+ "input": "until august 9",
+ "opts": {
+ "now": [
+ 2026,
+ 6,
+ 3,
+ 9,
+ 0,
+ 0
+ ]
+ },
+ "type": "date-range",
+ "kind": "date-range",
+ "base": {
+ "start": null,
+ "end": {
+ "year": 2026,
+ "month": 8,
+ "day": 9,
+ "hour": 0,
+ "minute": 0,
+ "second": 0
+ }
+ },
+ "unit": {
+ "start": null,
+ "end": "day"
+ },
+ "issues": [],
+ "span": {
+ "start": 0,
+ "end": 14
+ },
+ "confidence": 1
+ },
+ "next week [now:2026,6,3,9,0,0]": {
+ "input": "next week",
+ "opts": {
+ "now": [
+ 2026,
+ 6,
+ 3,
+ 9,
+ 0,
+ 0
+ ]
+ },
+ "type": "date-range",
+ "kind": "date-range",
+ "base": {
+ "start": {
+ "year": 2026,
+ "month": 7,
+ "day": 6,
+ "hour": 0,
+ "minute": 0,
+ "second": 0
+ },
+ "end": {
+ "year": 2026,
+ "month": 7,
+ "day": 12,
+ "hour": 0,
+ "minute": 0,
+ "second": 0
+ }
+ },
+ "unit": {
+ "start": "week",
+ "end": "day"
+ },
+ "issues": [],
+ "span": {
+ "start": 0,
+ "end": 9
+ },
+ "confidence": 1
+ },
+ "this month [now:2026,6,3,9,0,0]": {
+ "input": "this month",
+ "opts": {
+ "now": [
+ 2026,
+ 6,
+ 3,
+ 9,
+ 0,
+ 0
+ ]
+ },
+ "type": "date-range",
+ "kind": "date-range",
+ "base": {
+ "start": {
+ "year": 2026,
+ "month": 7,
+ "day": 1,
+ "hour": 0,
+ "minute": 0,
+ "second": 0
+ },
+ "end": {
+ "year": 2026,
+ "month": 7,
+ "day": 31,
+ "hour": 0,
+ "minute": 0,
+ "second": 0
+ }
+ },
+ "unit": {
+ "start": "month",
+ "end": "day"
+ },
+ "issues": [],
+ "span": {
+ "start": 0,
+ "end": 10
+ },
+ "confidence": 1
+ },
+ "next month [now:2026,6,3,9,0,0]": {
+ "input": "next month",
+ "opts": {
+ "now": [
+ 2026,
+ 6,
+ 3,
+ 9,
+ 0,
+ 0
+ ]
+ },
+ "type": "date-range",
+ "kind": "date-range",
+ "base": {
+ "start": {
+ "year": 2026,
+ "month": 8,
+ "day": 1,
+ "hour": 0,
+ "minute": 0,
+ "second": 0
+ },
+ "end": {
+ "year": 2026,
+ "month": 8,
+ "day": 31,
+ "hour": 0,
+ "minute": 0,
+ "second": 0
+ }
+ },
+ "unit": {
+ "start": "month",
+ "end": "day"
+ },
+ "issues": [],
+ "span": {
+ "start": 0,
+ "end": 10
+ },
+ "confidence": 1
+ },
+ "this year [now:2026,6,3,9,0,0]": {
+ "input": "this year",
+ "opts": {
+ "now": [
+ 2026,
+ 6,
+ 3,
+ 9,
+ 0,
+ 0
+ ]
+ },
+ "type": "date-range",
+ "kind": "date-range",
+ "base": {
+ "start": {
+ "year": 2026,
+ "month": 1,
+ "day": 1,
+ "hour": 0,
+ "minute": 0,
+ "second": 0
+ },
+ "end": {
+ "year": 2026,
+ "month": 12,
+ "day": 31,
+ "hour": 0,
+ "minute": 0,
+ "second": 0
+ }
+ },
+ "unit": {
+ "start": "year",
+ "end": "day"
+ },
+ "issues": [],
+ "span": {
+ "start": 0,
+ "end": 9
+ },
+ "confidence": 1
+ },
+ "August [now:2026,6,3,9,0,0]": {
+ "input": "August",
+ "opts": {
+ "now": [
+ 2026,
+ 6,
+ 3,
+ 9,
+ 0,
+ 0
+ ]
+ },
+ "type": "date-range",
+ "kind": "date-range",
+ "base": {
+ "start": {
+ "year": 2026,
+ "month": 8,
+ "day": 1,
+ "hour": 0,
+ "minute": 0,
+ "second": 0
+ },
+ "end": {
+ "year": 2026,
+ "month": 8,
+ "day": 31,
+ "hour": 0,
+ "minute": 0,
+ "second": 0
+ }
+ },
+ "unit": {
+ "start": "month",
+ "end": "day"
+ },
+ "issues": [],
+ "span": {
+ "start": 0,
+ "end": 6
+ },
+ "confidence": 1
+ },
+ "this weekend [now:2026,6,3,9,0,0]": {
+ "input": "this weekend",
+ "opts": {
+ "now": [
+ 2026,
+ 6,
+ 3,
+ 9,
+ 0,
+ 0
+ ]
+ },
+ "type": "date-range",
+ "kind": "date-range",
+ "base": {
+ "start": {
+ "year": 2026,
+ "month": 7,
+ "day": 4,
+ "hour": 0,
+ "minute": 0,
+ "second": 0
+ },
+ "end": {
+ "year": 2026,
+ "month": 7,
+ "day": 5,
+ "hour": 0,
+ "minute": 0,
+ "second": 0
+ }
+ },
+ "unit": {
+ "start": "day",
+ "end": "day"
+ },
+ "issues": [],
+ "span": {
+ "start": 0,
+ "end": 12
+ },
+ "confidence": 1
+ },
+ "next weekend [now:2026,6,3,9,0,0]": {
+ "input": "next weekend",
+ "opts": {
+ "now": [
+ 2026,
+ 6,
+ 3,
+ 9,
+ 0,
+ 0
+ ]
+ },
+ "type": "date-range",
+ "kind": "date-range",
+ "base": {
+ "start": {
+ "year": 2026,
+ "month": 7,
+ "day": 11,
+ "hour": 0,
+ "minute": 0,
+ "second": 0
+ },
+ "end": {
+ "year": 2026,
+ "month": 7,
+ "day": 12,
+ "hour": 0,
+ "minute": 0,
+ "second": 0
+ }
+ },
+ "unit": {
+ "start": "day",
+ "end": "day"
+ },
+ "issues": [],
+ "span": {
+ "start": 0,
+ "end": 12
+ },
+ "confidence": 1
+ },
+ "July 1 3pm to July 2 5pm [now:2026,6,3,9,0,0]": {
+ "input": "July 1 3pm to July 2 5pm",
+ "opts": {
+ "now": [
+ 2026,
+ 6,
+ 3,
+ 9,
+ 0,
+ 0
+ ]
+ },
+ "type": "date-range",
+ "kind": "date-range",
+ "base": {
+ "start": {
+ "year": 2027,
+ "month": 7,
+ "day": 1,
+ "hour": 15,
+ "minute": 0,
+ "second": 0
+ },
+ "end": {
+ "year": 2027,
+ "month": 7,
+ "day": 2,
+ "hour": 17,
+ "minute": 0,
+ "second": 0
+ }
+ },
+ "unit": {
+ "start": "hour",
+ "end": "hour"
+ },
+ "issues": [],
+ "span": {
+ "start": 0,
+ "end": 24
+ },
+ "confidence": 1
}
}
}
diff --git a/packages/lingo/tests/corpus/source.mjs b/packages/lingo/tests/corpus/source.mjs
index eeff959..ef8af6e 100644
--- a/packages/lingo/tests/corpus/source.mjs
+++ b/packages/lingo/tests/corpus/source.mjs
@@ -385,6 +385,8 @@ export const dateRows = [
['next year'],
['last year'],
['this weekend'],
+ ['next weekend'],
+ ['last weekend'],
['beginning of the week'],
['start of next month'],
['end of week'],
@@ -460,6 +462,25 @@ export const dateRangeRows = [
// A trailing zone applies to the whole slot; civil endpoints are kept (no
// applyZone) so the row is host-independent, and both TZ issues ride along.
['9am to 5pm EST', { now: MORNING }],
+ // Calendar ranges. Date endpoints reuse the same separators as clock slots;
+ // the end anchors to the start, so "July 1 to July 5" cannot read backwards.
+ ['July 1 to July 5', { now: MORNING }],
+ ['Aug 3 - Aug 9', { now: MORNING }],
+ ['from tomorrow to friday', { now: MORNING }],
+ ['2026-08-01 to 2026-08-05', { now: MORNING }],
+ ['2026-08-01 - 2026-08-05', { now: MORNING }],
+ ['Mon-Fri', { now: MORNING }],
+ ['from monday', { now: MORNING }],
+ ['until august 9', { now: MORNING }],
+ // A date coarser than a day names a period, so the range spans it.
+ ['next week', { now: MORNING }],
+ ['this month', { now: MORNING }],
+ ['next month', { now: MORNING }],
+ ['this year', { now: MORNING }],
+ ['August', { now: MORNING }],
+ ['this weekend', { now: MORNING }],
+ ['next weekend', { now: MORNING }],
+ ['July 1 3pm to July 2 5pm', { now: MORNING }],
]
export function buildContract({ lingo, parseDate, parseDateRange }) {
diff --git a/plans/005-dates-and-durations.md b/plans/005-dates-and-durations.md
index 2e7699d..0ae211d 100644
--- a/plans/005-dates-and-durations.md
+++ b/plans/005-dates-and-durations.md
@@ -60,8 +60,9 @@ wall-clock time).
interpretation (soonest occurrence) attached with its date; `last ` mirror.
- `next week/month/year` → grain-sized result (Monday of next week etc.);
`next ` / `last ` → the named month strictly after/before
- the current month period; `this weekend` → next Saturday (grain day), `end of
- (the) month/week/year`, `beginning/start of …`, `mid-` → 15th.
+ the current month period; `weekend` / `this|next|last weekend` → that week's
+ Saturday (grain day), `end of (the) month/week/year`, `beginning/start of …`,
+ `mid-` → 15th.
**Absolute**: ISO `2026-07-03`, `2026-07-03T14:30(:ss)`, compact `20260703` NOT
supported (ambiguity with big ints). `7/3/2026`, `7/3` — slash/dot/dash numeric
@@ -89,9 +90,30 @@ French `midi demain`, `le mois prochain`, `dans 3 jours`; Spanish `mañana`
Spanish ambiguity policy: bare `mañana` is tomorrow, while morning requires a
prepositional frame, so `mañana por la mañana` is tomorrow morning.
-**Date ranges**: `parseDateRange` covers time slots per plan 030 plus duration
-ranges anchored by a date/time: `N starting ` (including glued
-duration units like `3days starting tomorrow`) → `[anchor, anchor + duration)`.
+**Date ranges**: `parseDateRange` covers time slots per plan 030, duration
+ranges anchored by a date/time (`N starting `, including glued
+duration units like `3days starting tomorrow`) → `[anchor, anchor + duration)`,
+and calendar ranges per D71:
+
+- **Date to date** — the same separators as time slots, with date endpoints:
+ `July 1 to July 5`, `Aug 3 - Aug 9`, `between Aug 3 and Aug 9`,
+ `from tomorrow to friday`, `Mon-Fri`, `2026-08-01 to 2026-08-05`, plus open
+ ends (`from monday`, `until august 9`). The clock pass runs first, so `2pm to
+ 4pm` stays a time slot. The end parses against the *start* as its reference,
+ never `now`, so a pair cannot read backwards. A `\d{4}-\d{2}` run in the input
+ disables the lazy dash split and demands a spaced dash — `2026-08-01 -
+ 2026-08-05` parses, `2026-08-01-2026-08-05` does not.
+- **Calendar periods** — any date coarser than a day names a period, and the
+ range spans it: `next week` → Mon–Sun, `this month`/`next month` → 1st–last,
+ `this year`/`2027` → Jan 1–Dec 31, `August`/`next August` → the whole month.
+ No separate period-range grammar; it widens the single-date result.
+- **Weekends** — `weekend`, `this weekend`, `next weekend`, `last weekend` →
+ Saturday through Sunday. Day-grained, so widened explicitly.
+
+`humanizeDateRange` renders calendar ranges as dates (`2026-07-01 to
+2026-07-05`, `from 2026-07-06`, `until 2026-08-09`) rather than clock phrases,
+carrying the time only when an endpoint is hour-grained or finer. Provenance
+rides on a runtime-only `dated` flag, like `anchored`.
**Durations**: `90 min`, `1h30`, `1:30` (with kind duration: h:mm; warns of mm:ss
alternative), `1 h 30 min`, `an hour and a half`, `2 hours 15 minutes`, `three quarters
diff --git a/wiki/decisions.md b/wiki/decisions.md
index d358ad7..89425e5 100644
--- a/wiki/decisions.md
+++ b/wiki/decisions.md
@@ -595,3 +595,54 @@ locative fillers (`na proxima segunda-feira`), and a per-locale benchmark suite
so multi-language throughput is tracked rather than assumed. Revisit if a pack
wants glued splitting for a *Latin* script — the current filter is deliberately
`!/[a-z]/i`, on the theory that spaced scripts already tokenize correctly.
+
+**D71 · 2026-07-29 · Calendar ranges reuse the single-date parser instead of
+growing a date-range grammar.** `parseDateRange` covered clock slots and
+anchored durations; every calendar phrasing failed `UNSUPPORTED_DATE` —
+`July 1 to July 5`, `next week`, `this weekend`. The implementation is a second
+pass, not a second grammar: the clock pass runs unchanged, and only when it
+declines do the same `RANGE_SPLITS` separators get retried with
+`parseWithOptionalTime` on each side. Ordering is load-bearing — `2pm` parses as
+a date too (time-only), so a date-first pass would silently reclassify every
+existing slot.
+
+Three trade-offs worth recording:
+
+(1) **The end anchors to the start, not to `now`.** Endpoints resolved
+independently read `July 1 to July 5` on a July-3 reference as 2027-07-01 →
+2026-07-05: descending, because `forwardDates` rolls the start into next year
+and leaves the end in this one. Parsing the end against the start as its
+reference makes the pair always read left to right. Rejected: emitting a
+reversed-range issue, which describes a defect the parser caused itself.
+
+(2) **A period is a coarse-grained date, so it needs no period grammar.**
+`next week` already resolves to its Monday at `week` grain and `August` to the
+1st at `month` grain. Spanning them is a widening of a date the parser already
+produces, which is why the whole calendar-period feature is one `periodEnd`
+switch rather than a parallel set of range phrases — and why `2027` and
+`next August` work without being enumerated anywhere. Weekends are the
+exception: they resolve at `day` grain, so they widen explicitly. `weekend` also
+gained `next`/`last` modifiers, which it had never had.
+
+(3) **The lazy dash rule is disabled by ISO dates, not repaired.**
+`(.+?)\s*[-–—]\s*(.+)` splits `2026-08-01 - 2026-08-05` at the first ISO dash.
+When the input contains a `\d{4}-\d{2}` run the pass demands a *spaced* dash
+instead, which no ISO date contains. `2026-08-01-2026-08-05` stays unparseable —
+four dashes with no way to know which one splits, and guessing is exactly the
+silent wrong answer this library exists to avoid. `Mon-Fri` and `9-5` keep the
+lazy rule.
+
+`humanizeDateRange` gained a `dated` provenance flag alongside the existing
+`anchored` one, for the same reason it exists: without it a calendar range
+renders `"12:00 AM to 12:00 AM"` and re-parses to today, breaking the two-way
+guarantee. Runtime-only, never serialized.
+
+Budgets recalibrate for the capability (D19 escalation honored): `./date`
+standalone 43.3 → 43.8, `./date` marginal 15.7 → 16.2, `./ai` marginal
+18.6 → 19.1 (cascade only, no `/ai` code changed — `dateRangeField` gains the
+capability for free). The main entry is untouched at 39.18: the date module is
+not in it. Total cost 450 B gzip for date-to-date ranges, period spans,
+weekends, and the humanize branch. Every existing corpus row is unchanged; the
+18 new rows are additive. Revisit if multi-date lists (`today and tomorrow`)
+land — those need a new result type rather than a third endpoint, and the
+`dated` flag would likely generalize into it.
From 940652eb6cdbe55ea376a327cfaa49979c34ab70 Mon Sep 17 00:00:00 2001
From: Aymeric Rabot
Date: Wed, 29 Jul 2026 14:29:26 +0200
Subject: [PATCH 3/7] feat(site): adaptive calendar, LaTeX notation, and
normalizing grid demos
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Three demos for the shapes people actually build, each answering a
question the prose alone leaves open.
The date field shows that one input can serve a day picker, a two-month
range picker, and a time slot, because the reading says which shape it
found — no mode toggle for the person typing. It is the visible payoff
of the calendar-range work in the previous commit.
The notation demo makes the case that a canonical reading is structured,
not just cleaned up: unit id and value are separate fields, so mapping a
result to LaTeX is ~90 lines with no renderer in the library. KaTeX and
its stylesheet load in their own chunk on mount.
The grid puts a quantityField on a table column and lets every cell take
whatever people paste, which is the same field that guards a tool call —
"a column is a schema". It surfaces the honest-ambiguity story where it
is easiest to feel: totals add up only because the column has one unit,
assumptions are marked, and a GBP cell in a USD column refuses rather
than inventing a rate.
Shared useHydrated hook so both date-dependent demos hold a fixed
reference time through hydration and then switch to the real clock.
Co-authored-by: Cursor
---
apps/site/package.json | 3 +
apps/site/src/app/docs/page.tsx | 36 ++
.../components/site/calendar-field-demo.tsx | 349 ++++++++++++++
.../src/components/site/data-grid-demo.tsx | 435 ++++++++++++++++++
apps/site/src/components/site/katex-math.tsx | 44 ++
.../src/components/site/latex-units-demo.tsx | 129 ++++++
apps/site/src/components/site/use-hydrated.ts | 22 +
apps/site/src/lib/docs-catalog.ts | 32 ++
apps/site/src/lib/docs.md.ts | 12 +
apps/site/src/lib/latex.ts | 119 +++++
bun.lock | 13 +
11 files changed, 1194 insertions(+)
create mode 100644 apps/site/src/components/site/calendar-field-demo.tsx
create mode 100644 apps/site/src/components/site/data-grid-demo.tsx
create mode 100644 apps/site/src/components/site/katex-math.tsx
create mode 100644 apps/site/src/components/site/latex-units-demo.tsx
create mode 100644 apps/site/src/components/site/use-hydrated.ts
create mode 100644 apps/site/src/lib/latex.ts
diff --git a/apps/site/package.json b/apps/site/package.json
index 9108209..b6ee81f 100644
--- a/apps/site/package.json
+++ b/apps/site/package.json
@@ -18,10 +18,12 @@
"dependencies": {
"@base-ui/react": "^1.6.0",
"@pascal-app/lingo": "workspace:*",
+ "@tanstack/react-table": "^8.21.3",
"@vercel/analytics": "^2.0.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"geist": "^1.7.2",
+ "katex": "^0.18.1",
"lucide-react": "^1.23.0",
"marked": "^18.0.7",
"motion": "^12.42.2",
@@ -33,6 +35,7 @@
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
+ "@types/katex": "^0.16.8",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
diff --git a/apps/site/src/app/docs/page.tsx b/apps/site/src/app/docs/page.tsx
index 4c1ea90..c8ed774 100644
--- a/apps/site/src/app/docs/page.tsx
+++ b/apps/site/src/app/docs/page.tsx
@@ -8,14 +8,17 @@ import { IntegrationsTabs } from '@/app/integrations/integrations-tabs'
import { AiCanonicalizerDemo } from '@/components/site/ai-canonicalizer-demo'
import { AiEvalReadout } from '@/components/site/ai-eval-readout'
import { SectionHeading, SubHeading } from '@/components/site/anchor-heading'
+import { CalendarFieldDemo } from '@/components/site/calendar-field-demo'
import { CodeBlock } from '@/components/site/code-block'
import { CodeTabs } from '@/components/site/code-tabs'
import { CommandBlock } from '@/components/site/command-block'
import { CompletionsDemo } from '@/components/site/completions-demo'
import { CoverageExplorer } from '@/components/site/coverage-explorer'
+import { DataGridDemo } from '@/components/site/data-grid-demo'
import { DocsNav } from '@/components/site/docs-nav'
import { DocsPageActions } from '@/components/site/docs-page-actions'
import { FormUxGallery } from '@/components/site/form-ux-gallery'
+import { LatexUnitsDemo } from '@/components/site/latex-units-demo'
import { ParsePlayground } from '@/components/site/parse-playground'
import { PerformanceSection } from '@/components/site/performance-section'
import {
@@ -832,6 +835,16 @@ export default async function Home() {
{' '}
for fields lingo doesn't own.)
+
+ A column is a schema
+
+ The same idea scales past a single field. Give a table column a{' '}
+ quantityField and every cell in it accepts whatever people paste —
+ pounds and ounces, a comma decimal, Fahrenheit — while the column keeps one
+ canonical unit, so the totals row can just add numbers.
+
+
+ format() emits re-parses to the
same value.
+
+ Typeset the reading
+
+ A canonical reading is structured enough to render as notation, not just text. The
+ unit id and the numeric value are separate fields, so m/s2 becomes a
+ real fraction with a superscript and ± tolerance becomes a proper
+ interval. This demo maps results to LaTeX in ~90 lines; lingo itself ships no
+ renderer.
+
+
+
.
+
+ One field, three widgets
+
+ parseDateRange also reads date-to-date spans (
+ Aug 3 - Aug 9) and whole calendar periods (next week,{' '}
+ this weekend, next month), each expanded to its real first
+ and last day. Because the reading says which shape it found, one input can decide
+ between a day picker, a two-month range picker, and a time slot — no mode toggle for
+ the person typing.
+
+
+ addDays(first, i - lead))
+}
+
+/**
+ * Range first, then single date. `parseDateRange` is the stricter reader — it
+ * declines "tomorrow" — so trying it first is what lets one field decide
+ * between a day, a span, and a time slot without the caller picking a mode.
+ */
+function read(text: string, now: Date): Reading {
+ const empty: Reading = { end: null, mode: 'none', result: null, start: null }
+ if (text.trim() === '') {
+ return empty
+ }
+ const range = parseDateRange(text, { now })
+ if (range.ok) {
+ const start = range.start?.date ?? null
+ const end = range.end?.date ?? null
+ const clock = !range.dated
+ return { end, mode: clock ? 'slot' : 'range', result: range, start }
+ }
+ const single = parseDate(text, { now })
+ if (single.ok) {
+ return { end: null, mode: 'single', result: single, start: single.date }
+ }
+ return empty
+}
+
+function monthLabel(d: Date): string {
+ return d.toLocaleDateString('en-US', { month: 'long', year: 'numeric' })
+}
+
+function summary(reading: Reading): string {
+ const { start, end, mode } = reading
+ if (mode === 'none' || !(start || end)) {
+ return 'No reading'
+ }
+ if (mode === 'single' && start) {
+ return start.toLocaleDateString('en-US', { dateStyle: 'full' })
+ }
+ if (mode === 'slot' && reading.result && 'type' in reading.result) {
+ return humanizeDateRange(reading.result as DateRange)
+ }
+ const days =
+ start && end
+ ? Math.round((startOfDay(end).getTime() - startOfDay(start).getTime()) / 864e5) + 1
+ : 0
+ const left = start ? start.toLocaleDateString('en-US', { day: 'numeric', month: 'short' }) : '—'
+ const right = end
+ ? end.toLocaleDateString('en-US', { day: 'numeric', month: 'short', year: 'numeric' })
+ : 'open'
+ return days > 0
+ ? `${left} → ${right} · ${days} ${days === 1 ? 'day' : 'days'}`
+ : `${left} → ${right}`
+}
+
+const MODE_COPY: Record = {
+ none: 'no reading',
+ range: 'date range',
+ single: 'single date',
+ slot: 'time slot',
+}
+
+export function CalendarFieldDemo() {
+ const hydrated = useHydrated()
+ const now = useMemo(() => (hydrated ? new Date() : SSR_NOW), [hydrated])
+ const [value, setValue] = useState('next week')
+ const [cursor, setCursor] = useState(null)
+
+ const reading = useMemo(() => read(value, now), [value, now])
+ const { mode, start, end } = reading
+ const twoUp = mode === 'range'
+
+ // The calendar follows the parse unless the reader has paged away from it.
+ const anchor =
+ cursor ??
+ (start
+ ? new Date(start.getFullYear(), start.getMonth(), 1)
+ : new Date(now.getFullYear(), now.getMonth(), 1))
+ const months = twoUp ? [anchor, addMonths(anchor, 1)] : [anchor]
+
+ const select = useCallback(
+ (day: Date) => {
+ setCursor(new Date(day.getFullYear(), day.getMonth(), 1))
+ // Clicking inside a single-date reading extends it into a range — the
+ // widget grows a second calendar rather than the reader choosing one.
+ if (mode === 'single' && start && !sameDay(start, day)) {
+ const [a, b] = day < start ? [day, start] : [start, day]
+ setValue(`${ymd(a)} to ${ymd(b)}`)
+ return
+ }
+ setValue(ymd(day))
+ },
+ [mode, start],
+ )
+
+ const inRange = useCallback(
+ (day: Date): boolean => {
+ if (!(start && end) || mode === 'slot') {
+ return false
+ }
+ const t = startOfDay(day).getTime()
+ return t > startOfDay(start).getTime() && t < startOfDay(end).getTime()
+ },
+ [start, end, mode],
+ )
+
+ const isEdge = useCallback(
+ (day: Date): 'start' | 'end' | 'only' | null => {
+ if (mode === 'slot') {
+ return start && sameDay(start, day) ? 'only' : null
+ }
+ if (start && sameDay(start, day)) {
+ return end && !sameDay(start, end) ? 'start' : 'only'
+ }
+ if (end && sameDay(end, day)) {
+ return 'end'
+ }
+ return null
+ },
+ [start, end, mode],
+ )
+
+ return (
+ }
+ detailsLabel="Output"
+ stageClassName="min-h-[34rem] justify-start"
+ title="Adaptive date field"
+ >
+
+
+ {/* The second month is dropped below `sm` rather than scrolled:
+ 248px twice never fits a phone, and a clipped calendar reads as
+ broken where a single month reads as deliberate. */}
+
+ The totals only add up because every cell landed in its column’s canonical unit.
+ Dotted amber marks an assumption lingo made and recorded; red is a refusal. Cross-currency
+ conversion needs a rate, so £45 in a USD column
+ declines rather than inventing one.
+
+
+
+ )
+}
diff --git a/apps/site/src/components/site/katex-math.tsx b/apps/site/src/components/site/katex-math.tsx
new file mode 100644
index 0000000..9fde691
--- /dev/null
+++ b/apps/site/src/components/site/katex-math.tsx
@@ -0,0 +1,44 @@
+'use client'
+
+import 'katex/dist/katex.min.css'
+
+import katex from 'katex'
+import { useMemo } from 'react'
+
+import { cn } from '@/lib/utils'
+
+/**
+ * Typeset a LaTeX expression. Loaded through `next/dynamic` by its callers, so
+ * KaTeX and its stylesheet stay out of the docs route's initial chunk.
+ */
+export function KatexMath({
+ className,
+ display = true,
+ tex,
+}: {
+ className?: string
+ display?: boolean
+ tex: string
+}) {
+ const html = useMemo(
+ () =>
+ katex.renderToString(tex, {
+ displayMode: display,
+ output: 'html',
+ throwOnError: false,
+ }),
+ [tex, display],
+ )
+
+ // KaTeX's only HTML output path is a string. `throwOnError: false` makes it
+ // render malformed input as visible error markup rather than raw passthrough,
+ // and every `tex` here is generated by this app from a parsed lingo result.
+ return (
+
+ )
+}
+
+export default KatexMath
diff --git a/apps/site/src/components/site/latex-units-demo.tsx b/apps/site/src/components/site/latex-units-demo.tsx
new file mode 100644
index 0000000..0b4cd96
--- /dev/null
+++ b/apps/site/src/components/site/latex-units-demo.tsx
@@ -0,0 +1,129 @@
+'use client'
+
+import { lingo } from '@pascal-app/lingo'
+import dynamic from 'next/dynamic'
+import { useMemo, useState } from 'react'
+
+import { CopyButton } from '@/components/site/copy-button'
+import { DemoFrame } from '@/components/site/demo-frame'
+import { JsonView } from '@/components/site/json-view'
+import { Button } from '@/components/ui/button'
+import { Input } from '@/components/ui/input'
+import { resultToLatex, unitLatex } from '@/lib/latex'
+
+// KaTeX and its stylesheet are ~80 kB gzipped — a separate chunk, fetched when
+// this demo mounts rather than with the docs route.
+const KatexMath = dynamic(() => import('@/components/site/katex-math'), {
+ loading: () => ,
+ ssr: false,
+})
+
+const EXAMPLES = [
+ '72 in to cm',
+ `5'11"`,
+ '25 m/s to km/h',
+ 'between 5 and 10 kg',
+ '20 °C to °F',
+ '5 kg ± 200 g',
+ '9.8 m/s2',
+ 'at least 5 kg',
+] as const
+
+/** Derived and affine units, where plain text stops being readable. */
+const NOTATION = [
+ { kind: 'speed', unit: 'km/h' },
+ { kind: 'acceleration', unit: 'm/s2' },
+ { kind: 'volume', unit: 'm3' },
+ { kind: 'temperature', unit: 'C' },
+ { kind: 'pressure', unit: 'kg/cm2' },
+ { kind: 'fuel', unit: 'L/100km' },
+] as const
+
+export function LatexUnitsDemo() {
+ const [value, setValue] = useState('25 m/s to km/h')
+ const result = useMemo(() => (value.trim() === '' ? null : lingo(value)), [value])
+ const tex = useMemo(() => resultToLatex(result), [result])
+
+ return (
+ }
+ detailsLabel="Output"
+ stageClassName="min-h-[30rem] justify-start"
+ title="Measurements as notation"
+ >
+
+
+ setValue(e.target.value)}
+ placeholder="25 m/s to km/h"
+ spellCheck={false}
+ value={value}
+ />
+
+ {/* Label and notation stay adjacent inside each cell; spacing goes
+ between cells, or the reader pairs a unit with its neighbour. */}
+
+ {NOTATION.map(({ unit, kind }) => (
+
+ {unit}
+
+
+ ))}
+
+
+
+
+ )
+}
diff --git a/apps/site/src/components/site/use-hydrated.ts b/apps/site/src/components/site/use-hydrated.ts
new file mode 100644
index 0000000..3be16a6
--- /dev/null
+++ b/apps/site/src/components/site/use-hydrated.ts
@@ -0,0 +1,22 @@
+'use client'
+
+import { useSyncExternalStore } from 'react'
+
+function subscribe() {
+ return () => {
+ // Hydration is a one-way transition; there is nothing to unsubscribe from.
+ }
+}
+
+const onClient = () => true
+const onServer = () => false
+
+/**
+ * `false` during SSR and the first client render, `true` afterwards. Demos that
+ * parse relative dates use it to hold a fixed reference time through hydration
+ * and then switch to the real clock, so the markup matches but the reading is
+ * not frozen at build time.
+ */
+export function useHydrated(): boolean {
+ return useSyncExternalStore(subscribe, onClient, onServer)
+}
diff --git a/apps/site/src/lib/docs-catalog.ts b/apps/site/src/lib/docs-catalog.ts
index f97136e..bd4b84a 100644
--- a/apps/site/src/lib/docs-catalog.ts
+++ b/apps/site/src/lib/docs-catalog.ts
@@ -240,6 +240,13 @@ export const docsNavGroups: DocsNavGroup[] = [
'tool',
'bridge',
]),
+ page(
+ 'one-schema-grid',
+ 'A column is a schema',
+ 'Give a table column a field; every cell normalizes.',
+ ['data grid', 'table', 'tanstack', 'react-table', 'spreadsheet', 'cell', 'column', 'bulk'],
+ { depth: 3, markdownSectionId: 'one-schema' },
+ ),
],
},
{
@@ -256,6 +263,13 @@ export const docsNavGroups: DocsNavGroup[] = [
'best fit',
'delta',
]),
+ page(
+ 'convert-notation',
+ 'Typeset the reading',
+ 'Render canonical readings as LaTeX notation.',
+ ['latex', 'katex', 'mathjax', 'notation', 'scientific', 'typeset', 'superscript'],
+ { depth: 3, markdownSectionId: 'convert' },
+ ),
page('currency', 'Currency', 'Parse symbols and codes; convert with your own rates.', [
'currency',
'usd',
@@ -288,7 +302,25 @@ export const docsNavGroups: DocsNavGroup[] = [
'range',
'parseDateRange',
'humanizeDateRange',
+ 'calendar',
+ 'next week',
+ 'this weekend',
]),
+ page(
+ 'dates-calendar',
+ 'One field, three widgets',
+ 'Date-to-date spans, calendar periods, and slots in one input.',
+ [
+ 'calendar',
+ 'date range',
+ 'date picker',
+ 'next week',
+ 'this weekend',
+ 'next month',
+ 'two month',
+ ],
+ { depth: 3, markdownSectionId: 'dates' },
+ ),
page('locales', 'Locales', 'Load tree-shakeable language packs for parsing.', [
'locale',
'locale pack',
diff --git a/apps/site/src/lib/docs.md.ts b/apps/site/src/lib/docs.md.ts
index 8bfd800..02908de 100644
--- a/apps/site/src/lib/docs.md.ts
+++ b/apps/site/src/lib/docs.md.ts
@@ -275,6 +275,10 @@ useForm({ resolver: standardSchemaResolver(shipment) })`,
'',
'Type `5\'11"` or emit `"5 kg"`; both arrive canonical. (Whole-form resolvers use `lingoObject(shape, { passthrough: true })` for fields lingo doesn\'t own.)',
'',
+ '### A column is a schema',
+ '',
+ 'The same field works per table column. Attach a `quantityField` to a column and every cell accepts whatever people paste — `3 lb 4 oz`, `1,2 kg`, `72°F` — while the column keeps one canonical unit, so a totals row can add plain numbers. `safeParse` returns the canonical value plus any `warnings` (`UNIT_ASSUMED`, `AMBIGUOUS_UNIT`), so a grid can flag assumptions instead of hiding them.',
+ '',
'## Convert & format',
'',
'Convert between units with exact legal factors (temperature deltas included), then render values back with best-fit and compound output. Everything `format()` emits re-parses to the same value.',
@@ -283,6 +287,10 @@ useForm({ resolver: standardSchemaResolver(shipment) })`,
'',
'`convert` throws on a bad pair; `tryConvert` returns a structured issue instead. `convertDelta` converts a difference — a 5 °C rise is a 9 °F rise, not 41.',
'',
+ '### Typeset the reading',
+ '',
+ 'A result keeps the unit id and the numeric value in separate fields, so rendering notation is a pure mapping over the result — `m/s2` becomes a fraction with a superscript, `±` tolerance becomes an interval. lingo ships no renderer; the docs site maps results to LaTeX for KaTeX in about ninety lines.',
+ '',
'## Currency',
'',
'Parse symbols, ISO codes, and slang; format via Intl; convert with rates you supply. lingo never bundles or fetches FX.',
@@ -301,6 +309,10 @@ useForm({ resolver: standardSchemaResolver(shipment) })`,
'',
'Reference-dependent input needs an explicit `now`, so a queued job parses the same date every time. Fully absolute dates never require it. Browse the shorthand it reads under Catalog → Date shorthand and Time slots.',
'',
+ '### One field, three widgets',
+ '',
+ '`parseDateRange` reads three shapes: a time slot (`2pm to 4pm`), a date-to-date span (`July 1 to July 5`, `Aug 3 - Aug 9`, `2026-08-03..2026-08-09`), and a whole calendar period (`next week`, `this weekend`, `next month`, `next quarter`) expanded to its real first and last day. A `dated` flag on the result says whether the span came from date grammar or clock grammar, which is what lets one input drive a day picker, a two-month range picker, or a slot picker without asking the person to choose a mode first. `humanizeDateRange` renders each shape back in a form that re-parses.',
+ '',
'## Locales',
'',
'Locale packs are opt-in and tree-shakeable. English is built in; load overlays with `createLingo({ locales })` and pass `locale` when a field is known, or omit it for auto-detection among loaded packs plus English.',
diff --git a/apps/site/src/lib/latex.ts b/apps/site/src/lib/latex.ts
new file mode 100644
index 0000000..e77d245
--- /dev/null
+++ b/apps/site/src/lib/latex.ts
@@ -0,0 +1,119 @@
+import type { LingoResult, Quantity, QuantityRange } from '@pascal-app/lingo'
+
+/**
+ * Canonical readings rendered as LaTeX. The unit ID is already the machine
+ * form — "m/s2" carries its own solidus and exponent — so the notation is
+ * derived from the parse rather than kept in a lookup table that could drift.
+ */
+
+/** Thin space between value and unit, per SI typesetting. */
+const THIN = '\\,'
+
+function escapeText(text: string): string {
+ return text.replace(/[&%$#_{}]/g, (c) => `\\${c}`)
+}
+
+/** "s2" -> "\mathrm{s}^{2}", "km" -> "\mathrm{km}". */
+function factorLatex(factor: string): string {
+ const m = /^([^\d]+)(\d+)$/.exec(factor)
+ if (!m) {
+ return `\\mathrm{${escapeText(factor)}}`
+ }
+ return `\\mathrm{${escapeText(m[1] as string)}}^{${m[2]}}`
+}
+
+/** Unit ID to LaTeX. Handles the degree sign, currencies, and solidus units. */
+export function unitLatex(unit: string, kind?: string): string {
+ if (kind === 'temperature') {
+ return unit === 'K' ? '\\mathrm{K}' : `{}^{\\circ}\\mathrm{${escapeText(unit)}}`
+ }
+ if (kind === 'currency') {
+ return `\\mathrm{${escapeText(unit)}}`
+ }
+ if (unit === '%') {
+ return '\\%'
+ }
+ const [numerator, ...rest] = unit.split('/')
+ const top = factorLatex(numerator as string)
+ if (rest.length === 0) {
+ return top
+ }
+ return `\\frac{${top}}{${rest.map(factorLatex).join(THIN)}}`
+}
+
+function numberLatex(value: number, significant = 6): string {
+ if (!Number.isFinite(value)) {
+ return String(value)
+ }
+ const rounded = Number(value.toPrecision(significant))
+ if (rounded !== 0 && (Math.abs(rounded) >= 1e6 || Math.abs(rounded) < 1e-4)) {
+ const [mantissa, exponent] = rounded.toExponential().split('e')
+ return `${mantissa} \\times 10^{${Number(exponent)}}`
+ }
+ return String(rounded)
+}
+
+function quantityLatex(q: Quantity): string {
+ // A compound quantity keeps its parts — 5'11" is two terms, not 5.9166 ft.
+ if (q.parts && q.parts.length > 1) {
+ return q.parts
+ .map((part) => `${numberLatex(part.value)}${THIN}${unitLatex(part.unit, q.kind)}`)
+ .join('\\;')
+ }
+ // Percent sets tight against its number; SI units take a thin space.
+ const gap = q.unit === '%' ? '' : THIN
+ return `${numberLatex(q.value)}${gap}${unitLatex(q.unit, q.kind)}`
+}
+
+function rangeLatex(range: QuantityRange): string {
+ const min = range.min()
+ const max = range.max()
+ const center = range.plusMinus ? range.center() : null
+ if (center && max) {
+ return `${numberLatex(center.value)} \\pm ${numberLatex(max.value - center.value)}${THIN}${unitLatex(center.unit, range.kind)}`
+ }
+ if (min && max) {
+ const unit = unitLatex(max.unit, range.kind)
+ const sameUnit = min.unit === max.unit
+ const left = sameUnit
+ ? numberLatex(min.value)
+ : `${numberLatex(min.value)}${THIN}${unitLatex(min.unit, range.kind)}`
+ const leOrLt = range.exclusiveMin ? '<' : '\\le'
+ const geOrGt = range.exclusiveMax ? '<' : '\\le'
+ return `${left} ${leOrLt} x ${geOrGt} ${numberLatex(max.value)}${THIN}${unit}`
+ }
+ if (min) {
+ return `x ${range.exclusiveMin ? '>' : '\\ge'} ${quantityLatex(min)}`
+ }
+ if (max) {
+ return `x ${range.exclusiveMax ? '<' : '\\le'} ${quantityLatex(max)}`
+ }
+ return ''
+}
+
+/** A conversion side is a quantity or, for "between 5 and 10 kg in lb", a range. */
+function sideLatex(side: Quantity | QuantityRange): string {
+ return 'value' in side ? quantityLatex(side) : rangeLatex(side)
+}
+
+/**
+ * A successful reading as a LaTeX expression. Conversions render as equations
+ * so the source value survives — the arithmetic is the interesting part.
+ */
+export function resultToLatex(result: LingoResult | null): string | null {
+ if (!result?.ok) {
+ return null
+ }
+ switch (result.type) {
+ case 'number':
+ return numberLatex(result.value)
+ case 'quantity':
+ return `${result.quantity.approximate ? '\\approx ' : ''}${quantityLatex(result.quantity)}`
+ case 'range':
+ return rangeLatex(result.range)
+ case 'conversion':
+ return `${sideLatex(result.source)} = ${sideLatex(result.converted)}`
+ default:
+ return null
+ }
+}
diff --git a/bun.lock b/bun.lock
index 0c0fd16..c5ea11f 100644
--- a/bun.lock
+++ b/bun.lock
@@ -17,10 +17,12 @@
"dependencies": {
"@base-ui/react": "^1.6.0",
"@pascal-app/lingo": "workspace:*",
+ "@tanstack/react-table": "^8.21.3",
"@vercel/analytics": "^2.0.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"geist": "^1.7.2",
+ "katex": "^0.18.1",
"lucide-react": "^1.23.0",
"marked": "^18.0.7",
"motion": "^12.42.2",
@@ -32,6 +34,7 @@
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
+ "@types/katex": "^0.16.8",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
@@ -637,6 +640,10 @@
"@tailwindcss/postcss": ["@tailwindcss/postcss@4.3.2", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "@tailwindcss/node": "4.3.2", "@tailwindcss/oxide": "4.3.2", "postcss": "^8.5.15", "tailwindcss": "4.3.2" } }, "sha512-rjVWYCa7Ngbi5AarT6k8TkxUG3Wl1QKzHdIZVsjZSzf36Jmo2IKZt/NHRAwly8oDkbBOH0YTu+CHuf9jPxMc+g=="],
+ "@tanstack/react-table": ["@tanstack/react-table@8.21.3", "", { "dependencies": { "@tanstack/table-core": "8.21.3" }, "peerDependencies": { "react": ">=16.8", "react-dom": ">=16.8" } }, "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww=="],
+
+ "@tanstack/table-core": ["@tanstack/table-core@8.21.3", "", {}, "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg=="],
+
"@turbo/darwin-64": ["@turbo/darwin-64@2.10.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-guuiO2kKc7yUQFU2jhWyM/BrYGD/brqb0+JvJIXTQ/QI1NlWfHZ1id50kkkOWqxmBiDc8DgW2orfY85pQIc7gw=="],
"@turbo/darwin-arm64": ["@turbo/darwin-arm64@2.10.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-UglEDl/r1/h5Vw6oS6cEE29Jugz2sDLxHCSupNznKatj1fDMRXFqYKFcbvm8RTFhtYPI45NxkrPNo9BqNychBg=="],
@@ -665,6 +672,8 @@
"@types/json5": ["@types/json5@0.0.29", "", {}, "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ=="],
+ "@types/katex": ["@types/katex@0.16.8", "", {}, "sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg=="],
+
"@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="],
"@types/node": ["@types/node@20.19.43", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA=="],
@@ -1213,6 +1222,8 @@
"jsx-ast-utils": ["jsx-ast-utils@3.3.5", "", { "dependencies": { "array-includes": "^3.1.6", "array.prototype.flat": "^1.3.1", "object.assign": "^4.1.4", "object.values": "^1.1.6" } }, "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ=="],
+ "katex": ["katex@0.18.1", "", { "dependencies": { "commander": "^8.3.0" }, "bin": { "katex": "cli.js" } }, "sha512-Td8GCYSxDAoMhHOlKmCFMJ/hz5qlAAb71n66Dryw9nfCVfumLo7nhuotbvKom/XPADmrYC3O5QR71EPq4DarJQ=="],
+
"keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="],
"kleur": ["kleur@3.0.3", "", {}, "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w=="],
@@ -1815,6 +1826,8 @@
"is-bun-module/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="],
+ "katex/commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="],
+
"micromatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
"next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="],
From 6b22dce459e33216afbfbd7c0bdabfac6e12401e Mon Sep 17 00:00:00 2001
From: Aymeric Rabot
Date: Wed, 29 Jul 2026 14:53:32 +0200
Subject: [PATCH 4/7] fix(site): show the clock in the date reading, rule the
grid, publish the column code
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Browser-testing the three demos turned up three places where the surface
undersold or obscured what is actually happening.
The date field dropped the time. "tomorrow at 3pm" parses to an instant
with grain=hour, but the summary rendered dateStyle:'full' and showed
"Thursday, July 30, 2026" — the field looked less precise than the
reading. It now prints the clock when one was pinned, and `tomorrow` sits
beside `tomorrow at 3pm` in the examples so the difference is visible
without typing.
The grid's cells did not look editable: transparent inputs with no
resting border read as static text, so the one thing to do in the demo
was invisible. Vertical rules turn it into a spreadsheet, which is the
metaphor that already means "type here" — cheaper than 30 boxes. Price
also gains width, because RATE_REQUIRED was truncating to RATE_REQUIR….
The grid was the only demo with no source panel, and it is the one whose
whole claim is the field declaration. Its columns.ts snippet now shows
the four fields and the safeParse branch that separates a refusal from
an assumption.
Co-authored-by: Cursor
---
apps/site/src/app/docs/page.tsx | 18 +++++++++++++
.../components/site/calendar-field-demo.tsx | 17 +++++++++++-
.../src/components/site/data-grid-demo.tsx | 27 ++++++++++++-------
3 files changed, 51 insertions(+), 11 deletions(-)
diff --git a/apps/site/src/app/docs/page.tsx b/apps/site/src/app/docs/page.tsx
index c8ed774..9c1cddc 100644
--- a/apps/site/src/app/docs/page.tsx
+++ b/apps/site/src/app/docs/page.tsx
@@ -192,6 +192,23 @@ const formSchemaSnippet = `import { standardSchemaResolver } from '@hookform/res
// user types "5 kg" / picks a date; canonical on submit
useForm({ resolver: standardSchemaResolver(shipment) })`
+const gridColumnSnippet = `// The column owns the unit; the cell owns nothing but text.
+const columns = {
+ mass: quantityField({ kind: 'mass', unit: 'kg' }),
+ temp: quantityField({ kind: 'temperature', unit: 'C' }),
+ price: quantityField({ kind: 'currency', unit: 'USD' }),
+ shipBy: dateField({ now }),
+}
+
+function cell(column: keyof typeof columns, text: string) {
+ const { value, warnings, issues } = columns[column].safeParse(text)
+ if (issues) {
+ return { state: 'refused', note: issues[0].message }
+ }
+ // "$12.50" resolved to USD, "half a ton" to a short ton — say so.
+ return { state: warnings ? 'assumed' : 'ok', value, warnings }
+}`
+
// Sourced from the package so the badge wall can't drift from IssueCode.
const issueCodes = Object.keys(ISSUE_CODES)
@@ -844,6 +861,7 @@ export default async function Home() {
canonical unit, so the totals row can just add numbers.
+
diff --git a/apps/site/src/components/site/calendar-field-demo.tsx b/apps/site/src/components/site/calendar-field-demo.tsx
index fa27ec7..5e6d98f 100644
--- a/apps/site/src/components/site/calendar-field-demo.tsx
+++ b/apps/site/src/components/site/calendar-field-demo.tsx
@@ -21,11 +21,14 @@ import { cn } from '@/lib/utils'
/** SSR reference time. After hydration the field switches to the real clock. */
const SSR_NOW = new Date(2026, 6, 3, 9, 0, 0)
+/** `tomorrow` and `tomorrow at 3pm` sit next to each other on purpose: the pair
+ * is the shortest way to show that a clock time survives the reading. */
const EXAMPLES = [
'next week',
'this weekend',
'Aug 3 - Aug 9',
'tomorrow',
+ 'tomorrow at 3pm',
'3 days starting monday',
'2pm to 4pm',
] as const
@@ -100,13 +103,25 @@ function monthLabel(d: Date): string {
return d.toLocaleDateString('en-US', { month: 'long', year: 'numeric' })
}
+/** True once the reading pinned a time of day, not just a calendar day. */
+function isTimed(result: DateRange | DateResult | null): boolean {
+ if (!(result && 'grain' in result)) {
+ return false
+ }
+ return result.grain === 'hour' || result.grain === 'minute' || result.grain === 'second'
+}
+
function summary(reading: Reading): string {
const { start, end, mode } = reading
if (mode === 'none' || !(start || end)) {
return 'No reading'
}
if (mode === 'single' && start) {
- return start.toLocaleDateString('en-US', { dateStyle: 'full' })
+ // "tomorrow at 3pm" resolves to an instant, so dropping the clock here
+ // would show the field as less precise than the reading actually is.
+ return isTimed(reading.result)
+ ? start.toLocaleString('en-US', { dateStyle: 'full', timeStyle: 'short' })
+ : start.toLocaleDateString('en-US', { dateStyle: 'full' })
}
if (mode === 'slot' && reading.result && 'type' in reading.result) {
return humanizeDateRange(reading.result as DateRange)
diff --git a/apps/site/src/components/site/data-grid-demo.tsx b/apps/site/src/components/site/data-grid-demo.tsx
index 31ef9fd..0222f9a 100644
--- a/apps/site/src/components/site/data-grid-demo.tsx
+++ b/apps/site/src/components/site/data-grid-demo.tsx
@@ -206,7 +206,7 @@ function LingoCell({
aria-invalid={failed}
aria-label={`${HEADERS[columnKey]}, row ${rowIndex + 1}`}
className={cn(
- 'h-6 w-full min-w-0 rounded-[4px] bg-transparent px-1.5 font-mono text-[12px] outline-none',
+ 'h-6 w-full min-w-0 cursor-text rounded-[4px] bg-transparent px-1.5 font-mono text-[12px] outline-none',
'transition-[background-color,box-shadow] duration-[var(--motion-fast)] ease-[var(--ease-out)]',
'hover:bg-foreground/[0.045] focus-visible:bg-background focus-visible:shadow-[var(--surface-ring)]',
failed && 'text-destructive',
@@ -259,7 +259,7 @@ export function DataGridDemo() {
cell: (ctx) => (
update(ctx.row.original.id, 'sku', event.target.value)}
spellCheck={false}
value={ctx.getValue()}
@@ -339,24 +339,26 @@ export function DataGridDemo() {
>
-
+
Editable shipment rows. Each column parses free text into one canonical unit.
+ {/* Price carries the widest cell note (RATE_REQUIRED), so it gets
+ room the other numeric columns don't need. */}
-
-
-
-
+
-
+
+
+
+
{table.getHeaderGroups().map((group) => (
{group.headers.map((header) => (
@@ -369,8 +371,13 @@ export function DataGridDemo() {
{table.getRowModel().rows.map((row) => (
+ {/* Ruled on both axes: a spreadsheet grid is what tells the
+ reader these cells take typing, without 30 input boxes. */}
{row.getVisibleCells().map((cell) => (
-
))}
From a4dd686261154b7e835b29c4d42d1ecfa24c106c Mon Sep 17 00:00:00 2001
From: Aymeric Rabot
Date: Wed, 29 Jul 2026 15:11:32 +0200
Subject: [PATCH 5/7] fix(date): a weekend contains its Sunday, coarse ends
widen, reversed pairs swap
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Adversarial review of the calendar-range work found three wrong semantics, all
shipped by the previous commit and all now covered by tests.
"this weekend" rounded forward to the coming Saturday, so on a Sunday it meant
next weekend and the weekend the reader was standing in could only be reached
as "last weekend". Sunday now looks back a day.
A coarse endpoint only widened when it stood alone, so "August" spanned the
month but "July to August" ended on August 1. The widening is now shared by the
standalone path, the closing side of a split range, and "until X" — while
"from August" still opens on the 1st, which is the asymmetry we want.
"2026-08-09 to 2026-08-03" came back descending with no issue, contradicting
the claim that a pair never reads backwards. Dated pairs now swap and warn with
the existing RANGE_REVERSED code. Overnight clock slots are untouched: 9pm to
5am is a real slot, not a typo.
Tests go from 56 to 87: the weekend matrix runs all seven weekday references
instead of one Friday, and the round-trip cases add Sunday, year-end, leap and
common Februaries, and the reversed pair. Nine additive corpus rows, zero
breaking. Budgets unchanged.
Backlogged rather than fixed here: humanizeDateRange drops seconds (pre-existing,
verified against the commit before this feature), elliptical right sides like
"Aug 3-9", and quarters (needs a fiscalYearStart option to mean anything).
Co-authored-by: Cursor
---
packages/lingo/CHANGELOG.md | 9 +-
packages/lingo/src/date/absolute.ts | 2 +-
packages/lingo/src/date/parse.ts | 58 ++-
packages/lingo/src/date/range.test.ts | 105 +++++-
packages/lingo/src/date/relative.ts | 7 +-
packages/lingo/tests/corpus/contract-v1.json | 375 +++++++++++++++++++
packages/lingo/tests/corpus/source.mjs | 16 +
plans/005-dates-and-durations.md | 19 +-
plans/backlog.md | 19 +
wiki/decisions.md | 33 ++
10 files changed, 607 insertions(+), 36 deletions(-)
diff --git a/packages/lingo/CHANGELOG.md b/packages/lingo/CHANGELOG.md
index 3597e4c..a897de7 100644
--- a/packages/lingo/CHANGELOG.md
+++ b/packages/lingo/CHANGELOG.md
@@ -16,11 +16,18 @@ change**, even if the API is untouched.
`2026-08-01 to 2026-08-05`, and open ends (`from monday`, `until august 9`).
The end resolves against the start, so a pair never reads backwards.
- Calendar periods — `next week` spans Mon–Sun, `next month` the 1st to the
- last, `this year` and `2027` the whole year, `August` the whole month.
+ last, `this year` and `2027` the whole year, `August` the whole month. A
+ coarse endpoint widens when it closes a span too, so `July to August` ends
+ on August 31 and `until August` does the same, while `from August` still
+ opens on the 1st.
- Weekends — `this weekend`, `next weekend`, `last weekend` span Saturday
through Sunday. `parseDate` gained the `next`/`last` weekend modifiers too.
+ On a Saturday or Sunday, `this weekend` is the weekend in progress.
- `humanizeDateRange` renders these as dates rather than clock times, so they
round-trip. Time slots are unchanged.
+ - A descending dated pair such as `2026-08-09 to 2026-08-03` is swapped and
+ reported with the existing `RANGE_REVERSED` warning instead of being handed
+ back backwards. Overnight clock slots (`9pm to 5am`) are untouched.
- Chinese and Japanese calendar and clock grammar: numeric dates written with
suffixes (`2026年3月5日`, `3月5日`, and years spelled digit-by-digit as
`二〇二六年`), weekdays (`星期三`, `周三`, `水曜日`), clocks closed by
diff --git a/packages/lingo/src/date/absolute.ts b/packages/lingo/src/date/absolute.ts
index 4c672ca..c465e47 100644
--- a/packages/lingo/src/date/absolute.ts
+++ b/packages/lingo/src/date/absolute.ts
@@ -205,6 +205,6 @@ function buildYearless(p: P, month: number, day: number, year: number | undefine
return date
}
-function formatShortDate(date: Date): string {
+export function formatShortDate(date: Date): string {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
}
diff --git a/packages/lingo/src/date/parse.ts b/packages/lingo/src/date/parse.ts
index 57fb3e4..8b762fa 100644
--- a/packages/lingo/src/date/parse.ts
+++ b/packages/lingo/src/date/parse.ts
@@ -6,6 +6,7 @@ import type { LocalePack } from '../locale/types'
import { normalizeInput, toSourceSpan } from '../parse/normalize'
import type { SerializedResult } from '../parse/serialize'
import { tokenize } from '../parse/tokenize'
+import { formatShortDate } from './absolute'
import { addCalendar } from './civil'
import { parseUnitDuration } from './duration'
import {
@@ -553,6 +554,19 @@ function parseDateRangeImpl(text: string, opts?: DateOptions): DateRange | DateR
const SPACED_DASH = /^(.+?)\s+[-–—]\s+(.+)$/d
+/**
+ * Widen a coarse endpoint to the last day it covers. "August" ends on the 31st
+ * whether it stands alone or closes "July to August", and a weekend is named by
+ * its Saturday but runs one day longer. A day-grained endpoint widens to itself,
+ * which is what makes this safe to run over every closing endpoint.
+ */
+function widenToPeriodEnd(core: CoreDate, text: string): CoreDate {
+ const last = /weekend$/i.test(text)
+ ? addCalendar(core.date, { days: 1 })
+ : periodEnd(core.date, core.grain)
+ return last ? { ...core, date: last, grain: 'day', known: knownFor('day') } : core
+}
+
/** Last day of the period a coarse-grained date names, or null if it names a single day. */
function periodEnd(date: Date, grain: DateGrain): Date | null {
if (grain === 'week') {
@@ -596,17 +610,10 @@ function parseDateToDateRange(
const whole = parseWithOptionalTime(p, normStart, normStart + source.length)
// A weekend is named by its Saturday at day grain, so it needs the explicit
// widening the coarse grains get for free.
- const wholeEnd =
- whole &&
- (/weekend$/i.test(source)
- ? addCalendar(whole.date, { days: 1 })
- : periodEnd(whole.date, whole.grain))
- if (whole && wholeEnd) {
+ const wholeEnd = whole && widenToPeriodEnd(whole, source)
+ if (whole && wholeEnd !== whole) {
return finishDateRange(p, span, zoneSpan, [...issues, ...whole.issues], whole.ref === true, {
- end: dateEndpoint(
- { ...whole, date: wholeEnd, grain: 'day', known: knownFor('day') },
- rangeZone,
- ),
+ end: dateEndpoint(wholeEnd as CoreDate, rangeZone),
start: dateEndpoint(whole, rangeZone),
})
}
@@ -624,12 +631,18 @@ function parseDateToDateRange(
const [s, e] = m.indices![group]!
return [normStart + s, normStart + e]
}
+ const text = (group: number): string => {
+ const [s, e] = m.indices![group]!
+ return source.slice(s, e)
+ }
if (open) {
const only = parseWithOptionalTime(p, ...at(1))
if (!only) {
continue
}
- const ep = dateEndpoint(only, rangeZone)
+ // "until August" has to reach the end of August; "from August" opens at
+ // its first day, so only the closing side widens.
+ const ep = dateEndpoint(open === 'end' ? only : widenToPeriodEnd(only, text(1)), rangeZone)
return finishDateRange(p, span, zoneSpan, [...issues, ...only.issues], only.ref === true, {
...(open === 'end' ? { start: ep } : { end: ep }),
})
@@ -651,7 +664,10 @@ function parseDateToDateRange(
zoneSpan,
[...issues, ...startCore.issues, ...endCore.issues],
startCore.ref === true || endCore.ref === true,
- { end: dateEndpoint(endCore, rangeZone), start: dateEndpoint(startCore, rangeZone) },
+ {
+ end: dateEndpoint(widenToPeriodEnd(endCore, text(2)), rangeZone),
+ start: dateEndpoint(startCore, rangeZone),
+ },
)
}
return null
@@ -678,7 +694,23 @@ function finishDateRange(
issues: [...issues, makeIssue('NOW_REQUIRED', {}, span, p.opts.messages)],
}
}
- const range = finishRange(p, span, zoneSpan, issues, ends.start, ends.end, true)
+ let { start, end } = ends
+ const all = [...issues]
+ // A dated pair has no overnight reading the way a clock slot does, so
+ // descending endpoints are a typo. Swap and say so, the way a reversed
+ // quantity range already does, rather than handing back start > end.
+ if (start && end && start.date.getTime() > end.date.getTime()) {
+ ;[start, end] = [end, start]
+ all.push(
+ makeIssue(
+ 'RANGE_REVERSED',
+ { fixed: `${formatShortDate(start.date)} to ${formatShortDate(end.date)}` },
+ span,
+ p.opts.messages,
+ ),
+ )
+ }
+ const range = finishRange(p, span, zoneSpan, all, start, end, true)
if (range.ok) {
range.dated = true
}
diff --git a/packages/lingo/src/date/range.test.ts b/packages/lingo/src/date/range.test.ts
index 75abab4..5873699 100644
--- a/packages/lingo/src/date/range.test.ts
+++ b/packages/lingo/src/date/range.test.ts
@@ -477,31 +477,106 @@ describe('parseDateRange calendar periods', () => {
expect(span('last weekend')).toEqual(['2026-06-27', '2026-06-28'])
})
- it('handles a February in a leap year', () => {
- expect(span('February', new Date(2028, 0, 15))).toEqual(['2028-02-01', '2028-02-29'])
+ // Sunday is the case that breaks a naive "round forward to Saturday": the
+ // weekend in progress is behind you, so "this weekend" has to look back.
+ it.each([
+ ['Sunday', new Date(2026, 5, 28), ['2026-06-27', '2026-06-28']],
+ ['Monday', new Date(2026, 5, 29), ['2026-07-04', '2026-07-05']],
+ ['Tuesday', new Date(2026, 5, 30), ['2026-07-04', '2026-07-05']],
+ ['Wednesday', new Date(2026, 6, 1), ['2026-07-04', '2026-07-05']],
+ ['Thursday', new Date(2026, 6, 2), ['2026-07-04', '2026-07-05']],
+ ['Friday', new Date(2026, 6, 3), ['2026-07-04', '2026-07-05']],
+ ['Saturday', new Date(2026, 6, 4), ['2026-07-04', '2026-07-05']],
+ ])('reads "this weekend" from a %s as the weekend that contains it', (_day, now, expected) => {
+ expect(span('this weekend', now)).toEqual(expected)
+ })
+
+ it('keeps next and last a clean week either side on a weekend day', () => {
+ for (const now of [new Date(2026, 6, 4), new Date(2026, 6, 5)]) {
+ expect(span('this weekend', now)).toEqual(['2026-07-04', '2026-07-05'])
+ expect(span('next weekend', now)).toEqual(['2026-07-11', '2026-07-12'])
+ expect(span('last weekend', now)).toEqual(['2026-06-27', '2026-06-28'])
+ }
+ })
+
+ it('widens a coarse endpoint that closes a span', () => {
+ // The end of "July to August" is the end of August, not its first morning.
+ expect(span('July to August')).toEqual(['2027-07-01', '2027-08-31'])
+ expect(span('2026 to 2027')).toEqual(['2026-01-01', '2027-12-31'])
+ expect(span('this weekend to next weekend')).toEqual(['2026-07-04', '2026-07-12'])
+ expect(span('next week to next month')).toEqual(['2026-07-06', '2026-08-31'])
+ })
+
+ it('widens only the closing side of an open range', () => {
+ const until = parseDateRange('until August', { now: NOW })
+ expect(until.ok && ymd(until.end!.date)).toBe('2026-08-31')
+ const from = parseDateRange('from August', { now: NOW })
+ expect(from.ok && ymd(from.start!.date)).toBe('2026-08-01')
+ })
+
+ it('swaps a descending pair and says so', () => {
+ const r = parseDateRange('2026-08-09 to 2026-08-03', { now: NOW })
+ expect(r.ok && [ymd(r.start!.date), ymd(r.end!.date)]).toEqual(['2026-08-03', '2026-08-09'])
+ expect(r.issues.some((i) => i.code === 'RANGE_REVERSED')).toBe(true)
+ })
+
+ it('leaves an overnight clock slot alone', () => {
+ // 9pm to 5am is a real slot, not a reversal — only dated pairs swap.
+ const r = parseDateRange('9pm to 5am', { now: NOW })
+ expect(r.ok && r.issues.some((i) => i.code === 'RANGE_REVERSED')).toBe(false)
+ })
+
+ it.each([
+ ['leap February', new Date(2028, 0, 15), 'February', ['2028-02-01', '2028-02-29']],
+ ['common February', new Date(2027, 0, 15), 'February', ['2027-02-01', '2027-02-28']],
+ ['30-day April', new Date(2026, 0, 15), 'April', ['2026-04-01', '2026-04-30']],
+ ['December rollover', new Date(2026, 0, 15), 'December', ['2026-12-01', '2026-12-31']],
+ ['week over new year', new Date(2026, 11, 30), 'next week', ['2027-01-04', '2027-01-10']],
+ ])('gets the last day right for %s', (_name, now, text, expected) => {
+ expect(span(text, now)).toEqual(expected)
})
})
describe('humanizeDateRange round-trips calendar ranges', () => {
- const cases = [
- 'July 1 to July 5',
- 'next week',
- 'this weekend',
- 'next month',
- 'August',
- 'from monday',
- 'until august 9',
- '2026-08-01 to 2026-08-05',
- 'July 1 3pm to July 2 5pm',
+ // Every reference here is deliberate: SUNDAY catches weekend phrasing that
+ // only holds mid-week, and YEAR_END catches periods that cross into January.
+ const SUNDAY = new Date(2026, 5, 28, 9, 0, 0)
+ const YEAR_END = new Date(2026, 11, 30, 9, 0, 0)
+
+ const cases: [string, Date][] = [
+ ['July 1 to July 5', NOW],
+ ['next week', NOW],
+ ['this weekend', NOW],
+ ['next month', NOW],
+ ['August', NOW],
+ ['from monday', NOW],
+ ['until august 9', NOW],
+ ['2026-08-01 to 2026-08-05', NOW],
+ ['July 1 3pm to July 2 5pm', NOW],
+ ['this weekend', SUNDAY],
+ ['next weekend', SUNDAY],
+ ['last weekend', SUNDAY],
+ ['this weekend', new Date(2026, 6, 4, 9, 0, 0)],
+ ['July to August', NOW],
+ ['until August', NOW],
+ ['from August', NOW],
+ ['2026 to 2027', NOW],
+ ['this weekend to next weekend', NOW],
+ ['next week', YEAR_END],
+ ['next month', YEAR_END],
+ ['Dec 28 to Jan 3', NOW],
+ ['2026-08-09 to 2026-08-03', NOW],
+ ['February', new Date(2028, 0, 15, 9, 0, 0)],
+ ['3 days starting monday', NOW],
]
- it.each(cases)('%s', (text) => {
- const first = parseDateRange(text, { now: NOW })
+ it.each(cases)('%s', (text, now) => {
+ const first = parseDateRange(text, { now })
expect(first.ok).toBe(true)
if (!first.ok) {
return
}
- const again = parseDateRange(humanizeDateRange(first), { now: NOW })
+ const again = parseDateRange(humanizeDateRange(first), { now })
expect(again.ok).toBe(true)
if (!again.ok) {
return
diff --git a/packages/lingo/src/date/relative.ts b/packages/lingo/src/date/relative.ts
index 2f9f665..75cc0d5 100644
--- a/packages/lingo/src/date/relative.ts
+++ b/packages/lingo/src/date/relative.ts
@@ -458,7 +458,12 @@ function parseCalendarPeriod(p: P, start: number, end: number): CoreDate | null
const weekend = /^(?:(this|next|last)\s+)?weekend$/.exec(source)
if (weekend) {
const today = startOfDay(p.now)
- const saturday = addCalendar(today, { days: forwardDiff(today.getDay(), 6) })
+ const weekday = today.getDay()
+ // Sunday still belongs to the weekend that began the day before. Rounding
+ // forward would put "this weekend" a week out and leave the weekend the
+ // reader is standing in reachable only as "last weekend".
+ const toSaturday = weekday === 0 ? -1 : forwardDiff(weekday, 6)
+ const saturday = addCalendar(today, { days: toSaturday })
const shift = weekend[1] === 'next' ? 7 : weekend[1] === 'last' ? -7 : 0
return core(addCalendar(saturday, { days: shift }), 'day', knownFor('day'), start, end)
}
diff --git a/packages/lingo/tests/corpus/contract-v1.json b/packages/lingo/tests/corpus/contract-v1.json
index e7d32ba..240258c 100644
--- a/packages/lingo/tests/corpus/contract-v1.json
+++ b/packages/lingo/tests/corpus/contract-v1.json
@@ -8067,6 +8067,381 @@
"end": 24
},
"confidence": 1
+ },
+ "July to August [now:2026,6,3,9,0,0]": {
+ "input": "July to August",
+ "opts": {
+ "now": [
+ 2026,
+ 6,
+ 3,
+ 9,
+ 0,
+ 0
+ ]
+ },
+ "type": "date-range",
+ "kind": "date-range",
+ "base": {
+ "start": {
+ "year": 2027,
+ "month": 7,
+ "day": 1,
+ "hour": 0,
+ "minute": 0,
+ "second": 0
+ },
+ "end": {
+ "year": 2027,
+ "month": 8,
+ "day": 31,
+ "hour": 0,
+ "minute": 0,
+ "second": 0
+ }
+ },
+ "unit": {
+ "start": "month",
+ "end": "day"
+ },
+ "issues": [],
+ "span": {
+ "start": 0,
+ "end": 14
+ },
+ "confidence": 1
+ },
+ "until August [now:2026,6,3,9,0,0]": {
+ "input": "until August",
+ "opts": {
+ "now": [
+ 2026,
+ 6,
+ 3,
+ 9,
+ 0,
+ 0
+ ]
+ },
+ "type": "date-range",
+ "kind": "date-range",
+ "base": {
+ "start": null,
+ "end": {
+ "year": 2026,
+ "month": 8,
+ "day": 31,
+ "hour": 0,
+ "minute": 0,
+ "second": 0
+ }
+ },
+ "unit": {
+ "start": null,
+ "end": "day"
+ },
+ "issues": [],
+ "span": {
+ "start": 0,
+ "end": 12
+ },
+ "confidence": 1
+ },
+ "from August [now:2026,6,3,9,0,0]": {
+ "input": "from August",
+ "opts": {
+ "now": [
+ 2026,
+ 6,
+ 3,
+ 9,
+ 0,
+ 0
+ ]
+ },
+ "type": "date-range",
+ "kind": "date-range",
+ "base": {
+ "start": {
+ "year": 2026,
+ "month": 8,
+ "day": 1,
+ "hour": 0,
+ "minute": 0,
+ "second": 0
+ },
+ "end": null
+ },
+ "unit": {
+ "start": "month",
+ "end": null
+ },
+ "issues": [],
+ "span": {
+ "start": 0,
+ "end": 11
+ },
+ "confidence": 1
+ },
+ "2026 to 2027 [now:2026,6,3,9,0,0]": {
+ "input": "2026 to 2027",
+ "opts": {
+ "now": [
+ 2026,
+ 6,
+ 3,
+ 9,
+ 0,
+ 0
+ ]
+ },
+ "type": "date-range",
+ "kind": "date-range",
+ "base": {
+ "start": {
+ "year": 2026,
+ "month": 1,
+ "day": 1,
+ "hour": 0,
+ "minute": 0,
+ "second": 0
+ },
+ "end": {
+ "year": 2027,
+ "month": 12,
+ "day": 31,
+ "hour": 0,
+ "minute": 0,
+ "second": 0
+ }
+ },
+ "unit": {
+ "start": "year",
+ "end": "day"
+ },
+ "issues": [],
+ "span": {
+ "start": 0,
+ "end": 12
+ },
+ "confidence": 1
+ },
+ "this weekend to next weekend [now:2026,6,3,9,0,0]": {
+ "input": "this weekend to next weekend",
+ "opts": {
+ "now": [
+ 2026,
+ 6,
+ 3,
+ 9,
+ 0,
+ 0
+ ]
+ },
+ "type": "date-range",
+ "kind": "date-range",
+ "base": {
+ "start": {
+ "year": 2026,
+ "month": 7,
+ "day": 4,
+ "hour": 0,
+ "minute": 0,
+ "second": 0
+ },
+ "end": {
+ "year": 2026,
+ "month": 7,
+ "day": 12,
+ "hour": 0,
+ "minute": 0,
+ "second": 0
+ }
+ },
+ "unit": {
+ "start": "day",
+ "end": "day"
+ },
+ "issues": [],
+ "span": {
+ "start": 0,
+ "end": 28
+ },
+ "confidence": 1
+ },
+ "2026-08-09 to 2026-08-03 [now:2026,6,3,9,0,0]": {
+ "input": "2026-08-09 to 2026-08-03",
+ "opts": {
+ "now": [
+ 2026,
+ 6,
+ 3,
+ 9,
+ 0,
+ 0
+ ]
+ },
+ "type": "date-range",
+ "kind": "date-range",
+ "base": {
+ "start": {
+ "year": 2026,
+ "month": 8,
+ "day": 3,
+ "hour": 0,
+ "minute": 0,
+ "second": 0
+ },
+ "end": {
+ "year": 2026,
+ "month": 8,
+ "day": 9,
+ "hour": 0,
+ "minute": 0,
+ "second": 0
+ }
+ },
+ "unit": {
+ "start": "day",
+ "end": "day"
+ },
+ "issues": [
+ "RANGE_REVERSED"
+ ],
+ "span": {
+ "start": 0,
+ "end": 24
+ },
+ "confidence": 1
+ },
+ "this weekend [now:2026,5,28,9,0,0]": {
+ "input": "this weekend",
+ "opts": {
+ "now": [
+ 2026,
+ 5,
+ 28,
+ 9,
+ 0,
+ 0
+ ]
+ },
+ "type": "date-range",
+ "kind": "date-range",
+ "base": {
+ "start": {
+ "year": 2026,
+ "month": 6,
+ "day": 27,
+ "hour": 0,
+ "minute": 0,
+ "second": 0
+ },
+ "end": {
+ "year": 2026,
+ "month": 6,
+ "day": 28,
+ "hour": 0,
+ "minute": 0,
+ "second": 0
+ }
+ },
+ "unit": {
+ "start": "day",
+ "end": "day"
+ },
+ "issues": [],
+ "span": {
+ "start": 0,
+ "end": 12
+ },
+ "confidence": 1
+ },
+ "next weekend [now:2026,5,28,9,0,0]": {
+ "input": "next weekend",
+ "opts": {
+ "now": [
+ 2026,
+ 5,
+ 28,
+ 9,
+ 0,
+ 0
+ ]
+ },
+ "type": "date-range",
+ "kind": "date-range",
+ "base": {
+ "start": {
+ "year": 2026,
+ "month": 7,
+ "day": 4,
+ "hour": 0,
+ "minute": 0,
+ "second": 0
+ },
+ "end": {
+ "year": 2026,
+ "month": 7,
+ "day": 5,
+ "hour": 0,
+ "minute": 0,
+ "second": 0
+ }
+ },
+ "unit": {
+ "start": "day",
+ "end": "day"
+ },
+ "issues": [],
+ "span": {
+ "start": 0,
+ "end": 12
+ },
+ "confidence": 1
+ },
+ "last weekend [now:2026,5,28,9,0,0]": {
+ "input": "last weekend",
+ "opts": {
+ "now": [
+ 2026,
+ 5,
+ 28,
+ 9,
+ 0,
+ 0
+ ]
+ },
+ "type": "date-range",
+ "kind": "date-range",
+ "base": {
+ "start": {
+ "year": 2026,
+ "month": 6,
+ "day": 20,
+ "hour": 0,
+ "minute": 0,
+ "second": 0
+ },
+ "end": {
+ "year": 2026,
+ "month": 6,
+ "day": 21,
+ "hour": 0,
+ "minute": 0,
+ "second": 0
+ }
+ },
+ "unit": {
+ "start": "day",
+ "end": "day"
+ },
+ "issues": [],
+ "span": {
+ "start": 0,
+ "end": 12
+ },
+ "confidence": 1
}
}
}
diff --git a/packages/lingo/tests/corpus/source.mjs b/packages/lingo/tests/corpus/source.mjs
index ef8af6e..09758cf 100644
--- a/packages/lingo/tests/corpus/source.mjs
+++ b/packages/lingo/tests/corpus/source.mjs
@@ -447,6 +447,8 @@ export const dateRows = [
// Time slots (plan 030). A morning `now` keeps afternoon slots on the same
// civil day; civil endpoints read back host-independently (local wall-clock).
const MORNING = [2026, 6, 3, 9, 0, 0]
+/** A Sunday, so weekend phrasing is pinned on the day that used to read wrong. */
+const SUNDAY = [2026, 5, 28, 9, 0, 0]
export const dateRangeRows = [
['9-5', { now: MORNING }],
@@ -481,6 +483,20 @@ export const dateRangeRows = [
['this weekend', { now: MORNING }],
['next weekend', { now: MORNING }],
['July 1 3pm to July 2 5pm', { now: MORNING }],
+ // A coarse endpoint widens on the closing side too, so these end on the last
+ // day of the period rather than its first (D72).
+ ['July to August', { now: MORNING }],
+ ['until August', { now: MORNING }],
+ ['from August', { now: MORNING }],
+ ['2026 to 2027', { now: MORNING }],
+ ['this weekend to next weekend', { now: MORNING }],
+ // Absolute endpoints given backwards swap and warn; an overnight clock slot
+ // is left alone, which is why 10pm-to-2am sits above without an issue.
+ ['2026-08-09 to 2026-08-03', { now: MORNING }],
+ // Sunday is the reference that breaks a naive round-forward-to-Saturday.
+ ['this weekend', { now: SUNDAY }],
+ ['next weekend', { now: SUNDAY }],
+ ['last weekend', { now: SUNDAY }],
]
export function buildContract({ lingo, parseDate, parseDateRange }) {
diff --git a/plans/005-dates-and-durations.md b/plans/005-dates-and-durations.md
index 0ae211d..d8d3276 100644
--- a/plans/005-dates-and-durations.md
+++ b/plans/005-dates-and-durations.md
@@ -100,15 +100,24 @@ and calendar ranges per D71:
`from tomorrow to friday`, `Mon-Fri`, `2026-08-01 to 2026-08-05`, plus open
ends (`from monday`, `until august 9`). The clock pass runs first, so `2pm to
4pm` stays a time slot. The end parses against the *start* as its reference,
- never `now`, so a pair cannot read backwards. A `\d{4}-\d{2}` run in the input
- disables the lazy dash split and demands a spaced dash — `2026-08-01 -
- 2026-08-05` parses, `2026-08-01-2026-08-05` does not.
+ never `now`, so a relative pair cannot read backwards. Two absolute endpoints
+ can still be given in the wrong order — `2026-08-09 to 2026-08-03` is swapped
+ and reported with `RANGE_REVERSED`, per D72. Only the dated path swaps; `9pm
+ to 5am` is a real overnight slot. A `\d{4}-\d{2}` run in the input disables
+ the lazy dash split and demands a spaced dash — `2026-08-01 - 2026-08-05`
+ parses, `2026-08-01-2026-08-05` does not.
- **Calendar periods** — any date coarser than a day names a period, and the
range spans it: `next week` → Mon–Sun, `this month`/`next month` → 1st–last,
`this year`/`2027` → Jan 1–Dec 31, `August`/`next August` → the whole month.
- No separate period-range grammar; it widens the single-date result.
+ No separate period-range grammar; it widens the single-date result. The same
+ widening applies to a coarse endpoint that *closes* a span, so `July to
+ August` ends August 31 and `until August` ends August 31, while `from August`
+ opens on the 1st (D72).
- **Weekends** — `weekend`, `this weekend`, `next weekend`, `last weekend` →
- Saturday through Sunday. Day-grained, so widened explicitly.
+ Saturday through Sunday. Day-grained, so widened explicitly. On a Saturday or
+ Sunday, `this weekend` is the weekend in progress rather than the next one.
+- **Not covered** — elliptical right sides (`Aug 3–9`) and quarters (`Q3`); see
+ `plans/backlog.md`.
`humanizeDateRange` renders calendar ranges as dates (`2026-07-01 to
2026-07-05`, `from 2026-07-06`, `until 2026-08-09`) rather than clock phrases,
diff --git a/plans/backlog.md b/plans/backlog.md
index cb65350..f04e260 100644
--- a/plans/backlog.md
+++ b/plans/backlog.md
@@ -90,6 +90,25 @@ surfaces mid-task, add it here and keep going — don't act on it.
drift in the autocomplete display string, not in `format`/`humanize`). Slicing
to 13 (`…T15`) fixes it only if the date parser accepts a bare `THH` — verify
that round-trips before changing. Surfaced by the 0.2.0 completions review.
+- **`humanizeDateRange` drops seconds** — `formatClock` (`date/humanize.ts`)
+ never emits a seconds field, so `9:00:30am to 5:00:45pm` humanizes to
+ `9:00 AM to 5:00 PM` and re-parses a minute off. This predates the D71
+ calendar-range work (verified against the commit before it) and breaks the
+ two-way guarantee for second-grain endpoints. Render seconds whenever an
+ endpoint carries `second` grain; check the anchored-duration phrase at the
+ same time, since it shares the clock formatter. Surfaced by the plan-032
+ adversarial review pass.
+- **Elliptical range right sides** — `Aug 3–9`, `July 1-5`, `July 1 through 5`
+ all fail: `parseDateToDateRange` parses each endpoint independently, so a bare
+ day number on the right has no month to attach to. The right side should
+ inherit month and year from the left when it reads as a valid day. This is the
+ Latin-script twin of the CJK `3月5日~10日` entry above — do both with one
+ elliptical-endpoint rule rather than two special cases.
+- **Quarters (`Q3`, `next quarter`, `Q3 2026`)** — not a `DateGrain`, so
+ `periodEnd` has nothing to widen and the whole family reads as
+ `UNSUPPORTED_DATE`. Fiscal-year offsets are the reason this isn't free: `Q3`
+ means different months to different companies, so it needs a `fiscalYearStart`
+ option before it can be more than a calendar-quarter guess.
## Wire schema & types
diff --git a/wiki/decisions.md b/wiki/decisions.md
index 89425e5..f7a627d 100644
--- a/wiki/decisions.md
+++ b/wiki/decisions.md
@@ -646,3 +646,36 @@ weekends, and the humanize branch. Every existing corpus row is unchanged; the
18 new rows are additive. Revisit if multi-date lists (`today and tomorrow`)
land — those need a new result type rather than a third endpoint, and the
`dated` flag would likely generalize into it.
+
+**D72 · 2026-07-29 · A weekend contains the Sunday it ends on, a coarse endpoint
+widens on the closing side, and a descending dated pair swaps.**
+
+Three semantics D71 left wrong, all found by an adversarial review pass against
+the plan-032 branch and all fixed with tests before shipping.
+
+(1) **`this weekend` on a Sunday meant *next* weekend.** The Saturday was found
+by rounding `now` forward (`forwardDiff(day, 6)`), which on Sunday skips the
+weekend in progress. The reader standing in a weekend could only reach it as
+`last weekend` — the one phrasing nobody would try. Sunday now looks back a day;
+every other weekday still rounds forward. This is why the round-trip matrix now
+runs the full seven references instead of a single Friday.
+
+(2) **A coarse endpoint only meant its first day inside a compound range.**
+D71 widened `August` standing alone but not `July to August`, which ended on
+August 1 — the whole-period promise held for one shape and quietly failed for
+the other. `widenToPeriodEnd` is now shared by the standalone path, the closing
+side of a split range, and the `until X` open form. The opening side is
+deliberately left alone: `from August` starts on the 1st, `until August` ends on
+the 31st, and that asymmetry is the point.
+
+(3) **Descending dated pairs came back descending.** `2026-08-09 to
+2026-08-03` returned `start > end` with no issue, while D71's own prose claimed
+"a pair never reads backwards". Dated pairs now swap and warn with the existing
+`RANGE_REVERSED` code — the same treatment `10-5 kg` already gets, so no new
+vocabulary. The swap is scoped to the dated path on purpose: `9pm to 5am` is a
+legitimate overnight slot, not a typo, and clock ranges keep passing through
+untouched.
+
+Not fixed here, logged in `plans/backlog.md`: `humanizeDateRange` drops seconds
+(pre-existing, verified against the commit before D71), elliptical right sides
+(`Aug 3–9`), and quarters (needs a `fiscalYearStart` option to mean anything).
From e11782c2b5d8d92fdaabc8d2011dbe700be0e66f Mon Sep 17 00:00:00 2001
From: Aymeric Rabot
Date: Wed, 29 Jul 2026 15:11:34 +0200
Subject: [PATCH 6/7] fix(site): stop overclaiming in the demos, and make them
usable without a mouse
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The calendar demo classified anything undated as a time slot, so "3 days
starting monday" — one of its own example chips — showed a single-day time slot
instead of a three-day span. Day-grained endpoints now count as a span, and the
exclusive end of an anchored duration is normalized to the last day covered so
the highlight and the day count agree.
Copy the reviewers could disprove:
- "next quarter" was advertised; quarters parse as UNSUPPORTED_DATE.
- "lingo emits the source" sat under the LaTeX demo, while the same page says
lingo ships no renderer. The renderer is site code, and the caption now says
what actually makes it possible: value and unit id come back separately.
- "three widgets" promised a slot editor that does not exist — three readings.
- "accepts whatever people paste" is false for the GBP cell the demo
deliberately refuses two paragraphs later.
Accessibility, all of it reachable without a pointer:
- The calendar was 84 tab stops with no arrow keys. One roving tab stop now,
with arrows, Home/End, PageUp/PageDown, and paging when focus leaves the
visible months. In-range days report as pressed, endpoints say which end.
- KaTeX rendered html only, so a screen reader got loose glyphs. Paired with
MathML, visual layer hidden.
- Grid assumptions ("assuming short ton") lived in a title attribute; they are
in the described text now. Cell labels name the row item, not "row 1".
- Totals read "Accepted mass/price" while a cell is refused, since a sum that
silently drops a refusal is the exact failure the demo argues against.
Co-authored-by: Cursor
---
apps/site/src/app/docs/page.tsx | 9 +-
.../components/site/calendar-field-demo.tsx | 110 ++++++++++++++++--
.../src/components/site/data-grid-demo.tsx | 31 +++--
apps/site/src/components/site/katex-math.tsx | 5 +-
.../src/components/site/latex-units-demo.tsx | 2 +-
apps/site/src/lib/docs-catalog.ts | 2 +-
apps/site/src/lib/docs.md.ts | 6 +-
7 files changed, 135 insertions(+), 30 deletions(-)
diff --git a/apps/site/src/app/docs/page.tsx b/apps/site/src/app/docs/page.tsx
index 9c1cddc..5c5ace5 100644
--- a/apps/site/src/app/docs/page.tsx
+++ b/apps/site/src/app/docs/page.tsx
@@ -856,9 +856,10 @@ export default async function Home() {
A column is a schema
The same idea scales past a single field. Give a table column a{' '}
- quantityField and every cell in it accepts whatever people paste —
- pounds and ounces, a comma decimal, Fahrenheit — while the column keeps one
- canonical unit, so the totals row can just add numbers.
+ quantityField and a cell can take any notation that column can resolve
+ — pounds and ounces, a comma decimal, Fahrenheit — normalizing into one canonical
+ unit, so the totals row can just add numbers. What it cannot resolve stays an issue
+ on the cell that caused it.
- One field, three widgets
+ One field, three readings
parseDateRange also reads date-to-date spans (
Aug 3 - Aug 9) and whole calendar periods (next week,{' '}
diff --git a/apps/site/src/components/site/calendar-field-demo.tsx b/apps/site/src/components/site/calendar-field-demo.tsx
index 5e6d98f..3107928 100644
--- a/apps/site/src/components/site/calendar-field-demo.tsx
+++ b/apps/site/src/components/site/calendar-field-demo.tsx
@@ -8,7 +8,14 @@ import {
parseDateRange,
} from '@pascal-app/lingo/date'
import { ChevronLeft, ChevronRight } from 'lucide-react'
-import { useCallback, useMemo, useState } from 'react'
+import {
+ type KeyboardEvent as ReactKeyboardEvent,
+ useCallback,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+} from 'react'
import { DemoFrame } from '@/components/site/demo-frame'
import { JsonView } from '@/components/site/json-view'
@@ -89,8 +96,18 @@ function read(text: string, now: Date): Reading {
if (range.ok) {
const start = range.start?.date ?? null
const end = range.end?.date ?? null
- const clock = !range.dated
- return { end, mode: clock ? 'slot' : 'range', result: range, start }
+ // `dated` marks the calendar grammar, but an anchored duration ("3 days
+ // starting monday") is a span too, so day-grained endpoints count. Reading
+ // everything undated as a clock slot mislabels it and hides a month.
+ const dated = range.dated === true
+ const spans = dated || range.start?.grain === 'day' || range.end?.grain === 'day'
+ if (!spans) {
+ return { end, mode: 'slot', result: range, start }
+ }
+ // Calendar grammar reports an inclusive last day; an anchored duration
+ // reports an exclusive end. Normalize to the last day actually covered so
+ // the highlight and the day count agree.
+ return { end: end && !dated ? addDays(end, -1) : end, mode: 'range', result: range, start }
}
const single = parseDate(text, { now })
if (single.ok) {
@@ -151,18 +168,79 @@ export function CalendarFieldDemo() {
const now = useMemo(() => (hydrated ? new Date() : SSR_NOW), [hydrated])
const [value, setValue] = useState('next week')
const [cursor, setCursor] = useState(null)
+ const [roving, setRoving] = useState(null)
+ // Bumped only by arrow keys, so focus is never stolen on mount or on typing.
+ const [focusTick, setFocusTick] = useState(0)
+ const gridRef = useRef(null)
const reading = useMemo(() => read(value, now), [value, now])
const { mode, start, end } = reading
const twoUp = mode === 'range'
// The calendar follows the parse unless the reader has paged away from it.
- const anchor =
- cursor ??
- (start
- ? new Date(start.getFullYear(), start.getMonth(), 1)
- : new Date(now.getFullYear(), now.getMonth(), 1))
- const months = twoUp ? [anchor, addMonths(anchor, 1)] : [anchor]
+ const anchor = useMemo(
+ () =>
+ cursor ??
+ (start
+ ? new Date(start.getFullYear(), start.getMonth(), 1)
+ : new Date(now.getFullYear(), now.getMonth(), 1)),
+ [cursor, start, now],
+ )
+ const months = useMemo(() => (twoUp ? [anchor, addMonths(anchor, 1)] : [anchor]), [twoUp, anchor])
+
+ const onScreen = useCallback(
+ (day: Date) =>
+ months.some((m) => day.getFullYear() === m.getFullYear() && day.getMonth() === m.getMonth()),
+ [months],
+ )
+
+ // Exactly one day carries tabIndex 0, so the grid is a single tab stop
+ // instead of 42 (84 in two-month mode) and arrow keys do the rest.
+ const tabDay = useMemo(() => {
+ const candidate = roving ?? start ?? now
+ return onScreen(candidate) ? candidate : anchor
+ }, [roving, start, now, onScreen, anchor])
+
+ useEffect(() => {
+ if (focusTick === 0) {
+ return
+ }
+ gridRef.current?.querySelector(`[data-day="${ymd(tabDay)}"]`)?.focus()
+ }, [focusTick, tabDay])
+
+ const STEPS: Record = useMemo(
+ () => ({ ArrowDown: 7, ArrowLeft: -1, ArrowRight: 1, ArrowUp: -7 }),
+ [],
+ )
+
+ const moveFocus = useCallback(
+ (event: ReactKeyboardEvent, day: Date) => {
+ const weekIndex = (day.getDay() + 6) % 7
+ const step = STEPS[event.key]
+ const next =
+ step === undefined
+ ? event.key === 'Home'
+ ? addDays(day, -weekIndex)
+ : event.key === 'End'
+ ? addDays(day, 6 - weekIndex)
+ : event.key === 'PageUp'
+ ? new Date(day.getFullYear(), day.getMonth() - 1, day.getDate())
+ : event.key === 'PageDown'
+ ? new Date(day.getFullYear(), day.getMonth() + 1, day.getDate())
+ : null
+ : addDays(day, step)
+ if (!next) {
+ return
+ }
+ event.preventDefault()
+ if (!onScreen(next)) {
+ setCursor(new Date(next.getFullYear(), next.getMonth(), 1))
+ }
+ setRoving(next)
+ setFocusTick((tick) => tick + 1)
+ },
+ [STEPS, onScreen],
+ )
const select = useCallback(
(day: Date) => {
@@ -293,7 +371,7 @@ export function CalendarFieldDemo() {
-
+
{months.map((month, index) => (
diff --git a/apps/site/src/components/site/data-grid-demo.tsx b/apps/site/src/components/site/data-grid-demo.tsx
index 0222f9a..329891b 100644
--- a/apps/site/src/components/site/data-grid-demo.tsx
+++ b/apps/site/src/components/site/data-grid-demo.tsx
@@ -182,13 +182,13 @@ function LingoCell({
columnKey,
columns,
onChange,
- rowIndex,
+ rowLabel,
value,
}: {
columnKey: FieldKey
columns: Record
onChange: (next: string) => void
- rowIndex: number
+ rowLabel: string
value: string
}) {
const reading = useMemo(() => read(columnKey, value, columns), [columnKey, value, columns])
@@ -204,7 +204,7 @@ function LingoCell({
{reading.message}
>
) : (
-
- {reading.display}
-
+ <>
+
+ {reading.display}
+
+ {/* The dotted underline and the `title` both need a pointer to read.
+ Assumptions are the honest part of the story, so they have to
+ survive without one. */}
+ {assumed ? . {reading.message} : null}
+ >
)}
@@ -274,7 +280,7 @@ export function DataGridDemo() {
columnKey={key}
columns={fields}
onChange={(next) => update(ctx.row.original.id, key, next)}
- rowIndex={ctx.row.index}
+ rowLabel={ctx.row.original.sku || `row ${ctx.row.index + 1}`}
value={ctx.getValue()}
/>
),
@@ -389,12 +395,19 @@ export function DataGridDemo() {
+ {/* A refused cell contributes nothing, so calling the remainder a
+ total would be the exact silent-wrong-answer this demo argues
+ against. Name it for what it is instead. */}
diff --git a/apps/site/src/components/site/katex-math.tsx b/apps/site/src/components/site/katex-math.tsx
index 9fde691..9128dac 100644
--- a/apps/site/src/components/site/katex-math.tsx
+++ b/apps/site/src/components/site/katex-math.tsx
@@ -24,7 +24,10 @@ export function KatexMath({
() =>
katex.renderToString(tex, {
displayMode: display,
- output: 'html',
+ // `html` alone ships only the visual glyph spans, which a screen reader
+ // reads as scattered characters. This pairs them with MathML and hides
+ // the visual layer from the accessibility tree.
+ output: 'htmlAndMathml',
throwOnError: false,
}),
[tex, display],
diff --git a/apps/site/src/components/site/latex-units-demo.tsx b/apps/site/src/components/site/latex-units-demo.tsx
index 0b4cd96..8a94560 100644
--- a/apps/site/src/components/site/latex-units-demo.tsx
+++ b/apps/site/src/components/site/latex-units-demo.tsx
@@ -46,7 +46,7 @@ export function LatexUnitsDemo() {
return (
}
detailsLabel="Output"
stageClassName="min-h-[30rem] justify-start"
diff --git a/apps/site/src/lib/docs-catalog.ts b/apps/site/src/lib/docs-catalog.ts
index bd4b84a..0d4aeb2 100644
--- a/apps/site/src/lib/docs-catalog.ts
+++ b/apps/site/src/lib/docs-catalog.ts
@@ -308,7 +308,7 @@ export const docsNavGroups: DocsNavGroup[] = [
]),
page(
'dates-calendar',
- 'One field, three widgets',
+ 'One field, three readings',
'Date-to-date spans, calendar periods, and slots in one input.',
[
'calendar',
diff --git a/apps/site/src/lib/docs.md.ts b/apps/site/src/lib/docs.md.ts
index 02908de..55769b2 100644
--- a/apps/site/src/lib/docs.md.ts
+++ b/apps/site/src/lib/docs.md.ts
@@ -277,7 +277,7 @@ useForm({ resolver: standardSchemaResolver(shipment) })`,
'',
'### A column is a schema',
'',
- 'The same field works per table column. Attach a `quantityField` to a column and every cell accepts whatever people paste — `3 lb 4 oz`, `1,2 kg`, `72°F` — while the column keeps one canonical unit, so a totals row can add plain numbers. `safeParse` returns the canonical value plus any `warnings` (`UNIT_ASSUMED`, `AMBIGUOUS_UNIT`), so a grid can flag assumptions instead of hiding them.',
+ 'The same field works per table column. Attach a `quantityField` to a column and a cell can take any notation that column can resolve — `3 lb 4 oz`, `1,2 kg`, `72°F` — normalizing into one canonical unit, so a totals row can add plain numbers. `safeParse` returns the canonical value plus any `warnings` (`UNIT_ASSUMED`, `AMBIGUOUS_UNIT`), so a grid can flag assumptions instead of hiding them; what it cannot resolve, such as a GBP amount in a USD column, comes back as an issue on that cell instead of a guess.',
'',
'## Convert & format',
'',
@@ -309,9 +309,9 @@ useForm({ resolver: standardSchemaResolver(shipment) })`,
'',
'Reference-dependent input needs an explicit `now`, so a queued job parses the same date every time. Fully absolute dates never require it. Browse the shorthand it reads under Catalog → Date shorthand and Time slots.',
'',
- '### One field, three widgets',
+ '### One field, three readings',
'',
- '`parseDateRange` reads three shapes: a time slot (`2pm to 4pm`), a date-to-date span (`July 1 to July 5`, `Aug 3 - Aug 9`, `2026-08-03..2026-08-09`), and a whole calendar period (`next week`, `this weekend`, `next month`, `next quarter`) expanded to its real first and last day. A `dated` flag on the result says whether the span came from date grammar or clock grammar, which is what lets one input drive a day picker, a two-month range picker, or a slot picker without asking the person to choose a mode first. `humanizeDateRange` renders each shape back in a form that re-parses.',
+ '`parseDateRange` reads three shapes: a time slot (`2pm to 4pm`), a date-to-date span (`July 1 to July 5`, `Aug 3 - Aug 9`, `2026-08-03..2026-08-09`), and a whole calendar period (`next week`, `this weekend`, `next month`, `August`, `2027`) expanded to its real first and last day. A coarse endpoint widens on the closing side too, so `July to August` ends on August 31 and `until August` does the same. A `dated` flag on the result says whether the span came from date grammar or clock grammar, which is what lets one input drive a day picker, a two-month range picker, or a slot picker without asking the person to choose a mode first. `humanizeDateRange` renders each shape back in a form that re-parses, and a descending pair such as `2026-08-09 to 2026-08-03` is swapped with a `RANGE_REVERSED` warning rather than handed back backwards.',
'',
'## Locales',
'',
From bc426608afb26d0810968cdbb63c92b859c7e1bb Mon Sep 17 00:00:00 2001
From: Aymeric Rabot
Date: Wed, 29 Jul 2026 18:16:04 +0200
Subject: [PATCH 7/7] fix(site): unmangle the FAQ question and give the rows an
affordance
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The first question rendered as: How do I parse "5'11\"" into meters — a literal
backslash on the page. Inside a template literal \\" emits a backslash and a
quote, and the value needs no escaping at all; line 20 of this same file
already writes 5'11" correctly. Dropped the wrapping quotes too, since nesting
them around an inch mark is what invited the escape.
The answer had the same escape buried in a code expression. Rewrote it as prose
that names the option and the result without a nested string delimiter — the
1.8034 figure is verified against the parser.
The rows hid the native disclosure marker and put nothing in its place, so six
cards sat there with no sign they opened. Added a chevron that rotates on open,
a hover tint, and the site's standard focus ring, all on the existing motion
tokens.
Co-authored-by: Cursor
---
.../src/components/site/landing-sections.tsx | 17 +++++++++++------
1 file changed, 11 insertions(+), 6 deletions(-)
diff --git a/apps/site/src/components/site/landing-sections.tsx b/apps/site/src/components/site/landing-sections.tsx
index 5d3084d..0b14de7 100644
--- a/apps/site/src/components/site/landing-sections.tsx
+++ b/apps/site/src/components/site/landing-sections.tsx
@@ -1,4 +1,5 @@
import { lingo } from '@pascal-app/lingo'
+import { ChevronDownIcon } from 'lucide-react'
import Link from 'next/link'
import { CodeBlock } from '@/components/site/code-block'
@@ -109,8 +110,8 @@ const field = useLingoInput({ kind: 'mass', unit: 'kg', name: 'weight_kg' })
export const landingFaq = [
{
- question: `How do I parse "5'11\\"" into meters in JavaScript?`,
- answer: `parseQuantity("5'11\\"", { kind: "length" }).quantity.to("m").value returns 1.8034. lingo reads compounds (5'11", 2 lb 3 oz, 1h30), unicode (½, μm, ′ ″), number words, and typos with did-you-mean — zero dependencies.`,
+ question: `How do I parse 5'11" into meters in JavaScript?`,
+ answer: `parseQuantity with kind: 'length' reads it as one compound value, and .to('m').value returns 1.8034. The same call handles 2 lb 3 oz, 1h30, unicode (½, μm, ′ ″), number words, and typos with did-you-mean — zero dependencies.`,
href: '/docs/parse',
},
{
@@ -359,15 +360,19 @@ export function LandingSections() {