| 1 | # Quick add — design |
| 2 | |
| 3 | **Status:** specified 2026-09-02, before the code this time. Authored by |
| 4 | Claude Opus 5. Not yet reviewed by a model at least as capable as the |
| 5 | author, and not yet approved by Paul. |
| 6 | |
| 7 | Asked for: a small input beside "New event" that expands into a |
| 8 | popover textarea, takes natural language — "Meeting with Dermot Lacey |
| 9 | at 3pm in McCloskeys" — parses it AS YOU TYPE into a detail pane below |
| 10 | the input, and creates the event. Email addresses in the text become |
| 11 | invitees. Start, end and durations all parse. |
| 12 | |
| 13 | --- |
| 14 | |
| 15 | ## 1. One parser, and it is the Go one |
| 16 | |
| 17 | **The parse happens on the server. There is no JavaScript parser.** |
| 18 | |
| 19 | The live pane is a debounced `fetch` to `POST /events/quick/parse` |
| 20 | which returns the pane as an HTML fragment. Typing costs a round trip |
| 21 | of about a form submission; the pane is a preview of what the server |
| 22 | will actually do. |
| 23 | |
| 24 | Rejected: **parsing in JavaScript for instant feedback.** Two |
| 25 | implementations of "next Tuesday at half three" is two answers to the |
| 26 | same sentence, and the one the person read in the pane would not be the |
| 27 | one that got saved. A calendar that shows you 15:30 and books 03:30 has |
| 28 | done something worse than being slow. The scriptless floor needs the Go |
| 29 | parser regardless, so the JS one would be a second copy of a thing we |
| 30 | already have to have. |
| 31 | |
| 32 | Rejected: **compiling the Go parser to WASM** so one implementation can |
| 33 | run in both places. It is the right shape and the wrong weight — a |
| 34 | megabyte of WASM on every page load to save 80ms on a keystroke. |
| 35 | |
| 36 | ## 2. The scriptless floor is a working feature, not a fallback |
| 37 | |
| 38 | - The popover is a `<details>`. It opens with no script. |
| 39 | - The textarea is inside an ordinary `<form>` that POSTs to |
| 40 | `/events/quick`. |
| 41 | - Submitting **creates the event** and redirects to it. |
| 42 | |
| 43 | With script, the pane has already shown what will happen, so the submit |
| 44 | is a confirmation. Without script, the person has not seen the pane — |
| 45 | so the event page they land on is the confirmation, and it is one click |
| 46 | from Edit and one from Delete. Creating an event is cheap and |
| 47 | reversible, which is what makes "create, then show" honest here where |
| 48 | it would not be for a destructive action. |
| 49 | |
| 50 | Rejected: **always landing on a prefilled `/events/new` form.** Safer, |
| 51 | and it makes the fast path two steps for everybody in order to protect |
| 52 | the case where the parse was wrong — which the pane already covers for |
| 53 | anyone who has script, and which Edit covers for anyone who does not. |
| 54 | |
| 55 | ## 2.1 Enter adds it |
| 56 | |
| 57 | **Enter submits. Shift+Enter is a newline. Cmd/Ctrl+Enter also submits.** |
| 58 | The Slack convention, plus the chord people arrive from GitHub and |
| 59 | Linear expecting. |
| 60 | |
| 61 | Enter can fire before the debounced parse has landed, so the pane may |
| 62 | never have shown that sentence. Accepted, because **the pane is a |
| 63 | preview and never a gate**: the server parses again on submit, so |
| 64 | racing it can only mean "you did not get to look", never "the wrong |
| 65 | event was created". Anyone who wants to look pauses — the same pause |
| 66 | the debounce is waiting for. |
| 67 | |
| 68 | The hint that says so is revealed by `quickadd.js`, never rendered by |
| 69 | the template. With no script Enter is an ordinary newline, and a page |
| 70 | advertising a shortcut it cannot honour is worse than a silent one. The |
| 71 | browser gate asserts the hint is absent with JavaScript off. |
| 72 | |
| 73 | The Add button is untouched. This is an enhancement on a floor that |
| 74 | already worked. |
| 75 | |
| 76 | ## 2.2 A run of days is a run of days |
| 77 | |
| 78 | **A date RANGE makes one event spanning those days.** "london |
| 79 | tues–thurs" is Tuesday, Wednesday and Thursday in London. |
| 80 | |
| 81 | Every range pattern is tried before any single-date one, and that |
| 82 | ordering is the fix rather than a detail. Run the singles first and |
| 83 | "12–14 March" matches "14 March" — the range's own END — which books one |
| 84 | day and leaves a stray "12" in the title. The reported symptom was |
| 85 | exactly this shape: "london tues–thurs" became a Tuesday titled |
| 86 | "london –thurs". |
| 87 | |
| 88 | **A range with no clock in it is ALL DAY**, and the pane says so in |
| 89 | words. A three-day trip is not an hour on the Tuesday with two days of |
| 90 | silence after it. |
| 91 | |
| 92 | **A range WITH a clock is one continuous block**: the start time on the |
| 93 | first day, the end time on the last. "Summit Monday to Wednesday |
| 94 | 9am–5pm" is 09:00 Monday until 17:00 Wednesday. That is the literal |
| 95 | reading, and it is not the only possible one — somebody may have meant |
| 96 | nine to five on each of three days. That reading is a recurrence, and |
| 97 | ruling 4 forbids inventing a rule the picker cannot re-display, so the |
| 98 | literal one wins and the pane prints both ends in full for anyone who |
| 99 | meant the other. |
| 100 | |
| 101 | **A range only ever matches around a calendar word** — a month name, a |
| 102 | weekday name, or a slash. A digits-only range is a TIME, always: |
| 103 | "Retro 3-4pm" must never become the third to the fourth. |
| 104 | |
| 105 | The separators are `-`, `–`, `—`, `to`, `until`, `till`, `through` and |
| 106 | `thru`. The en dash is not decoration: macOS substitutes one for a |
| 107 | typed hyphen mid-sentence, so a parser that only knows ASCII fails on |
| 108 | text the person watched themselves type correctly. |
| 109 | |
| 110 | **The end date stored is EXCLUSIVE**, like every other range in this |
| 111 | app, and **the pane prints the INCLUSIVE last day**. Showing the stored |
| 112 | end would tell somebody their trip runs a day longer than it does. |
| 113 | |
| 114 | ## 3. Nothing is guessed silently |
| 115 | |
| 116 | Every defaulted field is **named in the pane in words**, not implied: |
| 117 | |
| 118 | - No time found → the next half hour, one hour long, and the pane says |
| 119 | so. |
| 120 | - A start with no end → one hour. |
| 121 | - Anything the parser could not place goes in the title, and the pane |
| 122 | shows the title it built so a swallowed word is visible. |
| 123 | |
| 124 | The pane is the contract. If it is not in the pane, the parser did not |
| 125 | do it. |
| 126 | |
| 127 | ## 4. Attendees are recorded, NOT invited |
| 128 | |
| 129 | An address in the text becomes an attendee row. **No mail is sent to |
| 130 | them, and the pane says that in those words.** |
| 131 | |
| 132 | This is the one place this feature can do real harm: somebody types |
| 133 | "lunch with doc@tito.io", reads the word "invitee", and believes Doc |
| 134 | has been told. Doc has not been told. Until there is an invitation flow |
| 135 | with an RSVP and a sender identity, the honest label is "Who's coming |
| 136 | (not emailed)" and the pane repeats it. |
| 137 | |
| 138 | **Attendee addresses are SEALED**, like a title. AGENTS.md's falsifiable |
| 139 | form gives up "who is on the team" — team members are already plaintext |
| 140 | in `people`. An external attendee is not on the team, and who this team |
| 141 | meets with is exactly the kind of fact the one rule exists to keep out |
| 142 | of the bucket. A hash column carries dedupe so sealing costs no lookup. |
| 143 | |
| 144 | ## 5. The recurrence subset is the one that already exists |
| 145 | |
| 146 | Ruling 4 stands: quick add offers no rule the picker does not. "every |
| 147 | Tuesday" maps to the existing weekly choice; anything else is left in |
| 148 | the title rather than invented, and the pane shows it sitting there. |
| 149 | |
| 150 | ## 6. What the parser takes |
| 151 | |
| 152 | Times `3pm`, `15:00`, `3:30pm`, `half three`; time ranges `3-4pm`, `3pm |
| 153 | to 4:30`, `from 3 until 4`; durations `for 90 minutes`, `for 2h`, `for |
| 154 | an hour`; dates `today`, `tomorrow`, `on Friday`, `next Tuesday`, `12 |
| 155 | March`, `12/3`; DATE RANGES `tues–thurs`, `Monday to Wednesday`, |
| 156 | `12–14 March`, `March 12-14`, `30 September to 2 October`, |
| 157 | `12/10-14/10`; `all day`; locations `in X` / `at X` where X is not a |
| 158 | time; attendees by email address; repeats `every day` / `every Tuesday` |
| 159 | / `every week` / `every month` / `every year`. |
| 160 | |
| 161 | **`at` is ambiguous on purpose and time wins.** "at 3" is a time, "at |
| 162 | McCloskeys" is a place, and the rule is: try to read a time, and only |
| 163 | if that fails treat the rest as a place. Getting this backwards puts |
| 164 | "3pm" in the location field of every event anybody types. |
| 165 | |
| 166 | ## 7. Testing |
| 167 | |
| 168 | The parser is a pure function of (text, now, zone) and is tested as |
| 169 | one — a table of sentences against expected fields, including the |
| 170 | ambiguities above. |
| 171 | |
| 172 | **A JS harness is added for the live pane**, approved by Paul in this |
| 173 | session: Playwright, which drives a real browser in this environment |
| 174 | where chromedp does not (it parks at `about:blank`; recorded as debt in |
| 175 | the calendar spec). It asserts the enhanced path — type, wait, read the |
| 176 | pane — and the scriptless path with JavaScript disabled, which is the |
| 177 | only way to prove the floor is real rather than claimed. |
| 178 | |