oficina / calendar Public

booking: the host's side — a handle, pages, a pane, and a Publish button that says what it does

Settings gains a handle, the first part of every booking link. A
booking page is a form whose availability sentence is read by the one
parser, on the server; booking.js only asks that parser while the host
types, and a save with an unreadable clause comes back at 422 with the
same pane inline. Publishing is a deliberate step with its own
sentence above the button, because a published page is an oracle for
when the host is free.

A booked event's form locks its scheduling. The controls are shown
disabled and their stored values ride along as hidden inputs — a
disabled control submits nothing, and a title-only save has to submit
exactly what is stored to pass the comparison — including the event's
own tz, so the save still works after the host moves zone. The event
page says how a booked meeting is moved: cancel it and let them rebook.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev 0f12b849b4e9b69b8aae1b91bd077db1aa95aa3f parent d31210c
20 files changed, +847 −49
  • .impeccable/hook.cache.json +1 −1
  • internal/calendar/funcs.go +1 −0
  • internal/calendar/handlers_booking_form.go +145 −0
  • internal/calendar/handlers_booking_settings.go +72 −0
  • internal/calendar/handlers_event_form.go +19 −3
  • internal/calendar/handlers_events.go +28 −6
  • internal/calendar/handlers_misc.go +0 −39
  • internal/calendar/handlers_settings.go +58 −0
  • internal/calendar/render.go +1 −0
  • internal/calendar/routes.go +10 −0
  • internal/calendar/static/booking.js +95 −0
  • internal/calendar/static/calendar.css +46 −0
  • internal/calendar/store_booking_read.go +13 −0
  • internal/calendar/templates/booking_page_form.html +69 −0
  • internal/calendar/templates/booking_pages.html +67 −0
  • internal/calendar/templates/event.html +8 −0
  • internal/calendar/templates/event_form.html +22 −0
  • internal/calendar/templates/layout.html +3 −0
  • internal/calendar/templates/settings.html +8 −0
  • internal/calendartest/booking_settings_test.go +181 −0
