diff --git a/apps/site/src/app/docs/page.tsx b/apps/site/src/app/docs/page.tsx index c73f208..b0ae09e 100644 --- a/apps/site/src/app/docs/page.tsx +++ b/apps/site/src/app/docs/page.tsx @@ -21,10 +21,13 @@ 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 { RemindMePopoverBlock } from '@/components/site/remind-me-popover' +import { SemanticTokenHighlighter } from '@/components/site/semantic-token-highlighter' import { StrictnessVariantWall, SystemNumberFormatVariantWall, } from '@/components/site/variant-walls' +import { WorkflowTriggerBlock } from '@/components/site/workflow-trigger-block' import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert' import { Badge } from '@/components/ui/badge' import { @@ -864,6 +867,17 @@ export default async function Home() { + +
+ Thresholds out of prose +

+ Automation prompts mix nouns a rules engine already knows with thresholds it does + not. findQuantities pulls the bounds (below $10k,{' '} + over 15 minutes) out of the sentence with spans into the original + string and the same issues a form field would see. +

+
+
+ +
+ Color only where it read +

+ Lingo reads one expression at a time and reports one span per result, so a sentence + has to be pre-segmented before it can be colored. The demo proposes slices with + regexes and lets parseDate, parseDateRange, and{' '} + parseDuration confirm each one — a slice lingo declines stays plain. +

+
+ + +
+ Presets and prose, one reader +

+ Quick presets and a free-text field share one reader, so the row a preset shows is + the same reading typing its phrase would give. A phrase lingo cannot read shows + lingo's own message instead of a made-up time. +

+
+
read(value, now), [value, now]) const { mode, start, end } = reading const twoUp = mode === 'range' + // The JSON prints UTC instants, and SSR_NOW is a local wall-clock time: the + // same wall clock is a different instant on the server and in the visitor's + // zone, so the panel waits for hydration instead of tripping React #418. + const output = hydrated ? JSON.stringify(reading.result, null, 2) : '' // The calendar follows the parse unless the reader has paged away from it. const anchor = useMemo( @@ -287,7 +291,7 @@ export function CalendarFieldDemo() { return ( } + details={} detailsLabel="Output" stageClassName="min-h-[34rem] justify-start" title="Adaptive date field" diff --git a/apps/site/src/components/site/coverage-explorer.tsx b/apps/site/src/components/site/coverage-explorer.tsx index e556448..776eea3 100644 --- a/apps/site/src/components/site/coverage-explorer.tsx +++ b/apps/site/src/components/site/coverage-explorer.tsx @@ -8,6 +8,7 @@ import { DemoFrame } from '@/components/site/demo-frame' import { DocsPane, DocsSplitPane } from '@/components/site/docs-split-pane' import { JsonView } from '@/components/site/json-view' import { Readout } from '@/components/site/readout' +import { useHydrated } from '@/components/site/use-hydrated' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { @@ -65,11 +66,15 @@ function clockLabel(date: Date): string { } export function CoverageExplorer() { + const hydrated = useHydrated() const [index, setIndex] = useState(0) const current = aliasExamples[index] ?? aliasExamples[0] const result = useMemo(() => lingo(current.text, { kind: current.kind }), [current]) const aliasCopyText = useMemo(() => JSON.stringify(resultToPlain(result), null, 2), [result]) const fuzzyCopyText = useMemo(() => JSON.stringify(temperatureVocabs, null, 2), []) + // Day words resolve in the visitor's zone ("today" at 12:00Z is already + // tomorrow in Auckland), so the instants cannot match between the server + // and the browser; the column fills in after hydration. const dateRows = useMemo( () => dateExamples.map((example) => { @@ -77,12 +82,15 @@ export function CoverageExplorer() { now: referenceNow, dayFirst: true, }) - const value = parsed.ok - ? parsed.date.toISOString().replace('.000Z', 'Z') - : (parsed.issues[0]?.code ?? 'UNSUPPORTED_DATE') + let value = '' + if (!parsed.ok) { + value = parsed.issues[0]?.code ?? 'UNSUPPORTED_DATE' + } else if (hydrated) { + value = parsed.date.toISOString().replace('.000Z', 'Z') + } return { example, parsed, value } }), - [], + [hydrated], ) const dateCopyText = useMemo( () => diff --git a/apps/site/src/components/site/landing-sections.tsx b/apps/site/src/components/site/landing-sections.tsx index 782897d..1900998 100644 --- a/apps/site/src/components/site/landing-sections.tsx +++ b/apps/site/src/components/site/landing-sections.tsx @@ -3,6 +3,9 @@ import { ChevronDownIcon } from 'lucide-react' import Link from 'next/link' import { CodeBlock } from '@/components/site/code-block' +import { RemindMePopoverBlock } from '@/components/site/remind-me-popover' +import { SemanticTokenHighlighter } from '@/components/site/semantic-token-highlighter' +import { WorkflowTriggerBlock } from '@/components/site/workflow-trigger-block' import { Badge } from '@/components/ui/badge' import { Table, @@ -259,6 +262,27 @@ export function LandingSections() {

