Skip to content

06interaction

Quick add

Natural-language event entry: parseQuickAdd, the grammar it understands, and the offsets that power the live underline.

One line of typing becomes a draft event. parseQuickAdd("lunch with sam@acme.com tomorrow 1pm for 90 minutes", now) resolves the when, collects the attendees, and — the part that makes it feel magic rather than guessy — reports the exact source offsets of every phrase it consumed, so the input underlines what it understood as you type.

Using it

Double-click empty grid (or press C for a new event at the next hour) and type. The parser also powers the ⌘K palette's Quick add group. It's pure — no React, no DOM, not even an import — and exported for your own surfaces:

now is a parameter on purpose — nothing inside calls new Date(), so every relative phrase resolves against the caller's clock and the whole thing is deterministic and testable. All arithmetic is local-time through the Date constructor's overflow, which is what keeps it correct across DST in every zone.

What it understands

InputTypeNotes
standup tomorrow 2pmdate + timeTomorrow 14:00–14:30 — a lone start defaults to 30 minutes.
review 1-2pmrange13:00–14:00 — the meridiem is borrowed leftwards across the dash.
call from 2 to 4range14:00–16:00 — bare hours read via the afternoon heuristic, both ends the same way; from/to, until, till, dashes all work.
call at 3 / standup at 9 / dinner at 7bare hourAfter at, 1–6 are afternoon and 7–11 morning; 12 is noon. An evening cue in the phrase (dinner, drinks, tonight, evening, night) reads 7–11 as evening, and straight after one of those words the at is optional when the hour ends the phrase: drinks 8, dinner 7-9. Elsewhere a bare number stays in the title (sprint 3 planning). 7am, 07:00 and 19:30 always mean what they say.
sync in 10 minutes / lunch in an houroffsetA start that far from now, at the default length — in half an hour, in 1.5 hours and in 1 hour 30 minutes too.
party tomorrow 9pm to midnightcross-midnightThe end carries onto the next day rather than inverting.
deep work for 90 minutes at 2pmduration14:00–15:30. for 30min, for 1h, for half an hour, fractional hours, and bare adjectival durations (45min gym) all parse. Adjacent hours and minutes add up: 1h 30m, for an hour and 15 minutes.
gym tomorrow morning for 45 minspart of daymorning / afternoon / evening / tonight supply the hour.
coffee next fridayweekdayA bare weekday resolves forwards (today counts); next friday is always exactly a week past the bare reading, so the two can never collide.
offsite all day on 12 Augall-dayExplicit all day, or any date with no time. Ordinals, month names, numeric dates (12/08, month-first by default) and years all parse.
dinner @ Padellalocationat / @ + a venue. at 3pm is never stolen — the time pass claims ranges first.
retro in the Blue Room / standup in Room 4locationin names a place only in forms that can't be anything else: the … Room, my office, Room 4, Turing Hall, Berlin office. lunch in London, training in Python and call in the morning stay as written.
lunch with sam@acme.comattendeesBare emails anywhere, deduped case-insensitively; a leading with / w/ is swallowed.

Passes run in one fixed left-to-right claim table, and each may only claim source ranges no earlier pass took — how at 3pm beats the location rule, and how an email that happens to contain 3pm is never read as a time. A time that already passed today rolls to tomorrow — unless a day was named, in which case you said what you meant. What isn't recognised stays in the title, casing and punctuation intact.

A phrase can also be understood and overruled. An explicit range is authoritative: in design sync 3-4pm for 90 minutes the event runs 15:00–16:00, and for 90 minutes is neither applied nor left in the title — it is reported in ignored, so a field can show that it was read and set aside. The same goes for a second length (gym for 1h for 2h is one hour) and for a time or length beside all day.

The result

proptype · description
title
string

The input with every tokens and ignored phrase stripped and the seams tidied.

start / end
Date | null

null only for blank input — otherwise there is always a usable draft.

allDay / durationMin
boolean / number | null

durationMin is null for all-day.

attendees / location
string[] / string | null

Bare emails in order; the first venue.

confidence
"none" | "partial" | "full"

none: treat the whole string as a title. full: a day and a time (or an explicit all-day) both landed.

tokens
QuickAddToken[]

Every span applied to the result — text, [start, end) offsets, kind: date | time | duration | location | attendee. Sorted by offset.

ignored
QuickAddToken[]

Spans understood but overruled, such as a length beside a range. Applied to nothing, stripped from the title, sorted by offset.

Options: dateOrder ("month-first" default; either way an unambiguous half wins) and defaultDurationMin (default 30, matching quick-create).

Building the preview UI

The view half is exported too. highlightSegments(input, result.tokens, result.ignored?) interleaves the input with its tokens — concatenating every segment's text reproduces the input exactly, the property the caret-aligned underline overlay depends on. Pass ignored and those runs come back flagged ignored: true, for a struck-through treatment. QUICK_ADD_KINDS holds each kind's label, swatch and mark classes — an underline style as well as a colour, because a theme may give two kinds the same colour — and QUICK_ADD_KIND_ORDER lists the kinds in legend order. quickAddPreview folds a result into one renderable line: the title (never blank — UNTITLED covers an all-metadata input), the resolved when, and chips for location and attendees.

And the finished field — underline, preview line, commit — ships assembled as QuickAddInput, with a live demo on its own page.

Note

The parser guarantees tokens are sorted, disjoint, in bounds, and that input.slice(t.start, t.end) === t.text — so the underline can never highlight the wrong at.