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..5c5ace5 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 {
@@ -189,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)
@@ -832,6 +852,18 @@ 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 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.
+
+
+
+ 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 readings
+
+ 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
+ // `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) {
+ 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' })
+}
+
+/** 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) {
+ // "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)
+ }
+ 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 [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 = 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) => {
+ 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. */}
+
+
+ )
+}
diff --git a/apps/site/src/components/site/data-grid-demo.tsx b/apps/site/src/components/site/data-grid-demo.tsx
new file mode 100644
index 0000000..329891b
--- /dev/null
+++ b/apps/site/src/components/site/data-grid-demo.tsx
@@ -0,0 +1,455 @@
+'use client'
+
+import { dateField, type LingoField, quantityField } from '@pascal-app/lingo/ai'
+import {
+ createColumnHelper,
+ flexRender,
+ getCoreRowModel,
+ useReactTable,
+} from '@tanstack/react-table'
+import { useCallback, useId, useMemo, useState } from 'react'
+
+import { DemoFrame } from '@/components/site/demo-frame'
+import { useHydrated } from '@/components/site/use-hydrated'
+import { Button } from '@/components/ui/button'
+import { cn } from '@/lib/utils'
+
+/** SSR reference time. After hydration the grid switches to the real clock. */
+const SSR_NOW = new Date(2026, 6, 3, 9, 0, 0)
+
+interface Shipment {
+ id: string
+ mass: string
+ price: string
+ shipBy: string
+ size: string
+ sku: string
+ temp: string
+}
+
+type FieldKey = 'mass' | 'size' | 'temp' | 'price' | 'shipBy'
+
+interface ColumnSpec {
+ field: LingoField | LingoField
+ header: string
+ suffix: string
+}
+
+/**
+ * Each column is a lingo field — the same Standard Schema field you would hand
+ * to an LLM tool. The column declares the canonical unit once; every cell in it
+ * is validated and converted by that declaration. Only the date column depends
+ * on `now`, but the whole record is rebuilt with it so there is one source.
+ */
+function makeColumns(now: Date): Record {
+ return {
+ mass: { field: quantityField({ kind: 'mass', unit: 'kg' }), header: 'Mass → kg', suffix: 'kg' },
+ size: {
+ field: quantityField({ kind: 'length', unit: 'cm' }),
+ header: 'Size → cm',
+ suffix: 'cm',
+ },
+ temp: {
+ field: quantityField({ kind: 'temperature', unit: 'C' }),
+ header: 'Temp → °C',
+ suffix: '°C',
+ },
+ price: {
+ field: quantityField({ kind: 'currency', unit: 'USD' }),
+ header: 'Price → USD',
+ suffix: 'USD',
+ },
+ shipBy: { field: dateField({ now }), header: 'Ship by → date', suffix: '' },
+ }
+}
+
+const HEADERS: Record = {
+ mass: 'Mass → kg',
+ price: 'Price → USD',
+ shipBy: 'Ship by → date',
+ size: 'Size → cm',
+ temp: 'Temp → °C',
+}
+
+const ORDER: FieldKey[] = ['mass', 'size', 'temp', 'price', 'shipBy']
+
+/**
+ * Six notations per column is the point — this is what one paste from a
+ * supplier sheet actually looks like. The £45 cell is deliberate: lingo refuses
+ * to invent an exchange rate rather than guessing one.
+ */
+const SEED: Shipment[] = [
+ {
+ id: '1',
+ mass: '3 lb 4 oz',
+ price: '$12.50',
+ shipBy: 'next friday',
+ size: `5'11"`,
+ sku: 'Rolled steel',
+ temp: '72°F',
+ },
+ {
+ id: '2',
+ mass: '1,2 kg',
+ price: '9,99',
+ shipBy: '2026-08-14',
+ size: '120 mm',
+ sku: 'Bearing set',
+ temp: '20C',
+ },
+ {
+ id: '3',
+ mass: 'half a ton',
+ price: '1.2k',
+ shipBy: 'in 3 weeks',
+ size: '2 m',
+ sku: 'Pallet, oak',
+ temp: '21',
+ },
+ {
+ id: '4',
+ mass: '850g',
+ price: '$8',
+ shipBy: 'tomorrow',
+ size: '30cm',
+ sku: 'Cable spool',
+ temp: '-4 °C',
+ },
+ {
+ id: '5',
+ mass: '12 stone',
+ price: '£45',
+ shipBy: 'end of the month',
+ size: '6 ft 2',
+ sku: 'Crate 12B',
+ temp: '300 K',
+ },
+]
+
+interface Reading {
+ code: string | null
+ display: string
+ message: string | null
+ number: number | null
+ state: 'ok' | 'assumed' | 'error' | 'empty'
+}
+
+const EMPTY: Reading = { code: null, display: '', message: null, number: null, state: 'empty' }
+
+function ymd(date: Date): string {
+ return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
+}
+
+/** Four significant digits reads well across 0.85 kg and 453.6 kg alike. */
+function round(value: number): number {
+ return Number(value.toPrecision(4))
+}
+
+function read(key: FieldKey, text: string, columns: Record): Reading {
+ if (text.trim() === '') {
+ return EMPTY
+ }
+ const spec = columns[key]
+ const result = spec.field.safeParse(text)
+ if (result.issues) {
+ const issue = result.issues[0]
+ return {
+ code: issue?.code ?? null,
+ display: '',
+ // Messages arrive as "[CODE] text" for LLM self-correction; the code is
+ // already on the issue, so the badge shows it and the copy stays human.
+ message: (issue?.message ?? 'Unreadable').replace(/^\[[A-Z_]+]\s*/, ''),
+ number: null,
+ state: 'error',
+ }
+ }
+ const warning = result.warnings?.[0] ?? null
+ const value = result.value
+ const display =
+ key === 'shipBy'
+ ? ymd(new Date(value as string))
+ : `${round(value as number)}${spec.suffix === '°C' ? '' : ' '}${spec.suffix}`
+ return {
+ code: warning?.code ?? null,
+ display,
+ message: warning?.message ?? null,
+ number: typeof value === 'number' ? value : null,
+ state: warning ? 'assumed' : 'ok',
+ }
+}
+
+function LingoCell({
+ columnKey,
+ columns,
+ onChange,
+ rowLabel,
+ value,
+}: {
+ columnKey: FieldKey
+ columns: Record
+ onChange: (next: string) => void
+ rowLabel: string
+ value: string
+}) {
+ const reading = useMemo(() => read(columnKey, value, columns), [columnKey, value, columns])
+ const noteId = useId()
+ const failed = reading.state === 'error'
+ const assumed = reading.state === 'assumed'
+
+ // Raw over canonical, rather than side by side: six columns of both on one
+ // line crushes every cell, and stacking is what makes "you typed / we stored"
+ // legible at a glance.
+ return (
+
+ onChange(event.target.value)}
+ spellCheck={false}
+ value={value}
+ />
+ {/* A span, not a p: the global `p { overflow-wrap: break-word }` would
+ split an issue code mid-word and grow the row. */}
+
+ {failed ? (
+ <>
+ {reading.code ?? 'error'}
+ {reading.message}
+ >
+ ) : (
+ <>
+
+ {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}
+ >
+ )}
+
+
+ {/* Ruled on both axes: a spreadsheet grid is what tells the
+ reader these cells take typing, without 30 input boxes. */}
+ {row.getVisibleCells().map((cell) => (
+
+ {/* 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. */}
+
+ 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..9128dac
--- /dev/null
+++ b/apps/site/src/components/site/katex-math.tsx
@@ -0,0 +1,47 @@
+'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,
+ // `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],
+ )
+
+ // 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/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() {
+ {/* 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..0d4aeb2 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 readings',
+ '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..55769b2 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 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',
'',
'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 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`, `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',
'',
'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=="],
diff --git a/packages/lingo/CHANGELOG.md b/packages/lingo/CHANGELOG.md
index 8661338..a897de7 100644
--- a/packages/lingo/CHANGELOG.md
+++ b/packages/lingo/CHANGELOG.md
@@ -9,6 +9,25 @@ 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. 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/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/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/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..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 {
@@ -387,6 +388,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 +541,182 @@ 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
+
+/**
+ * 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') {
+ 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 && widenToPeriodEnd(whole, source)
+ if (whole && wholeEnd !== whole) {
+ return finishDateRange(p, span, zoneSpan, [...issues, ...whole.issues], whole.ref === true, {
+ end: dateEndpoint(wholeEnd as CoreDate, 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]
+ }
+ 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
+ }
+ // "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 }),
+ })
+ }
+ 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(widenToPeriodEnd(endCore, text(2)), 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)],
+ }
+ }
+ 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
+ }
+ 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..5873699 100644
--- a/packages/lingo/src/date/range.test.ts
+++ b/packages/lingo/src/date/range.test.ts
@@ -402,3 +402,188 @@ 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'])
+ })
+
+ // 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', () => {
+ // 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, now) => {
+ const first = parseDateRange(text, { now })
+ expect(first.ok).toBe(true)
+ if (!first.ok) {
+ return
+ }
+ const again = parseDateRange(humanizeDateRange(first), { 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..75cc0d5 100644
--- a/packages/lingo/src/date/relative.ts
+++ b/packages/lingo/src/date/relative.ts
@@ -453,15 +453,19 @@ 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 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)
}
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..240258c 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,1062 @@
"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
+ },
+ "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 eeff959..09758cf 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'],
@@ -445,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 }],
@@ -460,6 +464,39 @@ 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 }],
+ // 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 2e7699d..d8d3276 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,39 @@ 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 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. 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. 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,
+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/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..f04e260 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.
@@ -76,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
@@ -324,3 +357,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/decisions.md b/wiki/decisions.md
index d358ad7..f7a627d 100644
--- a/wiki/decisions.md
+++ b/wiki/decisions.md
@@ -595,3 +595,87 @@ 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.
+
+**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).
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,