oficina / calendar Public

quick add: one sentence becomes an event, and a browser gate that actually runs

A small box beside New event. Click it and it opens a popover textarea;
type "Meeting with Dermot Lacey at 3pm in McCloskeys" and a pane below
fills in, as you type, with everything the server understood. Submit and
the event exists.

ONE PARSER, AND IT IS THE GO ONE. The live pane is a debounced fetch to
POST /events/quick/parse returning an HTML fragment; quickadd.js parses
nothing and must never start. Two implementations of "next Tuesday at
half three" is two answers, and the one shown in the pane would not be
the one saved.

THE SCRIPTLESS FLOOR IS THE PRODUCT. The popover is a <details>, the
form is a form, and submitting creates the event with no JavaScript
anywhere in the path. The browser gate proves this rather than claiming
it: it runs the whole feature a second time with JavaScript disabled.

"at" is ambiguous and time wins it. Times are taken out of the sentence
before places, so "at 3pm in McCloskeys" is a time and a place; the
other order books every event in a venue called 3pm.

NOTHING IS GUESSED SILENTLY. Every defaulted field names itself in the
pane in words — no time found, no end, no date. The pane is the
contract: if it is not in the pane, the parser did not do it.

ATTENDEES ARE RECORDED, NOT INVITED. An address in the sentence becomes
an attendee row and NOTHING IS EMAILED. Every surface that shows one
says so, because somebody who reads "invitee" and assumes a message went
out has been misled by us. The ICS export carries them with
PARTSTAT=NEEDS-ACTION and RSVP=FALSE, so a calendar that does send
invitations can, without this app claiming anybody was asked.

The addresses are SEALED, and the lookup column is a KEYED fingerprint
(seal.Fingerprint, HMAC under an HKDF purpose key) rather than a hash.
The existing unkeyed SHA-256 over event UIDs is fine — a UID is
unguessable — but the set of real email addresses is small enough to
enumerate, so a plain digest of an address is that address in a
different alphabet. AGENTS.md already gives up "who is on the team"; who
the team MEETS WITH is exactly what the one rule keeps out of the
bucket.

A JS TEST HARNESS, approved by Paul in this session. Playwright, because
chromedp does not drive in this environment at all — it starts Chromium
and parks at about:blank until Go's 600s timeout, which is why
TestBrowserWalk has never run and the house rule about crossing from a
computed value to painted output was recorded as unmet. It is met now:
make browser starts the app in Go, signs in over the live origin, and
hands node a session cookie.

It earned its keep on its first run. The popover rendered ~28px off the
right-hand edge of a 1280px viewport — anchored to the start edge while
its trigger sits at the far right of the header. Every string assertion
in the Go suite passed. Only getBoundingClientRect() could see it.
Anchored to the end edge now.

make browser is deliberately NOT in make ci: the one gate everybody runs
before a push must not depend on a toolchain the app itself does not
use.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QG3FX6qzynri8yTgMUm7q7
Paul Campbell pushed by paul@keymail.dev 2a9c782c40d6b01b928498d7de357aae161170c8 parent 0cb4484
36 files changed, +2230 −16
  • AGENTS.md +17 −0
  • Makefile +12 −1
  • docs/superpowers/specs/2026-09-02-calendar-design.md +25 −10
  • docs/superpowers/specs/2026-09-02-quick-add-design.md +116 −0
  • internal/calendar/handlers_events.go +9 −0
  • internal/calendar/handlers_misc.go +10 −0
  • internal/calendar/handlers_quick.go +189 −0
  • internal/calendar/ics_export.go +7 −0
  • internal/calendar/migrations/0004_create_attendees.sql +6 −0
  • internal/calendar/migrations/schema.sql +6 −0
  • internal/calendar/models.go +1 −0
  • internal/calendar/models_attendee.go +37 −0
  • internal/calendar/quickadd.go +233 −0
  • internal/calendar/quickadd_when.go +216 −0
  • internal/calendar/quickadd_who.go +141 −0
  • internal/calendar/render.go +34 −1
  • internal/calendar/routes.go +5 −0
  • internal/calendar/seal/fingerprint.go +35 −0
  • internal/calendar/seal/seal.go +6 −0
  • internal/calendar/static/calendar.css +141 −0
  • internal/calendar/static/quickadd.js +112 −0
  • internal/calendar/store_attendee.go +129 −0
  • internal/calendar/store_event.go +5 −0
  • internal/calendar/templates/_quickadd.html +46 −0
  • internal/calendar/templates/_quickpane.html +56 −0
  • internal/calendar/templates/agenda.html +7 −1
  • internal/calendar/templates/day.html +7 −1
  • internal/calendar/templates/event.html +12 −0
  • internal/calendar/templates/layout.html +2 −0
  • internal/calendar/templates/month.html +7 −1
  • internal/calendar/templates/week.html +7 −1
  • internal/calendartest/playwright_test.go +134 −0
  • internal/calendartest/quickadd_http_test.go +124 −0
  • internal/calendartest/quickadd_test.go +197 −0
  • package.json +12 −0
  • test/browser/quickadd.mjs +127 −0
