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>
20 files changed,
+847
−49
.impeccable/hook.cache.json+1 −1internal/calendar/funcs.go+1 −0internal/calendar/handlers_booking_form.go+145 −0internal/calendar/handlers_booking_settings.go+72 −0internal/calendar/handlers_event_form.go+19 −3internal/calendar/handlers_events.go+28 −6internal/calendar/handlers_misc.go+0 −39internal/calendar/handlers_settings.go+58 −0internal/calendar/render.go+1 −0internal/calendar/routes.go+10 −0internal/calendar/static/booking.js+95 −0internal/calendar/static/calendar.css+46 −0internal/calendar/store_booking_read.go+13 −0internal/calendar/templates/booking_page_form.html+69 −0internal/calendar/templates/booking_pages.html+67 −0internal/calendar/templates/event.html+8 −0internal/calendar/templates/event_form.html+22 −0internal/calendar/templates/layout.html+3 −0internal/calendar/templates/settings.html+8 −0internal/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 →</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 |
| +} |