oficina / calendar Public

booking: implementation plan against spec revision 2

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev c48c1e2ed114f376ea05aee31c1905d52d288763 parent 0999c0d
1 file changed, +445 −909
  • docs/superpowers/plans/2026-09-07-booking-plan.md +445 −909
diff --git a/docs/superpowers/plans/2026-09-07-booking-plan.md b/docs/superpowers/plans/2026-09-07-booking-plan.md
index d98b108..9715804 100644
--- a/docs/superpowers/plans/2026-09-07-booking-plan.md
+++ b/docs/superpowers/plans/2026-09-07-booking-plan.md
@@ -2,33 +2,34 @@
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
-**Goal:** A public "book time with me" page per host, with plain-text availability, email-link confirmation, a tokenised ICS, and booker-side reschedule and cancel.
+**Goal:** A public "book time with me" page per host, with plain-text availability, a mailed link whose button confirms, a tokenised ICS download, and booker-side reschedule and cancel.
-**Architecture:** Booking pages and their parsed windows are new tables owned by a person. A hold is a TENTATIVE event on the host's personal calendar plus a `bookings` row keyed by a hashed token; the existing range query is what makes a hold block a slot. Everything public lives beside `/feed/{token}.ics` in the chi router — inside CSRF, outside the session group. Holds expire on the existing tick.
+**Architecture:** Booking pages and their parsed windows are new tables owned by a person. A hold is a TENTATIVE event on the host's personal calendar plus a `bookings` row keyed by a hashed token; the existing range query is what makes a hold block a slot. Hold creation and rescheduling serialise on one process-wide mutex. Every state change is a conditional UPDATE that checks `RowsAffected`. Mail is an outbox column the existing drain retries. Public routes live beside `/feed/{token}.ics` — inside CSRF, outside the session group.
-**Tech Stack:** Go 1.2x, rastrillo v0.23.0 (chi router, GORM + modernc SQLite, `mail.Sender`, `csrf.Protect`, `carlos.Tick`), html/template, Playwright for the browser gate.
+**Tech Stack:** Go, rastrillo v0.23.0 (chi, GORM + modernc SQLite, `mail.Sender`, `csrf.Protect`, `carlos.Tick`), html/template + text/template, Playwright for the browser gate.
-**Spec:** `docs/superpowers/specs/2026-09-07-booking-design.md` — read it first, in full. Every task below cites the section it implements. Where this plan and the spec disagree, the spec wins and this plan is wrong.
+**Spec:** `docs/superpowers/specs/2026-09-07-booking-design.md` **revision 2**. Read it first, in full, including §12. Every task cites the sections it implements. Where this plan and the spec disagree, the spec wins and this plan is wrong — fix the plan in the same commit.
## Global Constraints
Copied from AGENTS.md and the spec. Every task inherits them.
-- **The one rule.** Booker name, address, note, page title, description and availability text are SEALED (`s.seal.SealString`). The booker address ALSO gets `s.seal.Fingerprint(seal.PurposeAttendeeLookup, addr)`. Windows (weekday, start_min, end_min) and `booker_zone` are plaintext. Tokens are stored as `HashFeedToken(token)` only.
-- **No title, place, note or address in any log line, error string or metric label.** Use `scrubErr(err)` on every logged error and a `"stage"` key.
-- **A row that isn't yours 404s, never 403s.** Unknown handle, slug or token → `http.NotFound`.
-- **Never bind a request body onto a model.** Explicit `Input` structs and explicit column allowlists in every `Updates`.
+- **The one rule.** Booker name, address, note, page title, description and availability text are SEALED (`s.seal.SealString`). The booker address ALSO gets `s.seal.Fingerprint(seal.PurposeAttendeeLookup, addr)`. Windows, `booker_zone`, the page `code` and `ip_fp` are plaintext. Tokens are stored as `HashFeedToken(token)` only.
+- **No title, place, note, address or token in any log line, error string or metric label.** `scrubErr(err)` on every logged error, with a `"stage"` key.
+- **A row that isn't yours 404s, never 403s.** Unknown handle, code or token → the same 404 the router gives an unknown route (`http.NotFound` is NOT it — see Task 6 for `notFoundLikeTheRouter`).
+- **Never bind a request body onto a model.** Explicit `Input` structs; explicit column allowlists in every `Updates`.
+- **Every booking transition is a conditional UPDATE on `state` that checks `RowsAffected == 1`.** Zero rows is "someone moved it first" — re-read and render, never retry.
+- **Hold and reschedule run inside `Store.holdMu`** (a `sync.Mutex` on `Store`), with the slot check inside the lock.
- **Never `sessions.UserID`.** Use `CurrentPerson(r)`.
-- **Migrations are generated**, never hand-written: delete `internal/calendar/migrations/schema.sql`, add the model to `Models`, run `go run github.com/carlosframework/rastrillo/cmd/rastrillo migration generate`, which writes the numbered file AND refreshes `schema.sql`. Never edit a shipped migration.
-- **300-line cap on every `.go` and `.js` file** (`internal/guardstest`). Split files by responsibility before they get there.
-- **`rst-*` attribute grammar**, never class spellings, except the seven class-only utilities (`rst-sr-only`, `rst-mono`, `rst-grow`, `rst-m-hide`, `rst-danger`, `rst-nm`, `rst-cell-mut`). This app's components take `cal-`.
-- **No affordance may require script.** Every page below works with JavaScript off; `booking.js` is a debounced fetch and nothing else.
-- **Every range is half-open.** `[start, end)`. Use `DayBounds`, never `Add(24h)`.
-- **Time is `start_local` + `tz`; UTC is derived.** Slots are computed in the host's zone.
-- **Work inside the request.** No goroutines for mail or sweeps.
-- **`make ci` before every commit you push**: `go vet`, `gofmt`, `go test ./...`, `migration check`. Run go commands with the sandbox off if the build cache is read-only.
-- **Commit trailer:** every commit ends with `Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>`.
-- **Test package:** tests live in `internal/calendartest` (package `calendartest`, imports `calendar "amadan.net/oficina/calendar/internal/calendar"`) unless they need unexported names, in which case they are `_test.go` files in `internal/calendar`. Helpers already there: `signedIn(t, addr)`, `getAs`, `postAs`, `follow`, `newAppWithMailer(t, *pickyMailer)`, `tick(t, h)`, `app.LastMailForTest()`, `app.GrepDatabaseForTest(t, needle)`, `app.DBForTest()`, `calendar.RequestForTest(session, method, target, form)`.
+- **Migrations are generated**: delete `internal/calendar/migrations/schema.sql`, add the model to `Models`, run `go run github.com/carlosframework/rastrillo/cmd/rastrillo migration generate`, then `... migration check`. Never edit a shipped migration.
+- **300-line cap on every `.go` and `.js` file** (`internal/guardstest`). Split by responsibility.
+- **`rst-*` attribute grammar**, never class spellings, except the seven class-only utilities. This app's components take `cal-`. Partials available from rastrillo: `field-text`, `field-textarea`, `field-select`, `field-check`, `form-foot`, `callout`, `page-header`, `detail-list`, `empty-state`, `badge` — see `settings.html` for the `dict` convention and the module cache `ui/partials/*.html` for exact keys (`field-textarea` takes `Name Label Value Rows Hint Error Required`).
+- **No affordance may require script.** `booking.js` is a debounced fetch plus a zone preselect and nothing else.
+- **Every range is half-open.** Use `DayBounds`, never `Add(24h)` for a day.
+- **Public zone input is bounded to `CommonZones`** (`zones.go`).
+- **Work inside the request.** No goroutines.
+- **`make ci` before every commit.** Run go with the sandbox off if the build cache is read-only. Commit trailer: `Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>`.
+- **Tests** live in `internal/calendartest` unless they need unexported names. Helpers there: `signedIn`, `getAs`, `postAs`, `follow`, `newAppWithMailer(t, *pickyMailer)`, `tick(t, h)`, `app.LastMailForTest()`, `app.GrepDatabaseForTest`, `app.DBForTest()`, `app.SignInForTest`, `calendar.RequestForTest(session, method, target, form)`. `pickyMailer.Send` refuses when `subject+body` contains `refuse`; Task 6 extends it to check `to` as well.
---
@@ -36,50 +37,44 @@ Copied from AGENTS.md and the spec. Every task inherits them.
| File | Responsibility | Task |
|---|---|---|
-| `internal/calendar/models_booking.go` | `BookingPage`, `BookingWindow`, `Booking` structs; state constants | 1 |
-| `internal/calendar/models.go` | `Person.Handle`; register three models | 1 |
-| `internal/calendar/seal/seal.go` | new purposes | 1 |
-| `internal/calendar/migrations/000N_*.sql`, `schema.sql` | generated | 1 |
-| `internal/calendar/store_booking_page.go` | handle, page CRUD, public lookup | 1 |
-| `internal/calendar/avail.go` | `ParseAvailability`, `DescribeWindows` | 2 |
-| `internal/calendar/quickadd_when.go` | export nothing; `reRange`, `applyMeridiem`, `clock`, `weekdayAlt`, `weekdays` are already package-level and reusable | 2 |
-| `internal/calendar/slots.go` | `Window`, `Slot`, `Busy`, `SlotRule`, `ComputeSlots` (pure) | 3 |
+| `internal/calendar/models_booking.go` | `BookingPage`, `BookingWindow`, `Booking`, `Window`, state + mail constants | 1 |
+| `internal/calendar/models.go` | `Person.Handle`; register models | 1 |
+| `internal/calendar/seal/seal.go` | purposes | 1 |
+| `internal/calendar/migrations/0005_*.sql`, `schema.sql` | generated | 1 |
+| `internal/calendar/store_booking_page.go` | handle, page CRUD, publish, public lookup | 1 |
+| `internal/calendar/testing.go` | `StoreForTest`, `PersonByAddressForTest` | 1 |
+| `internal/calendar/clock.go` | `Zone()` cache made safe, failures uncached | 2 |
+| `internal/calendar/avail.go`, `avail_windows.go` | parser; merge + describe | 2 |
+| `internal/calendar/templates/_bookingpane.html` | pane fragment | 2 |
+| `internal/calendar/slots.go` | `Slot`, `Busy`, `SlotRule`, `ComputeSlots` | 3 |
| `internal/calendar/store_slots.go` | `BusyFor`, `FreeSlots` | 3 |
-| `internal/calendar/store_booking_flow.go` | hold / by-token / confirm / reschedule / cancel / expire / caps | 4 |
+| `internal/calendar/store_booking_flow.go` | hold, confirm, reschedule, cancel | 4 |
+| `internal/calendar/store_booking_read.go` | `BookingByToken`, `BookingView`, `bookingByEvent` | 4 |
+| `internal/calendar/store_booking_sweep.go` | expire, owed mail, forget IPs, counts, `NextHoldExpiry` | 4 |
| `internal/calendar/store_reminder_next.go` | `NextDueAt` widened | 4 |
-| `internal/calendar/handlers_booking_settings.go` | host settings handlers | 5 |
-| `internal/calendar/templates/booking_pages.html`, `booking_page_form.html`, `_bookingpane.html` | host UI | 5 |
-| `internal/calendar/templates/settings.html` | handle field + link | 5 |
-| `internal/calendar/static/booking.js` | pane fetch; zone preselect | 5, 6 |
-| `internal/calendar/handlers_booking_public.go` | page GET/POST (hold) | 6 |
-| `internal/calendar/handlers_booking_manage.go` | confirm/manage/reschedule/cancel | 6 |
-| `internal/calendar/booking_ics.go` | one-VEVENT ICS with METHOD | 6 |
-| `internal/calendar/booking_mail.go` + `templates/mail/*.txt` | four mails | 6 |
-| `internal/calendar/templates/book_page.html`, `book_sent.html`, `book_manage.html`, `book_gone.html` | public UI | 6 |
-| `internal/calendar/handlers_jobs.go`, `store_drain.go` | sweep step | 7 |
-| `internal/calendar/routes.go` | all routes | 5, 6, 7 |
-| `internal/calendar/render.go` | parse new templates; `_bookingpane.html` into `fragments` | 5, 6 |
-| `internal/calendar/static/calendar.css` | `cal-book*` block | 5, 6 |
-| `internal/calendartest/avail_test.go`, `slots_test.go`, `booking_http_test.go`, `booking_sweep_test.go` | tests | 2–7 |
-| `internal/calendartest/events_test.go` | leak test fixture | 7 |
-| `test/browser/quickadd.mjs` → add `booking.mjs` and a Go driver | browser passes | 7 |
-| `AGENTS.md` | §11 of the spec | 8 |
+| `internal/calendar/store_event.go` | `UpdateEvent` refuses RRule on booked; `DeleteEvent` cancels booking | 4 |
+| `internal/calendar/handlers_booking_settings.go`, `handlers_booking_form.go` | host UI | 5 |
+| `internal/calendar/templates/booking_pages.html`, `booking_page_form.html`, `settings.html` | host UI | 5 |
+| `internal/calendar/static/booking.js` | pane fetch; zone preselect | 5 |
+| `internal/calendar/handlers_booking_public.go`, `handlers_booking_manage.go`, `handlers_booking_util.go` | public flow | 6 |
+| `internal/calendar/booking_ics.go`, `booking_mail.go`, `templates/mail/*.txt` | ICS + mail | 6 |
+| `internal/calendar/templates/book_page.html`, `book_sent.html`, `book_hold.html`, `book_manage.html`, `book_gone.html` | public UI | 6 |
+| `internal/calendar/handlers_jobs.go` | sweep steps in `drain`/`DrainOnRequest` | 7 |
+| `internal/calendar/routes.go`, `render.go`, `templates/layout.html`, `static/calendar.css` | wiring | 5, 6 |
+| `internal/calendartest/booking_store_test.go`, `avail_test.go`, `slots_test.go`, `booking_flow_test.go`, `booking_http_test.go`, `booking_abuse_test.go`, `booking_sweep_test.go`, `zone_test.go` | tests | 1–7 |
+| `internal/calendartest/events_test.go` | leak fixture | 7 |
+| `test/browser/booking.mjs`, `internal/calendartest/playwright_test.go`, `Makefile`, `package.json` | browser passes | 7 |
+| `AGENTS.md` | §11 | 8 |
---
-### Task 1: Models, migrations, seal purposes, page store
+### Task 1: Models, migration, purposes, page store
-Spec §2. Everything later depends on these names.
+Spec §2.1–2.3, §7.3 (publish).
-**Files:**
-- Create: `internal/calendar/models_booking.go`
-- Modify: `internal/calendar/models.go` (Person struct ~line 37; `Models` slice line 10)
-- Modify: `internal/calendar/seal/seal.go` (purpose constants ~line 41)
-- Create: `internal/calendar/store_booking_page.go`
-- Generate: `internal/calendar/migrations/0005_*.sql`, `schema.sql`
-- Test: `internal/calendartest/booking_store_test.go`
+**Files:** create `models_booking.go`, `store_booking_page.go`; modify `models.go`, `seal/seal.go`, `testing.go`; generate migration; test `booking_store_test.go`.
-**Interfaces — Produces:**
+**Produces:**
```go
// models_booking.go
@@ -88,20 +83,38 @@ const (
BookingConfirmed = "confirmed"
BookingCancelled = "cancelled"
BookingExpired = "expired"
+ BookingFailed = "failed"
+
+ MailNone = ""
+ MailHold = "hold"
+ MailConfirmed = "confirmed"
+ MailRescheduled = "rescheduled"
+ MailCancelled = "cancelled"
+
+ HoldTTL = 30 * time.Minute
+ MaxReschedules = 5
+ IPForgetAfter = 24 * time.Hour
)
+// Window is a weekly availability window, the plain value BookingWindow rows are read into.
+type Window struct {
+ Weekday time.Weekday
+ StartMin int
+ EndMin int
+}
+
type BookingPage struct {
- ID int64 `gorm:"primaryKey"`
- PersonID int64 `gorm:"column:person_id;not null;uniqueIndex:idx_booking_page_slug"`
- Slug string `gorm:"column:slug;not null;uniqueIndex:idx_booking_page_slug"`
- TitleSealed []byte `gorm:"column:title_sealed"`
- DescriptionSealed []byte `gorm:"column:description_sealed"`
- DurationMin int `gorm:"column:duration_min;not null"`
- BufferMin int `gorm:"column:buffer_min;not null"`
- NoticeMin int `gorm:"column:notice_min;not null"`
- HorizonDays int `gorm:"column:horizon_days;not null"`
- AvailabilitySealed []byte `gorm:"column:availability_text_sealed"`
- Active bool `gorm:"column:active;not null"`
+ ID int64 `gorm:"primaryKey"`
+ PersonID int64 `gorm:"column:person_id;not null;uniqueIndex:idx_booking_page_code"`
+ Code string `gorm:"column:code;not null;uniqueIndex:idx_booking_page_code"`
+ TitleSealed []byte `gorm:"column:title_sealed"`
+ DescriptionSealed []byte `gorm:"column:description_sealed"`
+ DurationMin int `gorm:"column:duration_min;not null"`
+ BufferMin int `gorm:"column:buffer_min;not null"`
+ NoticeMin int `gorm:"column:notice_min;not null"`
+ HorizonDays int `gorm:"column:horizon_days;not null"`
+ AvailabilitySealed []byte `gorm:"column:availability_text_sealed"`
+ PublishedAt *int64 `gorm:"column:published_at"`
CreatedAt time.Time `gorm:"column:created_at;not null"`
UpdatedAt time.Time `gorm:"column:updated_at;not null"`
}
@@ -109,33 +122,35 @@ type BookingPage struct {
type BookingWindow struct {
ID int64 `gorm:"primaryKey"`
PageID int64 `gorm:"column:page_id;not null;index"`
- Weekday int `gorm:"column:weekday;not null"` // 0 = Sunday, time.Weekday
+ Weekday int `gorm:"column:weekday;not null"`
StartMin int `gorm:"column:start_min;not null"`
EndMin int `gorm:"column:end_min;not null"`
}
type Booking struct {
- ID int64 `gorm:"primaryKey"`
- PageID int64 `gorm:"column:page_id;not null;index"`
- EventID *int64 `gorm:"column:event_id;index"`
- TokenHash string `gorm:"column:token_hash;not null;uniqueIndex"`
- State string `gorm:"column:state;not null;index"`
- BookerNameSealed []byte `gorm:"column:booker_name_sealed"`
- BookerAddressSealed []byte `gorm:"column:booker_address_sealed"`
- BookerAddressFP string `gorm:"column:booker_address_fp;not null;index"`
- NoteSealed []byte `gorm:"column:note_sealed"`
- BookerZone string `gorm:"column:booker_zone;not null"`
- HoldExpiresAt int64 `gorm:"column:hold_expires_at;not null;index"`
- ConfirmedAt *int64 `gorm:"column:confirmed_at"`
- CancelledAt *int64 `gorm:"column:cancelled_at"`
- Sequence int `gorm:"column:sequence;not null"`
- CreatedIPHash string `gorm:"column:created_ip_hash;not null;index"`
+ ID int64 `gorm:"primaryKey"`
+ PageID int64 `gorm:"column:page_id;not null;index"`
+ EventID int64 `gorm:"column:event_id;not null;index"`
+ TokenHash string `gorm:"column:token_hash;not null;uniqueIndex"`
+ State string `gorm:"column:state;not null;index"`
+ BookerNameSealed []byte `gorm:"column:booker_name_sealed"`
+ BookerAddressSealed []byte `gorm:"column:booker_address_sealed"`
+ BookerAddressFP string `gorm:"column:booker_address_fp;not null;index"`
+ NoteSealed []byte `gorm:"column:note_sealed"`
+ BookerZone string `gorm:"column:booker_zone;not null"`
+ HoldExpiresAt int64 `gorm:"column:hold_expires_at;not null;index"`
+ ConfirmedAt *int64 `gorm:"column:confirmed_at"`
+ CancelledAt *int64 `gorm:"column:cancelled_at"`
+ PendingMail string `gorm:"column:pending_mail;not null"`
+ MailAttempts int `gorm:"column:mail_attempts;not null"`
+ IPFP *string `gorm:"column:ip_fp;index"`
+ Reschedules int `gorm:"column:reschedules;not null"`
CreatedAt time.Time `gorm:"column:created_at;not null"`
UpdatedAt time.Time `gorm:"column:updated_at;not null"`
}
-// models.go — on Person, after Zone:
-Handle *string `gorm:"column:handle;uniqueIndex"` // nil = no public pages. SQLite's UNIQUE admits many NULLs.
+// models.go, on Person after Zone:
+Handle *string `gorm:"column:handle;uniqueIndex"`
// seal/seal.go
PurposeBookingTitle = "booking.title"
@@ -144,40 +159,41 @@ PurposeBookingAvailability = "booking.availability"
PurposeBookerName = "booking.booker.name"
PurposeBookerAddress = "booking.booker.address"
PurposeBookerNote = "booking.booker.note"
-PurposeBookingIP = "booking.ip" // Fingerprint only, never Seal
+PurposeBookingIP = "booking.ip"
// store_booking_page.go
type BookingPageInput struct {
- Slug, Title, Description, Availability string
+ Title, Description, Availability string
DurationMin, BufferMin, NoticeMin, HorizonDays int
- Active bool
}
type BookingPageView struct {
BookingPage
Title, Description, Availability string
- Windows []Window // from slots.go, Task 3 — declare Window in models_booking.go so Task 1 compiles alone
+ Windows []Window
+ Published bool
}
var ErrHandleTaken = errors.New("calendar: handle taken")
-var reservedHandles = map[string]bool{"m": true, "new": true, "static": true}
-
-func ValidateHandle(h string) error // [a-z0-9-]{2,32}, not reserved → ValidationError{"handle", ...}
-func ValidateBookingPageInput(in *BookingPageInput) error // trims; slug [a-z0-9-]{1,40}; ranges per spec §2.2
-func (s *Store) SaveHandle(ctx, personID int64, handle string) error // "" clears; unique violation → ErrHandleTaken
-func (s *Store) PersonByHandle(ctx, handle string) (Person, error) // ErrNotFound
-func (s *Store) CreateBookingPage(ctx, personID int64, in BookingPageInput, windows []Window) (BookingPage, error)
-func (s *Store) UpdateBookingPage(ctx, personID, pageID int64, in BookingPageInput, windows []Window) error // 404 if not owner; rewrites windows in the same transaction
-func (s *Store) SetBookingPageActive(ctx, personID, pageID int64, active bool) error
-func (s *Store) BookingPages(ctx, personID int64) ([]BookingPageView, error)
-func (s *Store) BookingPage(ctx, personID, pageID int64) (BookingPageView, error) // ErrNotFound
-func (s *Store) PublicBookingPage(ctx, handle, slug string) (BookingPageView, Person, error) // active only; ErrNotFound for every miss
-```
-Put `type Window struct { Weekday time.Weekday; StartMin, EndMin int }` in `models_booking.go` (not a GORM model — it is the plain value `BookingWindow` rows are read into).
+func ValidateHandle(h string) error // ^[a-z0-9-]{2,32}$, not in {m,new,static}; ValidationError{"handle", msg}
+func ValidateBookingPageInput(in *BookingPageInput) error // trims; title 1..120; description <= 1000; duration 5..480; buffer 0..120; notice 0..10080; horizon 1..90
+func newPageCode() string // 8 chars of [a-z0-9] from crypto/rand
+func (s *Store) SaveHandle(ctx context.Context, personID int64, handle string) error
+func (s *Store) PersonByHandle(ctx context.Context, handle string) (Person, error)
+func (s *Store) CreateBookingPage(ctx context.Context, personID int64, in BookingPageInput, windows []Window) (BookingPage, error)
+func (s *Store) UpdateBookingPage(ctx context.Context, personID, pageID int64, in BookingPageInput, windows []Window) error
+func (s *Store) PublishBookingPage(ctx context.Context, personID, pageID int64, publish bool) error
+func (s *Store) BookingPages(ctx context.Context, personID int64) ([]BookingPageView, error)
+func (s *Store) BookingPage(ctx context.Context, personID, pageID int64) (BookingPageView, error)
+func (s *Store) PublicBookingPage(ctx context.Context, handle, code string) (BookingPageView, Person, error) // published only; ErrNotFound for every miss
+
+// testing.go
+func (a *App) StoreForTest() *Store
+func (a *App) PersonByAddressForTest(t TB, address string) Person
+```
-- [ ] **Step 1: Write the failing store test**
+- [ ] **Step 1: Failing tests** — `internal/calendartest/booking_store_test.go`:
```go
-// internal/calendartest/booking_store_test.go
package calendartest
import (
@@ -188,33 +204,39 @@ import (
calendar "amadan.net/oficina/calendar/internal/calendar"
)
-func TestABookingPageRoundTripsWithItsWindows(t *testing.T) {
+func TestABookingPageRoundTripsSealedWithItsWindows(t *testing.T) {
app, _ := signedIn(t, "host@example.com")
ctx := context.Background()
- host, err := app.StoreForTest().PersonByAddress(ctx, "host@example.com")
- if err != nil {
- t.Fatal(err)
- }
- if err := app.StoreForTest().SaveHandle(ctx, host.ID, "paul"); err != nil {
+ st := app.StoreForTest()
+ host := app.PersonByAddressForTest(t, "host@example.com")
+ if err := st.SaveHandle(ctx, host.ID, "paul"); err != nil {
t.Fatalf("SaveHandle: %v", err)
}
- in := calendar.BookingPageInput{
- Slug: "chat", Title: "Quick chat", Description: "Fifteen minutes.",
- Availability: "mondays 12-3", DurationMin: 15, BufferMin: 10, NoticeMin: 120, HorizonDays: 30, Active: true,
- }
- windows := []calendar.Window{{Weekday: time.Monday, StartMin: 12 * 60, EndMin: 15 * 60}}
- page, err := app.StoreForTest().CreateBookingPage(ctx, host.ID, in, windows)
+ in := calendar.BookingPageInput{Title: "Quick chat", Description: "Fifteen minutes.",
+ Availability: "mondays 12-3", DurationMin: 15, BufferMin: 10, NoticeMin: 120, HorizonDays: 30}
+ windows := []calendar.Window{{Weekday: time.Monday, StartMin: 720, EndMin: 900}}
+ page, err := st.CreateBookingPage(ctx, host.ID, in, windows)
if err != nil {
t.Fatalf("CreateBookingPage: %v", err)
}
- got, _, err := app.StoreForTest().PublicBookingPage(ctx, "paul", "chat")
+ if len(page.Code) != 8 {
+ t.Errorf("code %q, want eight generated characters", page.Code)
+ }
+ // Unpublished: invisible.
+ if _, _, err := st.PublicBookingPage(ctx, "paul", page.Code); !calendar.NotFound(err) {
+ t.Errorf("unpublished page: err = %v, want ErrNotFound", err)
+ }
+ if err := st.PublishBookingPage(ctx, host.ID, page.ID, true); err != nil {
+ t.Fatal(err)
+ }
+ got, who, err := st.PublicBookingPage(ctx, "paul", page.Code)
if err != nil {
t.Fatalf("PublicBookingPage: %v", err)
}
- if got.ID != page.ID || got.Title != "Quick chat" || len(got.Windows) != 1 || got.Windows[0].EndMin != 900 {
+ if who.ID != host.ID || got.Title != "Quick chat" || got.Availability != "mondays 12-3" ||
+ len(got.Windows) != 1 || got.Windows[0].EndMin != 900 || !got.Published {
t.Errorf("round trip lost something: %+v", got)
}
- // Sealed: the raw file never holds the title or the sentence.
if _, err := app.DBForTest().Exec("PRAGMA wal_checkpoint(TRUNCATE)"); err != nil {
t.Fatal(err)
}
@@ -225,327 +247,203 @@ func TestABookingPageRoundTripsWithItsWindows(t *testing.T) {
}
}
-func TestAnInactivePageAndAnUnknownHandleBothVanish(t *testing.T) {
+func TestUnknownHandleUnknownCodeAndUnpublishedAllVanishTheSameWay(t *testing.T) {
app, _ := signedIn(t, "host@example.com")
ctx := context.Background()
st := app.StoreForTest()
- host, _ := st.PersonByAddress(ctx, "host@example.com")
+ host := app.PersonByAddressForTest(t, "host@example.com")
_ = st.SaveHandle(ctx, host.ID, "paul")
- page, _ := st.CreateBookingPage(ctx, host.ID, calendar.BookingPageInput{
- Slug: "chat", Title: "Chat", DurationMin: 30, HorizonDays: 30, Active: true}, nil)
- if err := st.SetBookingPageActive(ctx, host.ID, page.ID, false); err != nil {
- t.Fatal(err)
- }
- if _, _, err := st.PublicBookingPage(ctx, "paul", "chat"); !calendar.NotFound(err) {
- t.Errorf("paused page: err = %v, want ErrNotFound", err)
- }
- if _, _, err := st.PublicBookingPage(ctx, "nobody", "chat"); !calendar.NotFound(err) {
- t.Errorf("unknown handle: err = %v, want ErrNotFound", err)
+ page, _ := st.CreateBookingPage(ctx, host.ID, calendar.BookingPageInput{Title: "Chat", DurationMin: 30, HorizonDays: 30}, nil)
+ _ = st.PublishBookingPage(ctx, host.ID, page.ID, true)
+ _ = st.PublishBookingPage(ctx, host.ID, page.ID, false)
+ for _, c := range [][2]string{{"paul", page.Code}, {"nobody", page.Code}, {"paul", "zzzzzzzz"}} {
+ if _, _, err := st.PublicBookingPage(ctx, c[0], c[1]); !calendar.NotFound(err) {
+ t.Errorf("%v: err = %v, want ErrNotFound", c, err)
+ }
}
}
-func TestAHandleMustBeUniqueAndNotReserved(t *testing.T) {
+func TestAHandleIsUniqueLowercaseAndNotReserved(t *testing.T) {
app, _ := signedIn(t, "a@example.com")
ctx := context.Background()
st := app.StoreForTest()
- a, _ := st.PersonByAddress(ctx, "a@example.com")
+ a := app.PersonByAddressForTest(t, "a@example.com")
app.SignInForTest(t, "b@example.com")
- b, _ := st.PersonByAddress(ctx, "b@example.com")
- if err := st.SaveHandle(ctx, a.ID, "paul"); err != nil {
+ b := app.PersonByAddressForTest(t, "b@example.com")
+ if err := st.SaveHandle(ctx, a.ID, "Paul"); err != nil { // lowercased on save
t.Fatal(err)
}
+ if p, _ := st.PersonByHandle(ctx, "paul"); p.ID != a.ID {
+ t.Error("handle was not lowercased or not saved")
+ }
if err := st.SaveHandle(ctx, b.ID, "paul"); err != calendar.ErrHandleTaken {
t.Errorf("second claim: err = %v, want ErrHandleTaken", err)
}
- if err := calendar.ValidateHandle("m"); err == nil {
- t.Error("\"m\" must be reserved: it is the /book/m/{token} prefix")
+ for _, bad := range []string{"m", "new", "static", "a", "has space", "üñí"} {
+ if err := calendar.ValidateHandle(bad); err == nil {
+ t.Errorf("%q must be refused", bad)
+ }
}
- if err := calendar.ValidateHandle("Paul Campbell"); err == nil {
- t.Error("spaces and capitals must be refused")
+ if err := st.SaveHandle(ctx, a.ID, ""); err != nil {
+ t.Errorf("clearing a handle: %v", err)
}
}
-```
-
-`StoreForTest()` and `PersonByAddress` may not exist. Add to `internal/calendar/testing.go`: `func (a *App) StoreForTest() *Store { return a.store }`. Check `store.go` for an existing person-by-address reader (`EnsurePersonSetup` creates; look for a plain reader) and add `PersonByAddress(ctx, address string) (Person, error)` in `store_booking_page.go` if none exists, normalising with `normalizeAddress`.
-
-- [ ] **Step 2: Run it, confirm it fails to compile**
-
-```
-go test ./internal/calendartest/ -run 'BookingPage|Handle' 2>&1 | head
-```
-Expected: undefined `BookingPageInput`, `SaveHandle`, …
-
-- [ ] **Step 3: Write the models and purposes** exactly as in Interfaces. Add `&BookingPage{}, &BookingWindow{}, &Booking{}` to `Models`. Add `Handle *string` to `Person`.
-- [ ] **Step 4: Generate the migration**
-
-```
-rm internal/calendar/migrations/schema.sql
-go run github.com/carlosframework/rastrillo/cmd/rastrillo migration generate
-go run github.com/carlosframework/rastrillo/cmd/rastrillo migration check
-```
-Expected: a new `0005_*.sql` (name chosen by the tool) and a refreshed `schema.sql`; check prints "models and migrations agree". Open the SQL and confirm `handle` is nullable with a unique index and no `NOT NULL`.
-
-- [ ] **Step 5: Write `store_booking_page.go`**
-
-Shape (fill every function; this is the skeleton, not a placeholder):
-
-```go
-func (s *Store) SaveHandle(ctx context.Context, personID int64, handle string) error {
- handle = strings.ToLower(strings.TrimSpace(handle))
- var val any = nil
- if handle != "" {
- if err := ValidateHandle(handle); err != nil {
- return err
- }
- val = handle
+func TestAnotherPersonCannotEditOrPublishMyPage(t *testing.T) {
+ app, _ := signedIn(t, "me@example.com")
+ ctx := context.Background()
+ st := app.StoreForTest()
+ me := app.PersonByAddressForTest(t, "me@example.com")
+ app.SignInForTest(t, "them@example.com")
+ them := app.PersonByAddressForTest(t, "them@example.com")
+ page, _ := st.CreateBookingPage(ctx, me.ID, calendar.BookingPageInput{Title: "Mine", DurationMin: 30, HorizonDays: 30}, nil)
+ if err := st.PublishBookingPage(ctx, them.ID, page.ID, true); !calendar.NotFound(err) {
+ t.Errorf("publish as another person: %v, want ErrNotFound", err)
+ }
+ if err := st.UpdateBookingPage(ctx, them.ID, page.ID, calendar.BookingPageInput{Title: "Theirs", DurationMin: 30, HorizonDays: 30}, nil); !calendar.NotFound(err) {
+ t.Errorf("update as another person: %v, want ErrNotFound", err)
}
- err := s.g.WithContext(ctx).Model(&Person{}).Where("id = ?", personID).
- Update("handle", val).Error
- if err != nil && strings.Contains(err.Error(), "UNIQUE") {
- return ErrHandleTaken
+ if _, err := st.BookingPage(ctx, them.ID, page.ID); !calendar.NotFound(err) {
+ t.Errorf("read as another person: %v, want ErrNotFound", err)
}
- return err
}
```
-`CreateBookingPage`: validate, seal title/description/availability, insert page, insert windows, all in `s.g.Transaction`. `UpdateBookingPage`: `First(&page, "id = ? AND person_id = ?", pageID, personID)` → `ErrNotFound` on miss; `Updates(map[string]any{...explicit columns...})`; delete + reinsert windows in the same transaction. `PublicBookingPage`: join `people` on `handle = ?` then page on `slug = ? AND active = 1`; open the sealed columns; load windows ordered by weekday, start_min.
-
-- [ ] **Step 6: Run the tests**
-
-```
-go test ./internal/calendartest/ -run 'BookingPage|Handle' -count=1
-```
-Expected: PASS.
-
-- [ ] **Step 7: `make ci`, then commit**
-
-```
-make ci
-git add -A
-git commit -m "booking: pages, windows and bookings — the tables, sealed where they are content
-
-Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>"
-```
+- [ ] **Step 2: Run** `go test ./internal/calendartest/ -run 'BookingPage|Handle|MyPage' -count=1` — expect compile errors.
+- [ ] **Step 3: Models, purposes, `Person.Handle`, `Models` registration, `testing.go` helpers.** `PersonByAddressForTest` does `First(&p, "address = ?", normalizeAddress(address))` and `t.Fatalf` on miss.
+- [ ] **Step 4: Generate the migration** (`rm schema.sql`; `migration generate`; `migration check`). Open the SQL: `handle` nullable + unique index; `ip_fp` nullable; `published_at` nullable.
+- [ ] **Step 5: Implement `store_booking_page.go`.** `SaveHandle` lowercases and trims, `""` writes NULL, `UNIQUE` error → `ErrHandleTaken`. `CreateBookingPage`: validate; seal three columns; `Code: newPageCode()`; page + windows in one `Transaction`; on a `UNIQUE` collision of the code (astronomically rare) regenerate once. `UpdateBookingPage`, `PublishBookingPage`, `BookingPage`: `Where("id = ? AND person_id = ?", ...)`; a miss is `ErrNotFound`; updates by explicit map (`title_sealed`, `description_sealed`, `duration_min`, `buffer_min`, `notice_min`, `horizon_days`, `availability_text_sealed`, `updated_at`); windows deleted and re-inserted in the same transaction. `PublishBookingPage` writes `published_at = now` or `NULL`. `PublicBookingPage`: `PersonByHandle` → `First(&page, "person_id = ? AND code = ? AND published_at IS NOT NULL")` → open → windows ordered by `weekday, start_min`.
+- [ ] **Step 6: Run; pass.** **Step 7: `make ci`; commit** `booking: pages, windows and bookings — the tables, sealed where they are content`.
---
-### Task 2: The availability parser and its pane fragment
+### Task 2: `Zone()` made safe; the availability parser; the pane fragment
+
+Spec §3, §5.1 (bounded zone), §9 (`Zone()` test).
-Spec §3.
+**Files:** modify `clock.go:55-81`; create `avail.go`, `avail_windows.go`, `templates/_bookingpane.html`; modify `render.go` (`fragments` and every page's ParseFS); tests `zone_test.go`, `avail_test.go`.
-**Files:**
-- Create: `internal/calendar/avail.go`
-- Create: `internal/calendar/templates/_bookingpane.html`
-- Modify: `internal/calendar/render.go:71-77` (add `_bookingpane.html` to `fragments`)
-- Test: `internal/calendartest/avail_test.go`
+**Consumes:** `consumed`, `newConsumed`, `weekdayAlt`, `weekdays`, `rangeSep` from `quickadd*.go`. **Does NOT use** `reRange`, `clock`, `applyMeridiem` (they forgive; §3.1).
-**Interfaces — Consumes:** `consumed`, `newConsumed`, `reRange`, `applyMeridiem`, `clock`, `weekdayAlt`, `weekdays`, `rangeSep` (all package-level in `quickadd*.go`). **Produces:**
+**Produces:**
```go
-func ParseAvailability(text string) (windows []Window, problems []string)
-func DescribeWindows(ws []Window) []string // one string per weekday that has any: "Monday 12:00–15:00, 17:00–19:00"
-type BookingPaneView struct { Lines []string; Problems []string; Empty bool }
+func ParseAvailability(text string) (windows []Window, problems []string) // windows merged + sorted
+func MergeWindows(ws []Window) []Window // per weekday, overlapping/touching → one
+func DescribeWindows(ws []Window) []string // "Monday 12:00–15:00, 17:00–19:00" per weekday, Sun..Sat
+type BookingPaneView struct{ Lines, Problems []string; Empty bool }
func bookingPaneFor(text string) BookingPaneView
+func IsCommonZone(name string) bool // zones.go
```
-- [ ] **Step 1: Write the failing table test**
+- [ ] **Step 1: `Zone()` test** — `internal/calendartest/zone_test.go`:
```go
-// internal/calendartest/avail_test.go
-package calendartest
+func TestZoneIsSafeUnderConcurrentGarbage(t *testing.T) {
+ var wg sync.WaitGroup
+ for i := 0; i < 100; i++ {
+ wg.Add(1)
+ go func(i int) {
+ defer wg.Done()
+ _, _ = calendar.Zone(fmt.Sprintf("Nowhere/Town%d", i))
+ _, _ = calendar.Zone("Europe/Dublin")
+ }(i)
+ }
+ wg.Wait()
+ if n := calendar.ZoneCacheSizeForTest(); n > 40 {
+ t.Errorf("cache holds %d entries after 100 garbage names: failures are being cached", n)
+ }
+ if !calendar.IsCommonZone("Europe/Dublin") || calendar.IsCommonZone("Nowhere/Town1") {
+ t.Error("IsCommonZone is wrong")
+ }
+}
+```
+Run it with `-race`: `go test -race ./internal/calendartest/ -run ZoneIsSafe -count=1`. Expected: a data race report from the current map.
-import (
- "strings"
- "testing"
- "time"
+- [ ] **Step 2: Fix `clock.go`**: replace the closure's map with a `sync.Map` (or `sync.RWMutex` + map); store ONLY successes; add `func ZoneCacheSizeForTest() int` in `testing.go` (range-count the sync.Map). Add `IsCommonZone` to `zones.go` (a `map[string]bool` built from `CommonZones` in `init`).
- calendar "amadan.net/oficina/calendar/internal/calendar"
-)
+- [ ] **Step 3: Parser tests** — `avail_test.go`:
-func w(d time.Weekday, from, to string) calendar.Window {
- return calendar.Window{Weekday: d, StartMin: mins(from), EndMin: mins(to)}
-}
-func mins(hhmm string) int {
- t, err := time.Parse("15:04", hhmm)
- if err != nil {
- panic(err)
- }
- return t.Hour()*60 + t.Minute()
-}
+```go
+func w(d time.Weekday, from, to string) calendar.Window { return calendar.Window{Weekday: d, StartMin: mins(from), EndMin: mins(to)} }
+func mins(hhmm string) int { t, err := time.Parse("15:04", hhmm); if err != nil { panic(err) }; return t.Hour()*60 + t.Minute() }
+func allDays(from, to string) []calendar.Window { var out []calendar.Window; for d := time.Sunday; d <= time.Saturday; d++ { out = append(out, w(d, from, to)) }; return out }
func TestAvailabilityReadsTheSentence(t *testing.T) {
- cases := []struct {
- text string
- want []calendar.Window
- }{
- // The one from the request, including the clause that inherits its day.
- {"mondays between 12–3, tuesdays 12–2, 5-7", []calendar.Window{
- w(time.Monday, "12:00", "15:00"), w(time.Tuesday, "12:00", "14:00"), w(time.Tuesday, "17:00", "19:00")}},
- {"mon-wed 9-5", []calendar.Window{
- w(time.Monday, "09:00", "17:00"), w(time.Tuesday, "09:00", "17:00"), w(time.Wednesday, "09:00", "17:00")}},
- {"weekdays 10am to 4pm", []calendar.Window{
- w(time.Monday, "10:00", "16:00"), w(time.Tuesday, "10:00", "16:00"), w(time.Wednesday, "10:00", "16:00"),
- w(time.Thursday, "10:00", "16:00"), w(time.Friday, "10:00", "16:00")}},
+ cases := []struct{ text string; want []calendar.Window }{
+ {"mondays between 12–3, tuesdays 12–2, 5-7", []calendar.Window{w(time.Monday, "12:00", "15:00"), w(time.Tuesday, "12:00", "14:00"), w(time.Tuesday, "17:00", "19:00")}},
+ {"mon-wed 9-5", []calendar.Window{w(time.Monday, "09:00", "17:00"), w(time.Tuesday, "09:00", "17:00"), w(time.Wednesday, "09:00", "17:00")}},
+ {"weekdays 10am to 4pm", []calendar.Window{w(time.Monday, "10:00", "16:00"), w(time.Tuesday, "10:00", "16:00"), w(time.Wednesday, "10:00", "16:00"), w(time.Thursday, "10:00", "16:00"), w(time.Friday, "10:00", "16:00")}},
{"every day 8–6", allDays("08:00", "18:00")},
- {"saturday 10:30–13:00; sunday 14-16", []calendar.Window{
- w(time.Saturday, "10:30", "13:00"), w(time.Sunday, "14:00", "16:00")}},
- {"weekends 10-12", []calendar.Window{w(time.Saturday, "10:00", "12:00"), w(time.Sunday, "10:00", "12:00")}},
- {"Friday 9am-12pm, 1pm-3pm and 4-5", []calendar.Window{
- w(time.Friday, "09:00", "12:00"), w(time.Friday, "13:00", "15:00"), w(time.Friday, "16:00", "17:00")}},
+ {"saturday 10:30–13:00; sunday 14-16", []calendar.Window{w(time.Sunday, "14:00", "16:00"), w(time.Saturday, "10:30", "13:00")}},
+ {"weekends 10-12", []calendar.Window{w(time.Sunday, "10:00", "12:00"), w(time.Saturday, "10:00", "12:00")}},
+ {"Friday 9am-12pm, 1pm-3pm and 4-5", []calendar.Window{w(time.Friday, "09:00", "12:00"), w(time.Friday, "13:00", "15:00"), w(time.Friday, "16:00", "17:00")}},
{"tues 12 to 2", []calendar.Window{w(time.Tuesday, "12:00", "14:00")}},
{"every tuesday 12-2", []calendar.Window{w(time.Tuesday, "12:00", "14:00")}},
+ {"sun-tue 9-10", []calendar.Window{w(time.Sunday, "09:00", "10:00"), w(time.Monday, "09:00", "10:00"), w(time.Tuesday, "09:00", "10:00")}},
+ {"monday 9-12, monday 10-13", []calendar.Window{w(time.Monday, "09:00", "13:00")}}, // merged
+ {"monday 9-12, 12-1", []calendar.Window{w(time.Monday, "09:00", "13:00")}}, // touching → merged
+ {"monday 22:00-24:00", []calendar.Window{w(time.Monday, "22:00", "23:59")}}, // see note
+ {"monday 0-1am", []calendar.Window{w(time.Monday, "00:00", "01:00")}},
}
- for _, c := range cases {
- t.Run(c.text, func(t *testing.T) {
- got, problems := calendar.ParseAvailability(c.text)
- if len(problems) != 0 {
- t.Fatalf("problems = %v, want none", problems)
- }
- if len(got) != len(c.want) {
- t.Fatalf("windows = %v, want %v", got, c.want)
- }
- for i := range got {
- if got[i] != c.want[i] {
- t.Errorf("window %d = %v, want %v", i, got[i], c.want[i])
- }
- }
- })
- }
-}
-
-func allDays(from, to string) []calendar.Window {
- var out []calendar.Window
- for d := time.Sunday; d <= time.Saturday; d++ {
- out = append(out, w(d, from, to))
- }
- return out
+ // ...loop as in Task 1's style; windows are sorted Sunday-first then by StartMin.
}
+```
+Note on `24:00`: `mins("24:00")` cannot parse, so express the expectation as `calendar.Window{Weekday: time.Monday, StartMin: 1320, EndMin: 1440}` for that case, not via `w(...)`. Remove the `"23:59"` placeholder above when writing the file.
+```go
func TestAvailabilityRefusesWhatItCannotRead(t *testing.T) {
cases := []struct{ text, quoted string }{
- {"mondays 10pm-2", "10pm-2"}, // would cross midnight
- {"mondays 12-3, whenever", "whenever"}, // no time range
- {"12-3", "12-3"}, // no day, and nothing to inherit
- {"mondays 25-26", "25-26"}, // not a clock
- }
- for _, c := range cases {
- t.Run(c.text, func(t *testing.T) {
- ws, problems := calendar.ParseAvailability(c.text)
- if len(problems) == 0 {
- t.Fatalf("no problem reported; windows = %v", ws)
- }
- if !strings.Contains(strings.Join(problems, "\n"), c.quoted) {
- t.Errorf("problems %v do not quote %q", problems, c.quoted)
- }
- })
- }
- // Both bad clauses in one sentence are both reported.
+ {"mondays 10pm-2", "10pm-2"}, // crosses midnight
+ {"mondays 12-3, whenever", "whenever"},
+ {"12-3", "12-3"}, // no day, nothing to inherit
+ {"mondays 25-26", "25-26"},
+ {"mondays 9:60-10", "9:60-10"},
+ {"mondays 24:00-1", "24:00-1"}, // 24:00 only at the END
+ {"mondays 9-9", "9-9"}, // empty
+ {"mondays 9-5 in the office", "in the office"},
+ }
+ // each must produce >= 1 problem containing quoted
_, problems := calendar.ParseAvailability("mondays whenever, tuesdays sometime")
- if len(problems) != 2 {
- t.Errorf("problems = %v, want two", problems)
- }
+ if len(problems) != 2 { t.Errorf("problems = %v, want two", problems) }
}
func TestDescribeWindowsGroupsByDay(t *testing.T) {
ws, _ := calendar.ParseAvailability("tuesdays 12–2, 5-7, mondays 9-10")
- lines := calendar.DescribeWindows(ws)
- want := []string{"Monday 09:00–10:00", "Tuesday 12:00–14:00, 17:00–19:00"}
- if strings.Join(lines, "|") != strings.Join(want, "|") {
- t.Errorf("lines = %v, want %v", lines, want)
- }
+ got := strings.Join(calendar.DescribeWindows(ws), "|")
+ if got != "Monday 09:00–10:00|Tuesday 12:00–14:00, 17:00–19:00" { t.Errorf("got %q", got) }
}
```
-- [ ] **Step 2: Run, confirm compile failure.** `go test ./internal/calendartest/ -run Availability -count=1`
-
-- [ ] **Step 3: Implement `avail.go`**
-
-Algorithm (write it; the regexes below are the spec):
+- [ ] **Step 4: Implement.** `avail.go` (parser) and `avail_windows.go` (merge/describe) — keep each under 300 lines.
```go
-package calendar
-
-// ParseAvailability reads "mondays 12–3, tuesdays 12–2, 5-7" into
-// weekly windows. ONE parser, in Go, shared by the live pane and the
-// save — the same rule quick add lives by.
-//
-// Clauses split on , and ;. Each clause is a DAY PART then one or more
-// TIME RANGES. A clause with no day part inherits the previous clause's
-// days, which is the shape of the request itself: "tuesdays 12–2, 5-7"
-// is two windows on Tuesday.
-//
-// Anything not read is returned in problems with the clause quoted, and
-// the caller refuses to save while problems is non-empty. There is no
-// title here to leave a stray word in, so refusal is the safe failure.
-
var (
+ reAvailSplit = regexp.MustCompile(`\s*[,;]\s*`)
+ reAvailGroup = regexp.MustCompile(`(?i)^\s*(?:every\s+)?(weekdays|weekends|every\s+day|daily|all\s+days)\b`)
reAvailDayRange = regexp.MustCompile(`(?i)^\s*(?:every\s+)?(` + weekdayAlt + `)s?` + rangeSep + `(` + weekdayAlt + `)s?\b`)
reAvailDay = regexp.MustCompile(`(?i)^\s*(?:every\s+)?(` + weekdayAlt + `)s?\b`)
- reAvailGroup = regexp.MustCompile(`(?i)^\s*(?:every\s+)?(weekdays|weekends|every\s+day|daily|all\s+days)\b`)
- reAvailFiller = regexp.MustCompile(`(?i)\b(between|from|and|on|at)\b`)
- reAvailSplit = regexp.MustCompile(`\s*[,;]\s*`)
+ // A time range with RAW components; nothing is clamped here.
+ reAvailRange = regexp.MustCompile(`(?i)\b(\d{1,2})(?::(\d{2}))?\s*(am|pm)?\s*(?:[-–—]|to|until|till)\s*(\d{1,2})(?::(\d{2}))?\s*(am|pm)?\b`)
+ reAvailFiller = regexp.MustCompile(`(?i)\b(between|from|and|on|at)\b`)
)
-func ParseAvailability(text string) ([]Window, []string) {
- var out []Window
- var problems []string
- var days []time.Weekday // inherited across clauses
- for _, clause := range reAvailSplit.Split(strings.TrimSpace(text), -1) {
- if strings.TrimSpace(clause) == "" {
- continue
- }
- c := newConsumed(clause)
- if d, ok := takeAvailDays(c); ok {
- days = d
- }
- if len(days) == 0 {
- problems = append(problems, fmt.Sprintf("%q names no day", clause))
- continue
- }
- ranges, bad := takeAvailRanges(c) // loop c.take(reRange) until nil; each → clock/applyMeridiem; end<=start → bad
- rest := c.rest()
- rest = reAvailFiller.ReplaceAllString(rest, "")
- if len(ranges) == 0 || strings.TrimSpace(rest) != "" || bad != "" {
- what := bad
- if what == "" { what = clause }
- problems = append(problems, fmt.Sprintf("Could not read %q", what))
- continue
- }
- for _, d := range days {
- for _, r := range ranges {
- out = append(out, Window{Weekday: d, StartMin: r.start, EndMin: r.end})
- }
- }
- }
- sort.SliceStable(out, ...) // by weekday, then StartMin
- return out, problems
-}
+// readClock validates raw components. ok=false on hour>24, minute>59,
+// or 24 with a non-zero minute. 24:00 is returned as 1440 and the CALLER
+// decides whether the position allows it.
+func readClock(hh, mm, meridiem string, inferPM bool) (minutes int, ok bool)
```
+Meridiem rule: if either end has an explicit meridiem, the other end without one takes the same; if neither has one, `inferPM` applies to hours 1–7. `end <= start` → problem quoting the matched range text. `24:00` allowed only as end. Every problem: `fmt.Sprintf("Could not read %q", what)`.
-Notes for the implementer: `reRange` matches `(\d{1,2})(?::(\d{2}))?\s*(am|pm)?` … twice; use `clock(m[1], m[2])` and `clock(m[4], m[5])`, apply the "a meridiem on either end applies to both" rule exactly as `takeTimes` does (copy those six lines, do not call `takeTimes` — it returns a `clockTime` pair but also blanks only one range). Hours > 23 or minutes > 59 → bad. `weekdays` = Mon–Fri, `weekends` = Sat, Sun, `every day`/`daily`/`all days` = all seven. A day range walks forward from the first day modulo 7 to the second (`sun-tue` = Sun, Mon, Tue). `weekdays` map from `quickadd_when.go` resolves names.
-
-`DescribeWindows`: group by weekday in Sunday..Saturday order, join ranges with ", ", format `%02d:%02d–%02d:%02d` with an en dash.
-
-- [ ] **Step 4: Run the tests; make them pass.** `go test ./internal/calendartest/ -run 'Availability|DescribeWindows' -count=1 -v`
-
-- [ ] **Step 5: The pane fragment**
+`MergeWindows`: sort by weekday then StartMin; merge when `next.StartMin <= cur.EndMin`. `DescribeWindows` uses `fmt.Sprintf("%02d:%02d–%02d:%02d")` and prints `24:00` as `24:00`.
-`templates/_bookingpane.html`:
+- [ ] **Step 5: The fragment.** `_bookingpane.html`:
```html
-{{define "bookingpane"}}
-{{if .Empty}}<p cal-book-blank>Type when you are free, like <q>mondays 12–3, tuesdays 12–2, 5–7</q>.</p>
-{{else}}
-{{with .Problems}}<ul cal-book-problems role="alert">{{range .}}<li>{{.}}</li>{{end}}</ul>{{end}}
-{{with .Lines}}<ul cal-book-lines>{{range .}}<li>{{.}}</li>{{end}}</ul>{{end}}
-{{end}}
-{{end}}
+{{define "bookingpane"}}{{if .Empty}}<p cal-book-blank>Type when you are free — for example <q>mondays 12–3, tuesdays 12–2, 5–7</q>.</p>{{else}}{{with .Problems}}<ul cal-book-problems role="alert">{{range .}}<li>{{.}}</li>{{end}}</ul>{{end}}{{with .Lines}}<ul cal-book-lines>{{range .}}<li>{{.}}</li>{{end}}</ul>{{end}}{{end}}{{end}}
```
-Add `"templates/_bookingpane.html"` to the `fragments` ParseFS in `render.go` and to every page's ParseFS list (find where `_quickpane.html` is listed and add beside it).
+Add it to `fragments` and to the page ParseFS list in `render.go` beside `_quickpane.html`.
-`bookingPaneFor(text)`: empty text → `Empty: true`; else parse, `Lines = DescribeWindows(ws)`, `Problems = problems`.
-
-- [ ] **Step 6: `make ci`; commit** `booking: one parser for "mondays 12–3", and it is the Go one`.
+- [ ] **Step 6: Run all; pass. `make ci`; commit** `booking: one parser for "mondays 12–3", and Zone() stops caching garbage`.
---
@@ -553,144 +451,55 @@ Add `"templates/_bookingpane.html"` to the `fragments` ParseFS in `render.go` an
Spec §4.
-**Files:**
-- Create: `internal/calendar/slots.go` (pure), `internal/calendar/store_slots.go`
-- Test: `internal/calendartest/slots_test.go`
+**Files:** create `slots.go`, `store_slots.go`; test `slots_test.go`.
-**Interfaces — Produces:**
+**Produces:**
```go
-type Slot struct {
- StartUTC, EndUTC int64
- Local time.Time // host-zone wall clock of StartUTC
-}
+type Slot struct{ StartUTC, EndUTC int64; Local time.Time }
type Busy struct{ StartUTC, EndUTC int64 }
-type SlotRule struct {
- DurationMin, BufferMin, NoticeMin, HorizonDays int
- Zone string
- Windows []Window
-}
-// ComputeSlots is pure. from/to are UTC instants bounding the days of
-// interest; now applies notice and horizon. Slots are ascending.
+type SlotRule struct{ DurationMin, BufferMin, NoticeMin, HorizonDays int; Zone string; Windows []Window }
func ComputeSlots(rule SlotRule, busy []Busy, now, from, to time.Time) ([]Slot, error)
-
-// BusyFor is every interval that blocks a slot for host in [from, to):
-// EventsInRange(host) with Busy(kind), status != cancelled, tentative
-// INCLUDED, all-day expanded to its DayBounds. excludeEventID (0 = none)
-// is the booking being rescheduled.
-func (s *Store) BusyFor(ctx context.Context, hostID, from, to, excludeEventID int64) ([]Busy, error)
+func (s *Store) BusyFor(ctx context.Context, hostID, from, to int64, now time.Time, excludeEventID int64) ([]Busy, error)
func (s *Store) FreeSlots(ctx context.Context, page BookingPageView, host Person, now, from, to time.Time, excludeEventID int64) ([]Slot, error)
func ruleFor(page BookingPageView, host Person) SlotRule
```
-- [ ] **Step 1: Failing tests**
-
-```go
-// internal/calendartest/slots_test.go
-package calendartest
-
-import (
- "testing"
- "time"
-
- calendar "amadan.net/oficina/calendar/internal/calendar"
-)
-
-var dublin = mustZone("Europe/Dublin")
-
-func mustZone(n string) *time.Location { l, err := time.LoadLocation(n); if err != nil { panic(err) }; return l }
-
-func mondayRule(dur, buffer int) calendar.SlotRule {
- return calendar.SlotRule{DurationMin: dur, BufferMin: buffer, NoticeMin: 0, HorizonDays: 30, Zone: "Europe/Dublin",
- Windows: []calendar.Window{{Weekday: time.Monday, StartMin: 12 * 60, EndMin: 15 * 60}}}
-}
-
-// Monday 7 September 2026 is a Monday; IST (UTC+1).
-var mon = time.Date(2026, 9, 7, 0, 0, 0, 0, dublin)
-
-func starts(slots []calendar.Slot) []string {
- var out []string
- for _, s := range slots { out = append(out, s.Local.Format("Mon 15:04")) }
- return out
-}
-func eq(t *testing.T, got, want []string) {
- t.Helper()
- if len(got) != len(want) { t.Fatalf("got %v, want %v", got, want) }
- for i := range got { if got[i] != want[i] { t.Fatalf("got %v, want %v", got, want) } }
-}
-
-func TestSlotsStepByDurationInsideTheWindow(t *testing.T) {
- now := mon.Add(-24 * time.Hour)
- slots, err := calendar.ComputeSlots(mondayRule(60, 0), nil, now, mon, mon.AddDate(0, 0, 1))
- if err != nil { t.Fatal(err) }
- eq(t, starts(slots), []string{"Mon 12:00", "Mon 13:00", "Mon 14:00"})
-}
-
-func TestABusyBlockRemovesTheSlotItOverlaps(t *testing.T) {
- now := mon.Add(-24 * time.Hour)
- busy := []calendar.Busy{{StartUTC: mon.Add(13 * time.Hour).Unix(), EndUTC: mon.Add(13*time.Hour + 30*time.Minute).Unix()}}
- slots, _ := calendar.ComputeSlots(mondayRule(60, 0), busy, now, mon, mon.AddDate(0, 0, 1))
- eq(t, starts(slots), []string{"Mon 12:00", "Mon 14:00"})
-}
-
-func TestTheBufferPadsEverySlotBeforeTheOverlapTest(t *testing.T) {
- now := mon.Add(-24 * time.Hour)
- busy := []calendar.Busy{{StartUTC: mon.Add(13 * time.Hour).Unix(), EndUTC: mon.Add(13*time.Hour + 30*time.Minute).Unix()}}
- // 12:00 padded is [11:50, 13:10) which touches 13:00–13:30; 14:00 padded is [13:50, 15:10), which does not.
- slots, _ := calendar.ComputeSlots(mondayRule(60, 10), busy, now, mon, mon.AddDate(0, 0, 1))
- eq(t, starts(slots), []string{"Mon 14:00"})
-}
-
-func TestNoticeHidesTheNearFutureAndHorizonEndsTheList(t *testing.T) {
- rule := mondayRule(60, 0)
- rule.NoticeMin = 120
- rule.HorizonDays = 7
- now := mon.Add(11 * time.Hour) // 11:00 on the Monday: 12:00 is inside two hours
- slots, _ := calendar.ComputeSlots(rule, nil, now, mon, mon.AddDate(0, 0, 14))
- eq(t, starts(slots), []string{"Mon 13:00", "Mon 14:00"}) // the following Monday is past the 7-day horizon
-}
-
-func TestADSTSundayWindowIsTheWallClockItSays(t *testing.T) {
- // Spring forward: 29 March 2026, 01:00 → 02:00 in Europe/Dublin.
- sun := time.Date(2026, 3, 29, 0, 0, 0, 0, dublin)
- rule := calendar.SlotRule{DurationMin: 60, HorizonDays: 30, Zone: "Europe/Dublin",
- Windows: []calendar.Window{{Weekday: time.Sunday, StartMin: 0, EndMin: 4 * 60}}}
- slots, _ := calendar.ComputeSlots(rule, nil, sun.Add(-48*time.Hour), sun, sun.AddDate(0, 0, 1))
- // 00:00, 02:00, 03:00 — 01:00 does not exist that night. Pinned to real instants.
- eq(t, starts(slots), []string{"Sun 00:00", "Sun 02:00", "Sun 03:00"})
- if slots[1].StartUTC != time.Date(2026, 3, 29, 1, 0, 0, 0, time.UTC).Unix() {
- t.Errorf("02:00 IST is 01:00Z, got %d", slots[1].StartUTC)
- }
-}
-```
-
-The DST expectation is the one to get RIGHT rather than make pass: build each candidate with `time.Date(y, m, d, hh, mm, 0, 0, loc)` from window minutes, and drop a candidate whose `Local.Hour()*60+Minute()` does not equal the minute you asked for (that is the gap). Whatever Go actually does at 01:00 on that night, the test pins it; if the list Go produces differs from the three above, fix the TEST to the real behaviour and say so in its comment — `clock.go`'s comment explains the precedent.
+- [ ] **Step 1: Tests** — `slots_test.go`. Use `dublin := mustZone("Europe/Dublin")`, `mon := time.Date(2026, 9, 7, 0, 0, 0, 0, dublin)` (a Monday, IST), `mondayRule(dur, buffer)` with a 12:00–15:00 Monday window, helpers `starts(slots) []string` formatting `Local` as `"Mon 15:04"`, and `eq`.
-- [ ] **Step 2: Run, confirm failure.** `go test ./internal/calendartest/ -run 'Slots|Busy|Buffer|Notice|DST' -count=1`
+ - `TestSlotsStepByDurationInsideTheWindow`: 60-min → `12:00, 13:00, 14:00`.
+ - `TestABusyBlockRemovesTheSlotItOverlaps`: busy 13:00–13:30 → `12:00, 14:00`.
+ - `TestTheBufferPadsEverySlotBeforeTheOverlapTest`: same busy, buffer 10 → `14:00` only.
+ - `TestABusyBlockJustBeforeTheRangeStillCountsWhenBuffered`: `from = mon 12:00`, busy 11:50–11:55, buffer 10 → `13:00, 14:00` (12:00 padded to 11:50 overlaps).
+ - `TestNoticeAndHorizonAreTestedPerCandidate`: notice 120, now = mon 11:00, `to` = mon+14d, horizon 7 → `Mon 13:00, Mon 14:00` and nothing from the next Monday.
+ - `TestSpringForwardSundayOffersOnlyWallClocksThatExist`: 29 March 2026, window 00:00–04:00, 60-min → the local labels Go produces once 01:00 is dropped; pin `slots[i].StartUTC` to `time.Date(...UTC).Unix()` values. **Write the test to print what it got, run it once against the implementation, then pin the printed numbers as the expectation with a comment explaining each** — that is the `clock_test.go` convention.
+ - `TestFoldSundayKeepsTheOccurrenceToUTCWouldStore`: 25 October 2026, window 00:00–04:00. Every offered start must satisfy `calendar.ToUTC(calendar.FormatLocal(slot.Local, false), "Europe/Dublin", false) == slot.StartUTC`. Assert that for each slot, and pin the count.
+ - `TestATokyoHostIsBookedOnTheBookersSundayButTheHostsMonday`: rule zone `Asia/Tokyo`, Monday 09:00–10:00 window → one slot; `time.Unix(slot.StartUTC,0).In(LA).Weekday() == time.Sunday`.
-- [ ] **Step 3: Implement `slots.go`**
+- [ ] **Step 2: Run; confirm failure.**
+- [ ] **Step 3: Implement `slots.go`:**
```go
func ComputeSlots(rule SlotRule, busy []Busy, now, from, to time.Time) ([]Slot, error) {
loc, err := Zone(rule.Zone)
if err != nil { return nil, err }
earliest := now.Add(time.Duration(rule.NoticeMin) * time.Minute)
- latest := now.AddDate(0, 0, rule.HorizonDays)
- if from.Before(earliest) { from = earliest }
- if to.After(latest) { to = latest }
- byDay := map[time.Weekday][]Window{}
- for _, w := range rule.Windows { byDay[w.Weekday] = append(byDay[w.Weekday], w) }
+ latest := now.In(loc).AddDate(0, 0, rule.HorizonDays)
dur := time.Duration(rule.DurationMin) * time.Minute
pad := time.Duration(rule.BufferMin) * time.Minute
+ byDay := map[time.Weekday][]Window{}
+ for _, w := range rule.Windows { byDay[w.Weekday] = append(byDay[w.Weekday], w) }
var out []Slot
- day := time.Date(from.In(loc).Year(), from.In(loc).Month(), from.In(loc).Day(), 0, 0, 0, 0, loc)
- for ; day.Before(to); day = day.AddDate(0, 0, 1) { // AddDate on a midnight in loc is the next midnight in loc — a 23/24/25h day
+ f := from.In(loc)
+ for day := time.Date(f.Year(), f.Month(), f.Day(), 0, 0, 0, 0, loc); day.Before(to); day = day.AddDate(0, 0, 1) {
for _, w := range byDay[day.Weekday()] {
for m := w.StartMin; m+rule.DurationMin <= w.EndMin; m += rule.DurationMin {
start := time.Date(day.Year(), day.Month(), day.Day(), 0, m, 0, 0, loc)
- if start.Hour()*60+start.Minute() != m { continue } // the DST gap: this wall clock does not exist today
+ // Round-trip rule (§4.3): what is offered must be what the record can hold.
+ if start.Hour()*60+start.Minute() != m { continue }
+ if utc, err := ToUTC(FormatLocal(start, false), rule.Zone, false); err != nil || utc != start.Unix() { continue }
end := start.Add(dur)
- if start.Before(from) || end.After(to) { continue }
+ if start.Before(earliest) || start.Before(from) || end.After(to) || end.After(latest) { continue }
if overlapsAny(start.Add(-pad).Unix(), end.Add(pad).Unix(), busy) { continue }
out = append(out, Slot{StartUTC: start.Unix(), EndUTC: end.Unix(), Local: start})
}
@@ -698,521 +507,248 @@ func ComputeSlots(rule SlotRule, busy []Busy, now, from, to time.Time) ([]Slot,
}
return out, nil
}
-func overlapsAny(s, e int64, busy []Busy) bool { for _, b := range busy { if s < b.EndUTC && b.StartUTC < e { return true } }; return false }
```
+`store_slots.go` `BusyFor`: `EventsInRange(ctx, hostID, from-86400, to+86400)`; skip `!Busy(e.Kind)`, `e.Status == StatusCancelled`, `e.ID == excludeEventID`; for `e.AllDay` derive `[DayBounds(startDate, host.Zone), DayBounds(endDate-1, host.Zone).end)` — i.e. parse `StartLocal`/`EndLocal` as dates and take the host-zone bounds of each day in `[start, end)`; for tentative events, exclude those whose booking is `held` with `hold_expires_at <= now` (one query up front: `SELECT event_id FROM bookings WHERE state='held' AND hold_expires_at <= ?` into a set). `ruleFor` copies the page's four knobs, `host.Zone`, `page.Windows`.
-`store_slots.go`: `BusyFor` calls `s.EventsInRange(ctx, hostID, from, to)`, skips `!Busy(e.Kind)`, `e.Status == StatusCancelled`, `e.ID == excludeEventID`; for all-day uses `e.OccStartUTC/OccEndUTC` as stored (they are already day bounds in the event's own zone). `FreeSlots` = `ComputeSlots(ruleFor(page, host), busy, now, from, to)` with `busy` fetched over `[from-1day, to+1day)` so buffers at the edges see their neighbours.
-
-- [ ] **Step 4: Run; pass. Step 5: `make ci`; commit** `booking: free slots — the host's windows minus everything the host can see`.
+- [ ] **Step 4: Run; pin the DST numbers; pass. `make ci`; commit** `booking: free slots — the host's windows minus everything the host can see`.
---
-### Task 4: The booking state machine in the store
+### Task 4: The state machine, the outbox, the sweep queries, and the host's authority
-Spec §5 (store half), §7, §8.
+Spec §5.2–5.7 (store half), §6 (outbox columns), §7.1 (counts), §8 (queries).
-**Files:**
-- Create: `internal/calendar/store_booking_flow.go`, `internal/calendar/store_booking_caps.go`
-- Modify: `internal/calendar/store_reminder_next.go:54-64` (`NextDueAt`)
-- Test: `internal/calendartest/booking_store_test.go` (append)
+**Files:** create `store_booking_flow.go`, `store_booking_read.go`, `store_booking_sweep.go`; modify `store_reminder_next.go`, `store_event.go` (`UpdateEvent`, `DeleteEvent`), `store.go` (add `holdMu sync.Mutex` to `Store`); test `booking_flow_test.go`.
-**Interfaces — Produces:**
+**Produces:**
```go
-const HoldTTL = 30 * time.Minute
-const (
- MaxOpenHoldsPerIP = 5
- MaxOpenHoldsPerPage = 20
- MaxOpenHoldsPerAddress = 2
+var (
+ ErrSlotTaken = errors.New("calendar: slot taken")
+ ErrOverCap = errors.New("calendar: too many open holds")
+ ErrBookedEvent = ValidationError{"repeat", "This is a booked meeting; it cannot repeat."}
)
-var ErrSlotTaken = errors.New("calendar: slot taken")
-var ErrOverCap = errors.New("calendar: too many open holds")
-
type HoldInput struct {
- Page BookingPageView
- Host Person
- StartUTC, EndUTC int64
- Name, Address string
- Note, BookerZone string
- IPHash string
+ Page BookingPageView
+ Host Person
+ StartUTC, EndUTC int64
+ Name, Address string
+ Note, BookerZone string
+ IPFP string
}
type BookingView struct {
Booking
Page BookingPageView
Host Person
- Event EventView // zero when EventID is nil
+ Event EventView // Status/StartUTC/EndUTC/Sequence/DeletedAt are what public code reads; NEVER Notes/Location/Attendees
Name, Address, Note string
-}
-
-// HoldBooking re-checks the slot against FreeSlots INSIDE the write
-// transaction, creates the tentative event on the host's personal
-// calendar and the booking row, and returns the plaintext token once.
-func (s *Store) HoldBooking(ctx context.Context, in HoldInput, now time.Time) (token string, b Booking, err error)
-func (s *Store) DeleteHold(ctx context.Context, bookingID int64) error // hard-delete row + event, for a failed send
-func (s *Store) BookingByToken(ctx context.Context, token string) (BookingView, error) // ErrNotFound
-func (s *Store) ConfirmBooking(ctx context.Context, bookingID int64, now time.Time) (changed bool, err error) // idempotent; held+expired → no change, error nil, changed false
-func (s *Store) RescheduleBooking(ctx context.Context, bookingID, startUTC, endUTC int64, now time.Time) error // confirmed only; re-checks FreeSlots excluding own event; sequence++
-func (s *Store) CancelBooking(ctx context.Context, bookingID int64, now time.Time) error
-func (s *Store) ExpireHolds(ctx context.Context, now time.Time) (int, error) // held & hold_expires_at <= now → expired, event soft-deleted
-func (s *Store) NextHoldExpiry(ctx context.Context) time.Time // zero when none
-func (s *Store) OpenHoldCounts(ctx context.Context, pageID int64, ipHash, addressFP string, now time.Time) (perIP, perPage, perAddress int, err error)
+ HostName string // opened NameSealed, or the handle
+}
+type HoldCounts struct{ OpenIP, OpenPage, OpenAddress, AttemptsIP, AttemptsAddress int }
+
+func (s *Store) HoldBooking(ctx context.Context, in HoldInput, now time.Time) (token string, b Booking, err error) // holdMu; caps; slot; tx
+func (s *Store) FailHold(ctx context.Context, bookingID int64, now time.Time) error // held→failed, event soft-deleted
+func (s *Store) ConfirmBooking(ctx context.Context, bookingID int64, now time.Time) (changed bool, err error)
+func (s *Store) RescheduleBooking(ctx context.Context, bookingID, startUTC, endUTC int64, now time.Time) error // holdMu
+func (s *Store) CancelBooking(ctx context.Context, bookingID int64, now time.Time) (changed bool, err error)
+func (s *Store) BookingByToken(ctx context.Context, token string) (BookingView, error)
+func (s *Store) bookingIDForEvent(ctx context.Context, eventID int64) (int64, bool)
+func (s *Store) HoldCountsFor(ctx context.Context, pageID int64, ipFP, addressFP string, now time.Time) (HoldCounts, error)
+func (s *Store) ExpireHolds(ctx context.Context, now time.Time, limit int) (int, error)
+func (s *Store) ForgetOldIPs(ctx context.Context, now time.Time, limit int) (int, error)
+func (s *Store) OwedMail(ctx context.Context, limit int) ([]BookingView, error)
+func (s *Store) MarkMailSent(ctx context.Context, bookingID int64) error
+func (s *Store) MarkMailFailed(ctx context.Context, bookingID int64) error // attempts+1; at MaxReminderAttempts clear the marker
+func (s *Store) NextHoldExpiry(ctx context.Context) time.Time
+func (s *Store) MailIsOwed(ctx context.Context) bool
```
-- [ ] **Step 1: Failing tests** (append to `booking_store_test.go`)
-
+- [ ] **Step 1: Tests** — `booking_flow_test.go`. Helpers: `hostWithPage(t) (*calendar.App, calendar.Person, calendar.BookingPageView)` (handle `paul`, page `every day 9-5`, 30 min, buffer 0, notice 0, horizon 30, published) and `firstSlots(t, app, page, host, now, n) []calendar.Slot`. Tests:
+
+ - `TestAHoldIsATentativeEventTitledForTheBooker`: title `"Chat — Doc"`, status tentative, `PendingMail == MailHold`, token not on disk (grep).
+ - `TestTwentyStrangersOneSlotOneWinner`: 20 goroutines call `HoldBooking` for the same slot; count `err == nil` (must be 1) and `ErrSlotTaken` (19). Run under `-race`.
+ - `TestConfirmIsAConditionalUpdate`: first confirm `changed=true`; second `changed=false`; state confirmed; event confirmed with `Sequence == 1`; attendee recorded; `RemindersFor(host, event)` has one row (the default) when `host.DefaultReminderMin > 0` — set it via `SaveSettings` first; `PendingMail == MailConfirmed`.
+ - `TestConfirmAfterExpiryChangesNothing`: `now + HoldTTL + 1m` → `changed=false`, state still `held` (the SWEEP moves it, not confirm).
+ - `TestRescheduleMovesBumpsAndCanReturnToItsOwnSlot`: sequence goes 1→2; `Reschedules == 1`; moving back to the original slot succeeds (own event excluded); a sixth reschedule returns `ErrOverCap`.
+ - `TestCancelIsConditional`: first `changed=true` with event cancelled and `Sequence` bumped and `ReminderQueue` rows for the event gone; second `changed=false`.
+ - `TestHostDeleteCancelsTheBookingAndOwesMail`: `DeleteEvent(host, eventID)` → booking `cancelled`, `PendingMail == MailCancelled`.
+ - `TestABookedEventCannotBecomeASeries`: `UpdateEvent` with `RRule: "FREQ=WEEKLY"` on the event → `ErrBookedEvent`.
+ - `TestExpireHoldsFreesTheSlotAndNextHoldExpiryIsRight`.
+ - `TestHoldCountsIncludeFailedRowsForAttempts`: three holds from one `IPFP`, `FailHold` on all three → `OpenIP == 0`, `AttemptsIP == 3`.
+ - `TestOwedMailAndMarks`: after confirm, `OwedMail` returns the row; `MarkMailFailed` × `MaxReminderAttempts` clears the marker; `MailIsOwed` flips accordingly.
+ - `TestForgetOldIPs`: age `created_at` via `DBForTest().Exec`; `ForgetOldIPs` nulls `ip_fp`.
+ - `TestNextDueAtIsTheEarliestOfThree`: no reminders + a hold → hold expiry; owed mail → `<= now`.
+
+- [ ] **Step 2: Run; confirm failure.**
+- [ ] **Step 3: Implement.**
+
+`HoldBooking`:
```go
-func hostWithPage(t *testing.T) (*calendar.App, calendar.Person, calendar.BookingPageView) {
- t.Helper()
- app, _ := signedIn(t, "host@example.com")
- ctx := context.Background()
- st := app.StoreForTest()
- host, _ := st.PersonByAddress(ctx, "host@example.com")
- _ = st.SaveHandle(ctx, host.ID, "paul")
- ws, _ := calendar.ParseAvailability("every day 9-5")
- p, err := st.CreateBookingPage(ctx, host.ID, calendar.BookingPageInput{
- Slug: "chat", Title: "Chat", Availability: "every day 9-5", DurationMin: 30, BufferMin: 0, NoticeMin: 0, HorizonDays: 30, Active: true}, ws)
- if err != nil { t.Fatal(err) }
- page, _ := st.BookingPage(ctx, host.ID, p.ID)
- return app, host, page
-}
-
-func firstSlot(t *testing.T, app *calendar.App, page calendar.BookingPageView, host calendar.Person, now time.Time) calendar.Slot {
- t.Helper()
- slots, err := app.StoreForTest().FreeSlots(context.Background(), page, host, now, now, now.AddDate(0, 0, 3), 0)
- if err != nil || len(slots) == 0 { t.Fatalf("no free slots: %v", err) }
- return slots[0]
-}
-
-func TestAHoldIsATentativeEventAndBlocksTheSlot(t *testing.T) {
- app, host, page := hostWithPage(t)
- ctx := context.Background()
- st := app.StoreForTest()
- now := time.Now()
- slot := firstSlot(t, app, page, host, now)
- in := calendar.HoldInput{Page: page, Host: host, StartUTC: slot.StartUTC, EndUTC: slot.EndUTC,
- Name: "Doc", Address: "doc@tito.io", BookerZone: "Europe/Dublin", IPHash: "ip1"}
- token, b, err := st.HoldBooking(ctx, in, now)
- if err != nil || token == "" { t.Fatalf("hold: %v", err) }
- ev, err := st.Event(ctx, host.ID, *b.EventID)
- if err != nil { t.Fatal(err) }
- if ev.Status != calendar.StatusTentative { t.Errorf("status %q, want tentative", ev.Status) }
- if ev.Title != "Chat — Doc" { t.Errorf("title %q", ev.Title) }
- // The same slot is gone for the next stranger.
- if _, _, err := st.HoldBooking(ctx, in, now); err != calendar.ErrSlotTaken {
- t.Errorf("second hold: err = %v, want ErrSlotTaken", err)
- }
- // And the token is not on disk.
- if found, where := app.GrepDatabaseForTest(t, token); found { t.Errorf("token in %s", where) }
-}
-
-func TestConfirmIsIdempotentAndRecordsTheAttendee(t *testing.T) {
- app, host, page := hostWithPage(t)
- ctx := context.Background()
- st := app.StoreForTest()
- now := time.Now()
- slot := firstSlot(t, app, page, host, now)
- token, b, _ := st.HoldBooking(ctx, calendar.HoldInput{Page: page, Host: host, StartUTC: slot.StartUTC, EndUTC: slot.EndUTC,
- Name: "Doc", Address: "doc@tito.io", BookerZone: "UTC", IPHash: "ip"}, now)
- changed, err := st.ConfirmBooking(ctx, b.ID, now)
- if err != nil || !changed { t.Fatalf("confirm: changed=%v err=%v", changed, err) }
- changed, err = st.ConfirmBooking(ctx, b.ID, now)
- if err != nil || changed { t.Errorf("second confirm: changed=%v err=%v, want false,nil", changed, err) }
- v, _ := st.BookingByToken(ctx, token)
- if v.State != calendar.BookingConfirmed || v.Event.Status != calendar.StatusConfirmed { t.Errorf("state %q / %q", v.State, v.Event.Status) }
- att, _ := st.AttendeesFor(ctx, *b.EventID)
- if len(att) != 1 || att[0] != "doc@tito.io" { t.Errorf("attendees %v", att) }
-}
-
-func TestAnExpiredHoldIsSweptAndTheSlotIsFreeAgain(t *testing.T) {
- app, host, page := hostWithPage(t)
- ctx := context.Background()
- st := app.StoreForTest()
- now := time.Now()
- slot := firstSlot(t, app, page, host, now)
- _, b, _ := st.HoldBooking(ctx, calendar.HoldInput{Page: page, Host: host, StartUTC: slot.StartUTC, EndUTC: slot.EndUTC,
- Name: "Doc", Address: "doc@tito.io", BookerZone: "UTC", IPHash: "ip"}, now)
- if exp := st.NextHoldExpiry(ctx); exp.Unix() != b.HoldExpiresAt { t.Errorf("NextHoldExpiry %v, want %d", exp, b.HoldExpiresAt) }
- later := now.Add(calendar.HoldTTL + time.Minute)
- n, err := st.ExpireHolds(ctx, later)
- if err != nil || n != 1 { t.Fatalf("expire: n=%d err=%v", n, err) }
- if _, err := st.Event(ctx, host.ID, *b.EventID); !calendar.NotFound(err) { t.Errorf("held event still visible: %v", err) }
- if _, err := st.ConfirmBooking(ctx, b.ID, later); err == nil {
- // confirm after expiry is a no-op with no error, but the state must be expired
- }
- again := firstSlot(t, app, page, host, now)
- if again.StartUTC != slot.StartUTC { t.Errorf("slot not freed: got %d want %d", again.StartUTC, slot.StartUTC) }
-}
-
-func TestRescheduleMovesTheEventAndBumpsSequence(t *testing.T) {
- app, host, page := hostWithPage(t)
- ctx := context.Background()
- st := app.StoreForTest()
- now := time.Now()
- slots, _ := st.FreeSlots(ctx, page, host, now, now, now.AddDate(0, 0, 3), 0)
- first, second := slots[0], slots[1]
- token, b, _ := st.HoldBooking(ctx, calendar.HoldInput{Page: page, Host: host, StartUTC: first.StartUTC, EndUTC: first.EndUTC,
- Name: "Doc", Address: "doc@tito.io", BookerZone: "UTC", IPHash: "ip"}, now)
- _, _ = st.ConfirmBooking(ctx, b.ID, now)
- if err := st.RescheduleBooking(ctx, b.ID, second.StartUTC, second.EndUTC, now); err != nil { t.Fatal(err) }
- v, _ := st.BookingByToken(ctx, token)
- if v.Event.StartUTC != second.StartUTC { t.Errorf("event did not move") }
- if v.Sequence != 1 { t.Errorf("sequence %d, want 1", v.Sequence) }
- // Moving BACK to the original slot must work: the booking's own event is not busy against itself.
- if err := st.RescheduleBooking(ctx, b.ID, first.StartUTC, first.EndUTC, now); err != nil { t.Errorf("move back: %v", err) }
-}
-
-func TestCapsCountOnlyOpenHolds(t *testing.T) {
- app, host, page := hostWithPage(t)
- ctx := context.Background()
- st := app.StoreForTest()
- now := time.Now()
- slots, _ := st.FreeSlots(ctx, page, host, now, now, now.AddDate(0, 0, 5), 0)
- for i := 0; i < 3; i++ {
- _, _, err := st.HoldBooking(ctx, calendar.HoldInput{Page: page, Host: host, StartUTC: slots[i].StartUTC, EndUTC: slots[i].EndUTC,
- Name: "X", Address: fmt.Sprintf("x%d@example.com", i), BookerZone: "UTC", IPHash: "same-ip"}, now)
- if err != nil { t.Fatal(err) }
- }
- perIP, perPage, _, err := st.OpenHoldCounts(ctx, page.ID, "same-ip", "", now)
- if err != nil || perIP != 3 || perPage != 3 { t.Errorf("counts ip=%d page=%d err=%v", perIP, perPage, err) }
- _, _ = st.ExpireHolds(ctx, now.Add(calendar.HoldTTL+time.Minute))
- perIP, _, _, _ = st.OpenHoldCounts(ctx, page.ID, "same-ip", "", now.Add(calendar.HoldTTL+time.Minute))
- if perIP != 0 { t.Errorf("expired holds still counted: %d", perIP) }
-}
+s.holdMu.Lock(); defer s.holdMu.Unlock()
+counts, err := s.HoldCountsFor(ctx, in.Page.ID, in.IPFP, addrFP, now)
+if counts.OpenIP >= 5 || counts.OpenPage >= 20 || counts.OpenAddress >= 2 || counts.AttemptsIP >= 20 || counts.AttemptsAddress >= 6 { return "", Booking{}, ErrOverCap }
+day := time.Unix(in.StartUTC, 0).In(hostLoc); dayStart := midnight(day); slots, err := s.FreeSlots(ctx, in.Page, in.Host, now, dayStart, dayStart.AddDate(0,0,1), 0)
+if !containsStart(slots, in.StartUTC) { return "", Booking{}, ErrSlotTaken }
+// then the transaction: buildEvent(...StatusTentative...) + tx.Create(&e); token; seal; tx.Create(&b)
```
+`ConfirmBooking`: one transaction — `res := tx.Model(&Booking{}).Where("id = ? AND state = ? AND hold_expires_at > ?", id, BookingHeld, now.Unix()).Updates(map[string]any{"state": BookingConfirmed, "confirmed_at": now.Unix(), "pending_mail": MailConfirmed, "updated_at": now})`; `if res.RowsAffected == 0 { return false, nil }`; then event `status`, `sequence = sequence + 1` (use `gorm.Expr("sequence + 1")`), `updated_at`; `SetAttendees`; `EnsureDefaultReminder(ctx, host, event)` — NOTE it uses `s.g`, not `tx`; either make it accept a `*gorm.DB` or call it right after the transaction commits and accept the tiny window (say which in a comment; the reminder is the host's convenience, not the booking's correctness).
-- [ ] **Step 2: Run, confirm compile failure.**
-
-- [ ] **Step 3: Implement**
+`RescheduleBooking`: `holdMu`; read booking (must be `confirmed`, `Reschedules < MaxReschedules` else `ErrOverCap`); `FreeSlots(..., excludeEventID = b.EventID)`; transaction: event `start_local`, `end_local`, `start_utc`, `end_utc`, `sequence+1`, `updated_at`; booking conditional update `WHERE id=? AND state='confirmed' AND reschedules = ?` setting `reschedules+1`, `pending_mail`; then `RescheduleReminder` for each `RemindersFor(host, eventID)` (again outside `tx` if needed).
-`HoldBooking`, inside `s.g.WithContext(ctx).Transaction`:
-1. `FreeSlots(ctx, in.Page, in.Host, now, start, end, 0)` for the slot's own day; if no slot has `StartUTC == in.StartUTC` → `ErrSlotTaken`. (SQLite serialises writers, so this check inside the write transaction is the race guard; say so in a comment.)
-2. `calID, _ := s.DefaultCalendarID(ctx, in.Host.ID)`.
-3. `startLocal := time.Unix(in.StartUTC,0).In(hostLoc).Format(LayoutLocal)`, same for end. `buildEvent(in.Host.ID, EventInput{CalendarID: calID, Title: in.Page.Title + " — " + in.Name, Notes: in.Note, StartLocal, EndLocal, TZ: in.Host.Zone, Kind: KindMeeting, Status: StatusTentative})`; `tx.Create(&e)`.
-4. Token: 32 random bytes hex, `TokenHash: HashFeedToken(token)`. Seal name/address/note under the booking purposes; `BookerAddressFP: s.seal.Fingerprint(seal.PurposeAttendeeLookup, normalizeAddress(in.Address))`. `HoldExpiresAt: now.Add(HoldTTL).Unix()`. `tx.Create(&b)`.
+`CancelBooking`: conditional `WHERE id=? AND state='confirmed'` → `cancelled`, `cancelled_at`, `pending_mail`; event `status=cancelled`, `sequence+1`; `DELETE FROM reminder_queues WHERE reminder_id IN (SELECT id FROM reminders WHERE event_id = ?)`.
-`ConfirmBooking`: load row; `held` and `HoldExpiresAt > now` → in one transaction set state/confirmed_at, event `status = confirmed, sequence = sequence + 1, updated_at`, `SetAttendees(eventID, [address])`; return `true`. `confirmed` → `false, nil`. Anything else → `false, nil` (the handler reads the state to decide what to render).
+`store_event.go`: in `UpdateEvent`, after validation: `if in.RRule != "" { if _, booked := s.bookingIDForEvent(ctx, eventID); booked { return ErrBookedEvent } }`. In `DeleteEvent`'s transaction: `tx.Model(&Booking{}).Where("event_id = ? AND state = ?", eventID, BookingConfirmed).Updates(map[string]any{"state": BookingCancelled, "cancelled_at": now.Unix(), "pending_mail": MailCancelled, "updated_at": now})`; also `held` → `expired` (a deleted hold is a dead hold).
-`ExpireHolds`: `UPDATE bookings SET state='expired' WHERE state='held' AND hold_expires_at <= ?` returning the ids first (SELECT then UPDATE in one transaction), then `UPDATE events SET deleted_at = ? WHERE id IN (...)`.
+`NextDueAt`: reminder due → `min` with `NextHoldExpiry` → if `MailIsOwed` return `time.Now()`.
-`NextDueAt` in `store_reminder_next.go`: compute the reminder due as now, then `if h := s.NextHoldExpiry(ctx); !h.IsZero() && (due.IsZero() || h.Before(due)) { due = h }`.
-
-`OpenHoldCounts`: three `COUNT(*)` queries with `state = 'held' AND hold_expires_at > ?`.
-
-- [ ] **Step 4: Run; pass. Step 5: `make ci`; commit** `booking: held → confirmed → cancelled, and expired holds free their slot`.
+- [ ] **Step 4: `go test -race ./internal/calendartest/ -run 'Hold|Confirm|Reschedule|Cancel|Booked|Expire|Owed|Forget|NextDueAt' -count=1`; pass. `make ci`; commit** `booking: held → confirmed → cancelled as conditional updates, and mail as an outbox the drain owns`.
---
### Task 5: Host settings UI
-Spec §2.1, §3.4, plus the list/create/edit screens.
+Spec §2.1, §3.4, §7.3.
-**Files:**
-- Create: `internal/calendar/handlers_booking_settings.go`
-- Create: `internal/calendar/templates/booking_pages.html`, `booking_page_form.html`
-- Create: `internal/calendar/static/booking.js`
-- Modify: `internal/calendar/templates/settings.html` (handle field, link to pages), `handlers_misc.go:230-252` (`saveSettings` reads `handle`), `routes.go` (signed-in group), `render.go` (parse new pages), `templates/layout.html` (include `booking.js` — find where `quickadd.js` is included and add beside it), `static/calendar.css`
-- Test: `internal/calendartest/booking_settings_test.go`
+**Files:** create `handlers_booking_settings.go` (list, publish, parse), `handlers_booking_form.go` (new/edit/create/update), `templates/booking_pages.html`, `booking_page_form.html`, `static/booking.js`; modify `settings.html`, `handlers_misc.go` (`saveSettings` + `settings` read `handle`), `routes.go`, `render.go` (`pageNames` + ParseFS), `layout.html` (`booking.js` after `quickadd.js`), `calendar.css`; test `booking_settings_test.go`.
-**Routes (inside the signed-in group):**
-```
-GET /settings/booking list pages + "New page"
-GET /settings/booking/new form
-POST /settings/booking create → 303 /settings/booking?saved=1
-GET /settings/booking/{id} edit form (404 if not mine)
-POST /settings/booking/{id} update → 303
-POST /settings/booking/{id}/active toggle (field active=0|1)
-POST /settings/booking/parse fragment "bookingpane"
+**Routes (signed-in group):**
```
-
-- [ ] **Step 1: Failing router tests**
-
-```go
-func TestAHostCanMakeAPageAndSeeItsPublicURL(t *testing.T) {
- app, session := signedIn(t, "host@example.com")
- h := app.Handler()
- rec := postAs(t, h, session, "/settings", url.Values{"zone": {"Europe/Dublin"}, "week_start": {"1"},
- "default_reminder_min": {"10"}, "default_all_day_min": {"360"}, "handle": {"paul"}})
- if rec.Code != http.StatusSeeOther { t.Fatalf("save settings: %d\n%s", rec.Code, rec.Body.String()) }
- rec = postAs(t, h, session, "/settings/booking", url.Values{
- "slug": {"chat"}, "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"}, "active": {"1"}})
- if rec.Code != http.StatusSeeOther { t.Fatalf("create page: %d\n%s", rec.Code, rec.Body.String()) }
- body := getAs(t, h, session, "/settings/booking").Body.String()
- for _, want := range []string{"Quick chat", "/book/paul/chat", "Tuesday 12:00–14:00, 17:00–19:00"} {
- if !strings.Contains(body, want) { t.Errorf("list lacks %q:\n%s", want, body) }
- }
-}
-
-func TestAPageWithAnUnreadableSentenceIsNotSaved(t *testing.T) {
- app, session := signedIn(t, "host@example.com")
- h := app.Handler()
- postAs(t, h, session, "/settings", url.Values{"zone": {"UTC"}, "handle": {"paul"}})
- rec := postAs(t, h, session, "/settings/booking", url.Values{
- "slug": {"chat"}, "title": {"Chat"}, "availability": {"mondays whenever"}, "duration_min": {"30"}, "horizon_days": {"30"}})
- 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(), "/book/paul/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"}})
- if !strings.Contains(rec.Body.String(), "Friday 09:00–17:00") || strings.Contains(strings.ToLower(rec.Body.String()), "<html") {
- t.Errorf("pane:\n%s", rec.Body.String())
- }
-}
-
-func TestAnotherPersonsBookingPageIsNotFound(t *testing.T) {
- app, mine := signedIn(t, "me@example.com")
- h := app.Handler()
- postAs(t, h, mine, "/settings", url.Values{"zone": {"UTC"}, "handle": {"me"}})
- postAs(t, h, mine, "/settings/booking", url.Values{"slug": {"x"}, "title": {"X"}, "availability": {"mon 9-5"}, "duration_min": {"30"}, "horizon_days": {"30"}, "active": {"1"}})
- theirs := app.SignInForTest(t, "them@example.com")
- if rec := getAs(t, h, theirs, "/settings/booking/1"); rec.Code != http.StatusNotFound { t.Errorf("status %d, want 404", rec.Code) }
-}
+GET /settings/booking list
+GET /settings/booking/new form
+POST /settings/booking create → 303 /settings/booking?saved=1
+GET /settings/booking/{id} edit form; 404 if not mine
+POST /settings/booking/{id} update → 303
+POST /settings/booking/{id}/publish publish=1|0 → 303
+POST /settings/booking/parse fragment
```
-Note: `saveSettings` currently requires `week_start` etc.; check `SaveSettings` validation and pass whatever fields it needs so the handle test isolates the handle.
-
-- [ ] **Step 2: Run; confirm 404s/compile failures.**
+- [ ] **Step 1: Tests** — `booking_settings_test.go`:
+ - `TestAHostMakesAPageAndSeesItsPublicURLOnlyOncePublished`: save settings with `handle=paul`; create page; list shows title and windows but says "Not published" and shows no `/book/` URL; POST publish=1; list shows `/book/paul/<code>` and the publish sentence ("Anyone with this link can see when you are free"). Extract the code with `regexp.MustCompile(`/book/paul/([a-z0-9]{8})`)`.
+ - `TestAPageWithAnUnreadableSentenceIsNotSaved`: 422, quotes the clause, list has no such page.
+ - `TestThePaneFragmentDescribesTheWindows`: `fri 9-5` → `Friday 09:00–17:00`, no `<html`.
+ - `TestAnotherPersonsBookingPageIs404`: GET and POST `/settings/booking/{id}` as another person → 404; POST publish → 404.
+ - `TestATakenHandleIsRefusedOnSettings`: second person posts `handle=paul` → 422 containing "taken".
-- [ ] **Step 3: Implement handlers**
+- [ ] **Step 2: Run; confirm.**
+- [ ] **Step 3: Implement.** `saveSettings`: after `SaveSettings` succeeds, `SaveHandle`; `ErrHandleTaken` → 422 with "That handle is taken."; `ValidationError` → its message. `settings.html`: a `field-text` for `handle` (value `{{with .Data.Person.Handle}}{{.}}{{end}}`), hint "Lowercase letters, digits and dashes. Changing it changes every booking link you have shared.", and a link "Booking pages →" to `/settings/booking`.
-`saveSettings`: after `SaveSettings`, `if err := h.store.SaveHandle(ctx, p.ID, r.FormValue("handle")); err != nil` → `ErrHandleTaken` renders the settings page with `Error: "That handle is taken."` at 422; a `ValidationError` renders its message.
+`bookingPageForm` struct: `ID int64; Title, Description, Availability, DurationMin, BufferMin, NoticeMin, HorizonDays string; Pane BookingPaneView; Error string`. `readBookingPageForm(r) (BookingPageInput, bookingPageForm)` with defaults `30/10/120/30` when blank. Create/update: `ws, problems := ParseAvailability(in.Availability)`; problems → 422 with `Pane{Lines, Problems}`; `ValidationError` → 422 with `Error`; else store → 303.
-`bookingPageForm` struct: every field as string plus `Pane BookingPaneView`, `Error string`, `ID int64`, `PublicURL string`. `readBookingPageForm(r) (BookingPageInput, form)`: `strconv.Atoi` each number; defaults 10/120/30 when blank. `createBookingPage`: `ws, problems := ParseAvailability(in.Availability)`; problems non-empty → 422 with the form and pane; else `CreateBookingPage`; `ValidationError` → 422 too. Same for update.
+`booking_pages.html`: page-header "Booking pages" with a "New page" `rst-btn="primary"` link; when the person has no handle, a `callout` (warning): "Choose a handle in Settings before publishing — it is the first part of every booking link." Each page: title, duration, `DescribeWindows` lines, state badge (Published / Not published), the URL when published (`h.origin + "/book/" + handle + "/" + code`, as a `<code>` and a copyable link), Edit link, and a publish form whose button reads "Publish" or "Unpublish" with the §7.3 sentence above it as `<p cal-book-caveat>`.
-List page shows for each: title, duration, `Active` as "Live"/"Paused", the public URL `h.origin + "/book/" + handle + "/" + slug` (only when the person has a handle; otherwise a callout "Choose a handle in Settings to publish these"), `DescribeWindows` lines, Edit link, an active toggle form.
+`booking_page_form.html`: `field-text` title, `field-textarea` description, `field-textarea` availability with `cal-book-text` (wrap in `<div cal-book-form>` so the JS can find root), then `<div id="cal-book-pane" cal-book-pane aria-live="polite">{{template "bookingpane" .Data.Pane}}</div>`, `field-select` duration (5,10,15,20,30,45,60,90,120), `field-text` buffer/notice/horizon with `inputmode=numeric`, `form-foot` Save / Cancel → `/settings/booking`.
-Templates: use `field-input`, `field-textarea`, `field-select`, `field-check`, `form-foot`, `callout` from rastrillo's partials (see `settings.html` for the `dict` calling convention; check `field-input.html` and `field-textarea.html` in the module cache for exact keys). The availability textarea gets `cal-book-text` and the pane `<div id="cal-book-pane" cal-book-pane aria-live="polite">{{template "bookingpane" .Data.Pane}}</div>` directly under it.
+`booking.js` (clone `quickadd.js`; < 120 lines): `setupPane(root)` for `[cal-book-form]` → `/settings/booking/parse`; `setupZone()` — if `[cal-book-zone]` exists and has no `data-chosen`, set its value to `Intl.DateTimeFormat().resolvedOptions().timeZone` when that value is one of its options.
-`booking.js`: copy `quickadd.js`, root selector `[cal-book-form]`, text `[cal-book-text]`, pane `[cal-book-pane]`, endpoint `/settings/booking/parse`, no Enter handler, no hint. Keep it under 100 lines. It also does the public page's zone preselect (Task 6) — write that as a second `setup` in the same file, guarded by `[cal-book-zone]`.
+CSS: `[cal-book-pane]`, `[cal-book-lines]`, `[cal-book-problems]`, `[cal-book-caveat]`, `[cal-book-slots]`, `[cal-book-slot]` using existing `--rst-*` tokens already referenced in `calendar.css` (the guard test fails on a token that is not declared).
-CSS: a `cal-book*` block in `calendar.css` reusing the `cal-quick-pane` look (tokens only from `var(--rst-*)` that already exist in the file).
-
-- [ ] **Step 4: Run; pass. Step 5: `make ci`; commit** `booking: the host's side — a handle, pages, and a pane that says what "mondays 12–3" means`.
+- [ ] **Step 4: Run; pass. `make ci`; commit** `booking: the host's side — a handle, pages, a pane, and a Publish button that says what it does`.
---
### Task 6: The public flow, mail and ICS
-Spec §5, §6.
+Spec §5, §6, §7.1 (429), §2.5.
-**Files:**
-- Create: `internal/calendar/handlers_booking_public.go`, `handlers_booking_manage.go`, `booking_mail.go`, `booking_ics.go`
-- Create: `internal/calendar/templates/book_page.html`, `book_sent.html`, `book_manage.html`, `book_gone.html`, `templates/mail/booking_hold.txt`, `booking_confirmed_host.txt`, `booking_rescheduled.txt`, `booking_cancelled.txt`
-- Modify: `routes.go` (public routes beside `/feed`), `render.go`, `static/booking.js` (zone preselect), `static/calendar.css`
-- Test: `internal/calendartest/booking_http_test.go`
+**Files:** create `handlers_booking_public.go` (page GET/POST), `handlers_booking_manage.go` (manage GET, confirm, reschedule, cancel), `handlers_booking_util.go` (`clientIP`, `notFoundLikeTheRouter`, `publicHeaders`, zone pick), `booking_ics.go`, `booking_mail.go`, `templates/mail/{hold,confirmed_host,rescheduled,cancelled}.txt`, `templates/book_page.html`, `book_hold.html`, `book_sent.html`, `book_manage.html`, `book_gone.html`; modify `routes.go`, `render.go`, `calendar.css`; test `booking_http_test.go`, `booking_abuse_test.go`.
-**Routes (in the chi router, OUTSIDE the session group, AFTER `/feed`):**
+**Routes (chi router, outside the session group, registered BEFORE `/book/{handle}/{code}`):**
```
-GET /book/{handle}/{slug} page; ?day=YYYY-MM-DD&zone=Area/City
-POST /book/{handle}/{slug} hold: start (unix), zone, name, address, note
-GET /book/m/{token} confirm-if-held, then manage
-GET /book/m/{token}/calendar.ics the ICS (a path segment, not {token}.ics, so chi cannot confuse it with the manage route)
-GET /book/m/{token}/reschedule slot picker (?day=&zone=)
-POST /book/m/{token}/reschedule start
+GET /book/m/{token} state page (held: confirm button; confirmed: manage)
+POST /book/m/{token}/confirm
+GET /book/m/{token}/calendar.ics
+GET /book/m/{token}/reschedule ?day=&zone=
+POST /book/m/{token}/reschedule start, zone
POST /book/m/{token}/cancel
+GET /book/{handle}/{code} ?day=&zone=
+POST /book/{handle}/{code} start, zone [, name, address, note]
```
-Register `/book/m/...` BEFORE `/book/{handle}/{slug}`; `ValidateHandle` already refuses `m`.
-
-**Client IP for the cap:** `func clientIP(r *http.Request) string` — first comma-separated value of `X-Forwarded-For` if present, else host of `r.RemoteAddr`. Fingerprint it with `s.seal.Fingerprint(seal.PurposeBookingIP, ip)`. Never log it.
-
-**Mail (`booking_mail.go`):** `//go:embed templates/mail/*.txt` into a `text/template` set. Four funcs on `handlers`: `mailHold(ctx, v BookingView, token string) error`, `mailConfirmedToHost`, `mailRescheduled(both)`, `mailCancelled(both)`. Links are `h.origin + "/book/m/" + token`. Times formatted with `InZone(unix, zone).Format("Monday 2 January 2006, 15:04")` plus the zone name. The host's mail also links `h.origin + "/events/" + eventID`.
-
-**ICS (`booking_ics.go`):** `func WriteBookingICS(v BookingView, manageURL string) string` — VCALENDAR with `METHOD:REQUEST` (confirmed) or `METHOD:CANCEL` (cancelled); one VEVENT via `line(...)`/`escapeText`/`icsDateTime`/`utcStamp` from `ics_export.go`: `UID` = the event's opened UID (`v.Event.UIDSealedOpen`; load it — see how `EventsForFeed` does), `SEQUENCE` = `v.Sequence`, `DTSTART;TZID=`/`DTEND;TZID=` in the host zone, `SUMMARY` = page title, `DESCRIPTION` = page description, `ORGANIZER;CN=<host name or handle>:mailto:<host address>`, `ATTENDEE;PARTSTAT=ACCEPTED;RSVP=FALSE:mailto:<booker>`, `URL:` manage URL, `STATUS:CONFIRMED|CANCELLED`.
-
-- [ ] **Step 1: Failing router tests**
+**Utilities:**
```go
-// internal/calendartest/booking_http_test.go
-package calendartest
-
-func publishedPage(t *testing.T) (*calendar.App, *pickyMailer, string) {
- t.Helper()
- mailer := &pickyMailer{}
- app := newAppWithMailer(t, mailer)
- session := app.SignInForTest(t, "host@example.com")
- h := app.Handler()
- postAs(t, h, session, "/settings", url.Values{"zone": {"Europe/Dublin"}, "handle": {"paul"}})
- postAs(t, h, session, "/settings/booking", url.Values{"slug": {"chat"}, "title": {"Quick chat"},
- "availability": {"every day 9-5"}, "duration_min": {"30"}, "buffer_min": {"0"}, "notice_min": {"0"}, "horizon_days": {"30"}, "active": {"1"}})
- mailer.delivered = nil
- return app, mailer, "/book/paul/chat"
-}
-
-// A stranger has no session. RequestForTest(nil, ...) sends Origin on POST, as a browser would from our own page.
-func strangerGet(t *testing.T, h http.Handler, target string) *httptest.ResponseRecorder {
- rec := httptest.NewRecorder(); h.ServeHTTP(rec, calendar.RequestForTest(nil, http.MethodGet, target, nil)); return rec
-}
-func strangerPost(t *testing.T, h http.Handler, target string, form url.Values) *httptest.ResponseRecorder {
- rec := httptest.NewRecorder(); h.ServeHTTP(rec, calendar.RequestForTest(nil, http.MethodPost, target, form)); return rec
-}
-
-var reStart = regexp.MustCompile(`name="start" value="(\d+)"`)
-var reManage = regexp.MustCompile(`/book/m/([0-9a-f]{64})`)
-
-func TestAStrangerCanHoldConfirmAndSeeTheICS(t *testing.T) {
- app, mailer, path := publishedPage(t)
- h := app.Handler()
- day := time.Now().AddDate(0, 0, 1).Format("2006-01-02")
- page := strangerGet(t, h, path+"?day="+day+"&zone=Europe/Dublin")
- if page.Code != 200 { t.Fatalf("page: %d", page.Code) }
- m := reStart.FindStringSubmatch(page.Body.String())
- if m == nil { t.Fatalf("no slot buttons:\n%s", page.Body.String()) }
- rec := strangerPost(t, h, path, url.Values{"start": {m[1]}, "zone": {"Europe/Dublin"}, "name": {"Doc"}, "address": {"doc@tito.io"}, "note": {"About the thing"}})
- if rec.Code != 200 || !strings.Contains(rec.Body.String(), "doc@tito.io") { t.Fatalf("hold: %d\n%s", rec.Code, rec.Body.String()) }
- if len(mailer.delivered) != 1 { t.Fatalf("mails %d, want 1 (to the booker)", len(mailer.delivered)) }
- link := reManage.FindString(mailer.delivered[0])
- if link == "" { t.Fatalf("no manage link in:\n%s", mailer.delivered[0]) }
- // The link confirms.
- man := strangerGet(t, h, link)
- if man.Code != 200 || !strings.Contains(man.Body.String(), "Reschedule") { t.Fatalf("manage: %d\n%s", man.Code, man.Body.String()) }
- if len(mailer.delivered) != 2 || !strings.Contains(mailer.delivered[1], "Doc") { t.Errorf("host was not told: %v", mailer.delivered) }
- // Idempotent.
- strangerGet(t, h, link)
- if len(mailer.delivered) != 2 { t.Errorf("confirming twice mailed the host twice") }
- // The ICS.
- ics := strangerGet(t, h, link+"/calendar.ics")
- body := ics.Body.String()
- for _, want := range []string{"METHOD:REQUEST", "SUMMARY:Quick chat", "ATTENDEE;PARTSTAT=ACCEPTED;RSVP=FALSE:mailto:doc@tito.io", "ORGANIZER", "mailto:host@example.com", "URL:", "SEQUENCE:"} {
- if !strings.Contains(body, want) { t.Errorf("ics lacks %q:\n%s", want, body) }
- }
- if ct := ics.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/calendar") { t.Errorf("content-type %q", ct) }
- if _, err := calendar.ParseICS(strings.NewReader(body)); err != nil { t.Errorf("our own parser refuses it: %v", err) } // adapt to ParseICS's real signature
-}
-
-func TestTheSameSlotCannotBeHeldTwice(t *testing.T) {
- app, _, path := publishedPage(t)
- h := app.Handler()
- day := time.Now().AddDate(0, 0, 1).Format("2006-01-02")
- m := reStart.FindStringSubmatch(strangerGet(t, h, path+"?day="+day).Body.String())
- form := url.Values{"start": {m[1]}, "zone": {"UTC"}, "name": {"A"}, "address": {"a@example.com"}}
- strangerPost(t, h, path, form)
- form.Set("address", "b@example.com")
- rec := strangerPost(t, h, path, form)
- if rec.Code != http.StatusConflict || !strings.Contains(rec.Body.String(), "just gone") { t.Errorf("second hold: %d\n%s", rec.Code, rec.Body.String()) }
-}
-
-func TestAWrongTokenIsTheSame404AsAnUnknownRoute(t *testing.T) {
- app, _, _ := publishedPage(t)
- h := app.Handler()
- a := strangerGet(t, h, "/book/m/"+strings.Repeat("0", 64))
- b := strangerGet(t, h, "/no/such/route")
- if a.Code != 404 || a.Body.String() != b.Body.String() { t.Errorf("token 404 differs from route 404 (%d)", a.Code) }
-}
-
-func TestRescheduleAndCancelTellBothParties(t *testing.T) {
- app, mailer, path := publishedPage(t)
- h := app.Handler()
- day := time.Now().AddDate(0, 0, 1).Format("2006-01-02")
- all := reStart.FindAllStringSubmatch(strangerGet(t, h, path+"?day="+day).Body.String(), -1)
- strangerPost(t, h, path, url.Values{"start": {all[0][1]}, "zone": {"UTC"}, "name": {"Doc"}, "address": {"doc@tito.io"}})
- link := reManage.FindString(mailer.delivered[0])
- strangerGet(t, h, link)
- mailer.delivered = nil
- rec := strangerPost(t, h, link+"/reschedule", url.Values{"start": {all[1][1]}, "zone": {"UTC"}})
- if rec.Code != 200 { t.Fatalf("reschedule: %d\n%s", rec.Code, rec.Body.String()) }
- if len(mailer.delivered) != 2 { t.Errorf("reschedule mailed %d, want booker and host", len(mailer.delivered)) }
- if !strings.Contains(strangerGet(t, h, link+"/calendar.ics").Body.String(), "SEQUENCE:1") { t.Error("SEQUENCE not bumped") }
- mailer.delivered = nil
- rec = strangerPost(t, h, link+"/cancel", nil)
- if rec.Code != 200 || len(mailer.delivered) != 2 { t.Errorf("cancel: %d, mails %d", rec.Code, len(mailer.delivered)) }
- if !strings.Contains(strangerGet(t, h, link+"/calendar.ics").Body.String(), "METHOD:CANCEL") { t.Error("no METHOD:CANCEL") }
-}
-
-func TestAHoldWhoseMailFailsIsRolledBack(t *testing.T) {
- app, mailer, path := publishedPage(t)
- mailer.refuse = "bounce@example.com"
- h := app.Handler()
- day := time.Now().AddDate(0, 0, 1).Format("2006-01-02")
- m := reStart.FindStringSubmatch(strangerGet(t, h, path+"?day="+day).Body.String())
- rec := strangerPost(t, h, path, url.Values{"start": {m[1]}, "zone": {"UTC"}, "name": {"B"}, "address": {"bounce@example.com"}})
- if rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), "could not send") { t.Errorf("%d\n%s", rec.Code, rec.Body.String()) }
- // The slot is still free.
- again := reStart.FindStringSubmatch(strangerGet(t, h, path+"?day="+day).Body.String())
- if again == nil || again[1] != m[1] { t.Error("the failed hold kept the slot") }
-}
+func clientIP(r *http.Request) string // last XFF element, else RemoteAddr host; net.ParseIP(...).String() or "unknown"
+func notFoundLikeTheRouter(w http.ResponseWriter, r *http.Request) // ErrorPage(w, r, 404, "") — confirm this is what chi's NotFound is wired to in routes.go/main.go; if the router's 404 is plain http.NotFound, use that instead. The test compares bodies.
+func publicHeaders(w http.ResponseWriter) // Cache-Control: no-store; Referrer-Policy: no-referrer
+func pickZone(r *http.Request, fallback string) (zone string, chosen bool) // IsCommonZone(r.FormValue("zone")) else fallback
```
-`pickyMailer.refuse` matches on `subject+body`; the hold mail body must contain the address (it does: "You asked to book … we will send to …" — or match on the `to` argument: extend `pickyMailer.Send` to also check `to`, it is test code).
+**Mail:** `//go:embed templates/mail/*.txt` → `text/template`. `func (h *handlers) sendBookingMail(ctx, v BookingView, token string) error` switches on `v.PendingMail`; returns the first error. Each template receives `struct{ Title, Description, HostName, HostAddress, Name, Address, Note, WhenBooker, WhenHost, ExpiryBooker, ManageURL, EventURL string; Who string }`. `hold.txt` body per spec §6 (the "Somebody — we hope you —" wording). `ManageURL` is only known at hold time (the token is plaintext once); for later mails the booker gets the manage URL ONLY if the handler has the token in hand (confirm/reschedule/cancel handlers do, from the path); the DRAIN retry does not, so the rescheduled/cancelled templates say "the link in your first mail" when `ManageURL` is empty. Wrap `Send` in `context.WithTimeout(ctx, 20*time.Second)` with the comment the spec asks for.
-- [ ] **Step 2: Run, confirm failures.**
+**ICS:** `func WriteBookingICS(v BookingView) string` — `BEGIN:VCALENDAR / VERSION / PRODID / CALSCALE / METHOD:REQUEST|CANCEL` then one VEVENT: `UID` (event's opened UID: extend `BookingByToken` to open `UIDSealed` with `PurposeEventUID`), `DTSTAMP`, `SEQUENCE:{v.Event.Sequence}`, `DTSTART;TZID=host zone`, `DTEND`, `SUMMARY:{page title}`, `DESCRIPTION:{page description}` (only if non-empty), `ORGANIZER;CN={HostName}:mailto:{host address}`, `ATTENDEE;CN={Name};PARTSTAT=ACCEPTED;RSVP=FALSE:mailto:{booker}`, `STATUS:CONFIRMED|CANCELLED`. **No `URL:`. No event notes. No location. No other attendees.** Reuse `line`, `escapeText`, `icsDateTime`, `utcStamp` from `ics_export.go`.
-- [ ] **Step 3: Implement**
+- [ ] **Step 1: Tests** — `booking_http_test.go`. Helpers: `publishedPage(t) (*calendar.App, *pickyMailer, string /*path*/, calendar.Person)`; `strangerGet/strangerPost` (RequestForTest with nil session; for POSTs also set `req.RemoteAddr` when a test needs distinct IPs); `reStart = regexp.MustCompile(`name="start" value="(\d+)"`)`; `reManage = regexp.MustCompile(`/book/m/([0-9a-f]{64})`)`; extend `pickyMailer` so `refuse` also matches `to`.
-`bookPage` (GET): `PublicBookingPage` → 404 on error. Zone from `?zone=` if `Zone()` accepts it, else host's. `?day=` parsed with `ParseDate(day, hostZone)`; when absent, list the next `horizon_days` days that have at least one slot (call `FreeSlots` once for the whole horizon and group by host-zone date) as links. With a day: that day's slots as `<form method="post"><input type="hidden" name="start" value="{{.StartUTC}}"><input type="hidden" name="zone" …><button rst-btn="secondary">{{booker-zone time}}</button></form>` each, followed by the name/address/note form which is shown after a slot is chosen — simplest scriptless shape: the slot button posts `start` and the handler re-renders the page with the chosen slot fixed and the details form visible (`?start=` in the redirect, or render directly at 200). Then the details form posts `start`, `zone`, `name`, `address`, `note`. Two POSTs, no JS.
+ - `TestAStrangerHoldsAndTheLinkOnlyOffersAButton`: GET page with `?day=` → slot forms; POST slot (no name) → 200 with details form carrying `start` hidden; POST with name/address/note → 200 "Check your mail" echoing the address; exactly one mail, to the booker, containing `/book/m/`; GET the link → 200 with `Confirm this booking` and a form posting to `…/confirm`; **state still held, mails still 1**; POST confirm → 200 manage with "Reschedule"; mails now 2, the second to the host, containing "Doc"; POST confirm again → mails still 2.
+ - `TestTheICSIsARequestWithNoManagementLink`: after confirm, GET `…/calendar.ics`: `text/calendar`, `METHOD:REQUEST`, `SUMMARY:Quick chat`, `ATTENDEE;CN=Doc;PARTSTAT=ACCEPTED;RSVP=FALSE:mailto:doc@tito.io`, `ORGANIZER`, `mailto:host@example.com`, `SEQUENCE:1`, and `!strings.Contains(body, "URL:")`. Parse it with the app's importer (find the parse entry point in `ics_import.go`/`ics_parse.go` — `ParseICS` or similar — and assert one event).
+ - `TestTheHostsLaterEditsNeverReachTheBooker`: host `UpdateEvent` adding `Notes: "private"`, `Location: "Room 9"`, then `SetAttendees(event, [doc, "boss@example.com"])`; GET manage, GET ICS, and the cancelled mail body after a booker cancel must contain none of `private`, `Room 9`, `boss@example.com`.
+ - `TestTheSameSlotCannotBeHeldTwice` → 409 "just gone".
+ - `TestAWrongTokenIsTheSame404AsAnUnknownRoute`: bodies byte-identical.
+ - `TestAnExpiredHoldSaysSoOnlyToTheRightToken`: age `hold_expires_at`; GET link → 200 containing "expired"; GET wrong token → 404.
+ - `TestRescheduleAndCancelTellBothParties`: reschedule → 2 mails, ICS `SEQUENCE:2`; cancel → 2 more, `METHOD:CANCEL`; second cancel → no more mail.
+ - `TestHostDeletingTheEventTellsTheBooker`: host `POST /events/{id}/delete` (or `DeleteEvent`) → `tick` → the booker got a cancelled mail; GET manage says cancelled.
+ - `TestAHoldWhoseMailIsRefusedIsFailedAndFreesTheSlot`: `mailer.refuse = "bounce@example.com"` → 422 "could not send"; the slot is offered again; the row exists with state `failed` (query `DBForTest`).
+ - `TestPublicResponsesAreNoStoreNoReferrer`.
-`bookHold` (POST with `name` present): validate `name` (1–100), `address` (`reAddress` full match), `note` ≤ 2000; recompute slot; `OpenHoldCounts` vs the three caps → 429 `book_gone` with the cap message; `HoldBooking` → `ErrSlotTaken` → 409 re-render with "That time has just gone"; `mailHold` fails → `DeleteHold` → 422 "We could not send to that address"; else 200 `book_sent`.
+ `booking_abuse_test.go`:
+ - `TestTheSixthOpenHoldFromOneIPIs429` (six different slots, same `RemoteAddr`, distinct addresses).
+ - `TestTheThirdOpenHoldForOneAddressIs429`.
+ - `TestAttemptsCountFailedRows`: refuse the address; 6 failed holds from one address → the 7th is 429 even though none is open.
+ - `TestABodyOverTheLimitIs422` (a 20 KiB note).
+ - `TestAZoneOutsideCommonZonesFallsBackToTheHosts` (`?zone=Nowhere/Town` renders the host's zone selected).
-`bookManage` (GET `/book/m/{token}`): `BookingByToken` → 404. `held && !expired` → `ConfirmBooking`; if `changed` → `mailConfirmedToHost`. Render by state.
+- [ ] **Step 2: Run; confirm failure.**
+- [ ] **Step 3: Implement.** Handler shapes:
-`bookICS`: confirmed or cancelled only; held/expired → 404. Headers `Content-Type: text/calendar; charset=utf-8`, `Content-Disposition: attachment; filename="booking.ics"`, `Cache-Control: no-store`.
+`bookPage` GET: `PublicBookingPage` → 404; `publicHeaders`; `zone, chosen := pickZone(r, host.Zone)`; `FreeSlots` over `[now, now+horizon]` once; group by `time.Unix(s.StartUTC,0).In(bookerLoc).Format(LayoutDate)`; if `?day=` matches a group, render that day's slots as forms; else render the day list as links. Template data: `Page, HostName, Zone, Zones []string (CommonZones), Chosen bool, Days []struct{Date, Label string; N int}, Day string, Slots []struct{StartUTC int64; Label string}, Picked *struct{StartUTC int64; Label string}, Form struct{Name, Address, Note, Error string}`.
-Reschedule GET: same picker as `bookPage` with `excludeEventID = *v.EventID`. POST: `RescheduleBooking` → `ErrSlotTaken` → 409; else mail both, 200 manage.
+`bookPost` POST: `r.Body = http.MaxBytesReader(w, r.Body, 16<<10)`; `ParseForm` error → 422; `start` int; if `name == ""` → re-render the page with `Picked` set (verify `start` is currently free; if not → 409 "just gone" with the day's slots). Else validate name/address/note; `HoldBooking(HoldInput{..., IPFP: fp(clientIP(r))}, now)`: `ErrOverCap` → 429 `book_gone` with the §7 sentence; `ErrSlotTaken` → 409; else `sendBookingMail(hold)`: error → `FailHold` + 422 "We could not send to that address"; success → `MarkMailSent`; render `book_sent` (address, expiry in booker zone).
-Cancel POST: `CancelBooking`, mail both, 200 manage (state cancelled).
+`bookManage` GET: `BookingByToken` → 404; `publicHeaders`; switch state / expiry / `Event.Status == cancelled || Event.DeletedAt != nil` → `book_hold` (button) | `book_gone` (expired/cancelled/failed) | `book_manage`.
-Public pages render through `render(w, name, h.page(r, title, data))` — `page` sets `SignedIn=false` for a stranger and the layout already hides the nav (see `layout.html:73`).
+`bookConfirm` POST: `ConfirmBooking` → `changed` → `sendBookingMail(confirmed)` → `MarkMailSent`/`MarkMailFailed`; then render as `bookManage` does. Zero rows → just render.
-- [ ] **Step 4: Run; pass. Step 5: `make ci`; commit** `booking: a stranger holds a slot, a link confirms it, and the ICS is one click away`.
+`bookReschedule` GET/POST: picker with `excludeEventID`; POST → `RescheduleBooking` (`ErrSlotTaken` 409, `ErrOverCap` 429) → mail both → render manage.
----
+`bookCancel` POST: `CancelBooking` → if changed, mail both → render.
-### Task 7: Sweep on tick, caps through the router, leak test, browser passes
+`bookICS`: state `confirmed` or `cancelled` only, else 404; headers `text/calendar; charset=utf-8`, `Content-Disposition: attachment; filename="booking.ics"`, plus `publicHeaders`.
-Spec §7, §8, §9.
+Templates render through `render(w, name, h.page(r, title, data))`; the layout hides the nav for a stranger. Add the five names to `pageNames`.
-**Files:**
-- Modify: `internal/calendar/handlers_jobs.go` (`drain` calls `ExpireHolds` first; `drainOpportunistically` too)
-- Modify: `internal/calendartest/events_test.go` (leak fixture), new `booking_sweep_test.go`
-- Create: `test/browser/booking.mjs`; modify `internal/calendartest/playwright_test.go` to run it after `quickadd.mjs`
-- Modify: `Makefile` if the browser target names the script
+- [ ] **Step 4: Run; pass. `make ci`; commit** `booking: a stranger holds a slot, a button confirms it, and the ICS is one click away`.
-- [ ] **Step 1: Failing tests**
+---
-```go
-func TestTheTickExpiresHoldsAndFreesTheSlot(t *testing.T) {
- app, mailer, path := publishedPage(t)
- _ = mailer
- h := app.Handler()
- day := time.Now().AddDate(0, 0, 1).Format("2006-01-02")
- m := reStart.FindStringSubmatch(strangerGet(t, h, path+"?day="+day).Body.String())
- strangerPost(t, h, path, url.Values{"start": {m[1]}, "zone": {"UTC"}, "name": {"A"}, "address": {"a@example.com"}})
- // Age the hold in the database rather than sleeping thirty minutes.
- if _, err := app.DBForTest().Exec("UPDATE bookings SET hold_expires_at = ?", time.Now().Add(-time.Minute).Unix()); err != nil { t.Fatal(err) }
- if !app.NextDue().Before(time.Now()) { t.Error("NextDue does not see the expired hold") }
- if rec := tick(t, h); rec.Code != http.StatusNoContent { t.Fatalf("tick: %d", rec.Code) }
- again := reStart.FindStringSubmatch(strangerGet(t, h, path+"?day="+day).Body.String())
- if again == nil || again[1] != m[1] { t.Error("the slot did not come back") }
-}
+### Task 7: Sweep in the drain, leak test, log test, browser passes
-func TestTheSixthHoldFromOneAddressIs429(t *testing.T) { /* six holds, all zone UTC, from the same RemoteAddr (httptest default 192.0.2.1); the sixth → 429 */ }
-```
+Spec §8, §9.
-Extend `TestTheDatabaseNeverHoldsAPlaintextTitle` (or add `TestTheDatabaseNeverHoldsABooker`): make a page titled `"Interrogation about the acquisition"` with availability `"mondays 12-3"`, hold with name `"Dermot Lacey"`, address `"dermot.lacey@elsewhere.example"`, note `"Bring the folder"`, checkpoint, grep for all five strings and the token.
+**Files:** modify `handlers_jobs.go` (`drain` and `DrainOnRequest`), `events_test.go`; create `booking_sweep_test.go`, `test/browser/booking.mjs`; modify `playwright_test.go`, `Makefile`, `package.json`.
-Log test: with `newAppWithMailer` and a `slog` handler writing to a `bytes.Buffer` (add a `Logger` parameter path in the helper or construct `calendar.New` directly), force a mail failure and assert the buffer does not contain the address.
+- [ ] **Step 1: Tests** — `booking_sweep_test.go`:
+ - `TestTheTickExpiresHoldsFreesTheSlotAndWakesForThem`: hold; age `hold_expires_at`; `app.NextDue().Before(now)`; `tick` 204; slot offered again; the held event is gone from `/events/{id}` (404 as the host).
+ - `TestTheTickRetriesOwedMail`: confirm with the host address refused → `pending_mail='confirmed'`; un-refuse; `tick` → host mail delivered; marker cleared; `NextDue` no longer `<= now`.
+ - `TestAPageViewAlsoSweeps`: a signed-in GET `/today` after an expiry (DrainOnRequest) expires it.
+ - `TestIPFingerprintsAreForgottenAfterADay`.
+ - `TestNoAddressOrTokenReachesTheLog`: build the app with `Logger: slog.New(slog.NewTextHandler(&buf, nil))` (add a `newAppWithMailerAndLogger` helper); refuse the hold mail; assert `!bytes.Contains(buf, address)` and no 64-hex run.
+ - Extend `TestTheDatabaseNeverHoldsAPlaintextTitle` with a booking fixture: page title `"Interrogation about the acquisition"`, description `"Bring nothing"`, availability `"mondays 12-3"`, booker `"Dermot Lacey"`, `"dermot.lacey@elsewhere.example"`, note `"The folder"`, and the token; checkpoint; grep all seven.
-- [ ] **Step 2: Implement the sweep**: at the top of `drain`, `if n, err := h.store.ExpireHolds(ctx, now); err != nil { return 0, err } else if n > 0 { h.logger.Info("calendar: holds expired", "n", n) }`.
+- [ ] **Step 2: Implement the drain steps** at the top of `drain` AND in `DrainOnRequest` (bounded 200/20/500), each logging only counts with a stage. Mail retries call `sendBookingMail(v, "")` — no token — and `MarkMailSent`/`MarkMailFailed`.
-- [ ] **Step 3: Browser passes** — `test/browser/booking.mjs`, same argv contract as `quickadd.mjs`, plus a fourth arg: the public path. Pass A (JS off): open the page, click a day, click a slot, fill name/address, submit, assert "Check your mail". Pass B (JS on): open `/settings/booking/new`, type `tues 12-2`, wait for `[cal-book-pane]` to contain "Tuesday 12:00–14:00". The Go driver creates the host, handle and page through the store before launching node, and passes the path. Read `playwright_test.go` for how the origin and cookie are handed over.
+- [ ] **Step 3: Browser** — `test/browser/booking.mjs` taking `origin cookieName cookieValue publicPath`. Pass A (JS off, no cookie): open `publicPath`, click the first day link, click the first slot button, fill `name`/`address`, submit, assert "Check your mail". Then the Go side reads `app.LastMailForTest()`, extracts the link, and node pass B (JS off) opens it, asserts "Confirm this booking", presses the button, asserts "Reschedule". Pass C (JS on, signed-in cookie): `/settings/booking/new`, type `tues 12-2`, wait for `[cal-book-pane]` to contain `Tuesday 12:00–14:00`. Because pass B needs the mail from pass A, split the node script into two invocations (`booking.mjs hold …` and `booking.mjs confirm … <link>`) driven from a new `TestBookingInARealBrowser` in `playwright_test.go`; the Go test creates the host/handle/page/publish through the store first. Update `Makefile` and `package.json` to `-run 'InARealBrowser'`.
-- [ ] **Step 4: `make ci` and `make browser` green; commit** `booking: holds expire on the tick, caps hold, and a stranger with no script can book`.
+- [ ] **Step 4: `make ci` and `make browser` green; commit** `booking: holds expire on the tick, owed mail is retried, and a stranger with no script can book and confirm`.
---
### Task 8: AGENTS.md
-Spec §11 verbatim. Add the five rulings as rows in Settled decisions with "Booking spec §1, 2026-09-07" as the who/when; the Frontend, Data and Ticks bullets; the leak-list item 5. Commit `docs: the rules a booking page adds`.
+Spec §11, verbatim. Rows B1, B2, B4, B5, B8, B11, B12, B13 in Settled decisions ("Booking spec §1, 2026-09-07"); the Frontend booker exception; the two Data bullets; rename Reminders → Ticks with the `NextDue` bullet; leak-list item 5; the Time bullet on `Zone()`. Commit `docs: the rules a booking page adds`.
---
## Self-review
-- **Spec coverage:** §2 → T1; §3 → T2, T5; §4 → T3; §5 → T4, T6; §6 → T6; §7 → T4 (counts), T6 (429), T7 (test); §8 → T4, T7; §9 → every task's Step 1 plus T7; §10 is scope-out; §11 → T8. §5.1's "host's first name if set" — open `NameSealed` with `PurposeMemberName` in `PublicBookingPage`; if empty, show the handle.
-- **Type consistency:** `Window` declared once in `models_booking.go` and used by `avail.go`, `slots.go`, `store_booking_page.go`. `BookingPageView.Windows []Window`. `Slot.Local` is host zone. `HoldInput.Page` is a `BookingPageView` so `HoldBooking` can call `FreeSlots` without a second read.
-- **Placeholders:** none; where a test's expected value depends on Go's DST behaviour the plan says to pin the real number and explain it, which is the existing convention in `clock_test.go`.
+- **Spec coverage:** §2 → T1 (+ `ip_fp`, `pending_mail`, `reschedules` columns); §2.5 → T6 `clientIP`; §3 → T2, T5; §4 → T3 (round-trip rule, all-day via DayBounds, lapsed holds excluded, ±1 day envelope, per-candidate filters); §5.1–5.2 → T6 (two-step form, limits, caps 429, 409); §5.3–5.4 → T6 (GET shows button; POST confirms; idempotent by RowsAffected); §5.5 → T4 (`UpdateEvent`/`DeleteEvent`) + T6 (allowlist + test); §5.6–5.7 → T4, T6; §6 → T6 + T7 (retry); §7 → T4 counts, T6 429s, T7 forget; §8 → T7; §9 → each task's Step 1; §11 → T8.
+- **Type consistency:** `Window` in `models_booking.go`; `BookingPageView.Windows []Window`; `HoldInput.Page BookingPageView`; `BookingView.Event EventView` and public code reads only `Status/StartUTC/EndUTC/Sequence/DeletedAt/UIDSealedOpen` from it; `MailHold…MailCancelled` constants match `pending_mail` values in the spec; `HoldCounts` field names match the five caps.
+- **Placeholders:** the `"23:59"` line in Task 2's table is flagged for removal in the note beneath it; the DST expectations are pinned by running once and recording, which is the repository's stated convention for DST numbers.