booking: implementation plan against spec revision 2
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
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. |