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" + > +
+
+ { + setValue(e.target.value) + setCursor(null) + }} + placeholder="next week, Aug 3 - Aug 9, tomorrow…" + spellCheck={false} + value={value} + /> +
+ {EXAMPLES.map((example) => ( + + ))} +
+
+ +
+ + {MODE_COPY[mode]} + + {summary(reading)} +
+ +
+
+ + {/* 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. */} +
+ {months.map((month, index) => ( +
0 && 'hidden sm:block', + )} + key={month.getTime()} + > + {monthLabel(month)} +
+ ))} +
+ +
+
+ {months.map((month, index) => ( + 0 && 'hidden sm:table', + )} + key={month.getTime()} + > + + + + {WEEKDAYS.map((day) => ( + + ))} + + + + {Array.from({ length: 6 }, (_, week) => ( + + {monthGrid(month) + .slice(week * 7, week * 7 + 7) + .map((day) => { + const outside = day.getMonth() !== month.getMonth() + const edge = isEdge(day) + const between = inRange(day) + return ( + + ) + })} + + ))} + +
{monthLabel(month)}
+ {day.slice(0, 2)} + {day} +
+ +
+ ))} +
+
+
+
+ ) +} 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} + + )} + +
+ ) +} + +const columnHelper = createColumnHelper() + +export function DataGridDemo() { + const hydrated = useHydrated() + const [rows, setRows] = useState(SEED) + const fields = useMemo(() => makeColumns(hydrated ? new Date() : SSR_NOW), [hydrated]) + + const update = useCallback((id: string, key: keyof Shipment, next: string) => { + setRows((current) => current.map((row) => (row.id === id ? { ...row, [key]: next } : row))) + }, []) + + const columnDefs = useMemo( + () => [ + columnHelper.accessor('sku', { + cell: (ctx) => ( + update(ctx.row.original.id, 'sku', event.target.value)} + spellCheck={false} + value={ctx.getValue()} + /> + ), + header: 'Item', + }), + ...ORDER.map((key) => + columnHelper.accessor(key, { + cell: (ctx) => ( + update(ctx.row.original.id, key, next)} + rowLabel={ctx.row.original.sku || `row ${ctx.row.index + 1}`} + value={ctx.getValue()} + /> + ), + header: HEADERS[key], + }), + ), + ], + [update, fields], + ) + + const table = useReactTable({ + columns: columnDefs, + data: rows, + getCoreRowModel: getCoreRowModel(), + }) + + const totals = useMemo(() => { + let assumed = 0 + let mass = 0 + let price = 0 + let refused = 0 + let firstRefusal: { code: string | null; message: string; where: string } | null = null + for (const [index, row] of rows.entries()) { + for (const key of ORDER) { + const reading = read(key, row[key], fields) + if (reading.state === 'error') { + refused += 1 + firstRefusal ??= { + code: reading.code, + message: reading.message ?? '', + where: `${HEADERS[key].split(' ')[0]}, row ${index + 1}`, + } + continue + } + if (reading.state === 'assumed') { + assumed += 1 + } + if (reading.number === null) { + continue + } + if (key === 'mass') { + mass += reading.number + } else if (key === 'price') { + price += reading.number + } + } + } + return { + assumed, + firstRefusal, + mass: Math.round(mass * 100) / 100, + price: price.toFixed(2), + refused, + } + }, [rows]) + + return ( + +
+
+ + + {/* Price carries the widest cell note (RATE_REQUIRED), so it gets + room the other numeric columns don't need. */} + + + + + + + + + + {table.getHeaderGroups().map((group) => ( + + {group.headers.map((header) => ( + + ))} + + ))} + + + {table.getRowModel().rows.map((row) => ( + + {/* Ruled on both axes: a spreadsheet grid is what tells the + reader these cells take typing, without 30 input boxes. */} + {row.getVisibleCells().map((cell) => ( + + ))} + + ))} + +
+ Editable shipment rows. Each column parses free text into one canonical unit. +
+ {flexRender(header.column.columnDef.header, header.getContext())} +
+ {flexRender(cell.column.columnDef.cell, cell.getContext())} +
+
+ +
+
+ {/* 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. */} +
+
+ {totals.refused > 0 ? 'Accepted mass' : 'Total mass'} +
+
{totals.mass} kg
+
+
+
+ {totals.refused > 0 ? 'Accepted price' : 'Total price'} +
+
{totals.price} USD
+
+
+
Rows
+
{rows.length}
+
+
+
Assumptions
+
{totals.assumed}
+
+
+
0 ? 'text-destructive' : 'text-muted-foreground'}> + Refused +
+
0 ? 'text-destructive' : 'text-muted-foreground'}> + {totals.refused} +
+
+
+ +
+ + {totals.firstRefusal ? ( +

+ {totals.firstRefusal.code} + · {totals.firstRefusal.where} · + {totals.firstRefusal.message} +

+ ) : null} + +

+ 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() {
{landingFaq.map((item) => (
- + {item.question} + -

{item.answer}

+

{item.answer}

Read more → diff --git a/apps/site/src/components/site/latex-units-demo.tsx b/apps/site/src/components/site/latex-units-demo.tsx new file mode 100644 index 0000000..8a94560 --- /dev/null +++ b/apps/site/src/components/site/latex-units-demo.tsx @@ -0,0 +1,129 @@ +'use client' + +import { lingo } from '@pascal-app/lingo' +import dynamic from 'next/dynamic' +import { useMemo, useState } from 'react' + +import { CopyButton } from '@/components/site/copy-button' +import { DemoFrame } from '@/components/site/demo-frame' +import { JsonView } from '@/components/site/json-view' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { resultToLatex, unitLatex } from '@/lib/latex' + +// KaTeX and its stylesheet are ~80 kB gzipped — a separate chunk, fetched when +// this demo mounts rather than with the docs route. +const KatexMath = dynamic(() => import('@/components/site/katex-math'), { + loading: () =>
, + ssr: false, +}) + +const EXAMPLES = [ + '72 in to cm', + `5'11"`, + '25 m/s to km/h', + 'between 5 and 10 kg', + '20 °C to °F', + '5 kg ± 200 g', + '9.8 m/s2', + 'at least 5 kg', +] as const + +/** Derived and affine units, where plain text stops being readable. */ +const NOTATION = [ + { kind: 'speed', unit: 'km/h' }, + { kind: 'acceleration', unit: 'm/s2' }, + { kind: 'volume', unit: 'm3' }, + { kind: 'temperature', unit: 'C' }, + { kind: 'pressure', unit: 'kg/cm2' }, + { kind: 'fuel', unit: 'L/100km' }, +] as const + +export function LatexUnitsDemo() { + const [value, setValue] = useState('25 m/s to km/h') + const result = useMemo(() => (value.trim() === '' ? null : lingo(value)), [value]) + const tex = useMemo(() => resultToLatex(result), [result]) + + return ( + } + detailsLabel="Output" + stageClassName="min-h-[30rem] justify-start" + title="Measurements as notation" + > +
+
+ setValue(e.target.value)} + placeholder="25 m/s to km/h" + spellCheck={false} + value={value} + /> +
+ {EXAMPLES.map((example) => ( + + ))} +
+
+ +
+ {tex ? ( + + ) : ( + + {value.trim() === '' ? 'Awaiting input' : 'No reading to typeset'} + + )} +
+ +
+ + LaTeX + + + {tex ?? '—'} + + +
+ +
+
Derived units
+ {/* 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,