| new file mode 100644 |
| index 0000000..d98b108 |
| --- /dev/null |
| +++ b/docs/superpowers/plans/2026-09-07-booking-plan.md |
| @@ -0,0 +1,1218 @@ |
| +# Booking Pages Implementation Plan |
| + |
| +> **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. |
| + |
| +**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. |
| + |
| +**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. |
| + |
| +**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. |
| + |
| +## 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`. |
| +- **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)`. |
| + |
| +--- |
| + |
| +## File map |
| + |
| +| 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/store_slots.go` | `BusyFor`, `FreeSlots` | 3 | |
| +| `internal/calendar/store_booking_flow.go` | hold / by-token / confirm / reschedule / cancel / expire / caps | 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 | |
| + |
| +--- |
| + |
| +### Task 1: Models, migrations, seal purposes, page store |
| + |
| +Spec §2. Everything later depends on these names. |
| + |
| +**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` |
| + |
| +**Interfaces — Produces:** |
| + |
| +```go |
| +// models_booking.go |
| +const ( |
| + BookingHeld = "held" |
| + BookingConfirmed = "confirmed" |
| + BookingCancelled = "cancelled" |
| + BookingExpired = "expired" |
| +) |
| + |
| +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"` |
| + CreatedAt time.Time `gorm:"column:created_at;not null"` |
| + UpdatedAt time.Time `gorm:"column:updated_at;not null"` |
| +} |
| + |
| +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 |
| + 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"` |
| + 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. |
| + |
| +// seal/seal.go |
| +PurposeBookingTitle = "booking.title" |
| +PurposeBookingDescription = "booking.description" |
| +PurposeBookingAvailability = "booking.availability" |
| +PurposeBookerName = "booking.booker.name" |
| +PurposeBookerAddress = "booking.booker.address" |
| +PurposeBookerNote = "booking.booker.note" |
| +PurposeBookingIP = "booking.ip" // Fingerprint only, never Seal |
| + |
| +// store_booking_page.go |
| +type BookingPageInput struct { |
| + Slug, 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 |
| +} |
| +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). |
| + |
| +- [ ] **Step 1: Write the failing store test** |
| + |
| +```go |
| +// internal/calendartest/booking_store_test.go |
| +package calendartest |
| + |
| +import ( |
| + "context" |
| + "testing" |
| + "time" |
| + |
| + calendar "amadan.net/oficina/calendar/internal/calendar" |
| +) |
| + |
| +func TestABookingPageRoundTripsWithItsWindows(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 { |
| + 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) |
| + if err != nil { |
| + t.Fatalf("CreateBookingPage: %v", err) |
| + } |
| + got, _, err := app.StoreForTest().PublicBookingPage(ctx, "paul", "chat") |
| + 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 { |
| + 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) |
| + } |
| + for _, needle := range []string{"Quick chat", "Fifteen minutes.", "mondays 12-3"} { |
| + if found, where := app.GrepDatabaseForTest(t, needle); found { |
| + t.Errorf("%q is plaintext in %s", needle, where) |
| + } |
| + } |
| +} |
| + |
| +func TestAnInactivePageAndAnUnknownHandleBothVanish(t *testing.T) { |
| + 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") |
| + 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) |
| + } |
| +} |
| + |
| +func TestAHandleMustBeUniqueAndNotReserved(t *testing.T) { |
| + app, _ := signedIn(t, "a@example.com") |
| + ctx := context.Background() |
| + st := app.StoreForTest() |
| + a, _ := st.PersonByAddress(ctx, "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 { |
| + t.Fatal(err) |
| + } |
| + 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") |
| + } |
| + if err := calendar.ValidateHandle("Paul Campbell"); err == nil { |
| + t.Error("spaces and capitals must be refused") |
| + } |
| +} |
| +``` |
| + |
| +`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 |
| + } |
| + err := s.g.WithContext(ctx).Model(&Person{}).Where("id = ?", personID). |
| + Update("handle", val).Error |
| + if err != nil && strings.Contains(err.Error(), "UNIQUE") { |
| + return ErrHandleTaken |
| + } |
| + 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>" |
| +``` |
| + |
| +--- |
| + |
| +### Task 2: The availability parser and its pane fragment |
| + |
| +Spec §3. |
| + |
| +**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` |
| + |
| +**Interfaces — Consumes:** `consumed`, `newConsumed`, `reRange`, `applyMeridiem`, `clock`, `weekdayAlt`, `weekdays`, `rangeSep` (all package-level in `quickadd*.go`). **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 bookingPaneFor(text string) BookingPaneView |
| +``` |
| + |
| +- [ ] **Step 1: Write the failing table test** |
| + |
| +```go |
| +// internal/calendartest/avail_test.go |
| +package calendartest |
| + |
| +import ( |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + calendar "amadan.net/oficina/calendar/internal/calendar" |
| +) |
| + |
| +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 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")}}, |
| + {"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")}}, |
| + {"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")}}, |
| + } |
| + 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 |
| +} |
| + |
| +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. |
| + _, problems := calendar.ParseAvailability("mondays whenever, tuesdays sometime") |
| + 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) |
| + } |
| +} |
| +``` |
| + |
| +- [ ] **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): |
| + |
| +```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 ( |
| + 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*`) |
| +) |
| + |
| +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 |
| +} |
| +``` |
| + |
| +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** |
| + |
| +`templates/_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}} |
| +``` |
| +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). |
| + |
| +`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`. |
| + |
| +--- |
| + |
| +### Task 3: Slot arithmetic |
| + |
| +Spec §4. |
| + |
| +**Files:** |
| +- Create: `internal/calendar/slots.go` (pure), `internal/calendar/store_slots.go` |
| +- Test: `internal/calendartest/slots_test.go` |
| + |
| +**Interfaces — Produces:** |
| + |
| +```go |
| +type Slot struct { |
| + StartUTC, EndUTC int64 |
| + Local time.Time // host-zone wall clock of StartUTC |
| +} |
| +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. |
| +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) 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 2: Run, confirm failure.** `go test ./internal/calendartest/ -run 'Slots|Busy|Buffer|Notice|DST' -count=1` |
| + |
| +- [ ] **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) } |
| + dur := time.Duration(rule.DurationMin) * time.Minute |
| + pad := time.Duration(rule.BufferMin) * time.Minute |
| + 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 |
| + 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 |
| + end := start.Add(dur) |
| + if start.Before(from) || end.After(to) { 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}) |
| + } |
| + } |
| + } |
| + 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` 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`. |
| + |
| +--- |
| + |
| +### Task 4: The booking state machine in the store |
| + |
| +Spec §5 (store half), §7, §8. |
| + |
| +**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) |
| + |
| +**Interfaces — Produces:** |
| + |
| +```go |
| +const HoldTTL = 30 * time.Minute |
| +const ( |
| + MaxOpenHoldsPerIP = 5 |
| + MaxOpenHoldsPerPage = 20 |
| + MaxOpenHoldsPerAddress = 2 |
| +) |
| +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 |
| +} |
| +type BookingView struct { |
| + Booking |
| + Page BookingPageView |
| + Host Person |
| + Event EventView // zero when EventID is nil |
| + 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) |
| +``` |
| + |
| +- [ ] **Step 1: Failing tests** (append to `booking_store_test.go`) |
| + |
| +```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) } |
| +} |
| +``` |
| + |
| +- [ ] **Step 2: Run, confirm compile failure.** |
| + |
| +- [ ] **Step 3: Implement** |
| + |
| +`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)`. |
| + |
| +`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). |
| + |
| +`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` 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`. |
| + |
| +--- |
| + |
| +### Task 5: Host settings UI |
| + |
| +Spec §2.1, §3.4, plus the list/create/edit screens. |
| + |
| +**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` |
| + |
| +**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" |
| +``` |
| + |
| +- [ ] **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) } |
| +} |
| +``` |
| + |
| +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 3: Implement handlers** |
| + |
| +`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: 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. |
| + |
| +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. |
| + |
| +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`: 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: 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`. |
| + |
| +--- |
| + |
| +### Task 6: The public flow, mail and ICS |
| + |
| +Spec §5, §6. |
| + |
| +**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` |
| + |
| +**Routes (in the chi router, OUTSIDE the session group, AFTER `/feed`):** |
| +``` |
| +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 |
| +POST /book/m/{token}/cancel |
| +``` |
| +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** |
| + |
| +```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") } |
| +} |
| +``` |
| + |
| +`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). |
| + |
| +- [ ] **Step 2: Run, confirm failures.** |
| + |
| +- [ ] **Step 3: Implement** |
| + |
| +`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. |
| + |
| +`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`. |
| + |
| +`bookManage` (GET `/book/m/{token}`): `BookingByToken` → 404. `held && !expired` → `ConfirmBooking`; if `changed` → `mailConfirmedToHost`. Render by state. |
| + |
| +`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`. |
| + |
| +Reschedule GET: same picker as `bookPage` with `excludeEventID = *v.EventID`. POST: `RescheduleBooking` → `ErrSlotTaken` → 409; else mail both, 200 manage. |
| + |
| +Cancel POST: `CancelBooking`, mail both, 200 manage (state cancelled). |
| + |
| +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`). |
| + |
| +- [ ] **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`. |
| + |
| +--- |
| + |
| +### Task 7: Sweep on tick, caps through the router, leak test, browser passes |
| + |
| +Spec §7, §8, §9. |
| + |
| +**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 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") } |
| +} |
| + |
| +func TestTheSixthHoldFromOneAddressIs429(t *testing.T) { /* six holds, all zone UTC, from the same RemoteAddr (httptest default 192.0.2.1); the sixth → 429 */ } |
| +``` |
| + |
| +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. |
| + |
| +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 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 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 4: `make ci` and `make browser` green; commit** `booking: holds expire on the tick, caps hold, and a stranger with no script can book`. |
| + |
| +--- |
| + |
| +### 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`. |
| + |
| +--- |
| + |
| +## 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`. |