diff --git a/.impeccable/hook.cache.json b/.impeccable/hook.cache.json
index c6734fa..36356a9 100644
--- a/.impeccable/hook.cache.json
+++ b/.impeccable/hook.cache.json
@@ -1 +1 @@
-{"version":1,"sessions":{"f915c070-9e97-443d-986d-1eacbb36518a":{"updatedAt":1788307905253,"files":{"/home/paulca/amadan.net/oficina/calendar/.claude/worktrees/calendar-first-screens/internal/calendar/static/calendar.css":{"editCount":1,"findings":[],"cleanAcked":true},"/home/paulca/amadan.net/oficina/calendar/.claude/worktrees/calendar-first-screens/internal/calendar/templates/layout.html":{"editCount":4,"findings":[],"cleanAcked":true},"/home/paulca/amadan.net/oficina/calendar/.claude/worktrees/calendar-first-screens/internal/calendar/templates/_chip.html":{"editCount":1,"findings":[],"cleanAcked":true},"/home/paulca/amadan.net/oficina/calendar/.claude/worktrees/calendar-first-screens/internal/calendar/templates/day.html":{"editCount":1,"findings":[],"cleanAcked":true},"/home/paulca/amadan.net/oficina/calendar/.claude/worktrees/calendar-first-screens/internal/calendar/templates/week.html":{"editCount":1,"findings":[],"cleanAcked":true},"/home/paulca/amadan.net/oficina/calendar/.claude/worktrees/calendar-first-screens/internal/calendar/templates/month.html":{"editCount":1,"findings":[],"cleanAcked":true},"/home/paulca/amadan.net/oficina/calendar/.claude/worktrees/calendar-first-screens/internal/calendar/templates/agenda.html":{"editCount":1,"findings":[],"cleanAcked":true},"/home/paulca/amadan.net/oficina/calendar/.claude/worktrees/calendar-first-screens/internal/calendar/templates/event_form.html":{"editCount":2,"findings":[],"cleanAcked":true},"/home/paulca/amadan.net/oficina/calendar/.claude/worktrees/calendar-first-screens/internal/calendar/templates/event.html":{"editCount":3,"findings":[],"cleanAcked":true},"/home/paulca/amadan.net/oficina/calendar/.claude/worktrees/calendar-first-screens/internal/calendar/templates/event_delete.html":{"editCount":1,"findings":[],"cleanAcked":true},"/home/paulca/amadan.net/oficina/calendar/.claude/worktrees/calendar-first-screens/internal/calendar/templates/calendars.html":{"editCount":1,"findings":[],"cleanAcked":true},"/home/paulca/amadan.net/oficina/calendar/.claude/worktrees/calendar-first-screens/internal/calendar/templates/share.html":{"editCount":1,"findings":[],"cleanAcked":true},"/home/paulca/amadan.net/oficina/calendar/.claude/worktrees/calendar-first-screens/internal/calendar/templates/import.html":{"editCount":1,"findings":[],"cleanAcked":true},"/home/paulca/amadan.net/oficina/calendar/.claude/worktrees/calendar-first-screens/internal/calendar/templates/import_done.html":{"editCount":1,"findings":[],"cleanAcked":true},"/home/paulca/amadan.net/oficina/calendar/.claude/worktrees/calendar-first-screens/internal/calendar/templates/settings.html":{"editCount":1,"findings":[],"cleanAcked":true}}}}}
\ No newline at end of file
+{"version":1,"sessions":{"f915c070-9e97-443d-986d-1eacbb36518a":{"updatedAt":1788765935241,"files":{"/home/paulca/amadan.net/oficina/calendar/.claude/worktrees/calendar-first-screens/internal/calendar/static/calendar.css":{"editCount":1,"findings":[],"cleanAcked":true},"/home/paulca/amadan.net/oficina/calendar/.claude/worktrees/calendar-first-screens/internal/calendar/templates/layout.html":{"editCount":4,"findings":[],"cleanAcked":true},"/home/paulca/amadan.net/oficina/calendar/.claude/worktrees/calendar-first-screens/internal/calendar/templates/_chip.html":{"editCount":1,"findings":[],"cleanAcked":true},"/home/paulca/amadan.net/oficina/calendar/.claude/worktrees/calendar-first-screens/internal/calendar/templates/day.html":{"editCount":1,"findings":[],"cleanAcked":true},"/home/paulca/amadan.net/oficina/calendar/.claude/worktrees/calendar-first-screens/internal/calendar/templates/week.html":{"editCount":1,"findings":[],"cleanAcked":true},"/home/paulca/amadan.net/oficina/calendar/.claude/worktrees/calendar-first-screens/internal/calendar/templates/month.html":{"editCount":1,"findings":[],"cleanAcked":true},"/home/paulca/amadan.net/oficina/calendar/.claude/worktrees/calendar-first-screens/internal/calendar/templates/agenda.html":{"editCount":1,"findings":[],"cleanAcked":true},"/home/paulca/amadan.net/oficina/calendar/.claude/worktrees/calendar-first-screens/internal/calendar/templates/event_form.html":{"editCount":2,"findings":[],"cleanAcked":true},"/home/paulca/amadan.net/oficina/calendar/.claude/worktrees/calendar-first-screens/internal/calendar/templates/event.html":{"editCount":3,"findings":[],"cleanAcked":true},"/home/paulca/amadan.net/oficina/calendar/.claude/worktrees/calendar-first-screens/internal/calendar/templates/event_delete.html":{"editCount":1,"findings":[],"cleanAcked":true},"/home/paulca/amadan.net/oficina/calendar/.claude/worktrees/calendar-first-screens/internal/calendar/templates/calendars.html":{"editCount":1,"findings":[],"cleanAcked":true},"/home/paulca/amadan.net/oficina/calendar/.claude/worktrees/calendar-first-screens/internal/calendar/templates/share.html":{"editCount":1,"findings":[],"cleanAcked":true},"/home/paulca/amadan.net/oficina/calendar/.claude/worktrees/calendar-first-screens/internal/calendar/templates/import.html":{"editCount":1,"findings":[],"cleanAcked":true},"/home/paulca/amadan.net/oficina/calendar/.claude/worktrees/calendar-first-screens/internal/calendar/templates/import_done.html":{"editCount":1,"findings":[],"cleanAcked":true},"/home/paulca/amadan.net/oficina/calendar/.claude/worktrees/calendar-first-screens/internal/calendar/templates/settings.html":{"editCount":1,"findings":[],"cleanAcked":true},"/home/paulca/amadan.net/oficina/calendar/.claude/worktrees/calendar-first-screens/internal/calendar/templates/booking_pages.html":{"editCount":1,"findings":[],"cleanAcked":true},"/home/paulca/amadan.net/oficina/calendar/.claude/worktrees/calendar-first-screens/internal/calendar/templates/booking_page_form.html":{"editCount":1,"findings":[],"cleanAcked":true},"/home/paulca/amadan.net/oficina/calendar/.claude/worktrees/calendar-first-screens/internal/calendar/static/booking.js":{"editCount":1,"findings":[]}}}}}
\ No newline at end of file
diff --git a/internal/calendar/funcs.go b/internal/calendar/funcs.go
index fd15705..1fb41fd 100644
--- a/internal/calendar/funcs.go
+++ b/internal/calendar/funcs.go
@@ -20,6 +20,7 @@ var appFuncs = template.FuncMap{
"zoneOptions": zoneOptions,
"weekStartOptions": weekStartOptions,
"minutesLabel": minutesLabel,
+ "duration": humanDuration,
"feedURL": feedURL,
"deleteAllLabel": deleteAllLabel,
"canWrite": CanWrite,
diff --git a/internal/calendar/handlers_booking_form.go b/internal/calendar/handlers_booking_form.go
new file mode 100644
index 0000000..e900119
--- /dev/null
+++ b/internal/calendar/handlers_booking_form.go
@@ -0,0 +1,145 @@
+package calendar
+
+import (
+ "net/http"
+ "strconv"
+ "strings"
+
+ "github.com/go-chi/chi/v5"
+)
+
+// bookingPageForm is the new/edit form's view data. Strings, so a
+// rejected form re-renders exactly what was typed.
+type bookingPageForm struct {
+ ID int64
+ Action string
+ Heading string
+ Submit string
+ Title string
+ Description string
+ Availability string
+ DurationMin string
+ BufferMin string
+ NoticeMin string
+ HorizonDays string
+ Pane BookingPaneView
+ Error string
+ ErrorField string
+}
+
+func newBookingPageForm() bookingPageForm {
+ return bookingPageForm{
+ Action: "/settings/booking", Heading: "New booking page", Submit: "Create",
+ DurationMin: "30",
+ BufferMin: strconv.Itoa(DefaultBufferMin),
+ NoticeMin: strconv.Itoa(DefaultNoticeMin),
+ HorizonDays: strconv.Itoa(DefaultHorizonDays),
+ Pane: BookingPaneView{Empty: true},
+ }
+}
+
+func formOfPage(p BookingPageView) bookingPageForm {
+ return bookingPageForm{
+ ID: p.ID,
+ Action: "/settings/booking/" + strconv.FormatInt(p.ID, 10),
+ Heading: "Edit booking page", Submit: "Save",
+ Title: p.Title, Description: p.Description, Availability: p.Availability,
+ DurationMin: strconv.Itoa(p.DurationMin), BufferMin: strconv.Itoa(p.BufferMin),
+ NoticeMin: strconv.Itoa(p.NoticeMin), HorizonDays: strconv.Itoa(p.HorizonDays),
+ Pane: bookingPaneFor(p.Availability),
+ }
+}
+
+// readBookingPageForm parses a submission. Blank knobs take the
+// defaults; a knob of zero is a choice and stays zero.
+func readBookingPageForm(r *http.Request) (BookingPageInput, bookingPageForm) {
+ _ = r.ParseForm()
+ num := func(name string, def int) (int, string) {
+ raw := strings.TrimSpace(r.FormValue(name))
+ if raw == "" {
+ return def, strconv.Itoa(def)
+ }
+ n, err := strconv.Atoi(raw)
+ if err != nil {
+ return -1, raw
+ }
+ return n, raw
+ }
+ f := bookingPageForm{
+ Title: r.FormValue("title"),
+ Description: r.FormValue("description"),
+ Availability: r.FormValue("availability"),
+ }
+ in := BookingPageInput{Title: f.Title, Description: f.Description, Availability: f.Availability}
+ in.DurationMin, f.DurationMin = num("duration_min", 30)
+ in.BufferMin, f.BufferMin = num("buffer_min", DefaultBufferMin)
+ in.NoticeMin, f.NoticeMin = num("notice_min", DefaultNoticeMin)
+ in.HorizonDays, f.HorizonDays = num("horizon_days", DefaultHorizonDays)
+ f.Pane = bookingPaneFor(in.Availability)
+ return in, f
+}
+
+func (h *handlers) newBookingPage(w http.ResponseWriter, r *http.Request) {
+ render(w, "booking_page_form", h.page(r, "New booking page", newBookingPageForm()))
+}
+
+func (h *handlers) editBookingPage(w http.ResponseWriter, r *http.Request) {
+ p, _ := CurrentPerson(r)
+ id, _ := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
+ page, err := h.store.BookingPage(r.Context(), p.ID, id)
+ if NotFound(err) {
+ ErrorPage(w, r, http.StatusNotFound, "")
+ return
+ }
+ if err != nil {
+ h.fail(w, r, "read booking page", err)
+ return
+ }
+ render(w, "booking_page_form", h.page(r, "Edit booking page", formOfPage(page)))
+}
+
+// saveBookingPage is create and update: parse the sentence, refuse the
+// form while any clause is unreadable (the pane shows every problem),
+// then write.
+func (h *handlers) saveBookingPage(w http.ResponseWriter, r *http.Request) {
+ p, _ := CurrentPerson(r)
+ in, f := readBookingPageForm(r)
+ if raw := chi.URLParam(r, "id"); raw != "" {
+ f.ID, _ = strconv.ParseInt(raw, 10, 64)
+ f.Action, f.Heading, f.Submit = "/settings/booking/"+raw, "Edit booking page", "Save"
+ } else {
+ f.Action, f.Heading, f.Submit = "/settings/booking", "New booking page", "Create"
+ }
+ reject := func(field, msg string) {
+ f.Error, f.ErrorField = msg, field
+ w.WriteHeader(http.StatusUnprocessableEntity)
+ render(w, "booking_page_form", h.page(r, f.Heading, f))
+ }
+
+ windows, problems := ParseAvailability(in.Availability)
+ if len(problems) > 0 {
+ reject("availability", "Some of that could not be read. The parts that could are listed below the box.")
+ return
+ }
+ windows = MergeWindows(windows)
+
+ var err error
+ if f.ID == 0 {
+ _, err = h.store.CreateBookingPage(r.Context(), p.ID, in, windows)
+ } else {
+ err = h.store.UpdateBookingPage(r.Context(), p.ID, f.ID, in, windows)
+ }
+ if NotFound(err) {
+ ErrorPage(w, r, http.StatusNotFound, "")
+ return
+ }
+ if ve, ok := Invalid(err); ok {
+ reject(ve.Field, ve.Message)
+ return
+ }
+ if err != nil {
+ h.fail(w, r, "save booking page", err)
+ return
+ }
+ http.Redirect(w, r, "/settings/booking?saved=1", http.StatusSeeOther)
+}
diff --git a/internal/calendar/handlers_booking_settings.go b/internal/calendar/handlers_booking_settings.go
new file mode 100644
index 0000000..2203b05
--- /dev/null
+++ b/internal/calendar/handlers_booking_settings.go
@@ -0,0 +1,72 @@
+package calendar
+
+import (
+ "net/http"
+ "strconv"
+ "strings"
+
+ "github.com/go-chi/chi/v5"
+)
+
+// The host's side of booking (booking spec §2.1, §3.4, §7.4): the list
+// of pages, the publish switch, and the live pane's fragment. The forms
+// are in handlers_booking_form.go.
+
+// bookingPageRow is one page as the list prints it.
+type bookingPageRow struct {
+ BookingPageView
+ Lines []string
+ PublicURL string // empty until published AND the host has a handle
+}
+
+func (h *handlers) bookingPages(w http.ResponseWriter, r *http.Request) {
+ p, _ := CurrentPerson(r)
+ pages, err := h.store.BookingPages(r.Context(), p.ID)
+ if err != nil {
+ h.fail(w, r, "list booking pages", err)
+ return
+ }
+ handle := ""
+ if p.Handle != nil {
+ handle = *p.Handle
+ }
+ rows := make([]bookingPageRow, 0, len(pages))
+ for _, pg := range pages {
+ row := bookingPageRow{BookingPageView: pg, Lines: DescribeWindows(pg.Windows)}
+ if pg.Published && handle != "" {
+ row.PublicURL = h.origin + "/book/" + handle + "/" + pg.Code
+ }
+ rows = append(rows, row)
+ }
+ render(w, "booking_pages", h.page(r, "Booking pages", map[string]any{
+ "Pages": rows,
+ "Handle": handle,
+ "Saved": r.URL.Query().Get("saved") == "1",
+ }))
+}
+
+// publishBookingPage flips the switch. Publishing is deliberate: the
+// button's own sentence says a published page is an oracle for when the
+// host is free (spec §7.4).
+func (h *handlers) publishBookingPage(w http.ResponseWriter, r *http.Request) {
+ p, _ := CurrentPerson(r)
+ id, _ := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
+ _ = r.ParseForm()
+ err := h.store.PublishBookingPage(r.Context(), p.ID, id, r.FormValue("publish") == "1")
+ if NotFound(err) {
+ ErrorPage(w, r, http.StatusNotFound, "")
+ return
+ }
+ if err != nil {
+ h.fail(w, r, "publish booking page", err)
+ return
+ }
+ http.Redirect(w, r, "/settings/booking", http.StatusSeeOther)
+}
+
+// bookingParse answers the live pane: one parser, the Go one, exactly
+// as quick add (B7).
+func (h *handlers) bookingParse(w http.ResponseWriter, r *http.Request) {
+ _ = r.ParseForm()
+ renderFragment(w, "bookingpane", bookingPaneFor(strings.TrimSpace(r.FormValue("text"))))
+}
diff --git a/internal/calendar/handlers_event_form.go b/internal/calendar/handlers_event_form.go
index 914a101..171dbde 100644
--- a/internal/calendar/handlers_event_form.go
+++ b/internal/calendar/handlers_event_form.go
@@ -39,6 +39,14 @@ type eventForm struct {
// ask which occurrences the edit applies to.
Series bool
+ // Booked is set when a booking references this event. The form
+ // then shows the scheduling controls disabled and carries their
+ // stored values as hidden inputs, so a title-only save submits
+ // exactly what is stored (booking spec B18, B25). BookedBy is the
+ // booker's name for the sentence beside them.
+ Booked bool
+ BookedBy string
+
// Occurrence is which one is being edited, when the edit arrived
// from an occurrence URL. Empty means the series itself.
Occurrence string
@@ -51,6 +59,14 @@ func (h *handlers) readEventForm(r *http.Request, p Person) (EventInput, eventFo
calID, _ := strconv.ParseInt(r.FormValue("calendar_id"), 10, 64)
allDay := r.FormValue("all_day") == "1"
start := strings.TrimSpace(r.FormValue("start"))
+ // The zone is the person's, except on a booked event's form, which
+ // carries the EVENT's own zone as a hidden field: after the host
+ // changes their profile zone, a title-only save must still match
+ // the stored row (B25).
+ tz := strings.TrimSpace(r.FormValue("tz"))
+ if tz == "" {
+ tz = p.Zone
+ }
in := EventInput{
CalendarID: calID,
@@ -60,10 +76,10 @@ func (h *handlers) readEventForm(r *http.Request, p Person) (EventInput, eventFo
AllDay: allDay,
StartLocal: start,
EndLocal: strings.TrimSpace(r.FormValue("end")),
- TZ: p.Zone,
+ TZ: tz,
Kind: r.FormValue("kind"),
Status: r.FormValue("status"),
- RRule: RuleFromRepeat(r.FormValue("repeat"), r.FormValue("repeat_end"), start, p.Zone, allDay),
+ RRule: RuleFromRepeat(r.FormValue("repeat"), r.FormValue("repeat_end"), start, tz, allDay),
}
f := eventForm{
CalendarID: calID,
@@ -73,7 +89,7 @@ func (h *handlers) readEventForm(r *http.Request, p Person) (EventInput, eventFo
AllDay: allDay,
StartLocal: in.StartLocal,
EndLocal: in.EndLocal,
- TZ: p.Zone,
+ TZ: tz,
Kind: in.Kind,
Status: in.Status,
Repeat: r.FormValue("repeat"),
diff --git a/internal/calendar/handlers_events.go b/internal/calendar/handlers_events.go
index a8da52d..a924d5b 100644
--- a/internal/calendar/handlers_events.go
+++ b/internal/calendar/handlers_events.go
@@ -140,7 +140,10 @@ func (h *handlers) showEvent(w http.ResponseWriter, r *http.Request) {
return
}
+ bookedBy, booked := h.store.BookingSummaryFor(r.Context(), e.ID)
data := map[string]any{
+ "Booked": booked,
+ "BookedBy": bookedBy,
"Event": e,
"Chip": chipFor(e, p.Zone, time.Now().Unix()),
"Occurrence": occurrence,
@@ -174,7 +177,10 @@ func (h *handlers) editEvent(w http.ResponseWriter, r *http.Request) {
h.fail(w, r, "list calendars", err)
return
}
+ bookedBy, booked := h.store.BookingSummaryFor(r.Context(), e.ID)
render(w, "event_form", h.page(r, "Edit", eventForm{
+ Booked: booked,
+ BookedBy: bookedBy,
Action: "/events/" + strconv.FormatInt(e.ID, 10),
Title: "Edit",
Heading: "Edit event",
@@ -207,12 +213,7 @@ func (h *handlers) updateEvent(w http.ResponseWriter, r *http.Request) {
}
in, f, ok := h.readEventForm(r, p)
if !ok {
- f.Action = "/events/" + strconv.FormatInt(id, 10)
- f.Title, f.Heading, f.Submit = "Edit", "Edit event", "Save"
- cals, _ := h.store.Calendars(r.Context(), p.ID)
- f.Calendars = writable(cals)
- w.WriteHeader(http.StatusUnprocessableEntity)
- render(w, "event_form", h.page(r, f.Title, f))
+ h.rerenderEdit(w, r, p, id, f)
return
}
// Which occurrences the edit applies to. Only meaningful on a
@@ -235,6 +236,14 @@ func (h *handlers) updateEvent(w http.ResponseWriter, r *http.Request) {
default:
err = h.store.UpdateEvent(r.Context(), p.ID, id, in)
}
+ if ve, isVE := Invalid(err); isVE {
+ // The store refused — a booked event's locked field, or a
+ // status the booking's state does not allow. Back to the form
+ // with the message beside the field.
+ f.Error, f.ErrorField = ve.Message, ve.Field
+ h.rerenderEdit(w, r, p, id, f)
+ return
+ }
switch {
case NotFound(err):
ErrorPage(w, r, http.StatusNotFound, "")
@@ -246,3 +255,16 @@ func (h *handlers) updateEvent(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/events/"+strconv.FormatInt(id, 10), http.StatusSeeOther)
}
}
+
+// rerenderEdit puts the edit form back with what was typed and why it
+// was refused, at 422.
+func (h *handlers) rerenderEdit(w http.ResponseWriter, r *http.Request, p Person, id int64, f eventForm) {
+ f.Action = "/events/" + strconv.FormatInt(id, 10)
+ f.Title, f.Heading, f.Submit = "Edit", "Edit event", "Save"
+ f.EventID = id
+ cals, _ := h.store.Calendars(r.Context(), p.ID)
+ f.Calendars = writable(cals)
+ f.BookedBy, f.Booked = h.store.BookingSummaryFor(r.Context(), id)
+ w.WriteHeader(http.StatusUnprocessableEntity)
+ render(w, "event_form", h.page(r, f.Title, f))
+}
diff --git a/internal/calendar/handlers_misc.go b/internal/calendar/handlers_misc.go
index b319d83..a85ea46 100644
--- a/internal/calendar/handlers_misc.go
+++ b/internal/calendar/handlers_misc.go
@@ -212,45 +212,6 @@ func (h *handlers) importFailed(w http.ResponseWriter, r *http.Request, msg stri
}))
}
-func (h *handlers) settings(w http.ResponseWriter, r *http.Request) {
- p, _ := CurrentPerson(r)
- undeliverable, err := h.store.UndeliverableReminders(r.Context(), p.ID)
- if err != nil {
- h.fail(w, r, "count undeliverable", err)
- return
- }
- render(w, "settings", h.page(r, "Settings", map[string]any{
- "Person": p,
- "Zones": CommonZones,
- "Undeliverable": undeliverable,
- "Saved": r.URL.Query().Get("saved") == "1",
- }))
-}
-
-func (h *handlers) saveSettings(w http.ResponseWriter, r *http.Request) {
- p, _ := CurrentPerson(r)
- _ = r.ParseForm()
- weekStart, _ := strconv.Atoi(r.FormValue("week_start"))
- reminderMin, _ := strconv.Atoi(r.FormValue("default_reminder_min"))
- allDayMin, _ := strconv.Atoi(r.FormValue("default_all_day_min"))
-
- err := h.store.SaveSettings(r.Context(), p.ID, r.FormValue("zone"), weekStart, reminderMin, allDayMin)
- if err != nil {
- if ve, ok := Invalid(err); ok {
- undeliverable, _ := h.store.UndeliverableReminders(r.Context(), p.ID)
- w.WriteHeader(http.StatusUnprocessableEntity)
- render(w, "settings", h.page(r, "Settings", map[string]any{
- "Person": p, "Zones": CommonZones,
- "Undeliverable": undeliverable, "Error": ve.Message,
- }))
- return
- }
- h.fail(w, r, "save settings", err)
- return
- }
- http.Redirect(w, r, "/settings?saved=1", http.StatusSeeOther)
-}
-
func (h *handlers) addReminder(w http.ResponseWriter, r *http.Request) {
p, _ := CurrentPerson(r)
id, _ := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
diff --git a/internal/calendar/handlers_settings.go b/internal/calendar/handlers_settings.go
new file mode 100644
index 0000000..6837f0f
--- /dev/null
+++ b/internal/calendar/handlers_settings.go
@@ -0,0 +1,58 @@
+package calendar
+
+import (
+ "net/http"
+ "strconv"
+)
+
+// The settings page, split from handlers_misc.go so neither file passes
+// the 300-line cap.
+
+func (h *handlers) settings(w http.ResponseWriter, r *http.Request) {
+ p, _ := CurrentPerson(r)
+ undeliverable, err := h.store.UndeliverableReminders(r.Context(), p.ID)
+ if err != nil {
+ h.fail(w, r, "count undeliverable", err)
+ return
+ }
+ render(w, "settings", h.page(r, "Settings", map[string]any{
+ "Person": p,
+ "Zones": CommonZones,
+ "Undeliverable": undeliverable,
+ "Saved": r.URL.Query().Get("saved") == "1",
+ "Error": "",
+ "ErrorField": "",
+ }))
+}
+
+func (h *handlers) saveSettings(w http.ResponseWriter, r *http.Request) {
+ p, _ := CurrentPerson(r)
+ _ = r.ParseForm()
+ weekStart, _ := strconv.Atoi(r.FormValue("week_start"))
+ reminderMin, _ := strconv.Atoi(r.FormValue("default_reminder_min"))
+ allDayMin, _ := strconv.Atoi(r.FormValue("default_all_day_min"))
+
+ err := h.store.SaveSettings(r.Context(), p.ID, r.FormValue("zone"), weekStart, reminderMin, allDayMin)
+ if err == nil {
+ // The handle is the first part of every booking link. Saved
+ // after the rest so a refused handle does not lose the zone.
+ err = h.store.SaveHandle(r.Context(), p.ID, r.FormValue("handle"))
+ if err == ErrHandleTaken {
+ err = ValidationError{"handle", "That handle is taken."}
+ }
+ }
+ if err != nil {
+ if ve, ok := Invalid(err); ok {
+ undeliverable, _ := h.store.UndeliverableReminders(r.Context(), p.ID)
+ w.WriteHeader(http.StatusUnprocessableEntity)
+ render(w, "settings", h.page(r, "Settings", map[string]any{
+ "Person": p, "Zones": CommonZones,
+ "Undeliverable": undeliverable, "Error": ve.Message, "ErrorField": ve.Field,
+ }))
+ return
+ }
+ h.fail(w, r, "save settings", err)
+ return
+ }
+ http.Redirect(w, r, "/settings?saved=1", http.StatusSeeOther)
+}
diff --git a/internal/calendar/render.go b/internal/calendar/render.go
index abb02c8..c3b8223 100644
--- a/internal/calendar/render.go
+++ b/internal/calendar/render.go
@@ -34,6 +34,7 @@ var pageNames = []string{
"signin", "errors", "day", "week", "month", "agenda",
"event", "event_form", "event_delete", "calendars", "share",
"import", "import_done", "settings",
+ "booking_pages", "booking_page_form",
}
// pages is one *template.Template per page, each combining layout.html
diff --git a/internal/calendar/routes.go b/internal/calendar/routes.go
index 9fe3724..0bee8ce 100644
--- a/internal/calendar/routes.go
+++ b/internal/calendar/routes.go
@@ -94,6 +94,16 @@ func (a *App) routes(h *handlers, authPlugin *rauth.Auth, list allowlist, origin
r.Get("/settings", h.settings)
r.Post("/settings", h.saveSettings)
+
+ // Booking pages, the host's side. Person-scoped throughout: a
+ // page id that is not yours 404s (booking spec §2.6).
+ r.Get("/settings/booking", h.bookingPages)
+ r.Get("/settings/booking/new", h.newBookingPage)
+ r.Post("/settings/booking", h.saveBookingPage)
+ r.Post("/settings/booking/parse", h.bookingParse)
+ r.Get("/settings/booking/{id}", h.editBookingPage)
+ r.Post("/settings/booking/{id}", h.saveBookingPage)
+ r.Post("/settings/booking/{id}/publish", h.publishBookingPage)
})
mux := http.NewServeMux()
diff --git a/internal/calendar/static/booking.js b/internal/calendar/static/booking.js
new file mode 100644
index 0000000..00e6abb
--- /dev/null
+++ b/internal/calendar/static/booking.js
@@ -0,0 +1,95 @@
+// booking.js — the availability pane, and the zone preselect.
+//
+// Two enhancements, neither load-bearing. The pane asks the server what
+// the availability sentence means while the host types, and swaps the
+// answer in; it parses nothing, for the same reason quickadd.js parses
+// nothing — the server's parser is the one that decides what is saved.
+// The zone preselect picks the browser's zone on a public booking page
+// when nobody has chosen one yet. Without this file the form still
+// saves, the pane still fills in on the way back, and the page still
+// offers the host's zone.
+(function () {
+ "use strict";
+
+ var DEBOUNCE_MS = 220;
+
+ function setupPane(root) {
+ var text = root.querySelector("[cal-book-text]");
+ var pane = root.querySelector("[cal-book-pane]");
+ if (!text || !pane) return;
+
+ var timer = null;
+ var inflight = null;
+ var lastSent = null;
+
+ function ask() {
+ var value = text.value.trim();
+ if (value === lastSent) return;
+ lastSent = value;
+ if (inflight) inflight.abort();
+ var ctrl = new AbortController();
+ inflight = ctrl;
+
+ var body = new URLSearchParams();
+ body.set("text", value);
+ fetch("/settings/booking/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;
+ pane.innerHTML = html;
+ })
+ .catch(function (err) {
+ if (err && err.name === "AbortError") return;
+ pane.innerHTML = '<p cal-book-blank>Could not check that just now. Saving will still work.</p>';
+ });
+ }
+
+ text.addEventListener("input", function () {
+ if (timer) clearTimeout(timer);
+ timer = setTimeout(ask, DEBOUNCE_MS);
+ });
+ }
+
+ // The zone select on a public page: preselect the browser's zone, but
+ // only when it is one the page offers and nobody has chosen one yet
+ // (data-chosen is set when the URL carried a zone). A guess about a
+ // device, offered once; never a silent re-timing of a chosen value.
+ function setupZone() {
+ var select = document.querySelector("[cal-book-zone]");
+ if (!select || select.hasAttribute("data-chosen")) return;
+ var guess;
+ try {
+ guess = Intl.DateTimeFormat().resolvedOptions().timeZone;
+ } catch (e) {
+ return;
+ }
+ if (!guess) return;
+ for (var i = 0; i < select.options.length; i++) {
+ if (select.options[i].value === guess) {
+ select.value = guess;
+ return;
+ }
+ }
+ }
+
+ function init() {
+ var forms = document.querySelectorAll("[cal-book-form]");
+ for (var i = 0; i < forms.length; i++) setupPane(forms[i]);
+ setupZone();
+ }
+
+ if (document.readyState === "loading") {
+ document.addEventListener("DOMContentLoaded", init);
+ } else {
+ init();
+ }
+})();
diff --git a/internal/calendar/static/calendar.css b/internal/calendar/static/calendar.css
index 3480b78..e87b74e 100644
--- a/internal/calendar/static/calendar.css
+++ b/internal/calendar/static/calendar.css
@@ -461,3 +461,49 @@
color: var(--rst-text-faint);
font-size: var(--rst-fs-xs);
}
+
+/* ── Booking pages, the host's side ─────────────────────────────── */
+
+[cal-book-pane] {
+ background: var(--rst-surface-2);
+ border-radius: var(--rst-radius-sm);
+ font-size: var(--rst-fs-sm);
+ margin-block: var(--rst-sp-2) var(--rst-sp-4);
+ padding: var(--rst-sp-2) var(--rst-sp-3);
+}
+
+[cal-book-blank] { color: var(--rst-text-muted); margin: 0; }
+
+[cal-book-lines] { margin: 0; padding-inline-start: var(--rst-sp-4); }
+
+/* Problems first, and unmistakably: the sentence cannot be saved while
+ * one stands. */
+[cal-book-problems] {
+ color: var(--rst-tone-negative-fg);
+ margin: 0 0 var(--rst-sp-2);
+ padding-inline-start: var(--rst-sp-4);
+}
+
+[cal-book-page] { margin-block-end: var(--rst-sp-4); }
+
+[cal-book-page-body] { padding: var(--rst-sp-3); }
+
+[cal-book-url] { font-size: var(--rst-fs-sm); overflow-wrap: anywhere; }
+
+[cal-book-page-actions] {
+ align-items: flex-start;
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--rst-sp-3);
+ margin-block-start: var(--rst-sp-3);
+}
+
+/* The publish sentence sits above its own button and nowhere else. */
+[cal-book-caveat] {
+ color: var(--rst-text-muted);
+ font-size: var(--rst-fs-xs);
+ margin: 0 0 var(--rst-sp-2);
+ max-width: 40rem;
+}
+
+[cal-booked-when] { margin: 0; }
diff --git a/internal/calendar/store_booking_read.go b/internal/calendar/store_booking_read.go
index 82463a6..0e1d6f8 100644
--- a/internal/calendar/store_booking_read.go
+++ b/internal/calendar/store_booking_read.go
@@ -157,3 +157,16 @@ func (v BookingView) Cancelled() bool {
func (v BookingView) Live() bool {
return v.State == BookingConfirmed && !v.Cancelled()
}
+
+// BookingSummaryFor is what the host's own event page and form need to
+// know: that the event is booked, and by whom. The name, nothing else.
+func (s *Store) BookingSummaryFor(ctx context.Context, eventID int64) (name string, booked bool) {
+ b, ok, err := bookingFor(s.g.WithContext(ctx), eventID)
+ if err != nil || !ok {
+ return "", false
+ }
+ if n, err := s.seal.OpenString(seal.PurposeBookerName, b.BookerNameSealed); err == nil {
+ name = n
+ }
+ return name, true
+}
diff --git a/internal/calendar/templates/booking_page_form.html b/internal/calendar/templates/booking_page_form.html
new file mode 100644
index 0000000..61ace94
--- /dev/null
+++ b/internal/calendar/templates/booking_page_form.html
@@ -0,0 +1,69 @@
+{{define "title"}}{{.Title}}{{end}}
+{{define "content"}}
+<header rst-page-header>
+ <div rst-page-header-titles>
+ <h1>{{.Data.Heading}}</h1>
+ </div>
+</header>
+
+{{with .Data.Error}}
+{{template "callout" dict "Tone" "negative" "Body" . "Alert" true}}
+{{end}}
+
+<form rst-form method="post" action="{{.Data.Action}}" cal-book-form>
+ {{template "field" dict
+ "ID" "title" "Name" "title" "Label" "What is it?"
+ "Value" .Data.Title "Required" true "Autofocus" true
+ "Placeholder" "Quick chat"
+ "Error" (if_eq .Data.ErrorField "title" .Data.Error)}}
+
+ {{template "field-textarea" dict
+ "Name" "description" "Label" "A line or two for the person booking"
+ "Value" .Data.Description "Rows" 2
+ "Error" (if_eq .Data.ErrorField "description" .Data.Error)}}
+
+ {{/* The sentence. ONE parser reads it, on the server; booking.js
+ only asks that parser while you type. Scriptless, the pane
+ below is filled in when the form comes back. */}}
+ <div rst-field>
+ <label rst-field-label for="availability">When are you free?</label>
+ <textarea rst-textarea id="availability" name="availability" rows="2" cal-book-text
+ placeholder="mondays 12–3, tuesdays 12–2, 5–7">{{.Data.Availability}}</textarea>
+ <small rst-field-hint>Days and times, in your own zone. Commas between clauses; a clause with no day keeps the day before it.</small>
+ {{with (if_eq .Data.ErrorField "availability" .Data.Error)}}<small rst-field-error>{{.}}</small>{{end}}
+ </div>
+ <div id="cal-book-pane" cal-book-pane aria-live="polite">
+ {{template "bookingpane" .Data.Pane}}
+ </div>
+
+ {{template "field-select" dict
+ "ID" "duration_min" "Name" "duration_min" "Label" "How long is a slot?"
+ "Options" (list
+ (dict "Value" "15" "Label" "15 minutes" "Selected" (eq .Data.DurationMin "15"))
+ (dict "Value" "20" "Label" "20 minutes" "Selected" (eq .Data.DurationMin "20"))
+ (dict "Value" "30" "Label" "30 minutes" "Selected" (eq .Data.DurationMin "30"))
+ (dict "Value" "45" "Label" "45 minutes" "Selected" (eq .Data.DurationMin "45"))
+ (dict "Value" "60" "Label" "An hour" "Selected" (eq .Data.DurationMin "60"))
+ (dict "Value" "90" "Label" "An hour and a half" "Selected" (eq .Data.DurationMin "90"))
+ (dict "Value" "120" "Label" "Two hours" "Selected" (eq .Data.DurationMin "120")))
+ "Error" (if_eq .Data.ErrorField "duration_min" .Data.Error)}}
+
+ {{template "field" dict
+ "ID" "buffer_min" "Name" "buffer_min" "Label" "Minutes between slots"
+ "Value" .Data.BufferMin "Type" "number" "Min" "0" "Max" "120" "Short" true
+ "Error" (if_eq .Data.ErrorField "buffer_min" .Data.Error)}}
+
+ {{template "field" dict
+ "ID" "notice_min" "Name" "notice_min" "Label" "Minimum notice, in minutes"
+ "Value" .Data.NoticeMin "Type" "number" "Min" "0" "Max" "10080" "Short" true
+ "Help" "Nothing is ever offered less than ten minutes ahead."
+ "Error" (if_eq .Data.ErrorField "notice_min" .Data.Error)}}
+
+ {{template "field" dict
+ "ID" "horizon_days" "Name" "horizon_days" "Label" "How far ahead, in days"
+ "Value" .Data.HorizonDays "Type" "number" "Min" "1" "Max" "90" "Short" true
+ "Error" (if_eq .Data.ErrorField "horizon_days" .Data.Error)}}
+
+ {{template "form-foot" dict "Submit" .Data.Submit "CancelHref" "/settings/booking" "CancelLabel" "Cancel"}}
+</form>
+{{end}}
diff --git a/internal/calendar/templates/booking_pages.html b/internal/calendar/templates/booking_pages.html
new file mode 100644
index 0000000..9d7e32c
--- /dev/null
+++ b/internal/calendar/templates/booking_pages.html
@@ -0,0 +1,67 @@
+{{define "title"}}{{.Title}}{{end}}
+{{define "content"}}
+<header rst-page-header>
+ <div rst-page-header-titles>
+ <h1>Booking pages</h1>
+ <p rst-page-header-sub>A link you hand to somebody outside the team, so they can book a slot when you are free.</p>
+ </div>
+ <a rst-btn="primary" href="/settings/booking/new">New page</a>
+</header>
+
+{{if .Data.Saved}}
+{{template "callout" dict "Tone" "positive" "Body" "Saved."}}
+{{end}}
+
+{{if not .Data.Handle}}
+{{/* Said before the list, because every link below starts with it. */}}
+{{template "callout" dict "Tone" "warning"
+ "Title" "Choose a handle first"
+ "Body" "Choose a handle in Settings before publishing — it is the first part of every booking link."}}
+{{end}}
+
+{{if not .Data.Pages}}
+{{template "empty-state" dict "Title" "No booking pages yet"
+ "Body" "Make one, describe when you are free in plain words, and publish it when you are ready to share the link."}}
+{{end}}
+
+{{range .Data.Pages}}
+<section rst-box cal-book-page>
+ <header rst-box-head>
+ <h2>{{.Title}}</h2>
+ {{if .Published}}{{template "badge" dict "Tone" "positive" "Label" "Published"}}{{else}}{{template "badge" dict "Label" "Not published"}}{{end}}
+ </header>
+ <div cal-book-page-body>
+ {{with .Description}}<p>{{.}}</p>{{end}}
+ <p><strong>{{duration .DurationMin}}</strong> a slot.
+ {{if .BufferMin}}{{duration .BufferMin}} between slots.{{end}}
+ {{if .NoticeMin}}At least {{duration .NoticeMin}} ahead.{{end}}
+ Up to {{.HorizonDays}} days out.</p>
+ {{if .Lines}}
+ <ul cal-book-lines>{{range .Lines}}<li>{{.}}</li>{{end}}</ul>
+ {{else}}
+ <p cal-book-caveat>No windows yet, so this page offers nothing.</p>
+ {{end}}
+ {{with .PublicURL}}
+ <p cal-book-url><a href="{{.}}">{{.}}</a></p>
+ {{end}}
+ <div cal-book-page-actions>
+ <a rst-btn href="/settings/booking/{{.ID}}">Edit</a>
+ <form method="post" action="/settings/booking/{{.ID}}/publish">
+ {{if .Published}}
+ <input type="hidden" name="publish" value="0">
+ <button rst-btn type="submit">Unpublish</button>
+ {{else}}
+ <input type="hidden" name="publish" value="1">
+ {{/* The sentence is above the button, in full, every time. A
+ published page is a new disclosure — anyone can ask when
+ this person is free — and the button must not be pressed
+ without having read that (booking spec §7.4). */}}
+ <p cal-book-caveat>Anyone with this link can see when you are free — not what you are doing, but when — including time blocked by team calendars you can see.</p>
+ <button rst-btn="primary" type="submit"{{if not $.Data.Handle}} disabled{{end}}>Publish</button>
+ {{end}}
+ </form>
+ </div>
+ </div>
+</section>
+{{end}}
+{{end}}
diff --git a/internal/calendar/templates/event.html b/internal/calendar/templates/event.html
index 505732c..00bb875 100644
--- a/internal/calendar/templates/event.html
+++ b/internal/calendar/templates/event.html
@@ -13,6 +13,14 @@
{{end}}
</header>
+{{if .Data.Booked}}
+{{/* Booked through a booking page. The sentence says how to move it,
+ because the form will refuse to (booking spec B18, §10). */}}
+{{template "callout" dict "Tone" "info"
+ "Title" (printf "Booked by %s" (or .Data.BookedBy "a visitor"))
+ "Body" (printf "To move this meeting, cancel it and let %s rebook." (or .Data.BookedBy "them"))}}
+{{end}}
+
{{if eq .Data.Event.Status "cancelled"}}
{{template "callout" dict "Tone" "warning" "Body" "This event is cancelled. It stays on the calendar, struck through, so nobody wonders whether it was ever there."}}
{{end}}
diff --git a/internal/calendar/templates/event_form.html b/internal/calendar/templates/event_form.html
index 1e63cda..5ebe117 100644
--- a/internal/calendar/templates/event_form.html
+++ b/internal/calendar/templates/event_form.html
@@ -35,6 +35,26 @@
"Value" .Data.EventTitle "Required" true "Autofocus" true
"Error" (if_eq .Data.ErrorField "title" .Data.Error)}}
+ {{if .Data.Booked}}
+ {{/* A BOOKED meeting's scheduling belongs to the person who booked
+ it (booking spec B18). The controls are shown disabled, and the
+ stored values ride along as hidden inputs — a disabled control
+ submits nothing, and a title-only save has to submit exactly
+ what is stored to pass the comparison (B25). tz is the EVENT's
+ own, not the host's profile zone. */}}
+ <input type="hidden" name="start" value="{{.Data.StartLocal}}">
+ <input type="hidden" name="end" value="{{.Data.EndLocal}}">
+ <input type="hidden" name="tz" value="{{.Data.TZ}}">
+ <input type="hidden" name="kind" value="{{.Data.Kind}}">
+ <input type="hidden" name="repeat" value="none">
+ {{if .Data.AllDay}}<input type="hidden" name="all_day" value="1">{{end}}
+ <div rst-field cal-booked-lock>
+ <span rst-field-label>When</span>
+ <p cal-booked-when>{{.Data.StartLocal}} – {{.Data.EndLocal}} <small>({{.Data.TZ}})</small></p>
+ <small rst-field-hint>This is a booked meeting{{with .Data.BookedBy}} with {{.}}{{end}}. Its time is changed by the person who booked it, or by cancelling and letting them rebook.</small>
+ {{with (if_eq .Data.ErrorField "start" .Data.Error)}}<small rst-field-error>{{.}}</small>{{end}}
+ </div>
+ {{else}}
{{/* Three kinds, one radio group, and the descriptions say what the
difference actually is — because "meeting / commitment / note"
without them is three words nobody can choose between. They
@@ -76,6 +96,7 @@
{{template "field-date" dict
"Name" "repeat_end" "Label" "Repeat until" "Value" .Data.RepeatEnd
"Hint" "Leave empty for a series with no end."}}
+ {{end}}
{{template "field" dict
"ID" "location" "Name" "location" "Label" "Where" "Value" .Data.Location}}
@@ -89,6 +110,7 @@
{{template "field-select" dict
"ID" "status" "Name" "status" "Label" "Status"
+ "Error" (if_eq .Data.ErrorField "status" .Data.Error)
"Options" (list
(dict "Value" "confirmed" "Label" "Confirmed" "Selected" (eq .Data.Status "confirmed"))
(dict "Value" "tentative" "Label" "Tentative" "Selected" (eq .Data.Status "tentative"))
diff --git a/internal/calendar/templates/layout.html b/internal/calendar/templates/layout.html
index 56a5e4d..08b4304 100644
--- a/internal/calendar/templates/layout.html
+++ b/internal/calendar/templates/layout.html
@@ -18,6 +18,9 @@
<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>
+{{/* The booking availability pane and the public page's zone guess.
+ Both forms work without it. */}}
+<script defer src="{{asset "static/booking.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/settings.html b/internal/calendar/templates/settings.html
index 9d89e93..baf3fc0 100644
--- a/internal/calendar/templates/settings.html
+++ b/internal/calendar/templates/settings.html
@@ -27,6 +27,14 @@
"Options" (zoneOptions .Data.Person.Zone)
"Hint" "Every time on the calendar is shown in this zone. It is never guessed from your browser: a calendar that silently re-times itself because you opened it on holiday is a calendar that lies."}}
+ {{template "field" dict
+ "ID" "handle" "Name" "handle" "Label" "Your handle"
+ "Value" .Data.Person.Handle
+ "Placeholder" "paul" "Autocomplete" "off"
+ "Help" "Lowercase letters, digits and dashes. It is the first part of every booking link you share, so changing it changes the links."
+ "Error" (if_eq .Data.ErrorField "handle" .Data.Error)}}
+ <p><a href="/settings/booking">Booking pages &rarr;</a></p>
+
{{template "field-select" dict
"ID" "week_start" "Name" "week_start" "Label" "Weeks start on"
"Options" (weekStartOptions .Data.Person.WeekStart)}}
diff --git a/internal/calendartest/booking_settings_test.go b/internal/calendartest/booking_settings_test.go
new file mode 100644
index 0000000..9eeec3b
--- /dev/null
+++ b/internal/calendartest/booking_settings_test.go
@@ -0,0 +1,181 @@
+package calendartest
+
+import (
+ "context"
+ "net/http"
+ "net/url"
+ "regexp"
+ "strconv"
+ "strings"
+ "testing"
+ "time"
+)
+
+// The host's side of booking, through the router (booking spec §2.1,
+// §3.4, §7.4, B18, B25).
+
+func settingsForm(handle string) url.Values {
+ return url.Values{"zone": {"Europe/Dublin"}, "week_start": {"1"},
+ "default_reminder_min": {"10"}, "default_all_day_min": {"360"}, "handle": {handle}}
+}
+
+func pageForm() url.Values {
+ return url.Values{"title": {"Quick chat"}, "description": {"Fifteen minutes."},
+ "availability": {"mondays 12-3, tuesdays 12-2, 5-7"}, "duration_min": {"15"},
+ "buffer_min": {"10"}, "notice_min": {"120"}, "horizon_days": {"30"}}
+}
+
+var rePublicURL = regexp.MustCompile(`/book/paul/([a-z0-9]{8})`)
+
+func TestAHostMakesAPageAndSeesItsLinkOnlyOncePublished(t *testing.T) {
+ app, session := signedIn(t, "host@example.com")
+ h := app.Handler()
+ if rec := postAs(t, h, session, "/settings", settingsForm("paul")); rec.Code != http.StatusSeeOther {
+ t.Fatalf("settings: %d\n%s", rec.Code, rec.Body.String())
+ }
+ if rec := postAs(t, h, session, "/settings/booking", pageForm()); rec.Code != http.StatusSeeOther {
+ t.Fatalf("create: %d\n%s", rec.Code, rec.Body.String())
+ }
+ body := getAs(t, h, session, "/settings/booking").Body.String()
+ for _, want := range []string{"Quick chat", "Not published", "Tuesday 12:00–14:00, 17:00–19:00", "Anyone with this link can see when you are free"} {
+ if !strings.Contains(body, want) {
+ t.Errorf("list lacks %q", want)
+ }
+ }
+ if rePublicURL.MatchString(body) {
+ t.Error("an unpublished page shows a public URL")
+ }
+ if rec := postAs(t, h, session, "/settings/booking/1/publish", url.Values{"publish": {"1"}}); rec.Code != http.StatusSeeOther {
+ t.Fatalf("publish: %d", rec.Code)
+ }
+ body = getAs(t, h, session, "/settings/booking").Body.String()
+ if !rePublicURL.MatchString(body) || !strings.Contains(body, "Published") {
+ t.Errorf("a published page shows no public URL:\n%s", body)
+ }
+}
+
+func TestAPageWithAnUnreadableSentenceIsNotSaved(t *testing.T) {
+ app, session := signedIn(t, "host@example.com")
+ h := app.Handler()
+ postAs(t, h, session, "/settings", settingsForm("paul"))
+ form := pageForm()
+ form.Set("availability", "mondays whenever")
+ rec := postAs(t, h, session, "/settings/booking", form)
+ if rec.Code != http.StatusUnprocessableEntity {
+ t.Fatalf("status %d, want 422", rec.Code)
+ }
+ if !strings.Contains(rec.Body.String(), "whenever") {
+ t.Error("the refusal does not quote the clause")
+ }
+ if strings.Contains(getAs(t, h, session, "/settings/booking").Body.String(), "Quick chat") {
+ t.Error("the page was saved anyway")
+ }
+}
+
+func TestThePaneFragmentDescribesTheWindows(t *testing.T) {
+ app, session := signedIn(t, "host@example.com")
+ rec := postAs(t, app.Handler(), session, "/settings/booking/parse", url.Values{"text": {"fri 9-5"}})
+ body := rec.Body.String()
+ if !strings.Contains(body, "Friday 09:00–17:00") || strings.Contains(strings.ToLower(body), "<html") {
+ t.Errorf("pane:\n%s", body)
+ }
+}
+
+func TestAnotherPersonsBookingPageIs404(t *testing.T) {
+ app, mine := signedIn(t, "me@example.com")
+ h := app.Handler()
+ postAs(t, h, mine, "/settings", settingsForm("me"))
+ postAs(t, h, mine, "/settings/booking", pageForm())
+ theirs := app.SignInForTest(t, "them@example.com")
+ for _, c := range []struct {
+ method, path string
+ }{{"GET", "/settings/booking/1"}, {"POST", "/settings/booking/1"}, {"POST", "/settings/booking/1/publish"}} {
+ var rec = getAs(t, h, theirs, c.path)
+ if c.method == "POST" {
+ rec = postAs(t, h, theirs, c.path, pageForm())
+ }
+ if rec.Code != http.StatusNotFound {
+ t.Errorf("%s %s as another person: %d, want 404", c.method, c.path, rec.Code)
+ }
+ }
+}
+
+func TestATakenHandleIsRefusedOnSettings(t *testing.T) {
+ app, a := signedIn(t, "a@example.com")
+ h := app.Handler()
+ postAs(t, h, a, "/settings", settingsForm("paul"))
+ b := app.SignInForTest(t, "b@example.com")
+ rec := postAs(t, h, b, "/settings", settingsForm("paul"))
+ if rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), "taken") {
+ t.Errorf("second claim: %d\n%s", rec.Code, rec.Body.String())
+ }
+}
+
+// A booked event's form: the scheduling controls are disabled and their
+// stored values ride along hidden, so a scriptless title-only save
+// works — and still works after the host changes their profile zone,
+// because the hidden tz is the event's own (B25).
+func TestABookedEventsFormLocksItsTimeButSavesItsTitle(t *testing.T) {
+ app, host, page := hostWithPage(t)
+ h := app.Handler()
+ session := app.SignInForTest(t, host.Address)
+ st := app.StoreForTest()
+ now := time.Now()
+ slot := slotsFor(t, app, page, host, now, 3)[0]
+ b, err := st.HoldBooking(context.Background(), holdInput(page, host, slot, "doc@tito.io", "ip"), now)
+ if err != nil {
+ t.Fatal(err)
+ }
+ st.ConfirmBooking(context.Background(), b.ID, now)
+
+ edit := "/events/" + strconv.FormatInt(b.EventID, 10) + "/edit"
+ body := getAs(t, h, session, edit).Body.String()
+ for _, want := range []string{`name="tz" value="UTC"`, `name="start" value="`, "This is a booked meeting", "Doc"} {
+ if !strings.Contains(body, want) {
+ t.Errorf("the booked form lacks %q", want)
+ }
+ }
+ // The host moves zone; a title-only save still passes.
+ postAs(t, h, session, "/settings", settingsForm("paul"))
+ form := formValuesFrom(body)
+ form.Set("title", "Renamed by the host")
+ rec := postAs(t, h, session, "/events/"+strconv.FormatInt(b.EventID, 10), form)
+ if rec.Code != http.StatusSeeOther {
+ t.Fatalf("title-only save: %d\n%s", rec.Code, rec.Body.String())
+ }
+ // And a stale form with the old time after a public reschedule is refused.
+ slots := slotsFor(t, app, page, host, now, 3)
+ v, _ := st.BookingByID(context.Background(), b.ID, false)
+ if changed, err := st.RescheduleBooking(context.Background(), b.ID, slots[3].StartUTC, slots[3].EndUTC, v.Event.Sequence, now); err != nil || !changed {
+ t.Fatalf("reschedule: changed=%v err=%v", changed, err)
+ }
+ rec = postAs(t, h, session, "/events/"+strconv.FormatInt(b.EventID, 10), form)
+ if rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), "booked meeting") {
+ t.Errorf("stale form after a public reschedule: %d", rec.Code)
+ }
+ // The event page says how to move it.
+ if page := getAs(t, h, session, "/events/"+strconv.FormatInt(b.EventID, 10)).Body.String(); !strings.Contains(page, "cancel it and let Doc rebook") {
+ t.Error("the event page does not say how a booked meeting is moved")
+ }
+}
+
+// formValuesFrom pulls every hidden input off a rendered form, plus the
+// fields a title-only save needs, the way a browser would submit them.
+func formValuesFrom(body string) url.Values {
+ form := url.Values{}
+ for _, m := range regexp.MustCompile(`name="([a-z_]+)" value="([^"]*)"`).FindAllStringSubmatch(body, -1) {
+ if form.Get(m[1]) == "" {
+ form.Set(m[1], m[2])
+ }
+ }
+ // The status select's selected option.
+ if m := regexp.MustCompile(`<option value="(confirmed|tentative|cancelled)" selected`).FindStringSubmatch(body); m != nil {
+ form.Set("status", m[1])
+ }
+ if m := regexp.MustCompile(`name="calendar_id"[^>]*>[\s\S]*?<option value="(\d+)" selected`).FindStringSubmatch(body); m != nil {
+ form.Set("calendar_id", m[1])
+ }
+ form.Set("title", "Renamed")
+ form.Del("all_day")
+ return form
+}