+ + + + + + + + + + + + (hydrated ? new Date() : SSR_NOW), [hydrated]) + const [open, setOpen] = useState(false) + const [query, setQuery] = useState('') + const [condition, setCondition] = useState('if no reply') + // Only the phrase is stored; the date is re-read from `now`, so a reminder + // set before hydration is never a stale SSR instant. + const [reminder, setReminder] = useState({ + condition: 'if no reply', + phrase: PRESETS[0].phrase, + }) + + const queryReading = useMemo(() => read(query, now), [query, now]) + const reminderReading = useMemo(() => read(reminder.phrase ?? '', now), [reminder.phrase, now]) + const presetReadings = useMemo( + () => PRESETS.map((preset) => (preset.phrase ? read(preset.phrase, now) : null)), + [now], + ) + // The JSON prints UTC instants, and a local SSR_NOW is a different instant in + // every zone, so the panel waits for hydration. + const output = hydrated ? JSON.stringify(reminderReading.result, null, 2) : '' + + const commit = (phrase: string | null) => { + setReminder({ condition, phrase }) + setQuery('') + setOpen(false) + } + + return ( + } + detailsLabel="Output" + stageClassName="min-h-[16rem] justify-start" + title="Remind me" + > +
+
+
+ Review Q3 forecast + Thread from Dana · 3 messages +
+ + }> + + Remind me + + +
+ + Remind me +
+
+ setQuery(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Enter' && queryReading.ok) { + event.preventDefault() + commit(query.trim()) + } + }} + placeholder="8 am, in 3 days, aug 7" + spellCheck={false} + value={query} + /> + setValue(event.target.value)} + placeholder="tomorrow at 9am, in 20 minutes, 2 hours 30 minutes…" + spellCheck={false} + value={value} + /> +
+ {EXAMPLES.map((example) => ( + + ))} +
+
+ + {/* Decorative twin of the list below; the list carries the semantics. */} +
+ {spans.length === 0 ? ( + Type to see tokens + ) : ( + spans.map((span) => + span.category === 'plain' ? ( + + {span.text} + + ) : ( + + {span.text} + + ), + ) + )} +
+ +
+ + {MODE_COPY[reading.mode]} + + + {summary(reading, tokens.length)} + +
+ +
    + {tokens.length === 0 ? ( +
  • + No slice confirmed — lingo declined every candidate. +
  • + ) : ( + tokens.map((token) => ( +
  • + + {token.text} + + {token.category} + + + {token.reading} + +
  • + )) + )} +
+ +
+
    + {CATEGORIES.map((category) => ( +
  • + + {category} +
  • + ))} +
+ + relative to {formatDate(now)} + {zone ? ` · ${zone}` : ''} + +
+
+ + ) +} diff --git a/apps/site/src/components/site/workflow-trigger-block.tsx b/apps/site/src/components/site/workflow-trigger-block.tsx new file mode 100644 index 0000000..461dcb4 --- /dev/null +++ b/apps/site/src/components/site/workflow-trigger-block.tsx @@ -0,0 +1,283 @@ +'use client' + +import { type FoundQuantity, findQuantities } from '@pascal-app/lingo' +import { + ActivityIcon, + BotIcon, + CloudIcon, + FolderGit2Icon, + HammerIcon, + type LucideIcon, + PackageIcon, + TicketIcon, +} from 'lucide-react' +import { useMemo, useState } from 'react' + +import { DemoFrame } from '@/components/site/demo-frame' +import { JsonView } from '@/components/site/json-view' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { issueClass } from '@/lib/lingo-display' +import { cn } from '@/lib/utils' + +interface Entity { + icon: LucideIcon + role: 'entity' | 'agent' | 'target' + text: string +} + +interface Rule { + /** Hand-labelled nouns — the part a rules engine supplies. Lingo only owns + * the numbers, so these are shown as plain chips, never as parse output. */ + entities: readonly Entity[] + id: string + prompt: string +} + +/** The last rule has no threshold on purpose: an event-only trigger is a + * legitimate reading, and the readout should say so rather than invent one. */ +const RULES: readonly Rule[] = [ + { + id: 'credits', + prompt: 'Let me know when cloud credits fall below $10k', + entities: [{ icon: CloudIcon, role: 'entity', text: 'cloud credits' }], + }, + { + id: 'build', + prompt: 'Alert me if the build takes over 15 minutes', + entities: [{ icon: HammerIcon, role: 'entity', text: 'the build' }], + }, + { + id: 'tickets', + prompt: 'Escalate tickets that stay open for more than 3 days', + entities: [{ icon: TicketIcon, role: 'entity', text: 'tickets' }], + }, + { + id: 'errors', + prompt: 'Page me when the error rate is above 2% for 10 minutes', + entities: [{ icon: ActivityIcon, role: 'entity', text: 'error rate' }], + }, + { + id: 'release', + prompt: 'When the framework ships a new release, ask the assistant to upgrade my repos', + entities: [ + { icon: PackageIcon, role: 'entity', text: 'framework' }, + { icon: BotIcon, role: 'agent', text: 'assistant' }, + { icon: FolderGit2Icon, role: 'target', text: 'repos' }, + ], + }, +] + +type Segment = + | { end: number; kind: 'plain'; start: number } + | { end: number; entity: Entity; kind: 'entity'; start: number } + | { end: number; hit: FoundQuantity; kind: 'bound'; start: number } + +/** Bare numbers ("25" in "under 25 units") are not thresholds. */ +function bounds(text: string): FoundQuantity[] { + return findQuantities(text).filter((hit) => hit.result.type !== 'number') +} + +function segment(text: string, rule: Rule | undefined, hits: FoundQuantity[]): Segment[] { + const marks: Segment[] = hits.map((hit) => ({ + end: hit.span.end, + hit, + kind: 'bound', + start: hit.span.start, + })) + for (const entity of rule?.entities ?? []) { + const start = text.indexOf(entity.text) + if (start >= 0) { + const end = start + entity.text.length + if (marks.every((m) => end <= m.start || start >= m.end)) { + marks.push({ end, entity, kind: 'entity', start }) + } + } + } + marks.sort((a, b) => a.start - b.start) + const out: Segment[] = [] + let cursor = 0 + for (const mark of marks) { + if (mark.start > cursor) { + out.push({ end: mark.start, kind: 'plain', start: cursor }) + } + out.push(mark) + cursor = mark.end + } + if (cursor < text.length) { + out.push({ end: text.length, kind: 'plain', start: cursor }) + } + return out +} + +function describe(hit: FoundQuantity): { kind: string; value: string } { + const { result } = hit + switch (result.type) { + case 'range': + return { kind: result.range.kind, value: result.range.format({ grouping: true }) } + case 'quantity': + return { kind: result.quantity.kind, value: result.quantity.format({ grouping: true }) } + case 'conversion': + return { kind: result.converted.kind, value: result.converted.format({ grouping: true }) } + default: + return { kind: result.type, value: hit.result.text.slice(hit.span.start, hit.span.end) } + } +} + +export function WorkflowTriggerBlock() { + const [text, setText] = useState(RULES[0]!.prompt) + const rule = useMemo(() => RULES.find((r) => r.prompt === text), [text]) + const hits = useMemo(() => bounds(text), [text]) + const segments = useMemo(() => segment(text, rule, hits), [text, rule, hits]) + + return ( + ({ span: hit.span, result: hit.result })), + null, + 2, + )} + /> + } + detailsLabel="Output" + stageClassName="min-h-[26rem] justify-start" + title="Workflow rule" + > +
+
+ setText(event.target.value)} + placeholder="Let me know when cloud credits fall below $10k" + spellCheck={false} + value={text} + /> +
+ {RULES.map((sample) => ( + + ))} +
+
+ +
+ {segments.length === 0 ? ( + Type a rule + ) : ( + segments.map((seg) => { + const slice = text.slice(seg.start, seg.end) + if (seg.kind === 'plain') { + return ( + + {slice} + + ) + } + if (seg.kind === 'entity') { + const Icon = seg.entity.icon + return ( + + + {slice} + + ) + } + return ( + + {slice} + + ) + }) + )} +
+ +
    + {hits.length === 0 ? ( +
  • + + event only + + + No numeric bound in this rule — it fires on an event, not a threshold. + +
  • + ) : ( + hits.map((hit) => { + const { kind, value } = describe(hit) + return ( +
  • +
    + + + {text.slice(hit.span.start, hit.span.end)} + + + {kind} · [{hit.span.start}, {hit.span.end}) + + {value} +
    + {hit.result.issues.length > 0 ? ( +
      + {hit.result.issues.map((issue) => ( +
    • + {issue.code} + {issue.message} +
    • + ))} +
    + ) : null} +
  • + ) + }) + )} +