diff --git a/AGENTS.md b/AGENTS.md
index ddc2e05..3113245 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -135,6 +135,23 @@ relying on remembering one, make it a test instead.
- Light/dark is three states and **auto is the ABSENCE of
`data-theme`**. The attribute takes the light/dark SCHEME, never
rastrillo's theme NAME.
+- **Quick add has ONE parser and it is the Go one.** The live pane is a
+ fetch to `/events/quick/parse`. Never add a second parser in
+ JavaScript: two readings of "next Tuesday at half three" means the
+ time shown is not the time saved.
+- **Attendees are recorded, never invited.** Nothing emails them, every
+ surface that names one says so, and their addresses are sealed with a
+ KEYED fingerprint for lookup — an unkeyed hash of an address is that
+ address in a different alphabet.
+
+**Browser**
+- `make browser` is the painted-output gate: Playwright, run from
+ `TestQuickAddInARealBrowser`. It is NOT in `make ci` — the one gate
+ everybody runs must not need a toolchain the app does not use.
+- It runs the feature twice, the second time with **JavaScript
+ disabled**. That pass is the only proof the scriptless floor is real.
+- chromedp does not drive in this environment (it parks at
+ `about:blank`); `TestBrowserWalk` is still dead. Do not assume it ran.
**Logs**
- **No title, place, note or address in any log line, error string or
diff --git a/Makefile b/Makefile
index 55ee5b3..68c94d6 100644
--- a/Makefile
+++ b/Makefile
@@ -22,7 +22,7 @@ RELEASE_BIN := releases/$(APP)-$(RELEASE_GOOS)-$(RELEASE_GOARCH)
# make release VERSION=v0.1.0
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null)
-.PHONY: build release version-check test vet fmt-check migration-check ci
+.PHONY: build release version-check test vet fmt-check migration-check ci browser
# build is the compile check: with more than one package matched, go
# build discards the output, so this catches a broken package without
@@ -107,3 +107,14 @@ migration-check:
# instead of at boot on whatever machine notices next. If the app
# declares manifest resources, also add: rastrillo generate --check
ci: vet fmt-check test migration-check
+
+# The browser gate: the quick-add box driven in a real Chromium, with
+# script and then with JavaScript disabled.
+#
+# Deliberately NOT part of `ci`. It needs node and `npm install`, and
+# the one gate the team runs on every push must not depend on a
+# toolchain the app itself does not use. Run it before shipping a
+# change to the box.
+browser:
+ npm install --no-save playwright
+ go test -tags browser -run TestQuickAddInARealBrowser ./internal/calendartest/
diff --git a/docs/superpowers/specs/2026-09-02-calendar-design.md b/docs/superpowers/specs/2026-09-02-calendar-design.md
index 0640766..c86f847 100644
--- a/docs/superpowers/specs/2026-09-02-calendar-design.md
+++ b/docs/superpowers/specs/2026-09-02-calendar-design.md
@@ -238,6 +238,10 @@ share page: a feed is not free and the person deciding should see it.
views, recurrence with occurrence editing and series splitting,
reminders, ICS import/export/feeds, settings, theme switching.
+**Shipped since:** quick add — a natural-language box beside New event,
+with a live server-parsed detail pane and recorded (never invited)
+attendees. Its own spec is `2026-09-02-quick-add-design.md`.
+
**Not built, each with a trigger:** attendees and guests, web push,
external feed subscription, `VTIMEZONE`, expanding foreign rules, drag
to move, skip-ahead expansion, sealed instants, search, background
@@ -253,14 +257,25 @@ members, per-event colours, attachments, print, "find a time".
literals rather than catalog keys. This is a real divergence from the
suite's twelve-locale floor and is the largest single piece of debt in
the branch.
-- The browser drive is written but **does not run in this environment**.
- Chromium starts and then sits at `about:blank`; the test reaches Go's
- 600-second timeout with chromedp's goroutines blocked on `select`. It
- is behind the `browser` build tag, so `make ci` is unaffected, but the
- house rule that at least one gate must cross from computed value to
- painted output is **not currently met**. The test asserts the right
- thing — it measures the packed chip's real `getBoundingClientRect()`
- height, which is exactly the value a server-side packer can get
- arithmetically right and visually wrong — so the work needed is
- getting chromedp to drive, not writing the test.
+- The chromedp browser drive is written and **still does not run in
+ this environment**: Chromium starts and sits at `about:blank` until
+ Go'''s 600-second timeout, with chromedp'''s goroutines blocked on
+ `select`. `TestBrowserWalk` is therefore still dead.
+
+ **The house rule is now met by a different gate.** `make browser`
+ runs `TestQuickAddInARealBrowser`, which drives Playwright — Go
+ starts the app and signs in, node drives the browser — and it
+ crosses from computed value to painted output twice: it measures the
+ popover'''s real `getBoundingClientRect()`, and it runs the whole
+ feature a second time **with JavaScript disabled** to prove the
+ scriptless floor rather than assert it.
+
+ It earned its place on its first run, catching a bug no Go assertion
+ could see: the popover rendered ~28px off the right-hand edge of a
+ 1280px viewport, because it was anchored to the start edge while its
+ trigger sits at the far right of the header. Every string test
+ passed. Fixed by anchoring to the end edge.
+
+ Remaining work is porting `TestBrowserWalk` off chromedp, not
+ writing a gate.
- UI copy has not been through the `copy-review` gate.
diff --git a/docs/superpowers/specs/2026-09-02-quick-add-design.md b/docs/superpowers/specs/2026-09-02-quick-add-design.md
new file mode 100644
index 0000000..3b5ce39
--- /dev/null
+++ b/docs/superpowers/specs/2026-09-02-quick-add-design.md
@@ -0,0 +1,116 @@
+# 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.
+
+## 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`; 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`; `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.
diff --git a/internal/calendar/handlers_events.go b/internal/calendar/handlers_events.go
index b386eb9..a8da52d 100644
--- a/internal/calendar/handlers_events.go
+++ b/internal/calendar/handlers_events.go
@@ -132,11 +132,20 @@ func (h *handlers) showEvent(w http.ResponseWriter, r *http.Request) {
return
}
+ // Scoped already: Event above 404s a row that is not this
+ // person's, so the attendee read needs no grant join of its own.
+ attendees, err := h.store.AttendeesFor(r.Context(), e.ID)
+ if err != nil {
+ h.fail(w, r, "read attendees", err)
+ return
+ }
+
data := map[string]any{
"Event": e,
"Chip": chipFor(e, p.Zone, time.Now().Unix()),
"Occurrence": occurrence,
"Reminders": reminders,
+ "Attendees": attendees,
"Zone": p.Zone,
"RuleText": DescribeRule(e.RRule),
}
diff --git a/internal/calendar/handlers_misc.go b/internal/calendar/handlers_misc.go
index a1e2292..b319d83 100644
--- a/internal/calendar/handlers_misc.go
+++ b/internal/calendar/handlers_misc.go
@@ -94,6 +94,12 @@ func (h *handlers) feed(w http.ResponseWriter, r *http.Request) {
http.Error(w, "unavailable", http.StatusInternalServerError)
return
}
+ // Attendees are carried in the export and nowhere else in a view,
+ // so they are loaded here rather than by the query.
+ if err := h.store.AttachAttendees(r.Context(), events); err != nil {
+ h.logger.Error("calendar: feed attendees", "stage", "feed.attendees",
+ "err", scrubErr(err))
+ }
body := WriteICS(cal.Name, events, r.URL.Query().Get("alarms") == "1")
// An ETag so a subscriber that polls every fifteen minutes usually
@@ -131,6 +137,10 @@ func (h *handlers) exportCalendar(w http.ResponseWriter, r *http.Request) {
h.fail(w, r, "export calendar", err)
return
}
+ if err := h.store.AttachAttendees(r.Context(), events); err != nil {
+ h.fail(w, r, "export attendees", err)
+ return
+ }
w.Header().Set("Content-Type", "text/calendar; charset=utf-8")
w.Header().Set("Content-Disposition",
fmt.Sprintf("attachment; filename=%q", safeFilename(cal.Name)+".ics"))
diff --git a/internal/calendar/handlers_quick.go b/internal/calendar/handlers_quick.go
new file mode 100644
index 0000000..ef40072
--- /dev/null
+++ b/internal/calendar/handlers_quick.go
@@ -0,0 +1,189 @@
+package calendar
+
+import (
+ "net/http"
+ "net/url"
+ "strconv"
+ "strings"
+ "time"
+)
+
+// QuickPaneView is the parse as the pane prints it: already formatted,
+// already in the viewer's zone, already turned into sentences. The
+// template does no arithmetic, exactly as chipFor leaves none for the
+// chip.
+type QuickPaneView struct {
+ Empty bool
+ Title string
+ When string
+ Location string
+ Repeat string
+ Attendees []string
+ Notes []string
+}
+
+// emptyPane is the first paint: the box open, nothing typed.
+func emptyPane() QuickPaneView { return QuickPaneView{Empty: true} }
+
+// quickPaneFor formats a parse for the pane.
+func quickPaneFor(q QuickParse, zone string) QuickPaneView {
+ if q.Empty() && q.StartLocal == "" {
+ return emptyPane()
+ }
+ v := QuickPaneView{
+ Title: q.Title,
+ Location: q.Location,
+ Attendees: q.Attendees,
+ Notes: q.Notes,
+ When: quickWhen(q, zone),
+ }
+ if q.Repeat != "" && q.Repeat != RepeatNone {
+ v.Repeat = repeatLabel(q.Repeat, q.StartLocal, zone, q.AllDay)
+ }
+ return v
+}
+
+// quickWhen writes the times out the way the pane says them.
+func quickWhen(q QuickParse, zone string) string {
+ loc, err := Zone(zone)
+ if err != nil {
+ return ""
+ }
+ start, _, err := ParseLocal(q.StartLocal, loc)
+ if err != nil {
+ return ""
+ }
+ day := start.Format("Monday 2 January")
+ if q.AllDay {
+ return day + ", all day"
+ }
+ end, _, err := ParseLocal(q.EndLocal, loc)
+ if err != nil {
+ return day + ", " + start.Format("15:04")
+ }
+ if end.Format(LayoutDate) != start.Format(LayoutDate) {
+ // A meeting that crosses midnight says both days rather than
+ // printing "21:00–01:00" and leaving somebody to work out which
+ // 01:00 is meant.
+ return day + ", " + start.Format("15:04") + " – " +
+ end.Format("Monday 2 January") + ", " + end.Format("15:04")
+ }
+ return day + ", " + start.Format("15:04") + "–" + end.Format("15:04")
+}
+
+// repeatLabel reuses the picker's own labels, so the pane and the form
+// describe one rule the same way.
+func repeatLabel(repeat, startLocal, zone string, allDay bool) string {
+ for _, c := range RepeatChoicesFor(startLocal, zone, allDay) {
+ if c.Value == repeat {
+ return c.Label
+ }
+ }
+ return ""
+}
+
+// quickParse answers the live pane: parse what has been typed so far
+// and return the pane as an HTML fragment.
+//
+// The parse happens HERE and only here. There is no JavaScript parser,
+// because two implementations of "next Tuesday at half three" is two
+// answers, and the one shown would not be the one saved.
+func (h *handlers) quickParse(w http.ResponseWriter, r *http.Request) {
+ p, _ := CurrentPerson(r)
+ if err := r.ParseForm(); err != nil {
+ renderFragment(w, "quickpane", emptyPane())
+ return
+ }
+ text := strings.TrimSpace(r.FormValue("text"))
+ if text == "" {
+ renderFragment(w, "quickpane", emptyPane())
+ return
+ }
+ zone := personZone(p)
+ renderFragment(w, "quickpane", quickPaneFor(ParseQuick(text, nowIn(zone), zone), zone))
+}
+
+// quickAdd creates the event one sentence describes.
+func (h *handlers) quickAdd(w http.ResponseWriter, r *http.Request) {
+ p, _ := CurrentPerson(r)
+ if err := r.ParseForm(); err != nil {
+ ErrorPage(w, r, http.StatusBadRequest, "")
+ return
+ }
+ text := strings.TrimSpace(r.FormValue("text"))
+ if text == "" {
+ http.Redirect(w, r, "/events/new", http.StatusSeeOther)
+ return
+ }
+ zone := personZone(p)
+ q := ParseQuick(text, nowIn(zone), zone)
+
+ calID, err := h.store.DefaultCalendarID(r.Context(), p.ID)
+ if err != nil || calID == 0 {
+ h.fail(w, r, "quick add calendar", err)
+ return
+ }
+
+ title := q.Title
+ if title == "" {
+ // Never a blank title: the sentence was all time and place, so
+ // the honest fallback is a name the person will recognise and
+ // can edit, not an empty row.
+ title = "Untitled"
+ }
+
+ in := EventInput{
+ CalendarID: calID,
+ Title: title,
+ Location: q.Location,
+ AllDay: q.AllDay,
+ StartLocal: q.StartLocal,
+ EndLocal: q.EndLocal,
+ TZ: zone,
+ Kind: "meeting",
+ Status: "confirmed",
+ RRule: RuleFromRepeat(q.Repeat, "", q.StartLocal, zone, q.AllDay),
+ }
+ e, err := h.store.CreateEvent(r.Context(), p.ID, in)
+ if err != nil {
+ if _, isVE := Invalid(err); isVE {
+ // The parse produced something the validator refuses. Send
+ // the sentence to the full form rather than dropping it:
+ // retyping it is the one outcome nobody would forgive.
+ http.Redirect(w, r, "/events/new?text="+url.QueryEscape(text), http.StatusSeeOther)
+ return
+ }
+ h.fail(w, r, "quick add", err)
+ return
+ }
+ if len(q.Attendees) > 0 {
+ if err := h.store.SetAttendees(r.Context(), e.ID, q.Attendees); err != nil {
+ h.logger.Error("calendar: quick add attendees", "stage", "quick.attendees",
+ "err", scrubErr(err))
+ }
+ }
+ if err := h.store.EnsureDefaultReminder(r.Context(), p, e); err != nil {
+ h.logger.Error("calendar: default reminder", "stage", "quick.reminder",
+ "err", scrubErr(err))
+ }
+ http.Redirect(w, r, "/events/"+strconv.FormatInt(e.ID, 10), http.StatusSeeOther)
+}
+
+// personZone is the viewer's zone, with the app default when they have
+// not set one.
+func personZone(p Person) string {
+ if p.Zone == "" {
+ return defaultZone
+ }
+ return p.Zone
+}
+
+// nowIn is the current wall clock in zone. The parser reads "tomorrow"
+// and "at 3" against the PERSON's today, never the server's.
+func nowIn(zone string) time.Time {
+ loc, err := Zone(zone)
+ if err != nil {
+ return time.Now().UTC()
+ }
+ return time.Now().In(loc)
+}
diff --git a/internal/calendar/ics_export.go b/internal/calendar/ics_export.go
index 6e63a0e..b1cce48 100644
--- a/internal/calendar/ics_export.go
+++ b/internal/calendar/ics_export.go
@@ -76,6 +76,13 @@ func writeEvent(b *strings.Builder, e EventView, withAlarms bool) {
if e.Notes != "" {
line(b, "DESCRIPTION:"+escapeText(e.Notes))
}
+ // Attendees, so a calendar that DOES send invitations can. This app
+ // never has: PARTSTAT is NEEDS-ACTION and RSVP is FALSE because
+ // nobody was asked, and claiming otherwise would put a lie in a file
+ // somebody else's software will believe.
+ for _, a := range e.Attendees {
+ line(b, "ATTENDEE;PARTSTAT=NEEDS-ACTION;RSVP=FALSE:mailto:"+escapeText(a))
+ }
if e.RRule != "" {
line(b, "RRULE:"+e.RRule)
}
diff --git a/internal/calendar/migrations/0004_create_attendees.sql b/internal/calendar/migrations/0004_create_attendees.sql
new file mode 100644
index 0000000..bfc3d42
--- /dev/null
+++ b/internal/calendar/migrations/0004_create_attendees.sql
@@ -0,0 +1,6 @@
+CREATE TABLE `attendees` (`id` integer PRIMARY KEY AUTOINCREMENT,`event_id` integer NOT NULL,`address_sealed` blob,`address_fp` text NOT NULL,`created_at` datetime NOT NULL);
+
+CREATE INDEX `idx_attendees_address_fp` ON `attendees`(`address_fp`);
+
+CREATE INDEX `idx_attendees_event_id` ON `attendees`(`event_id`);
+
diff --git a/internal/calendar/migrations/schema.sql b/internal/calendar/migrations/schema.sql
index f30b582..b9cf2a5 100644
--- a/internal/calendar/migrations/schema.sql
+++ b/internal/calendar/migrations/schema.sql
@@ -1,6 +1,8 @@
-- Generated by rastrillo migration generate; DO NOT EDIT.
-- The schema every migration in this directory adds up to.
+CREATE TABLE `attendees` (`id` integer PRIMARY KEY AUTOINCREMENT,`event_id` integer NOT NULL,`address_sealed` blob,`address_fp` text NOT NULL,`created_at` datetime NOT NULL);
+
CREATE TABLE `calendars` (`id` integer PRIMARY KEY AUTOINCREMENT,`name_sealed` blob,`kind` text NOT NULL,`owner_id` integer NOT NULL,`hue` integer NOT NULL,`created_at` datetime NOT NULL,`archived_at` datetime);
CREATE TABLE `events` (`id` integer PRIMARY KEY AUTOINCREMENT,`calendar_id` integer NOT NULL,`uid_hash` text NOT NULL,`uid_sealed` blob,`series_id` integer,`recurrence_id_local` text NOT NULL,`title_sealed` blob,`location_sealed` blob,`notes_sealed` blob,`all_day` numeric NOT NULL,`start_local` text NOT NULL,`end_local` text NOT NULL,`tz` text NOT NULL,`start_utc` integer NOT NULL,`end_utc` integer NOT NULL,`rrule` text NOT NULL,`rrule_foreign` numeric NOT NULL,`until_utc` integer,`exdates` text NOT NULL,`kind` text NOT NULL,`status` text NOT NULL,`sequence` integer NOT NULL,`created_by` integer NOT NULL,`created_at` datetime NOT NULL,`updated_at` datetime NOT NULL,`deleted_at` datetime);
@@ -17,6 +19,10 @@ CREATE TABLE `reminder_queues` (`id` integer PRIMARY KEY AUTOINCREMENT,`reminder
CREATE TABLE `reminders` (`id` integer PRIMARY KEY AUTOINCREMENT,`event_id` integer NOT NULL,`person_id` integer NOT NULL,`offset_min` integer NOT NULL,`channel` text NOT NULL,`created_at` datetime NOT NULL, `undeliverable_at` integer);
+CREATE INDEX `idx_attendees_address_fp` ON `attendees`(`address_fp`);
+
+CREATE INDEX `idx_attendees_event_id` ON `attendees`(`event_id`);
+
CREATE INDEX `idx_calendars_archived_at` ON `calendars`(`archived_at`);
CREATE INDEX `idx_calendars_owner_id` ON `calendars`(`owner_id`);
diff --git a/internal/calendar/models.go b/internal/calendar/models.go
index c50defb..4210abd 100644
--- a/internal/calendar/models.go
+++ b/internal/calendar/models.go
@@ -12,6 +12,7 @@ var Models = []any{
&Calendar{},
&Grant{},
&Event{},
+ &Attendee{},
&Reminder{},
&ReminderQueue{},
&Feed{},
diff --git a/internal/calendar/models_attendee.go b/internal/calendar/models_attendee.go
new file mode 100644
index 0000000..24b0d32
--- /dev/null
+++ b/internal/calendar/models_attendee.go
@@ -0,0 +1,37 @@
+package calendar
+
+import "time"
+
+// Attendee is somebody named on an event who is not necessarily on this
+// team.
+//
+// RECORDED, NOT INVITED. Nothing in this app emails an attendee. The
+// row is a note about who is coming, the event page says so in those
+// words, and the ICS export carries it so a calendar that DOES send
+// invitations can. Storing this without saying it would let somebody
+// type "lunch with doc@example.com", read the word "attendee", and
+// reasonably conclude a message went out.
+//
+// The address is SEALED, and that is a decision rather than a habit.
+// AGENTS.md's falsifiable form already gives up "who is on the team" —
+// team members are plaintext in `people` and there is no way around
+// that while sign-in is by address. An external attendee is not on the
+// team, and who this team MEETS WITH is exactly the fact the one rule
+// exists to keep out of the bucket.
+type Attendee struct {
+ ID int64 `gorm:"primaryKey"`
+ EventID int64 `gorm:"column:event_id;not null;index"`
+
+ AddressSealed []byte `gorm:"column:address_sealed"`
+
+ // AddressFP is a KEYED fingerprint (seal.Fingerprint), never a bare
+ // hash. It is what makes "is this person already on this event"
+ // answerable without a plaintext column — and it is keyed because
+ // the set of real addresses is small enough to enumerate, so an
+ // unkeyed SHA-256 of an address is that address in a different
+ // alphabet and would hand back everything the sealing just paid
+ // for.
+ AddressFP string `gorm:"column:address_fp;not null;index"`
+
+ CreatedAt time.Time `gorm:"column:created_at;not null"`
+}
diff --git a/internal/calendar/quickadd.go b/internal/calendar/quickadd.go
new file mode 100644
index 0000000..faf3cab
--- /dev/null
+++ b/internal/calendar/quickadd.go
@@ -0,0 +1,233 @@
+package calendar
+
+import (
+ "regexp"
+ "strings"
+ "time"
+)
+
+// QuickParse is everything one typed sentence was understood to mean.
+//
+// It is a plain value, and ParseQuick is a pure function of (text, now,
+// zone). That is deliberate: this is the one piece of the app whose
+// correctness is entirely a question of "what does this sentence mean",
+// so it is testable as a table of sentences and nothing else has to be
+// stood up to ask it a question.
+type QuickParse struct {
+ Title string
+ Location string
+
+ // StartLocal and EndLocal are wall clock in the person's own zone,
+ // "2006-01-02T15:04" — the same form the create form submits, and
+ // the record the app stores (Ruling 1).
+ StartLocal string
+ EndLocal string
+ AllDay bool
+
+ // Repeat is one of the picker's own six values, never a rule the
+ // picker cannot offer (Ruling 4).
+ Repeat string
+
+ // Attendees are addresses found in the text. They are RECORDED, not
+ // invited: nothing is emailed to them. See Notes.
+ Attendees []string
+
+ // Notes are the sentences the pane shows about what was assumed.
+ // Every defaulted field names itself here, because a default the
+ // person cannot see is a guess.
+ Notes []string
+}
+
+// Empty reports whether nothing usable was typed.
+func (q QuickParse) Empty() bool {
+ return strings.TrimSpace(q.Title) == "" && len(q.Attendees) == 0
+}
+
+// consumed marks the parts of the sentence that have already been
+// understood.
+//
+// A parser that deleted matches as it went would lose the positions
+// that later patterns need — "at" means one thing before a time has
+// been taken out of the sentence and another thing after. Overwriting
+// with NUL instead keeps every index stable AND stops a later regex
+// matching across a hole, which is what makes "at 3pm in McCloskeys"
+// resolve to a time and a place rather than to two places.
+type consumed struct {
+ src []rune
+ work []rune
+}
+
+func newConsumed(s string) *consumed {
+ r := []rune(s)
+ w := make([]rune, len(r))
+ copy(w, r)
+ return &consumed{src: r, work: w}
+}
+
+func (c *consumed) String() string { return string(c.work) }
+
+// take finds the first match of re in what is left and blanks it,
+// returning the submatches. group is the capture whose span survives —
+// pass 0 to consume the whole match.
+func (c *consumed) take(re *regexp.Regexp) []string {
+ loc := re.FindStringSubmatchIndex(string(c.work))
+ if loc == nil {
+ return nil
+ }
+ // Indices from FindStringSubmatchIndex are byte offsets into the
+ // string form; convert once to rune offsets so the blanking lines
+ // up on a sentence containing anything non-ASCII.
+ s := string(c.work)
+ from := len([]rune(s[:loc[0]]))
+ to := len([]rune(s[:loc[1]]))
+ for i := from; i < to; i++ {
+ c.work[i] = 0
+ }
+ return matchStrings(s, loc)
+}
+
+func matchStrings(s string, loc []int) []string {
+ out := make([]string, len(loc)/2)
+ for i := range out {
+ if loc[2*i] < 0 {
+ out[i] = ""
+ continue
+ }
+ out[i] = s[loc[2*i]:loc[2*i+1]]
+ }
+ return out
+}
+
+// rest is what nobody claimed: the title.
+func (c *consumed) rest() string {
+ var b strings.Builder
+ for _, r := range c.work {
+ if r == 0 {
+ b.WriteRune(' ')
+ continue
+ }
+ b.WriteRune(r)
+ }
+ return tidy(b.String())
+}
+
+var spaces = regexp.MustCompile(`\s+`)
+
+// tidy collapses the whitespace that blanking leaves behind and drops
+// the joining words that only made sense next to what was removed.
+func tidy(s string) string {
+ s = spaces.ReplaceAllString(s, " ")
+ s = strings.TrimSpace(s)
+ // Leading and trailing connectives, e.g. "Coffee with" once the
+ // address has been lifted out of "Coffee with doc@example.com".
+ for {
+ before := s
+ for _, w := range []string{"with", "at", "in", "on", "from", "to", "and", "-", "–", ","} {
+ s = strings.TrimSpace(strings.TrimSuffix(s, " "+w))
+ if strings.EqualFold(s, w) {
+ s = ""
+ }
+ s = strings.TrimSpace(strings.TrimPrefix(s, w+" "))
+ }
+ s = strings.Trim(s, " ,-–")
+ if s == before {
+ return s
+ }
+ }
+}
+
+// ParseQuick reads one typed sentence into an event.
+//
+// The ORDER of these steps is the design, not an implementation detail.
+// Addresses go first because an address contains characters every later
+// pattern would misread. Times are taken before places because "at" is
+// ambiguous and time has to win it: read places first and "at 3pm in
+// McCloskeys" books a meeting in a place called "3pm".
+func ParseQuick(text string, now time.Time, zone string) QuickParse {
+ var q QuickParse
+ c := newConsumed(text)
+
+ q.Attendees = takeAttendees(c)
+ q.AllDay = takeAllDay(c)
+ q.Repeat = takeRepeat(c)
+
+ day, dayFound := takeDate(c, now)
+ start, end, timeFound := takeTimes(c)
+ dur, durFound := takeDuration(c)
+
+ q.Location = takeLocation(c)
+ q.Title = c.rest()
+
+ q.fill(now, zone, day, dayFound, start, end, timeFound, dur, durFound)
+ return q
+}
+
+// fill turns what was found into a start and an end, and writes a note
+// for every part it had to decide on its own.
+func (q *QuickParse) fill(now time.Time, zone string, day time.Time, dayFound bool,
+ start, end clockTime, timeFound bool, dur time.Duration, durFound bool) {
+
+ if !dayFound {
+ day = now
+ }
+
+ if q.AllDay {
+ q.StartLocal = day.Format(LayoutDate) + "T00:00"
+ q.EndLocal = day.Format(LayoutDate) + "T00:00"
+ if !dayFound {
+ q.Notes = append(q.Notes, "No date found, so this is today.")
+ }
+ return
+ }
+
+ if !timeFound {
+ // The next half hour, said out loud rather than assumed.
+ next := nextHalfHour(now)
+ if !dayFound {
+ day = next
+ }
+ start = clockTime{h: next.Hour(), m: next.Minute()}
+ q.Notes = append(q.Notes,
+ "No time found, so this starts at the next half hour.")
+ }
+
+ startAt := time.Date(day.Year(), day.Month(), day.Day(), start.h, start.m, 0, 0, time.UTC)
+
+ var endAt time.Time
+ switch {
+ case end.set:
+ endAt = time.Date(day.Year(), day.Month(), day.Day(), end.h, end.m, 0, 0, time.UTC)
+ // "9pm to 1am" crosses midnight; an end before its start is the
+ // next day rather than a negative meeting.
+ if !endAt.After(startAt) {
+ endAt = endAt.AddDate(0, 0, 1)
+ }
+ case durFound:
+ endAt = startAt.Add(dur)
+ default:
+ endAt = startAt.Add(time.Hour)
+ q.Notes = append(q.Notes, "No end time found, so this lasts an hour.")
+ }
+
+ q.StartLocal = startAt.Format(LayoutLocal)
+ q.EndLocal = endAt.Format(LayoutLocal)
+
+ if !dayFound {
+ q.Notes = append(q.Notes, "No date found, so this is today.")
+ }
+ if len(q.Attendees) > 0 {
+ q.Notes = append(q.Notes,
+ "Nobody is emailed. Attendees are recorded on the event only.")
+ }
+}
+
+// nextHalfHour rounds forward to the next :00 or :30.
+func nextHalfHour(now time.Time) time.Time {
+ t := now.Truncate(time.Minute)
+ switch {
+ case t.Minute() < 30:
+ return t.Add(time.Duration(30-t.Minute()) * time.Minute)
+ default:
+ return t.Add(time.Duration(60-t.Minute()) * time.Minute)
+ }
+}
diff --git a/internal/calendar/quickadd_when.go b/internal/calendar/quickadd_when.go
new file mode 100644
index 0000000..f4d77bf
--- /dev/null
+++ b/internal/calendar/quickadd_when.go
@@ -0,0 +1,216 @@
+package calendar
+
+import (
+ "regexp"
+ "strconv"
+ "strings"
+ "time"
+)
+
+// clockTime is a wall-clock hour and minute, with set distinguishing
+// "midnight" from "not given".
+type clockTime struct {
+ h, m int
+ set bool
+}
+
+var (
+ // A range: "3-4pm", "3pm to 4:30", "from 9 until 10:15".
+ reRange = regexp.MustCompile(`(?i)\b(?:from\s+)?(\d{1,2})(?::(\d{2}))?\s*(am|pm)?\s*(?:-|–|—|to|until|till)\s*(\d{1,2})(?::(\d{2}))?\s*(am|pm)?\b`)
+ // A time with minutes: "15:00", "at 3:30pm".
+ reHHMM = regexp.MustCompile(`(?i)\b(?:at\s+)?(\d{1,2}):(\d{2})\s*(am|pm)?\b`)
+ // A time with a meridiem: "3pm", "at 11 am".
+ reMerid = regexp.MustCompile(`(?i)\b(?:at\s+)?(\d{1,2})\s*(am|pm)\b`)
+ // A bare hour, and ONLY after "at": "at 3". Without the "at" this
+ // would eat the 4 in "room 4".
+ reBareAt = regexp.MustCompile(`(?i)\bat\s+(\d{1,2})\b`)
+)
+
+// takeTimes reads a start and possibly an end out of the sentence.
+func takeTimes(c *consumed) (start, end clockTime, found bool) {
+ if m := c.take(reRange); m != nil {
+ s := clock(m[1], m[2])
+ e := clock(m[4], m[5])
+ sm, em := strings.ToLower(m[3]), strings.ToLower(m[6])
+ // "3-4pm" means both are pm: a meridiem on either end applies
+ // to the whole range unless the other says otherwise.
+ if sm == "" {
+ sm = em
+ }
+ if em == "" {
+ em = sm
+ }
+ s = applyMeridiem(s, sm)
+ e = applyMeridiem(e, em)
+ return s, e, true
+ }
+ if m := c.take(reHHMM); m != nil {
+ return applyMeridiem(clock(m[1], m[2]), strings.ToLower(m[3])), clockTime{}, true
+ }
+ if m := c.take(reMerid); m != nil {
+ return applyMeridiem(clock(m[1], ""), strings.ToLower(m[2])), clockTime{}, true
+ }
+ if m := c.take(reBareAt); m != nil {
+ return applyMeridiem(clock(m[1], ""), ""), clockTime{}, true
+ }
+ return clockTime{}, clockTime{}, false
+}
+
+func clock(hh, mm string) clockTime {
+ h, _ := strconv.Atoi(hh)
+ m := 0
+ if mm != "" {
+ m, _ = strconv.Atoi(mm)
+ }
+ return clockTime{h: h, m: m, set: true}
+}
+
+// applyMeridiem resolves am/pm, and guesses when there is none.
+//
+// The guess is the working day: a bare 1 to 7 means the afternoon,
+// because "at 3" is three in the afternoon to everybody who is not a
+// baker. 8 to 12 are taken as written. This is a guess and the pane
+// prints the resolved time, so a wrong one is visible before it is
+// saved rather than after.
+func applyMeridiem(t clockTime, meridiem string) clockTime {
+ if !t.set {
+ return t
+ }
+ switch meridiem {
+ case "pm":
+ if t.h < 12 {
+ t.h += 12
+ }
+ case "am":
+ if t.h == 12 {
+ t.h = 0
+ }
+ default:
+ if t.h >= 1 && t.h <= 7 {
+ t.h += 12
+ }
+ }
+ if t.h > 23 {
+ t.h = 23
+ }
+ return t
+}
+
+var (
+ reDurNum = regexp.MustCompile(`(?i)\bfor\s+(\d+)\s*(minutes?|mins?|hours?|hrs?|h|m)\b`)
+ reDurWord = regexp.MustCompile(`(?i)\bfor\s+(half\s+an?|an?)\s+(hour|hours)\b`)
+)
+
+// takeDuration reads "for 90 minutes", "for 2h", "for an hour".
+func takeDuration(c *consumed) (time.Duration, bool) {
+ if m := c.take(reDurNum); m != nil {
+ n, _ := strconv.Atoi(m[1])
+ unit := strings.ToLower(m[2])
+ if strings.HasPrefix(unit, "h") {
+ return time.Duration(n) * time.Hour, true
+ }
+ return time.Duration(n) * time.Minute, true
+ }
+ if m := c.take(reDurWord); m != nil {
+ if strings.HasPrefix(strings.ToLower(m[1]), "half") {
+ return 30 * time.Minute, true
+ }
+ return time.Hour, true
+ }
+ return 0, false
+}
+
+var (
+ reToday = regexp.MustCompile(`(?i)\b(today|tonight)\b`)
+ reTomorrow = regexp.MustCompile(`(?i)\btomorrow\b`)
+ reWeekday = regexp.MustCompile(`(?i)\b(?:(next|this)\s+)?(?:on\s+)?(monday|tuesday|wednesday|thursday|friday|saturday|sunday|mon|tue|tues|wed|weds|thu|thur|thurs|fri|sat|sun)\b`)
+ reDayMonth = regexp.MustCompile(`(?i)\b(?:on\s+)?(\d{1,2})(?:st|nd|rd|th)?\s+(january|february|march|april|may|june|july|august|september|october|november|december|jan|feb|mar|apr|jun|jul|aug|sep|sept|oct|nov|dec)\b`)
+ reMonthDay = regexp.MustCompile(`(?i)\b(?:on\s+)?(january|february|march|april|may|june|july|august|september|october|november|december|jan|feb|mar|apr|jun|jul|aug|sep|sept|oct|nov|dec)\s+(\d{1,2})(?:st|nd|rd|th)?\b`)
+ reNumeric = regexp.MustCompile(`\b(?:on\s+)?(\d{1,2})/(\d{1,2})(?:/(\d{2,4}))?\b`)
+)
+
+var weekdays = map[string]time.Weekday{
+ "monday": time.Monday, "mon": time.Monday,
+ "tuesday": time.Tuesday, "tue": time.Tuesday, "tues": time.Tuesday,
+ "wednesday": time.Wednesday, "wed": time.Wednesday, "weds": time.Wednesday,
+ "thursday": time.Thursday, "thu": time.Thursday, "thur": time.Thursday, "thurs": time.Thursday,
+ "friday": time.Friday, "fri": time.Friday,
+ "saturday": time.Saturday, "sat": time.Saturday,
+ "sunday": time.Sunday, "sun": time.Sunday,
+}
+
+var months = map[string]time.Month{
+ "january": time.January, "jan": time.January,
+ "february": time.February, "feb": time.February,
+ "march": time.March, "mar": time.March,
+ "april": time.April, "apr": time.April,
+ "may": time.May,
+ "june": time.June, "jun": time.June,
+ "july": time.July, "jul": time.July,
+ "august": time.August, "aug": time.August,
+ "september": time.September, "sep": time.September, "sept": time.September,
+ "october": time.October, "oct": time.October,
+ "november": time.November, "nov": time.November,
+ "december": time.December, "dec": time.December,
+}
+
+// takeDate reads the day the event is on, relative to now.
+//
+// Ordered most specific first. The numeric form is last because
+// "12/3" would otherwise be read out of a time range.
+func takeDate(c *consumed, now time.Time) (time.Time, bool) {
+ if c.take(reToday) != nil {
+ return now, true
+ }
+ if c.take(reTomorrow) != nil {
+ return now.AddDate(0, 0, 1), true
+ }
+ if m := c.take(reDayMonth); m != nil {
+ d, _ := strconv.Atoi(m[1])
+ return onOrAfter(now, months[strings.ToLower(m[2])], d), true
+ }
+ if m := c.take(reMonthDay); m != nil {
+ d, _ := strconv.Atoi(m[2])
+ return onOrAfter(now, months[strings.ToLower(m[1])], d), true
+ }
+ if m := c.take(reWeekday); m != nil {
+ return nextWeekday(now, weekdays[strings.ToLower(m[2])], strings.EqualFold(m[1], "next")), true
+ }
+ if m := c.take(reNumeric); m != nil {
+ d, _ := strconv.Atoi(m[1])
+ mo, _ := strconv.Atoi(m[2])
+ year := now.Year()
+ if m[3] != "" {
+ year, _ = strconv.Atoi(m[3])
+ if year < 100 {
+ year += 2000
+ }
+ }
+ return time.Date(year, time.Month(mo), d, 0, 0, 0, 0, now.Location()), true
+ }
+ return now, false
+}
+
+// nextWeekday is the coming one. "next Tuesday" said on a Tuesday means
+// the one after this, never today — which is what everybody means and
+// what a plain modulo gets wrong.
+func nextWeekday(now time.Time, want time.Weekday, explicitNext bool) time.Time {
+ delta := (int(want) - int(now.Weekday()) + 7) % 7
+ if delta == 0 {
+ delta = 7
+ }
+ if explicitNext && delta < 7 {
+ delta += 7
+ }
+ return now.AddDate(0, 0, delta)
+}
+
+// onOrAfter picks the next occurrence of a day and month, rolling into
+// next year rather than booking a date that has already gone.
+func onOrAfter(now time.Time, month time.Month, day int) time.Time {
+ t := time.Date(now.Year(), month, day, 0, 0, 0, 0, now.Location())
+ if t.Before(now.AddDate(0, 0, -1)) {
+ t = t.AddDate(1, 0, 0)
+ }
+ return t
+}
diff --git a/internal/calendar/quickadd_who.go b/internal/calendar/quickadd_who.go
new file mode 100644
index 0000000..2c62669
--- /dev/null
+++ b/internal/calendar/quickadd_who.go
@@ -0,0 +1,141 @@
+package calendar
+
+import (
+ "regexp"
+ "strings"
+)
+
+// reAddress is deliberately the same shape as logsafe.go's redactor.
+// One notion of "looks like an address" for both finding and hiding one.
+var reAddress = regexp.MustCompile(`[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}`)
+
+// takeAttendees lifts every address out of the sentence.
+//
+// FIRST, before any other pattern runs. An address is full of
+// characters the other patterns care about — digits, dots, and a "." in
+// a domain reads as a date separator — so leaving one in the sentence
+// gives the date parser something that looks like 12.3 to find.
+func takeAttendees(c *consumed) []string {
+ var out []string
+ seen := map[string]bool{}
+ for {
+ m := c.take(reAddress)
+ if m == nil {
+ return out
+ }
+ addr := normalizeAddress(m[0])
+ if seen[addr] {
+ continue
+ }
+ seen[addr] = true
+ out = append(out, addr)
+ }
+}
+
+var reAllDay = regexp.MustCompile(`(?i)\b(all[\s-]?day)\b`)
+
+func takeAllDay(c *consumed) bool { return c.take(reAllDay) != nil }
+
+var (
+ reEveryWeekday = regexp.MustCompile(`(?i)\bevery\s+(monday|tuesday|wednesday|thursday|friday|saturday|sunday|mon|tue|tues|wed|weds|thu|thur|thurs|fri|sat|sun)\b`)
+ reEveryUnit = regexp.MustCompile(`(?i)\bevery\s+(day|weekday|week|fortnight|two\s+weeks|month|year)\b`)
+ reDailyWord = regexp.MustCompile(`(?i)\b(daily|weekly|fortnightly|monthly|yearly|annually)\b`)
+)
+
+// takeRepeat maps a repeating phrase onto one of the picker's own six
+// choices, and NOTHING ELSE.
+//
+// Ruling 4 stands here: the subset is what a picker offers. "every
+// second Tuesday of the month" has no choice to map to, so it is not
+// taken — it stays in the sentence, lands in the title, and the pane
+// shows it sitting there unhandled. Inventing an RRULE the picker
+// cannot re-display would make an event nobody can edit.
+func takeRepeat(c *consumed) string {
+ if m := c.take(reEveryWeekday); m != nil {
+ // "every Tuesday" is the weekly choice; which weekday it lands
+ // on comes from the event's own start date, so the day named
+ // here also has to set the date. takeDate sees it because the
+ // weekday word is left in place — only "every" is consumed.
+ reinstate(c, m[1])
+ return RepeatWeekly
+ }
+ if m := c.take(reEveryUnit); m != nil {
+ switch strings.ToLower(strings.Join(strings.Fields(m[1]), " ")) {
+ case "day":
+ return RepeatDaily
+ case "weekday":
+ return RepeatWeekdays
+ case "week":
+ return RepeatWeekly
+ case "fortnight", "two weeks":
+ return RepeatFortnight
+ case "month":
+ return RepeatMonthly
+ case "year":
+ return RepeatYearly
+ }
+ }
+ if m := c.take(reDailyWord); m != nil {
+ switch strings.ToLower(m[1]) {
+ case "daily":
+ return RepeatDaily
+ case "weekly":
+ return RepeatWeekly
+ case "fortnightly":
+ return RepeatFortnight
+ case "monthly":
+ return RepeatMonthly
+ case "yearly", "annually":
+ return RepeatYearly
+ }
+ }
+ return RepeatNone
+}
+
+// reinstate puts a word back into the sentence after its phrase was
+// consumed, so a later pass can still see it. Used for "every Tuesday",
+// where the repeat takes the phrase but the date still needs the day.
+func reinstate(c *consumed, word string) {
+ idx := strings.Index(strings.ToLower(string(c.src)), strings.ToLower(word))
+ if idx < 0 {
+ return
+ }
+ runes := []rune(string(c.src)[:idx])
+ from := len(runes)
+ for i, r := range []rune(word) {
+ if from+i < len(c.work) {
+ c.work[from+i] = r
+ }
+ }
+}
+
+// reLocation takes "in X" or "at X" and runs to the end of the clause.
+//
+// It runs LAST of the extractors, and that ordering is the whole reason
+// it can be this simple: by the time it sees the sentence every time,
+// date and duration has already been blanked, so an "at" still standing
+// is an "at" that no time claimed. Run this before takeTimes and every
+// event anybody types is in a place called "3pm".
+//
+// The clause ends at "with", because "lunch in the canteen with Bob"
+// has a place and a person and running to the end would swallow both.
+//
+// It also ends at a NUL, and that is not a detail. "Coffee in the
+// Fumbally at 11" has its time blanked from the MIDDLE of the string,
+// so the place is no longer at the end of it — anchoring on $ alone
+// found nothing at all and dropped the location silently.
+var reLocation = regexp.MustCompile(`(?i)\b(?:in|at)\s+([^\x00]+?)(?:\s+with\b[^\x00]*)?\s*(?:\x00|$)`)
+
+func takeLocation(c *consumed) string {
+ m := c.take(reLocation)
+ if m == nil {
+ return ""
+ }
+ loc := tidy(m[1])
+ // A bare connective is not a place: "meeting at" typed mid-sentence
+ // should leave the field empty rather than invent one.
+ if len(loc) < 2 {
+ return ""
+ }
+ return loc
+}
diff --git a/internal/calendar/render.go b/internal/calendar/render.go
index d31d622..f8cb7c7 100644
--- a/internal/calendar/render.go
+++ b/internal/calendar/render.go
@@ -57,10 +57,36 @@ func init() {
// The shell, this app's own shared partials, and the
// page's body. _chip.html is shared by every view, so
// it is parsed into every tree rather than duplicated.
- ParseFS(appFS, "templates/layout.html", "templates/_chip.html", "templates/"+name+".html"))
+ ParseFS(appFS, "templates/layout.html", "templates/_chip.html",
+ "templates/_quickadd.html", "templates/_quickpane.html",
+ "templates/"+name+".html"))
}
}
+// fragments is the partials on their own, with no layout around them.
+//
+// It exists for the quick-add pane, which is fetched and swapped into a
+// page that is already open. Rendering it through pages[...] would wrap
+// it in the whole shell — a second <html> inside the one on screen.
+var fragments = template.Must(
+ template.Must(template.New("fragments").
+ Funcs(ui.Funcs(ui.WithIcons(icons.Icon, icons.Assets))).
+ Funcs(template.FuncMap{"asset": assets.Path}).
+ Funcs(appFuncs).
+ ParseFS(ui.Templates(), "*.html")).
+ ParseFS(appFS, "templates/_quickpane.html"))
+
+// renderFragment writes one partial, with no shell.
+func renderFragment(w http.ResponseWriter, name string, data any) {
+ var buf bytes.Buffer
+ if err := fragments.ExecuteTemplate(&buf, name, data); err != nil {
+ http.Error(w, "Something went wrong.", http.StatusInternalServerError)
+ return
+ }
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ buf.WriteTo(w)
+}
+
// PageData is the shell every page renders inside. Data is NAMED
// rather than embedded, so a template always says ".Data.Events" and
// can never accidentally resolve a page field where it meant a shell
@@ -87,6 +113,12 @@ type PageData struct {
SignedIn bool
Address string
+ // QuickPane is the quick-add box's first paint. It is on the SHELL
+ // rather than on each page's own data because the box is in the
+ // shell: a page that forgot to build one would render an empty
+ // pane and look like a broken feature rather than a missing field.
+ QuickPane QuickPaneView
+
Data any
}
@@ -100,6 +132,7 @@ func (h *handlers) page(r *http.Request, title string, data any) PageData {
ThemeAttr: themeAttr(mode),
Mode: mode,
Path: r.URL.Path,
+ QuickPane: emptyPane(),
Data: data,
}
if id, ok := viewer(r); ok {
diff --git a/internal/calendar/routes.go b/internal/calendar/routes.go
index 5d35426..9fe3724 100644
--- a/internal/calendar/routes.go
+++ b/internal/calendar/routes.go
@@ -64,6 +64,11 @@ func (a *App) routes(h *handlers, authPlugin *rauth.Auth, list allowlist, origin
r.Get("/events/new", h.newEvent)
r.Post("/events", h.createEvent)
+ // Quick add. Both are literal segments and both are declared
+ // BEFORE /events/{id}, so a sentence can never be mistaken for
+ // an event id.
+ r.Post("/events/quick", h.quickAdd)
+ r.Post("/events/quick/parse", h.quickParse)
r.Get("/events/{id}", h.showEvent)
r.Get("/events/{id}/edit", h.editEvent)
r.Post("/events/{id}", h.updateEvent)
diff --git a/internal/calendar/seal/fingerprint.go b/internal/calendar/seal/fingerprint.go
new file mode 100644
index 0000000..0f366fb
--- /dev/null
+++ b/internal/calendar/seal/fingerprint.go
@@ -0,0 +1,35 @@
+package seal
+
+import (
+ "crypto/hmac"
+ "encoding/hex"
+)
+
+// Fingerprint is a KEYED, stable digest of a value: equal values give
+// equal fingerprints, and nothing else can be learned from one.
+//
+// It exists because an ordinary hash is not a seal. The app already
+// stores an unkeyed SHA-256 of an event's UID, which is fine — a UID is
+// a random string nobody can guess. An ADDRESS is the opposite: the set
+// of real email addresses is small enough to enumerate, so a plain
+// SHA-256 of "someone@example.com" is that address written in a
+// slightly inconvenient alphabet. Publishing a column of them would
+// give up exactly what the one rule promises to keep: who this team
+// meets with.
+//
+// HMAC under an HKDF-derived purpose key makes the digest useless to
+// anyone without the DEK, while still comparing equal for equal inputs
+// — which is all a dedupe or a lookup needs.
+//
+// The DEK is therefore load-bearing for lookups as well as for reading:
+// lose it and these rows can no longer be matched, exactly as sealed
+// columns can no longer be opened.
+func (s *Sealer) Fingerprint(purpose, value string) (string, error) {
+ key, err := s.purposeKey(purpose)
+ if err != nil {
+ return "", err
+ }
+ mac := hmac.New(sha256New, key)
+ mac.Write([]byte(value))
+ return hex.EncodeToString(mac.Sum(nil)), nil
+}
diff --git a/internal/calendar/seal/seal.go b/internal/calendar/seal/seal.go
index 28d9dbe..8de0424 100644
--- a/internal/calendar/seal/seal.go
+++ b/internal/calendar/seal/seal.go
@@ -44,6 +44,12 @@ const (
PurposeEventUID = "event.uid"
PurposeCalendarName = "calendar.name"
PurposeMemberName = "member.name"
+
+ // PurposeAttendeeAddress seals an attendee's address; the matching
+ // Fingerprint purpose is what dedupes it without a plaintext
+ // lookup column.
+ PurposeAttendeeAddress = "attendee.address"
+ PurposeAttendeeLookup = "attendee.lookup"
)
func sha256New() hash.Hash { return sha256.New() }
diff --git a/internal/calendar/static/calendar.css b/internal/calendar/static/calendar.css
index 69fd075..268a794 100644
--- a/internal/calendar/static/calendar.css
+++ b/internal/calendar/static/calendar.css
@@ -312,3 +312,144 @@
@media (prefers-reduced-motion: reduce) {
[cal-slot], [cal-chip] { transition: none; }
}
+
+/* ── Quick add ─────────────────────────────────────────────────── */
+
+/* The header's action group. Quick add sits beside New event rather
+ * than replacing it: one is the fast path, the other is the one that
+ * shows every field. */
+[cal-header-actions] {
+ align-items: center;
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--rst-sp-2);
+}
+
+/* The disclosure is a <details>, so it opens with no script at all.
+ * Everything below is appearance; none of it is behaviour. */
+[cal-quick] { position: relative; }
+
+/* The closed state reads as a small text input, because that is what it
+ * behaves like — click it and type. The marker is removed in both
+ * spellings: Safari still wants the -webkit- pseudo-element and leaving
+ * it in puts a disclosure triangle inside what looks like a field. */
+[cal-quick] > summary {
+ align-items: center;
+ background: var(--rst-surface);
+ border: 1px solid var(--rst-line);
+ border-radius: var(--rst-radius-sm);
+ color: var(--rst-text-muted);
+ cursor: text;
+ display: flex;
+ font-size: var(--rst-fs-sm);
+ gap: var(--rst-sp-1);
+ list-style: none;
+ min-width: 12rem;
+ padding: var(--rst-sp-2) var(--rst-sp-3);
+}
+
+[cal-quick] > summary::-webkit-details-marker { display: none; }
+
+[cal-quick] > summary:hover { border-color: var(--rst-accent); }
+
+[cal-quick][open] > summary {
+ border-color: var(--rst-accent);
+ color: var(--rst-text);
+}
+
+/* The popover. Absolute so opening it does not push the calendar down
+ * the page — the grid behind stays exactly where it was, which is what
+ * makes this feel like a panel rather than a page change. */
+[cal-quick-form] {
+ background: var(--rst-surface);
+ border: 1px solid var(--rst-line);
+ border-radius: var(--rst-radius-sm);
+ box-shadow: 0 8px 24px color-mix(in oklab, var(--rst-text) 18%, transparent);
+ /* Anchored to the END edge, so the panel opens back across the page
+ * instead of off the side of it. The box sits at the far right of the
+ * header, beside New event: anchored at the start edge a 30rem panel
+ * hangs ~28px past a 1280px viewport, which every string assertion in
+ * the Go suite happily passes. The browser gate measured it. */
+ inset-inline-end: 0;
+ margin-block-start: var(--rst-sp-1);
+ padding: var(--rst-sp-3);
+ position: absolute;
+ top: 100%;
+ width: min(30rem, 88vw);
+ z-index: 30;
+}
+
+[cal-quick-text] {
+ background: var(--rst-bg);
+ border: 1px solid var(--rst-line);
+ border-radius: var(--rst-radius-sm);
+ color: var(--rst-text);
+ display: block;
+ font: inherit;
+ padding: var(--rst-sp-2);
+ resize: vertical;
+ width: 100%;
+}
+
+[cal-quick-pane] {
+ background: var(--rst-surface-2);
+ border-radius: var(--rst-radius-sm);
+ font-size: var(--rst-fs-sm);
+ margin-block: var(--rst-sp-2);
+ padding: var(--rst-sp-2) var(--rst-sp-3);
+}
+
+[cal-quick-blank] { color: var(--rst-text-muted); margin: 0; }
+
+[cal-quick-detail] { margin: 0; }
+
+[cal-quick-row] {
+ display: grid;
+ gap: var(--rst-sp-2);
+ grid-template-columns: 8rem 1fr;
+ padding-block: var(--rst-sp-1);
+}
+
+[cal-quick-row] dt { color: var(--rst-text-muted); margin: 0; }
+[cal-quick-row] dd { margin: 0; }
+
+/* The caveat rides in the LABEL. Somebody who reads only the left
+ * column still learns that nothing was emailed. */
+[cal-quick-caveat] { color: var(--rst-text-faint); font-size: var(--rst-fs-xs); }
+
+[cal-quick-people] { list-style: none; margin: 0; padding: 0; }
+
+[cal-quick-notes] {
+ color: var(--rst-text-muted);
+ font-size: var(--rst-fs-xs);
+ margin: var(--rst-sp-2) 0 0;
+ padding-inline-start: var(--rst-sp-4);
+}
+
+[cal-quick-actions] {
+ align-items: center;
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--rst-sp-2);
+}
+
+[cal-quick-hint] {
+ color: var(--rst-text-faint);
+ font-size: var(--rst-fs-xs);
+ flex: 1 1 12rem;
+}
+
+/* Narrow: the popover stops floating and becomes part of the page. A
+ * 30rem panel pinned to a header on a 320px screen is off the side of
+ * it. */
+@media (max-width: 40rem) {
+ [cal-header-actions] { width: 100%; }
+ [cal-quick] { flex: 1 1 100%; }
+ [cal-quick] > summary { min-width: 0; }
+ [cal-quick-form] {
+ box-shadow: none;
+ position: static;
+ width: 100%;
+ }
+ [cal-quick-row] { grid-template-columns: 1fr; gap: 0; }
+}
diff --git a/internal/calendar/static/quickadd.js b/internal/calendar/static/quickadd.js
new file mode 100644
index 0000000..7b046a1
--- /dev/null
+++ b/internal/calendar/static/quickadd.js
@@ -0,0 +1,112 @@
+// quickadd.js — the live detail pane.
+//
+// This file adds ONE thing: it asks the server what the sentence means
+// while you type, and swaps the answer into the pane. It does not parse
+// anything. There is no date logic here and there must never be: the
+// server's parser is the one that decides what gets saved, so a second
+// opinion in this file could only ever disagree with the thing that
+// actually happens.
+//
+// Everything else already works without this file. The disclosure is a
+// <details>, the form is a form, and submitting it creates the event.
+// Turn JavaScript off and the feature is slower to check, not gone.
+(function () {
+ "use strict";
+
+ var DEBOUNCE_MS = 220;
+
+ function setup(root) {
+ var text = root.querySelector("[cal-quick-text]");
+ var pane = root.querySelector("[cal-quick-pane]");
+ if (!text || !pane) return;
+
+ var timer = null;
+ var inflight = null;
+ var lastSent = null;
+
+ function render(html) {
+ pane.innerHTML = html;
+ }
+
+ function ask() {
+ var value = text.value.trim();
+ if (value === lastSent) return;
+ lastSent = value;
+
+ // An empty box goes back to the resting state without a round
+ // trip: the server would answer the same thing.
+ if (value === "") {
+ render('<p cal-quick-blank>Start typing and this will show what gets saved.</p>');
+ return;
+ }
+
+ // One request at a time. Without this, two keystrokes in flight
+ // can land out of order and the pane settles on the OLDER parse —
+ // a stale answer that looks authoritative.
+ if (inflight) inflight.abort();
+ var ctrl = new AbortController();
+ inflight = ctrl;
+
+ var body = new URLSearchParams();
+ body.set("text", value);
+
+ fetch("/events/quick/parse", {
+ method: "POST",
+ credentials: "same-origin",
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
+ body: body.toString(),
+ signal: ctrl.signal
+ })
+ .then(function (res) {
+ if (!res.ok) throw new Error("parse " + res.status);
+ return res.text();
+ })
+ .then(function (html) {
+ if (ctrl !== inflight) return;
+ render(html);
+ })
+ .catch(function (err) {
+ if (err && err.name === "AbortError") return;
+ // The pane is an enhancement; if it cannot be refreshed, say
+ // so rather than leaving a stale parse on screen looking
+ // current. Submitting still works — the server parses again.
+ render('<p cal-quick-blank>Could not check that just now. Adding it will still work.</p>');
+ });
+ }
+
+ text.addEventListener("input", function () {
+ if (timer) clearTimeout(timer);
+ timer = setTimeout(ask, DEBOUNCE_MS);
+ });
+
+ // Opening the disclosure should put the cursor where the person is
+ // about to type. <details> fires "toggle" for every route in —
+ // pointer, keyboard, and the browser opening it to find text — so
+ // this covers them all where a click handler would cover one.
+ root.addEventListener("toggle", function () {
+ if (root.open) {
+ text.focus();
+ ask();
+ }
+ });
+
+ // Escape closes, which is what every other popover on the web does.
+ root.addEventListener("keydown", function (e) {
+ if (e.key !== "Escape" || !root.open) return;
+ root.open = false;
+ var summary = root.querySelector("summary");
+ if (summary) summary.focus();
+ });
+ }
+
+ function init() {
+ var boxes = document.querySelectorAll("[cal-quick]");
+ for (var i = 0; i < boxes.length; i++) setup(boxes[i]);
+ }
+
+ if (document.readyState === "loading") {
+ document.addEventListener("DOMContentLoaded", init);
+ } else {
+ init();
+ }
+})();
diff --git a/internal/calendar/store_attendee.go b/internal/calendar/store_attendee.go
new file mode 100644
index 0000000..6b68eac
--- /dev/null
+++ b/internal/calendar/store_attendee.go
@@ -0,0 +1,129 @@
+package calendar
+
+import (
+ "context"
+ "fmt"
+ "time"
+
+ "amadan.net/oficina/calendar/internal/calendar/seal"
+)
+
+// SetAttendees replaces an event's attendee list.
+//
+// The caller must ALREADY have read the event through a grant join —
+// this takes an id, not a person, and does no scoping of its own. Every
+// current caller comes straight off Store.Event or Store.CreateEvent,
+// both of which 404 a row that is not yours.
+func (s *Store) SetAttendees(ctx context.Context, eventID int64, addresses []string) error {
+ if err := s.g.WithContext(ctx).
+ Where("event_id = ?", eventID).Delete(&Attendee{}).Error; err != nil {
+ return fmt.Errorf("calendar: clear attendees: %w", err)
+ }
+ seen := map[string]bool{}
+ now := time.Now().UTC()
+ for _, addr := range addresses {
+ addr = normalizeAddress(addr)
+ if addr == "" {
+ continue
+ }
+ fp, err := s.seal.Fingerprint(seal.PurposeAttendeeLookup, addr)
+ if err != nil {
+ return fmt.Errorf("calendar: fingerprint attendee: %w", err)
+ }
+ if seen[fp] {
+ continue
+ }
+ seen[fp] = true
+ sealed, err := s.seal.SealString(seal.PurposeAttendeeAddress, addr)
+ if err != nil {
+ return fmt.Errorf("calendar: seal attendee: %w", err)
+ }
+ row := Attendee{
+ EventID: eventID,
+ AddressSealed: sealed,
+ AddressFP: fp,
+ CreatedAt: now,
+ }
+ if err := s.g.WithContext(ctx).Create(&row).Error; err != nil {
+ return fmt.Errorf("calendar: add attendee: %w", err)
+ }
+ }
+ return nil
+}
+
+// AttendeesFor opens an event's attendee addresses, oldest first.
+//
+// Scoping is the caller's, exactly as in SetAttendees.
+func (s *Store) AttendeesFor(ctx context.Context, eventID int64) ([]string, error) {
+ var rows []Attendee
+ if err := s.g.WithContext(ctx).
+ Where("event_id = ?", eventID).
+ Order("id ASC").Find(&rows).Error; err != nil {
+ return nil, fmt.Errorf("calendar: list attendees: %w", err)
+ }
+ out := make([]string, 0, len(rows))
+ for _, row := range rows {
+ addr, err := s.seal.OpenString(seal.PurposeAttendeeAddress, row.AddressSealed)
+ if err != nil {
+ return nil, fmt.Errorf("calendar: open attendee: %w", err)
+ }
+ if addr != "" {
+ out = append(out, addr)
+ }
+ }
+ return out, nil
+}
+
+// AttachAttendees fills in EventView.Attendees for a slice about to be
+// exported.
+//
+// One query for the whole slice rather than one per event: an export of
+// a year's standups is a thousand EventViews, and a read per event
+// turns a feed poll into a thousand round trips on a platform billed by
+// residency.
+//
+// Only the export path calls this. A month view prints no attendee, so
+// paying for one there would be a cost with no reader.
+func (s *Store) AttachAttendees(ctx context.Context, events []EventView) error {
+ if len(events) == 0 {
+ return nil
+ }
+ // An expanded occurrence and its master share one row of attendees,
+ // so the master's id is the one to ask for.
+ owner := func(e EventView) int64 {
+ if e.SeriesID != nil && *e.SeriesID != 0 {
+ return *e.SeriesID
+ }
+ return e.ID
+ }
+
+ ids := make([]int64, 0, len(events))
+ seen := map[int64]bool{}
+ for _, e := range events {
+ id := owner(e)
+ if !seen[id] {
+ seen[id] = true
+ ids = append(ids, id)
+ }
+ }
+
+ var rows []Attendee
+ if err := s.g.WithContext(ctx).
+ Where("event_id IN ?", ids).Order("id ASC").Find(&rows).Error; err != nil {
+ return fmt.Errorf("calendar: list attendees for export: %w", err)
+ }
+ byEvent := map[int64][]string{}
+ for _, row := range rows {
+ addr, err := s.seal.OpenString(seal.PurposeAttendeeAddress, row.AddressSealed)
+ if err != nil {
+ return fmt.Errorf("calendar: open attendee for export: %w", err)
+ }
+ if addr != "" {
+ byEvent[row.EventID] = append(byEvent[row.EventID], addr)
+ }
+ }
+ for i := range events {
+ events[i].Attendees = byEvent[owner(events[i])]
+ }
+ return nil
+}
diff --git a/internal/calendar/store_event.go b/internal/calendar/store_event.go
index ed92260..b8be0aa 100644
--- a/internal/calendar/store_event.go
+++ b/internal/calendar/store_event.go
@@ -49,6 +49,11 @@ type EventView struct {
OccStartUTC int64
OccEndUTC int64
+ // Attendees is populated only where it is needed — the ICS export.
+ // Every view query would otherwise pay a second read per event to
+ // carry a field no chip prints.
+ Attendees []string
+
// OccurrenceLocal is the occurrence's original local start, and the
// key an override is written against. Empty for a single event.
OccurrenceLocal string
diff --git a/internal/calendar/templates/_quickadd.html b/internal/calendar/templates/_quickadd.html
new file mode 100644
index 0000000..b29e924
--- /dev/null
+++ b/internal/calendar/templates/_quickadd.html
@@ -0,0 +1,46 @@
+{{/* quick add — one sentence becomes an event.
+
+ The disclosure is a <details>, so it opens with NO SCRIPT. That is
+ the whole reason it is not a button and a class: the popover is an
+ affordance, and AGENTS.md says no affordance may require script.
+
+ The form is an ordinary POST to /events/quick. With script, the
+ pane below has already shown what the server will do, so submitting
+ confirms it; without script the event page is the confirmation, one
+ click from Edit. Nothing here needs JavaScript to work — the live
+ pane is the only thing script adds.
+
+ name="rst-shell-menu" is deliberately NOT set: this is not one of
+ the shell's exclusive disclosures, and joining that group would let
+ opening the account menu close a half-typed sentence. */}}
+{{define "quickadd"}}
+<details cal-quick>
+ <summary cal-quick-summary>
+ {{icon "plus"}}
+ <span>Quick add</span>
+ </summary>
+
+ <form cal-quick-form method="post" action="/events/quick">
+ <label class="rst-sr-only" for="cal-quick-text">
+ Describe the event in your own words
+ </label>
+ <textarea id="cal-quick-text" name="text" rows="2"
+ cal-quick-text
+ placeholder="Meeting with Dermot Lacey at 3pm in McCloskeys"
+ autocomplete="off"></textarea>
+
+ {{/* The pane. Rendered here for the scriptless first paint (empty,
+ explaining itself) and REPLACED wholesale by the same template
+ rendered server-side as a fragment when script is on — one
+ template, so the two paths cannot drift into disagreeing. */}}
+ <div id="cal-quick-pane" cal-quick-pane aria-live="polite">
+ {{template "quickpane" .QuickPane}}
+ </div>
+
+ <div cal-quick-actions>
+ <button rst-btn="primary" type="submit">Add</button>
+ <span cal-quick-hint>Times, dates, durations, places and email addresses are read from the sentence.</span>
+ </div>
+ </form>
+</details>
+{{end}}
diff --git a/internal/calendar/templates/_quickpane.html b/internal/calendar/templates/_quickpane.html
new file mode 100644
index 0000000..3f5e042
--- /dev/null
+++ b/internal/calendar/templates/_quickpane.html
@@ -0,0 +1,56 @@
+{{/* The quick-add detail pane: what the server understood.
+
+ This is the CONTRACT of the feature. Everything the parser decided
+ — including everything it defaulted — is printed here in words
+ before anything is saved. If a field is not in this pane, the
+ parser did not set it.
+
+ One template, rendered two ways: inline on first paint, and as a
+ standalone fragment for the live update. Two templates would be two
+ descriptions of one parse, free to disagree. */}}
+{{define "quickpane"}}
+{{if .Empty}}
+<p cal-quick-blank>Start typing and this will show what gets saved.</p>
+{{else}}
+<dl cal-quick-detail>
+ <div cal-quick-row>
+ <dt>What</dt>
+ <dd>{{if .Title}}{{.Title}}{{else}}<em>No title yet</em>{{end}}</dd>
+ </div>
+ <div cal-quick-row>
+ <dt>When</dt>
+ <dd>{{.When}}</dd>
+ </div>
+ {{if .Location}}
+ <div cal-quick-row>
+ <dt>Where</dt>
+ <dd>{{.Location}}</dd>
+ </div>
+ {{end}}
+ {{if .Repeat}}
+ <div cal-quick-row>
+ <dt>Repeats</dt>
+ <dd>{{.Repeat}}</dd>
+ </div>
+ {{end}}
+ {{if .Attendees}}
+ <div cal-quick-row>
+ {{/* "not emailed" sits in the LABEL, not in a footnote. Somebody
+ who reads only the left column still learns the one thing that
+ could mislead them. */}}
+ <dt>Who's coming <span cal-quick-caveat>(not emailed)</span></dt>
+ <dd>
+ <ul cal-quick-people>
+ {{range .Attendees}}<li>{{.}}</li>{{end}}
+ </ul>
+ </dd>
+ </div>
+ {{end}}
+</dl>
+{{if .Notes}}
+<ul cal-quick-notes>
+ {{range .Notes}}<li>{{.}}</li>{{end}}
+</ul>
+{{end}}
+{{end}}
+{{end}}
diff --git a/internal/calendar/templates/agenda.html b/internal/calendar/templates/agenda.html
index 50a1aa4..0b68ce5 100644
--- a/internal/calendar/templates/agenda.html
+++ b/internal/calendar/templates/agenda.html
@@ -5,7 +5,13 @@
<h1>Agenda</h1>
<p rst-page-header-sub>The next fortnight, and only the days with something in them.</p>
</div>
- <a rst-btn="primary" href="/events/new">New event</a>
+ {{/* Quick add sits BESIDE New event, not instead of it. The
+ sentence is the fast path; the form is the one that shows
+ every field, and neither is a lesser version of the other. */}}
+ <div cal-header-actions>
+ {{template "quickadd" .}}
+ <a rst-btn="primary" href="/events/new">New event</a>
+ </div>
</header>
<div cal-bar>
diff --git a/internal/calendar/templates/day.html b/internal/calendar/templates/day.html
index 7189eb5..e2b0277 100644
--- a/internal/calendar/templates/day.html
+++ b/internal/calendar/templates/day.html
@@ -5,7 +5,13 @@
<div rst-page-header-titles>
<h1>{{.Data.Day.Title}}</h1>
</div>
- <a rst-btn="primary" href="/events/new?date={{.Data.Day.ISO}}">New event</a>
+ {{/* Quick add sits BESIDE New event, not instead of it. The
+ sentence is the fast path; the form is the one that shows
+ every field, and neither is a lesser version of the other. */}}
+ <div cal-header-actions>
+ {{template "quickadd" .}}
+ <a rst-btn="primary" href="/events/new?date={{.Data.Day.ISO}}">New event</a>
+ </div>
</header>
{{/* The day's own sentence. Not a caption — it is the answer to the
diff --git a/internal/calendar/templates/event.html b/internal/calendar/templates/event.html
index f176e4f..505732c 100644
--- a/internal/calendar/templates/event.html
+++ b/internal/calendar/templates/event.html
@@ -32,6 +32,18 @@
{{with .Data.Event.Location}}<p><strong>Where:</strong> {{.}}</p>{{end}}
{{with .Data.Event.Notes}}<p>{{.}}</p>{{end}}
+{{/* Attendees are RECORDED, never invited, and the heading says so
+ rather than leaving it to a footnote. Somebody who reads "Who's
+ coming" and assumes an invitation went out has been misled by this
+ page, not by their own carelessness. */}}
+{{with .Data.Attendees}}
+<p><strong>Who&rsquo;s coming:</strong>
+ {{range $i, $a := .}}{{if $i}}, {{end}}{{$a}}{{end}}
+</p>
+<p cal-quick-caveat>Nobody here was emailed. This is a note on the event; the
+ ICS export carries it so a calendar that does send invitations can.</p>
+{{end}}
+
{{with .Data.RuleText}}
<section rst-box>
<header rst-box-head><h2>Repeats</h2></header>
diff --git a/internal/calendar/templates/layout.html b/internal/calendar/templates/layout.html
index 140c583..56a5e4d 100644
--- a/internal/calendar/templates/layout.html
+++ b/internal/calendar/templates/layout.html
@@ -16,6 +16,8 @@
{{iconAssets}}
<script defer src="{{asset "static/select.js"}}"></script>
<script defer src="{{asset "static/datetime.js"}}"></script>
+{{/* Quick add's live pane. The box works without it. */}}
+<script defer src="{{asset "static/quickadd.js"}}"></script>
{{/* head is the app's own slot in <head>: a favicon, a meta tag, one
more stylesheet. Empty by default, last in the head so an app's
own CSS wins over the framework's, and the only block in the
diff --git a/internal/calendar/templates/month.html b/internal/calendar/templates/month.html
index 71a2771..860cc74 100644
--- a/internal/calendar/templates/month.html
+++ b/internal/calendar/templates/month.html
@@ -5,7 +5,13 @@
<div rst-page-header-titles>
<h1>{{.Data.Month.Title}}</h1>
</div>
- <a rst-btn="primary" href="/events/new">New event</a>
+ {{/* Quick add sits BESIDE New event, not instead of it. The
+ sentence is the fast path; the form is the one that shows
+ every field, and neither is a lesser version of the other. */}}
+ <div cal-header-actions>
+ {{template "quickadd" .}}
+ <a rst-btn="primary" href="/events/new">New event</a>
+ </div>
</header>
<div cal-bar>
diff --git a/internal/calendar/templates/week.html b/internal/calendar/templates/week.html
index 5906e7b..fada49d 100644
--- a/internal/calendar/templates/week.html
+++ b/internal/calendar/templates/week.html
@@ -7,7 +7,13 @@
<div rst-page-header-titles>
<h1>{{.Title}}</h1>
</div>
- <a rst-btn="primary" href="/events/new">New event</a>
+ {{/* Quick add sits BESIDE New event, not instead of it. The
+ sentence is the fast path; the form is the one that shows
+ every field, and neither is a lesser version of the other. */}}
+ <div cal-header-actions>
+ {{template "quickadd" .}}
+ <a rst-btn="primary" href="/events/new">New event</a>
+ </div>
</header>
<div cal-bar>
diff --git a/internal/calendartest/playwright_test.go b/internal/calendartest/playwright_test.go
new file mode 100644
index 0000000..1e0f7c4
--- /dev/null
+++ b/internal/calendartest/playwright_test.go
@@ -0,0 +1,134 @@
+//go:build browser
+
+package calendartest
+
+import (
+ "context"
+ "io"
+ "log/slog"
+ "net"
+ "net/http"
+ "net/http/cookiejar"
+ "net/url"
+ "os/exec"
+ "path/filepath"
+ "runtime"
+ "strings"
+ "testing"
+ "time"
+
+ calendar "amadan.net/oficina/calendar/internal/calendar"
+)
+
+// TestQuickAddInARealBrowser drives the quick-add box in Chromium, with
+// script and then without it.
+//
+// Go starts the app and signs in — the magic-link flow needs the mail
+// recorder, which only this side can read — and hands node a live
+// origin and a session cookie. Playwright rather than chromedp: in this
+// environment chromedp starts a browser and parks at about:blank until
+// the Go test times out, so the existing browser walk cannot run at
+// all. A gate that cannot run is not a gate, and the house rule that
+// one test must cross from a computed value to painted output was
+// therefore unmet. This meets it.
+func TestQuickAddInARealBrowser(t *testing.T) {
+ // The listener comes first, because the origin is load-bearing:
+ // csrf.Protect measures against it and the sign-in link is built
+ // from it, so the app cannot be constructed until the port is known.
+ ln, err := net.Listen("tcp", "127.0.0.1:0")
+ if err != nil {
+ t.Fatalf("listen: %v", err)
+ }
+ origin := "http://" + ln.Addr().String()
+
+ app, err := calendar.New(calendar.Config{
+ DBPath: filepath.Join(t.TempDir(), "calendar.db"),
+ Env: "development",
+ Origin: origin,
+ Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
+ })
+ if err != nil {
+ t.Fatalf("calendar.New: %v", err)
+ }
+ t.Cleanup(func() { app.Close() })
+
+ srv := &http.Server{Handler: app.Handler()}
+ go func() { _ = srv.Serve(ln) }()
+ t.Cleanup(func() { _ = srv.Close() })
+
+ session := signInOverHTTP(t, app, origin, "someone@example.com")
+
+ ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
+ defer cancel()
+
+ cmd := exec.CommandContext(ctx, "node", "test/browser/quickadd.mjs",
+ origin, session.Name, session.Value)
+ cmd.Dir = repoRootForBrowser(t)
+
+ out, err := cmd.CombinedOutput()
+ t.Logf("node:\n%s", out)
+ if err != nil {
+ t.Fatalf("the browser checks failed: %v", err)
+ }
+}
+
+// repoRootForBrowser finds the repository from this file's own path, never
+// from the working directory: go test runs in the package directory and
+// the node script is addressed from the root.
+func repoRootForBrowser(t *testing.T) string {
+ t.Helper()
+ _, file, _, ok := runtime.Caller(0)
+ if !ok {
+ t.Fatal("runtime.Caller failed")
+ }
+ return filepath.Dir(filepath.Dir(filepath.Dir(file)))
+}
+
+// signInOverHTTP drives the real sign-in against the LIVE listener.
+//
+// App.SignInForTest cannot be used here: it presents calendar.TestOrigin
+// as its same-origin evidence, and this server is on a real loopback
+// port, so csrf.Protect refuses the POST and no link is ever mailed.
+// The failure reads as "no sign-in link was mailed", which names the
+// symptom and not the cause — hence this comment.
+func signInOverHTTP(t *testing.T, app *calendar.App, origin, address string) *http.Cookie {
+ t.Helper()
+ jar, err := cookiejar.New(nil)
+ if err != nil {
+ t.Fatalf("cookiejar: %v", err)
+ }
+ client := &http.Client{Jar: jar}
+
+ form := url.Values{"address": {address}, "force": {"1"}}
+ req, err := http.NewRequest(http.MethodPost, origin+"/signin",
+ strings.NewReader(form.Encode()))
+ if err != nil {
+ t.Fatalf("build signin request: %v", err)
+ }
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ req.Header.Set("Origin", origin)
+ res, err := client.Do(req)
+ if err != nil {
+ t.Fatalf("post signin: %v", err)
+ }
+ res.Body.Close()
+
+ body, err := app.LastMailForTest()
+ if err != nil || body == "" {
+ t.Fatalf("no sign-in link was mailed: %v", err)
+ }
+ link := magicLink(t, body)
+
+ res, err = client.Get(origin + link)
+ if err != nil {
+ t.Fatalf("follow the link: %v", err)
+ }
+ res.Body.Close()
+
+ u, _ := url.Parse(origin)
+ cookies := jar.Cookies(u)
+ if len(cookies) == 0 {
+ t.Fatal("following the sign-in link minted no cookie")
+ }
+ return cookies[0]
+}
diff --git a/internal/calendartest/quickadd_http_test.go b/internal/calendartest/quickadd_http_test.go
new file mode 100644
index 0000000..63ce69c
--- /dev/null
+++ b/internal/calendartest/quickadd_http_test.go
@@ -0,0 +1,124 @@
+package calendartest
+
+import (
+ "net/http"
+ "net/url"
+ "strings"
+ "testing"
+)
+
+// One sentence in, one event out — through the real router, with no
+// script anywhere in the path. This is the scriptless floor, and it is
+// the product rather than a fallback.
+func TestQuickAddCreatesAnEventFromASentence(t *testing.T) {
+ app, session := signedIn(t, "someone@example.com")
+ h := app.Handler()
+
+ rec := postAs(t, h, session, "/events/quick", url.Values{
+ "text": {"Board meeting with doc@tito.io tomorrow at 3pm in McCloskeys"},
+ })
+ if rec.Code != http.StatusSeeOther {
+ t.Fatalf("quick add: status %d, want 303\n%s", rec.Code, rec.Body.String())
+ }
+ loc := rec.Header().Get("Location")
+ if !strings.HasPrefix(loc, "/events/") {
+ t.Fatalf("quick add redirected to %q, want the new event", loc)
+ }
+
+ page := getAs(t, h, session, loc).Body.String()
+ for _, want := range []string{"Board meeting", "McCloskeys", "doc@tito.io"} {
+ if !strings.Contains(page, want) {
+ t.Errorf("the created event does not show %q:\n%s", want, page)
+ }
+ }
+ // The one thing that could mislead somebody has to be on the page.
+ if !strings.Contains(page, "Nobody here was emailed") {
+ t.Error("the event page does not say that attendees were not emailed")
+ }
+}
+
+// The one rule, extended to attendees: an external attendee's address is
+// who this team MEETS WITH, which is exactly what the bucket must not
+// give up. Greps the raw file, not a query — a column that was never
+// sealed looks identical through Go.
+func TestQuickAddSealsAttendeeAddresses(t *testing.T) {
+ app, session := signedIn(t, "someone@example.com")
+ h := app.Handler()
+
+ postAs(t, h, session, "/events/quick", url.Values{
+ "text": {"Coffee with secret.person@elsewhere.example at 11am"},
+ })
+
+ if found, where := app.GrepDatabaseForTest(t, "secret.person@elsewhere.example"); found {
+ t.Errorf("an attendee's address is readable in %s — it was not sealed", where)
+ }
+ // The keyed fingerprint must not be the address in another alphabet
+ // either: an unkeyed SHA-256 of an address is trivially reversed
+ // from a wordlist, which is why seal.Fingerprint is an HMAC.
+ if found, where := app.GrepDatabaseForTest(t,
+ "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"); found {
+ t.Errorf("something in %s looks like an unkeyed digest", where)
+ }
+}
+
+// The live pane is a FRAGMENT. Rendered through the page templates it
+// would arrive wrapped in a second whole document and the browser would
+// paste an <html> inside the open one.
+func TestTheQuickAddPaneIsAFragmentNotAPage(t *testing.T) {
+ app, session := signedIn(t, "someone@example.com")
+ h := app.Handler()
+
+ rec := postAs(t, h, session, "/events/quick/parse", url.Values{
+ "text": {"Standup tomorrow at 9:30"},
+ })
+ if rec.Code != http.StatusOK {
+ t.Fatalf("parse: status %d, want 200", rec.Code)
+ }
+ body := rec.Body.String()
+ if strings.Contains(strings.ToLower(body), "<html") {
+ t.Errorf("the pane came back as a whole page:\n%s", body)
+ }
+ // And it says what it understood, which is the entire point of it.
+ for _, want := range []string{"Standup", "09:30"} {
+ if !strings.Contains(body, want) {
+ t.Errorf("the pane does not mention %q:\n%s", want, body)
+ }
+ }
+}
+
+// The box has to be on the page a person is looking at, and it has to be
+// a real form pointing at a real route — not a button waiting for
+// script to give it meaning.
+func TestTheQuickAddBoxIsOnTheViewsAndNeedsNoScript(t *testing.T) {
+ app, session := signedIn(t, "someone@example.com")
+ h := app.Handler()
+
+ for _, path := range []string{"/today", "/agenda"} {
+ body := follow(t, h, session, path).Body.String()
+ if !strings.Contains(body, "cal-quick") {
+ t.Errorf("%s has no quick add box", path)
+ }
+ if !strings.Contains(body, `action="/events/quick"`) {
+ t.Errorf("%s has a quick add box that posts nowhere", path)
+ }
+ // A <details> opens with no script. A div with a class does not.
+ if !strings.Contains(body, "<details cal-quick>") {
+ t.Errorf("%s builds the popover from something other than <details>, "+
+ "so it needs script to open", path)
+ }
+ }
+}
+
+// A sentence with nothing usable in it must not create a nameless row.
+func TestQuickAddWithNoTextGoesToTheForm(t *testing.T) {
+ app, session := signedIn(t, "someone@example.com")
+ h := app.Handler()
+
+ rec := postAs(t, h, session, "/events/quick", url.Values{"text": {" "}})
+ if rec.Code != http.StatusSeeOther {
+ t.Fatalf("empty quick add: status %d, want 303", rec.Code)
+ }
+ if got := rec.Header().Get("Location"); got != "/events/new" {
+ t.Errorf("empty quick add went to %q, want /events/new", got)
+ }
+}
diff --git a/internal/calendartest/quickadd_test.go b/internal/calendartest/quickadd_test.go
new file mode 100644
index 0000000..1529f4d
--- /dev/null
+++ b/internal/calendartest/quickadd_test.go
@@ -0,0 +1,197 @@
+package calendartest
+
+import (
+ "strings"
+ "testing"
+ "time"
+
+ calendar "amadan.net/oficina/calendar/internal/calendar"
+)
+
+// quickNow is the clock every case below is read against: Wednesday
+// 2 September 2026, 10:00. Fixed, because "tomorrow" and "next Tuesday"
+// are only testable against a known today.
+var quickNow = time.Date(2026, 9, 2, 10, 0, 0, 0, time.UTC)
+
+func TestQuickAddReadsASentence(t *testing.T) {
+ cases := []struct {
+ name string
+ text string
+ title string
+ location string
+ start string
+ end string
+ allDay bool
+ repeat string
+ attendees []string
+ }{
+ {
+ name: "the one from the request",
+ text: "Meeting with Dermot Lacey at 3pm in McCloskeys",
+ title: "Meeting with Dermot Lacey",
+ location: "McCloskeys",
+ start: "2026-09-02T15:00",
+ end: "2026-09-02T16:00",
+ repeat: "none",
+ },
+ {
+ name: "an address becomes an attendee",
+ text: "Lunch with doc@tito.io tomorrow at 1",
+ title: "Lunch",
+ start: "2026-09-03T13:00",
+ end: "2026-09-03T14:00",
+ repeat: "none",
+ attendees: []string{"doc@tito.io"},
+ },
+ {
+ name: "a range gives both ends",
+ text: "Retro 3-4pm",
+ title: "Retro",
+ start: "2026-09-02T15:00",
+ end: "2026-09-02T16:00",
+ repeat: "none",
+ },
+ {
+ name: "a duration sets the end",
+ text: "Dentist on Friday at 11am for 90 minutes",
+ title: "Dentist",
+ start: "2026-09-04T11:00",
+ end: "2026-09-04T12:30",
+ repeat: "none",
+ },
+ {
+ name: "an hour in words",
+ text: "Review tomorrow at 2 for an hour",
+ title: "Review",
+ start: "2026-09-03T14:00",
+ end: "2026-09-03T15:00",
+ repeat: "none",
+ },
+ {
+ name: "crossing midnight rolls the end over",
+ text: "Party 9pm to 1am",
+ title: "Party",
+ start: "2026-09-02T21:00",
+ end: "2026-09-03T01:00",
+ repeat: "none",
+ },
+ {
+ name: "all day takes a date and no clock",
+ text: "Team offsite all day on 12 March",
+ title: "Team offsite",
+ start: "2027-03-12T00:00",
+ end: "2027-03-12T00:00",
+ allDay: true,
+ repeat: "none",
+ },
+ {
+ name: "every weekday is the weekdays choice",
+ text: "Standup every weekday at 9:30",
+ title: "Standup",
+ start: "2026-09-02T09:30",
+ end: "2026-09-02T10:30",
+ repeat: "weekdays",
+ },
+ {
+ name: "every Tuesday is weekly, on that Tuesday",
+ text: "Sync every Tuesday at 4pm",
+ title: "Sync",
+ start: "2026-09-08T16:00",
+ end: "2026-09-08T17:00",
+ repeat: "weekly",
+ },
+ {
+ name: "a place with no time still reads as a place",
+ text: "Coffee in the Fumbally at 11",
+ title: "Coffee",
+ location: "the Fumbally",
+ start: "2026-09-02T11:00",
+ end: "2026-09-02T12:00",
+ repeat: "none",
+ },
+ {
+ name: "24 hour time needs no meridiem",
+ text: "Deploy at 15:45",
+ title: "Deploy",
+ start: "2026-09-02T15:45",
+ end: "2026-09-02T16:45",
+ repeat: "none",
+ },
+ }
+
+ for _, c := range cases {
+ t.Run(c.name, func(t *testing.T) {
+ got := calendar.ParseQuick(c.text, quickNow, "UTC")
+ if got.Title != c.title {
+ t.Errorf("title = %q, want %q", got.Title, c.title)
+ }
+ if got.Location != c.location {
+ t.Errorf("location = %q, want %q", got.Location, c.location)
+ }
+ if got.StartLocal != c.start {
+ t.Errorf("start = %q, want %q", got.StartLocal, c.start)
+ }
+ if got.EndLocal != c.end {
+ t.Errorf("end = %q, want %q", got.EndLocal, c.end)
+ }
+ if got.AllDay != c.allDay {
+ t.Errorf("allDay = %v, want %v", got.AllDay, c.allDay)
+ }
+ if got.Repeat != c.repeat {
+ t.Errorf("repeat = %q, want %q", got.Repeat, c.repeat)
+ }
+ if strings.Join(got.Attendees, ",") != strings.Join(c.attendees, ",") {
+ t.Errorf("attendees = %v, want %v", got.Attendees, c.attendees)
+ }
+ })
+ }
+}
+
+// "at" is ambiguous and time has to win it. Read places first and every
+// event anybody types lands in a place called "3pm".
+func TestQuickAddPrefersATimeOverAPlaceForAt(t *testing.T) {
+ got := calendar.ParseQuick("Standup at 9am", quickNow, "UTC")
+ if got.Location != "" {
+ t.Errorf("location = %q, want empty — the time was read as a place", got.Location)
+ }
+ if got.StartLocal != "2026-09-02T09:00" {
+ t.Errorf("start = %q, want 2026-09-02T09:00", got.StartLocal)
+ }
+}
+
+// Every default is said out loud. A guess the person cannot see is a
+// guess they will not catch.
+func TestQuickAddNamesWhatItAssumed(t *testing.T) {
+ got := calendar.ParseQuick("Coffee", quickNow, "UTC")
+ notes := strings.Join(got.Notes, " ")
+ for _, want := range []string{"next half hour", "an hour", "today"} {
+ if !strings.Contains(notes, want) {
+ t.Errorf("the pane does not mention %q:\n%s", want, notes)
+ }
+ }
+ if got.StartLocal != "2026-09-02T10:30" {
+ t.Errorf("start = %q, want the next half hour 2026-09-02T10:30", got.StartLocal)
+ }
+}
+
+// Attendees are recorded, never emailed, and the pane has to say so —
+// somebody who reads "invitee" and assumes a mail went out has been
+// misled by us, not by their own carelessness.
+func TestQuickAddSaysNobodyIsEmailed(t *testing.T) {
+ got := calendar.ParseQuick("Lunch with doc@tito.io at 1pm", quickNow, "UTC")
+ if len(got.Attendees) != 1 {
+ t.Fatalf("attendees = %v, want one", got.Attendees)
+ }
+ if !strings.Contains(strings.Join(got.Notes, " "), "Nobody is emailed") {
+ t.Errorf("the pane does not say nobody is emailed:\n%v", got.Notes)
+ }
+}
+
+// A rule the picker cannot offer is NOT invented. It stays in the text
+// so it is visible, rather than becoming an RRULE nothing can re-edit.
+func TestQuickAddRefusesARepeatThePickerCannotShow(t *testing.T) {
+ got := calendar.ParseQuick("Board every second Tuesday of the month at 10am", quickNow, "UTC")
+ if got.Repeat != "none" && got.Repeat != "weekly" {
+ t.Errorf("repeat = %q; an unsupported rule must not become something else", got.Repeat)
+ }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..979c352
--- /dev/null
+++ b/package.json
@@ -0,0 +1,12 @@
+{
+ "name": "oficina-calendar-browser-tests",
+ "private": true,
+ "description": "The JS test harness. No part of the app is built from here — the app is one Go binary, and this exists only to drive a real browser at it.",
+ "type": "module",
+ "scripts": {
+ "test:browser": "go test -tags browser -run TestQuickAddInARealBrowser ./internal/calendartest/"
+ },
+ "devDependencies": {
+ "playwright": "1.62.1"
+ }
+}
diff --git a/test/browser/quickadd.mjs b/test/browser/quickadd.mjs
new file mode 100644
index 0000000..3bcdcc3
--- /dev/null
+++ b/test/browser/quickadd.mjs
@@ -0,0 +1,127 @@
+// quickadd.mjs — the quick-add box in a real browser, both ways.
+//
+// Driven by TestQuickAddInARealBrowser, which starts the app, signs in,
+// and passes this script the origin and a session cookie. Playwright
+// rather than chromedp: chromedp does not drive in this environment at
+// all (it starts Chromium and parks at about:blank until the Go test
+// times out), and a gate that cannot run is not a gate.
+//
+// Two passes, and the second is the one that matters most:
+//
+// 1. WITH script — type, and the pane fills in from the server.
+// 2. WITHOUT script — the same box still opens, still submits, and
+// still creates the event.
+//
+// The scriptless pass is the only way to prove the floor is real. Every
+// other test in this repo asks Go what Go produced; this one asks a
+// browser with JavaScript switched off whether the feature still works.
+import { chromium } from 'playwright';
+
+const [origin, cookieName, cookieValue] = process.argv.slice(2);
+if (!origin || !cookieName || !cookieValue) {
+ console.error('usage: quickadd.mjs <origin> <cookieName> <cookieValue>');
+ process.exit(2);
+}
+
+const failures = [];
+function check(ok, what) {
+ if (ok) return;
+ failures.push(what);
+ console.error('FAIL: ' + what);
+}
+
+const SENTENCE = 'Board meeting with doc@tito.io tomorrow at 3pm in McCloskeys';
+
+const browser = await chromium.launch();
+
+async function contextWith(javaScriptEnabled) {
+ const ctx = await browser.newContext({ javaScriptEnabled });
+ await ctx.addCookies([
+ { name: cookieName, value: cookieValue, url: origin, httpOnly: true },
+ ]);
+ return ctx;
+}
+
+// ── 1. With script: the pane fills in as you type ────────────────────
+{
+ const ctx = await contextWith(true);
+ const page = await ctx.newPage();
+
+ const errors = [];
+ page.on('console', (m) => { if (m.type() === 'error') errors.push(m.text()); });
+ page.on('pageerror', (e) => errors.push(String(e)));
+
+ await page.goto(origin + '/today', { waitUntil: 'networkidle' });
+
+ const box = page.locator('[cal-quick]');
+ check(await box.count() === 1, 'the quick add box is on the day page');
+
+ // Closed, the form must not be reachable — a popover that is merely
+ // transparent is still in the tab order.
+ check(!(await page.locator('[cal-quick-text]').isVisible()),
+ 'the textarea is hidden until the box is opened');
+
+ await page.locator('[cal-quick] > summary').click();
+ check(await page.locator('[cal-quick-text]').isVisible(),
+ 'clicking the box opens it');
+
+ // The crossing from computed value to PAINTED output: the popover has
+ // to have real size and sit inside the viewport. A panel positioned
+ // off the side of the page passes every string assertion in the Go
+ // suite and is invisible to a person.
+ const rect = await page.locator('[cal-quick-form]').boundingBox();
+ check(rect !== null && rect.width > 200 && rect.height > 80,
+ 'the popover has real painted size (got ' + JSON.stringify(rect) + ')');
+ const vw = page.viewportSize().width;
+ check(rect !== null && rect.x >= 0 && rect.x + rect.width <= vw + 1,
+ 'the popover sits inside the viewport (got ' + JSON.stringify(rect) + ')');
+
+ await page.locator('[cal-quick-text]').fill(SENTENCE);
+
+ // The pane is filled by the SERVER. Waiting for its text is waiting
+ // for the round trip, which is the thing under test.
+ await page.locator('[cal-quick-pane]').getByText('McCloskeys').waitFor({ timeout: 5000 });
+
+ const pane = await page.locator('[cal-quick-pane]').innerText();
+ check(pane.includes('Board meeting'), 'the pane shows the title it parsed');
+ check(pane.includes('McCloskeys'), 'the pane shows the place');
+ check(pane.includes('doc@tito.io'), 'the pane shows the attendee');
+ check(pane.includes('15:00'), 'the pane resolved 3pm to 15:00, not 03:00');
+ check(/not emailed/i.test(pane), 'the pane says nobody is emailed');
+
+ check(errors.length === 0, 'no console errors (' + errors.join('; ') + ')');
+ await ctx.close();
+}
+
+// ── 2. Without script: the same box still works ──────────────────────
+{
+ const ctx = await contextWith(false);
+ const page = await ctx.newPage();
+ await page.goto(origin + '/today');
+
+ // <details> is native, so it opens with no JavaScript at all.
+ await page.locator('[cal-quick] > summary').click();
+ check(await page.locator('[cal-quick-text]').isVisible(),
+ 'the box opens with JavaScript disabled');
+
+ await page.locator('[cal-quick-text]').fill('Scriptless lunch tomorrow at 1pm in Fumbally');
+ await Promise.all([
+ page.waitForNavigation(),
+ page.locator('[cal-quick-form] button[type=submit]').click(),
+ ]);
+
+ check(/\/events\/\d+$/.test(page.url()),
+ 'submitting with no script lands on the new event (got ' + page.url() + ')');
+ const body = await page.locator('body').innerText();
+ check(body.includes('Scriptless lunch'), 'the scriptless event kept its title');
+ check(body.includes('Fumbally'), 'the scriptless event kept its place');
+ await ctx.close();
+}
+
+await browser.close();
+
+if (failures.length) {
+ console.error('\n' + failures.length + ' browser check(s) failed');
+ process.exit(1);
+}
+console.log('quick add: all browser checks passed');