# Quick add — design

**Status:** specified 2026-09-02, before the code this time. Authored by
Claude Opus 5. Not yet reviewed by a model at least as capable as the
author, and not yet approved by Paul.

Asked for: a small input beside "New event" that expands into a
popover textarea, takes natural language — "Meeting with Dermot Lacey
at 3pm in McCloskeys" — parses it AS YOU TYPE into a detail pane below
the input, and creates the event. Email addresses in the text become
invitees. Start, end and durations all parse.

---

## 1. One parser, and it is the Go one

**The parse happens on the server. There is no JavaScript parser.**

The live pane is a debounced `fetch` to `POST /events/quick/parse`
which returns the pane as an HTML fragment. Typing costs a round trip
of about a form submission; the pane is a preview of what the server
will actually do.

Rejected: **parsing in JavaScript for instant feedback.** Two
implementations of "next Tuesday at half three" is two answers to the
same sentence, and the one the person read in the pane would not be the
one that got saved. A calendar that shows you 15:30 and books 03:30 has
done something worse than being slow. The scriptless floor needs the Go
parser regardless, so the JS one would be a second copy of a thing we
already have to have.

Rejected: **compiling the Go parser to WASM** so one implementation can
run in both places. It is the right shape and the wrong weight — a
megabyte of WASM on every page load to save 80ms on a keystroke.

## 2. The scriptless floor is a working feature, not a fallback

- The popover is a `<details>`. It opens with no script.
- The textarea is inside an ordinary `<form>` that POSTs to
  `/events/quick`.
- Submitting **creates the event** and redirects to it.

With script, the pane has already shown what will happen, so the submit
is a confirmation. Without script, the person has not seen the pane —
so the event page they land on is the confirmation, and it is one click
from Edit and one from Delete. Creating an event is cheap and
reversible, which is what makes "create, then show" honest here where
it would not be for a destructive action.

Rejected: **always landing on a prefilled `/events/new` form.** Safer,
and it makes the fast path two steps for everybody in order to protect
the case where the parse was wrong — which the pane already covers for
anyone who has script, and which Edit covers for anyone who does not.

## 2.1 Enter adds it

**Enter submits. Shift+Enter is a newline. Cmd/Ctrl+Enter also submits.**
The Slack convention, plus the chord people arrive from GitHub and
Linear expecting.

Enter can fire before the debounced parse has landed, so the pane may
never have shown that sentence. Accepted, because **the pane is a
preview and never a gate**: the server parses again on submit, so
racing it can only mean "you did not get to look", never "the wrong
event was created". Anyone who wants to look pauses — the same pause
the debounce is waiting for.

The hint that says so is revealed by `quickadd.js`, never rendered by
the template. With no script Enter is an ordinary newline, and a page
advertising a shortcut it cannot honour is worse than a silent one. The
browser gate asserts the hint is absent with JavaScript off.

The Add button is untouched. This is an enhancement on a floor that
already worked.

## 2.2 A run of days is a run of days

**A date RANGE makes one event spanning those days.** "london
tues–thurs" is Tuesday, Wednesday and Thursday in London.

Every range pattern is tried before any single-date one, and that
ordering is the fix rather than a detail. Run the singles first and
"12–14 March" matches "14 March" — the range's own END — which books one
day and leaves a stray "12" in the title. The reported symptom was
exactly this shape: "london tues–thurs" became a Tuesday titled
"london –thurs".

**A range with no clock in it is ALL DAY**, and the pane says so in
words. A three-day trip is not an hour on the Tuesday with two days of
silence after it.

**A range WITH a clock is one continuous block**: the start time on the
first day, the end time on the last. "Summit Monday to Wednesday
9am–5pm" is 09:00 Monday until 17:00 Wednesday. That is the literal
reading, and it is not the only possible one — somebody may have meant
nine to five on each of three days. That reading is a recurrence, and
ruling 4 forbids inventing a rule the picker cannot re-display, so the
literal one wins and the pane prints both ends in full for anyone who
meant the other.

**A range only ever matches around a calendar word** — a month name, a
weekday name, or a slash. A digits-only range is a TIME, always:
"Retro 3-4pm" must never become the third to the fourth.

The separators are `-`, `–`, `—`, `to`, `until`, `till`, `through` and
`thru`. The en dash is not decoration: macOS substitutes one for a
typed hyphen mid-sentence, so a parser that only knows ASCII fails on
text the person watched themselves type correctly.

**The end date stored is EXCLUSIVE**, like every other range in this
app, and **the pane prints the INCLUSIVE last day**. Showing the stored
end would tell somebody their trip runs a day longer than it does.

## 3. Nothing is guessed silently

Every defaulted field is **named in the pane in words**, not implied:

- No time found → the next half hour, one hour long, and the pane says
  so.
- A start with no end → one hour.
- Anything the parser could not place goes in the title, and the pane
  shows the title it built so a swallowed word is visible.

The pane is the contract. If it is not in the pane, the parser did not
do it.

## 4. Attendees are recorded, NOT invited

An address in the text becomes an attendee row. **No mail is sent to
them, and the pane says that in those words.**

This is the one place this feature can do real harm: somebody types
"lunch with doc@tito.io", reads the word "invitee", and believes Doc
has been told. Doc has not been told. Until there is an invitation flow
with an RSVP and a sender identity, the honest label is "Who's coming
(not emailed)" and the pane repeats it.

**Attendee addresses are SEALED**, like a title. AGENTS.md's falsifiable
form gives up "who is on the team" — team members are already plaintext
in `people`. An external attendee is not on the team, and who this team
meets with is exactly the kind of fact the one rule exists to keep out
of the bucket. A hash column carries dedupe so sealing costs no lookup.

## 5. The recurrence subset is the one that already exists

Ruling 4 stands: quick add offers no rule the picker does not. "every
Tuesday" maps to the existing weekly choice; anything else is left in
the title rather than invented, and the pane shows it sitting there.

## 6. What the parser takes

Times `3pm`, `15:00`, `3:30pm`, `half three`; time ranges `3-4pm`, `3pm
to 4:30`, `from 3 until 4`; durations `for 90 minutes`, `for 2h`, `for
an hour`; dates `today`, `tomorrow`, `on Friday`, `next Tuesday`, `12
March`, `12/3`; DATE RANGES `tues–thurs`, `Monday to Wednesday`,
`12–14 March`, `March 12-14`, `30 September to 2 October`,
`12/10-14/10`; `all day`; locations `in X` / `at X` where X is not a
time; attendees by email address; repeats `every day` / `every Tuesday`
/ `every week` / `every month` / `every year`.

**`at` is ambiguous on purpose and time wins.** "at 3" is a time, "at
McCloskeys" is a place, and the rule is: try to read a time, and only
if that fails treat the rest as a place. Getting this backwards puts
"3pm" in the location field of every event anybody types.

## 7. Testing

The parser is a pure function of (text, now, zone) and is tested as
one — a table of sentences against expected fields, including the
ambiguities above.

**A JS harness is added for the live pane**, approved by Paul in this
session: Playwright, which drives a real browser in this environment
where chromedp does not (it parks at `about:blank`; recorded as debt in
the calendar spec). It asserts the enhanced path — type, wait, read the
pane — and the scriptless path with JavaScript disabled, which is the
only way to prove the floor is real rather than claimed.