+ +

+ Spans index the original string, so the chips above are sliced from it — no re-tokenizing + on the way to the UI. Edit the rule to see the bound move or disappear. +

+
+
+ ) +} diff --git a/apps/site/src/components/ui/popover.tsx b/apps/site/src/components/ui/popover.tsx new file mode 100644 index 0000000..4930ec6 --- /dev/null +++ b/apps/site/src/components/ui/popover.tsx @@ -0,0 +1,56 @@ +'use client' + +import { Popover as PopoverPrimitive } from '@base-ui/react/popover' + +import { cn } from '@/lib/utils' + +function Popover({ ...props }: PopoverPrimitive.Root.Props) { + return +} + +function PopoverTrigger({ ...props }: PopoverPrimitive.Trigger.Props) { + return +} + +function PopoverContent({ + align = 'start', + alignOffset = 0, + side = 'bottom', + sideOffset = 6, + className, + ...props +}: PopoverPrimitive.Popup.Props & + Pick) { + return ( + + + + + + ) +} + +function PopoverTitle({ className, ...props }: PopoverPrimitive.Title.Props) { + return ( + + ) +} + +export { Popover, PopoverContent, PopoverTitle, PopoverTrigger } diff --git a/apps/site/src/lib/date-display.ts b/apps/site/src/lib/date-display.ts new file mode 100644 index 0000000..472f710 --- /dev/null +++ b/apps/site/src/lib/date-display.ts @@ -0,0 +1,63 @@ +import type { DateGrain, DateRange, DateResult } from '@pascal-app/lingo/date' + +/** True when a reading pinned a time of day, not just a calendar day. */ +export function isTimedGrain(grain: DateGrain | undefined): boolean { + return grain === 'hour' || grain === 'minute' || grain === 'second' +} + +/** "Sat, Sep 12" — the day without a year, for tight rows. */ +export function formatDay(date: Date): string { + return date.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' }) +} + +/** "Sat, Sep 12, 2026" — the day with its year. */ +export function formatDate(date: Date): string { + return date.toLocaleDateString('en-US', { + weekday: 'short', + month: 'short', + day: 'numeric', + year: 'numeric', + }) +} + +/** "PDT" — the short zone name, which only exists on the visitor's clock. */ +export function formatZone(date: Date): string | undefined { + return date.toLocaleTimeString('en-US', { timeZoneName: 'short' }).split(' ').pop() +} + +/** "9:00 AM" */ +export function formatClock(date: Date): string { + return date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' }) +} + +/** Day plus clock when the grain carries one: "Sat, Sep 12 · 9:00 AM". */ +export function formatWhen(date: Date, grain: DateGrain | undefined): string { + return isTimedGrain(grain) ? `${formatDay(date)} · ${formatClock(date)}` : formatDay(date) +} + +/** + * One line for a range: dates when the endpoints are calendar days, day plus + * clock when either endpoint carries a time of day, so an overnight shift keeps + * its 10 PM and 2 AM. + */ +export function formatRange(range: DateRange): string { + const start = range.start + const end = range.end + if (!(start || end)) { + return 'open range' + } + const timed = isTimedGrain(start?.grain) || isTimedGrain(end?.grain) + const show = (date: Date, grain: DateGrain | undefined) => + timed ? formatWhen(date, grain) : formatDay(date) + if (start && end) { + return `${show(start.date, start.grain)} → ${show(end.date, end.grain)}` + } + if (start) { + return `from ${show(start.date, start.grain)}` + } + return `until ${show(end!.date, end!.grain)}` +} + +export function formatDateResult(result: DateResult): string { + return formatWhen(result.date, result.grain) +} diff --git a/apps/site/src/lib/docs-catalog.ts b/apps/site/src/lib/docs-catalog.ts index aca1acc..d51727c 100644 --- a/apps/site/src/lib/docs-catalog.ts +++ b/apps/site/src/lib/docs-catalog.ts @@ -247,6 +247,13 @@ export const docsNavGroups: DocsNavGroup[] = [ ['data grid', 'table', 'tanstack', 'react-table', 'spreadsheet', 'cell', 'column', 'bulk'], { depth: 3, markdownSectionId: 'one-schema' }, ), + page( + 'one-schema-workflow', + 'Thresholds out of prose', + 'Pull numeric thresholds out of automation prompts with findQuantities.', + ['workflow', 'rule', 'trigger', 'automation', 'threshold', 'findQuantities', 'bound'], + { depth: 3, markdownSectionId: 'one-schema' }, + ), ], }, { @@ -321,6 +328,20 @@ export const docsNavGroups: DocsNavGroup[] = [ ], { depth: 3, markdownSectionId: 'dates' }, ), + page( + 'dates-semantic-tokens', + 'Color only where it read', + 'Pre-segment a sentence, then let lingo confirm each date, time, and duration slice.', + ['token', 'highlighter', 'span', 'slice', 'category', 'legend', 'confirm'], + { depth: 3, markdownSectionId: 'dates' }, + ), + page( + 'dates-remind-me', + 'Presets and prose, one reader', + 'Scheduling popover whose presets and free text share one date reader.', + ['remind me', 'popover', 'scheduling', 'preset', 'tomorrow', 'weekend', 'someday'], + { depth: 3, markdownSectionId: 'dates' }, + ), page( 'calculations', 'Calculations', diff --git a/apps/site/src/lib/semantic-spans.ts b/apps/site/src/lib/semantic-spans.ts new file mode 100644 index 0000000..dff2740 --- /dev/null +++ b/apps/site/src/lib/semantic-spans.ts @@ -0,0 +1,179 @@ +import { findQuantities } from '@pascal-app/lingo' +import { humanizeDuration, parseDate, parseDuration } from '@pascal-app/lingo/date' + +import { formatClock, formatDay, formatWhen, isTimedGrain } from '@/lib/date-display' + +export type TokenCategory = 'date' | 'time' | 'duration' | 'quantity' | 'plain' + +export interface ClassifiedSpan { + category: TokenCategory + end: number + /** What lingo read the slice as — absent for plain text. */ + reading?: string + start: number + text: string +} + +interface Candidate extends ClassifiedSpan { + priority: number +} + +const WEEKDAY = 'monday|tuesday|wednesday|thursday|friday|saturday|sunday' +const MONTH = + 'january|february|march|april|may|june|july|august|september|october|november|december|jan|feb|mar|apr|jun|jul|aug|sept|sep|oct|nov|dec' +const DAY_UNIT = 'days?|weeks?|wks?|months?|years?|yrs?' +const CLOCK_UNIT = 'seconds?|secs?|minutes?|mins?|hours?|hrs?' +const SMALL_WORD = 'a|an|one|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve' +const HOUR_WORD = 'one|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve' + +/** + * Regexes only propose slices; `parseDate` has to accept each one before it is + * shown as a token. Lingo exposes one whole-match span per result, not + * sub-token spans, so pre-segmentation is how a sentence is broken up — and + * validation is what keeps the colors honest. + */ +const DATE_CANDIDATES = [ + /\bday\s+after\s+tomorrow\b/gi, + /\b(?:today|tomorrow|yesterday)\b/gi, + new RegExp(`\\b(?:next|last|this)\\s+(?:week|weekend|month|year|${WEEKDAY})\\b`, 'gi'), + new RegExp(`\\b(?:${WEEKDAY})\\b`, 'gi'), + /\bend\s+of\s+(?:the\s+)?(?:week|month|year)\b/gi, + new RegExp(`\\bin\\s+(?:${SMALL_WORD}|\\d+)\\s+(?:${DAY_UNIT})\\b`, 'gi'), + new RegExp(`\\b(?:${SMALL_WORD}|\\d+)\\s+(?:${DAY_UNIT})\\s+ago\\b`, 'gi'), + new RegExp(`\\b(?:${MONTH})\\.?\\s+\\d{1,2}(?:st|nd|rd|th)?(?:\\s*,?\\s*\\d{4})?\\b`, 'gi'), + new RegExp( + `\\b\\d{1,2}(?:st|nd|rd|th)?\\s+(?:of\\s+)?(?:${MONTH})\\b(?:\\s*,?\\s*\\d{4})?`, + 'gi', + ), + /\b\d{4}-\d{2}-\d{2}(?:T\d{2}:\d{2}(?::\d{2})?)?\b/g, +] + +const TIME_CANDIDATES = [ + /\b(?:tonight|this\s+(?:morning|afternoon|evening))\b/gi, + new RegExp(`\\b(?:tomorrow|${WEEKDAY})\\s+(?:morning|afternoon|evening|night)\\b`, 'gi'), + new RegExp(`\\bin\\s+(?:${SMALL_WORD}|\\d+)\\s+(?:${CLOCK_UNIT})\\b`, 'gi'), + /\b(?:at\s+)?\d{1,2}(?::\d{2})?\s*(?:am|pm|a\.m\.|p\.m\.)\b/gi, + /\b(?:at\s+)?\d{1,2}:\d{2}\b/gi, + new RegExp(`\\b(?:at\\s+)?(?:${HOUR_WORD}|\\d{1,2})\\s+o'clock\\b`, 'gi'), + /\b(?:quarter|half)\s+(?:past|to)\s+(?:\w+)\b/gi, + /\b(?:at\s+)?(?:noon|midnight)\b/gi, +] + +const DURATION_CANDIDATES = [ + /\bhalf\s+an\s+hour\b/gi, + new RegExp(`\\b(?:${SMALL_WORD})\\s+(?:${CLOCK_UNIT}|${DAY_UNIT})\\s+and\\s+a\\s+half\\b`, 'gi'), + new RegExp( + `\\b(?:${SMALL_WORD}|\\d+(?:\\.\\d+)?)\\s*(?:${CLOCK_UNIT}|${DAY_UNIT})(?:\\s+(?:and\\s+)?\\d+\\s*(?:${CLOCK_UNIT}))?\\b`, + 'gi', + ), +] + +const DAY_WORDS = + /\b(?:in|ago|today|tomorrow|tonight|yesterday|this|next|last|morning|afternoon|evening|night)\b|\d{4}-\d{2}-\d{2}/i + +function scan(input: string, patterns: RegExp[], visit: (start: number, end: number) => void) { + for (const re of patterns) { + re.lastIndex = 0 + let m = re.exec(input) + while (m !== null) { + if (m[0].length > 0) { + visit(m.index, m.index + m[0].length) + } + m = re.exec(input) + } + } +} + +export function classifyTextSpans(input: string, now: Date): ClassifiedSpan[] { + if (!input) { + return [] + } + + const candidates: Candidate[] = [] + const propose = (start: number, end: number, category: TokenCategory, reading: string) => { + const priority = + category === 'time' ? 4 : category === 'date' ? 3 : category === 'duration' ? 2 : 1 + candidates.push({ category, end, priority, reading, start, text: input.slice(start, end) }) + } + + const confirmDate = (start: number, end: number, category: 'date' | 'time') => { + const slice = input.slice(start, end) + const result = parseDate(slice, { now }) + if (!result.ok) { + return + } + // A relative offset like "in 2 hours" lands in the time list, but the + // reading decides the label: the grain is what the user actually pinned. + const timed = isTimedGrain(result.grain) + const label = + category === 'time' && !timed ? 'date' : category === 'date' && timed ? 'time' : category + // A bare clock ("at 3pm") only pins a time of day; naming the day lingo + // filled in would over-claim what the slice says. Offsets and day words + // ("in 20 minutes", "tomorrow morning") do carry a day. + const carriesDay = result.known.includes('weekday') || DAY_WORDS.test(slice) + const reading = timed + ? carriesDay + ? formatWhen(result.date, result.grain) + : formatClock(result.date) + : formatDay(result.date) + propose(start, end, label, reading) + } + + scan(input, DATE_CANDIDATES, (start, end) => confirmDate(start, end, 'date')) + scan(input, TIME_CANDIDATES, (start, end) => confirmDate(start, end, 'time')) + scan(input, DURATION_CANDIDATES, (start, end) => { + const result = parseDuration(input.slice(start, end)) + if (result.ok) { + propose(start, end, 'duration', humanizeDuration(result.duration)) + } + }) + + for (const hit of findQuantities(input)) { + const { result } = hit + // A bare number ("9" in "at 9am") is not a measurement. + if (result.type === 'number') { + continue + } + const reading = + result.type === 'quantity' + ? result.quantity.format() + : result.type === 'range' + ? result.range.format() + : result.type === 'conversion' + ? result.converted.format() + : input.slice(hit.span.start, hit.span.end) + propose(hit.span.start, hit.span.end, 'quantity', reading) + } + + // Highest priority first, then the longest slice, then reading order; take + // greedily so a confirmed time is never swallowed by a quantity underneath it. + candidates.sort( + (a, b) => b.priority - a.priority || b.end - b.start - (a.end - a.start) || a.start - b.start, + ) + const chosen: Candidate[] = [] + for (const c of candidates) { + if (chosen.every((k) => c.end <= k.start || c.start >= k.end)) { + chosen.push(c) + } + } + chosen.sort((a, b) => a.start - b.start) + + const spans: ClassifiedSpan[] = [] + let cursor = 0 + for (const { priority: _, ...span } of chosen) { + if (span.start > cursor) { + spans.push({ + category: 'plain', + end: span.start, + start: cursor, + text: input.slice(cursor, span.start), + }) + } + spans.push(span) + cursor = span.end + } + if (cursor < input.length) { + spans.push({ category: 'plain', end: input.length, start: cursor, text: input.slice(cursor) }) + } + return spans +} diff --git a/bun.lock b/bun.lock index c5ea11f..6002e7f 100644 --- a/bun.lock +++ b/bun.lock @@ -51,7 +51,7 @@ }, "packages/lingo": { "name": "@pascal-app/lingo", - "version": "0.3.0", + "version": "0.5.0", "devDependencies": { "@standard-schema/spec": "^1.1.0", "@types/react": "^19.2.17", diff --git a/packages/lingo/CHANGELOG.md b/packages/lingo/CHANGELOG.md index 2fb65ed..6de3123 100644 --- a/packages/lingo/CHANGELOG.md +++ b/packages/lingo/CHANGELOG.md @@ -7,6 +7,21 @@ change**, even if the API is untouched. ## [Unreleased] +### Added + +- Docs site: three demos built on spans — a token highlighter that + pre-segments a sentence with regexes and lets `parseDate`/`parseDateRange`/ + `parseDuration` decide what each piece is; a "Remind me" popover whose + presets and free-text field read through `./date`; and a workflow-rule + list that extracts numeric bounds with `findQuantities`. + +### Fixed + +- `findQuantities` returned mid-word spans for open-bound ranges and fuzzy + spreads that did not start the input (`call mom over 5 min` → `[2, 19)`). + The qualifier branch passed a token index where `okRange` expects a + normalized offset; spans now start at the qualifier (`over 5 min`). + ## [0.5.0] - 2026-08-23 ### Added diff --git a/packages/lingo/src/parse/grammar.test.ts b/packages/lingo/src/parse/grammar.test.ts index 31d3b98..1ac7f6b 100644 --- a/packages/lingo/src/parse/grammar.test.ts +++ b/packages/lingo/src/parse/grammar.test.ts @@ -604,6 +604,20 @@ describe('free-text extraction', () => { expect(hits.map((hit) => hit.result.type)).toEqual(['quantity', 'quantity', 'conversion']) expect(hits[2]!.result.text.slice(hits[2]!.span.start, hits[2]!.span.end)).toBe('72 in to cm') }) + + it('anchors open-bound and fuzzy-spread spans at the qualifier, not mid-word', () => { + const text = 'Page me when AWS credits fall below $10k or the build takes over 15 minutes' + const hits = findQuantities(text) + expect(hits.map((hit) => text.slice(hit.span.start, hit.span.end))).toEqual([ + 'below $10k', + 'over 15 minutes', + ]) + expect(hits.map((hit) => hit.result.type)).toEqual(['range', 'range']) + + const fuzzy = 'wait a few minutes first' + const [spread] = findQuantities(fuzzy) + expect(fuzzy.slice(spread!.span.start, spread!.span.end)).toBe('a few minutes') + }) }) describe('errors', () => { diff --git a/packages/lingo/src/parse/range.ts b/packages/lingo/src/parse/range.ts index c6e7321..421b497 100644 --- a/packages/lingo/src/parse/range.ts +++ b/packages/lingo/src/parse/range.ts @@ -100,7 +100,7 @@ export function parseRangeOrQty(p: ParserState, i: number, atStart: boolean): Pa approximate: quals.approximate || a.approximate, }) return { - result: okRange(p, range, i, end), + result: okRange(p, range, p.tokens[i]!.start, end), nextToken: trailing?.next ?? a.nextToken, } } @@ -136,7 +136,7 @@ export function parseRangeOrQty(p: ParserState, i: number, atStart: boolean): Pa max: { base: toBase(unit, a.spread[1]), unit: a.headUnit }, approximate: true, }) - return { result: okRange(p, range, i, a.normEnd), nextToken: a.nextToken } + return { result: okRange(p, range, p.tokens[i]!.start, a.normEnd), nextToken: a.nextToken } } // Single quantity / bare number.