oficina / calendar Public
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.