oficina / calendar Public

booking: one parser for "mondays 12–3", and Zone() stops caching garbage

ParseAvailability reads the host's availability sentence into weekly
windows (spec §3). Clauses split on , and ;, each a day part then time
ranges, with days inherited from the previous clause. It shares the
scanner and the day names with quick add but NOT its clock reading:
clock() and applyMeridiem() clamp and forgive, and a rule that decides
when strangers may book has to refuse "25-26", "13pm", "9:60" and a
"24:00" that is not at the end, with the range quoted. Every unread
token is a problem, all problems are reported, and what could be read
is returned alongside so the pane shows both. Windows are merged when
they overlap or touch and sorted Sunday-first.

Zone()'s cache was a plain map behind a closure, written from every
request at once — the race detector reports it the first time a hundred
goroutines ask together — and it cached failures, so any string in a
?zone= parameter grew it by one entry forever. It is now a sync.Map
holding successes only, with IsCommonZone as the bound on what a
stranger may name.

The merge/describe file is avail_describe.go, not the avail_windows.go
the spec and plan named: Go reads a _windows.go suffix as a GOOS build
constraint and silently skipped the file on Linux. Both documents are
corrected in this commit.

One test expectation was rewritten after the parser refused it, and the
parser is right: "7:45-8:15" is inverted under the spec's hour-component
rule (7 → 19:45, 8 stays), so it sits in the refusing table with the
"7:45am" spelling in the reading one.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev 11eecd80e2b4ac3a69bf3697726c9ee30ec317c9 parent 13f855b
11 files changed, +704 −24
  • docs/superpowers/plans/2026-09-07-booking-plan.md +2 −2
  • docs/superpowers/specs/2026-09-07-booking-design.md +3 −1
  • internal/calendar/avail.go +249 −0
  • internal/calendar/avail_describe.go +88 −0
  • internal/calendar/clock.go +25 −17
  • internal/calendar/render.go +6 −4
  • internal/calendar/templates/_bookingpane.html +25 −0
  • internal/calendar/testing.go +13 −0
  • internal/calendar/zones.go +17 −0
  • internal/calendartest/avail_test.go +225 −0
  • internal/calendartest/zone_test.go +51 −0
diff --git a/docs/superpowers/plans/2026-09-07-booking-plan.md b/docs/superpowers/plans/2026-09-07-booking-plan.md
index 933fb0d..988bfa5 100644
--- a/docs/superpowers/plans/2026-09-07-booking-plan.md
+++ b/docs/superpowers/plans/2026-09-07-booking-plan.md
@@ -43,7 +43,7 @@ Copied from AGENTS.md and the spec. Every task inherits them.
| `internal/calendar/migrations/0005_*.sql`, `schema.sql` | generated | 1 |
| `internal/calendar/store_booking_page.go` | handle, page CRUD, publish, public lookup, ownership | 1 |
| `internal/calendar/clock.go`, `zones.go` | `Zone()` safe; `IsCommonZone` | 2 |
-| `internal/calendar/avail.go`, `avail_windows.go`, `templates/_bookingpane.html` | parser; merge/describe; pane | 2 |
+| `internal/calendar/avail.go`, `avail_describe.go`, `templates/_bookingpane.html` | parser; merge/describe; pane | 2 |
| `internal/calendar/store_range.go` | override-of-finished-series fix | 3 |
| `internal/calendar/slots.go`, `store_slots.go` | `ComputeSlots`; `BusyFor`; `FreeSlots` | 3 |
| `internal/calendar/store_booking_flow.go` | hold, confirm, reschedule, cancel | 4 |
@@ -234,7 +234,7 @@ func ZoneCacheSizeForTest() int // testing.go
- [ ] **Step 1: `zone_test.go`**: 100 goroutines resolve `Nowhere/TownN` and `Europe/Dublin`; run with `-race`; afterwards `ZoneCacheSizeForTest() <= len(CommonZones)+5`.
- [ ] **Step 2: `clock.go`**: `sync.Map`, successes only. `zones.go`: `IsCommonZone`.
- [ ] **Step 3: `avail_test.go`** — the reading table (every §3.1 example plus: `sun-tue 9-10`; `monday 9-12, monday 10-13` → one `09:00–13:00`; `monday 9-12, 12-1` → one; `monday 22:00-24:00` → `{Monday, 1320, 1440}`; `monday 0-1am`; `12:30-2:30` under `tuesdays` → `12:30–14:30`; `9pm-11` → `21:00–23:00`), the refusing table (`10pm-2`, `whenever`, leading `12-3` with no day, `25-26`, `9:60-10`, `13pm-2pm`, `24:00-1`, `9-9`, `9-5 in the office` quoting `in the office`; two bad clauses → two problems), and `DescribeWindows`.
-- [ ] **Step 4: Implement** `avail.go` + `avail_windows.go`:
+- [ ] **Step 4: Implement** `avail.go` + `avail_describe.go` (a `_windows.go` suffix is a GOOS build constraint; Go would skip the file on Linux):
```go
var (
diff --git a/docs/superpowers/specs/2026-09-07-booking-design.md b/docs/superpowers/specs/2026-09-07-booking-design.md
index 534834c..2d1dd0a 100644
--- a/docs/superpowers/specs/2026-09-07-booking-design.md
+++ b/docs/superpowers/specs/2026-09-07-booking-design.md
@@ -238,7 +238,9 @@ reported. The page cannot be saved while any exists.
### 3.3 Where it lives
-`internal/calendar/avail.go` + `avail_windows.go`. `ParseAvailability
+`internal/calendar/avail.go` + `avail_describe.go` (not `avail_windows.go`:
+Go reads a `_windows.go` suffix as a GOOS constraint and never compiles
+the file on Linux — found when the first build could not see it). `ParseAvailability
(text) (windows []Window, problems []string)`, pure, merged, sorted.
Shares `consumed`, `weekdayAlt`, `weekdays`, `rangeSep` with quick add;
does NOT call `clock`/`applyMeridiem`.
diff --git a/internal/calendar/avail.go b/internal/calendar/avail.go
new file mode 100644
index 0000000..3ee98c8
--- /dev/null
+++ b/internal/calendar/avail.go
@@ -0,0 +1,249 @@
+package calendar
+
+import (
+ "fmt"
+ "regexp"
+ "strconv"
+ "strings"
+ "time"
+ "unicode"
+)
+
+// The availability sentence: "mondays between 12–3, tuesdays 12–2, 5-7".
+//
+// This is the ONE parser for it (Ruling B7). It shares the scanner and
+// the day names with quick add, and deliberately NOT quick add's clock
+// reading: clock() and applyMeridiem() forgive — they clamp 25 to 23
+// and read 13pm as 13 — which is right for a sentence someone is about
+// to see rendered and wrong for a rule that quietly decides when
+// strangers may book a month of Mondays. Here every raw component is
+// checked first and nothing is clamped (spec §3.1).
+
+var (
+ // Clauses split on commas and semicolons.
+ reAvailSplit = regexp.MustCompile(`\s*[,;]\s*`)
+
+ // Day parts, all anchored: a clause is a day part THEN its ranges.
+ 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`)
+
+ // A time range with RAW components. Two digits at most, minutes
+ // optional, a meridiem optional on either end; the validation is
+ // readClock's, not the pattern's, so "25-26" is matched here and
+ // refused there with the range quoted — rather than left over as a
+ // token nobody claimed, which would be true but unhelpful.
+ 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`)
+
+ // Words that only join: dropped after the ranges are read.
+ reAvailFiller = regexp.MustCompile(`(?i)\b(between|from|and|on|at)\b`)
+)
+
+// ParseAvailability reads the sentence into weekly windows.
+//
+// Pure: the same text always gives the same windows and the same
+// problems. Windows are merged where they overlap or touch and sorted
+// Sunday-first then by start. Problems are every part of the text that
+// could not be read, each quoted, ALL of them — the pane shows the lot
+// so the host fixes the sentence once, and the page cannot be saved
+// while any remain (§3.2). What could be read is returned alongside.
+func ParseAvailability(text string) (windows []Window, problems []string) {
+ if strings.TrimSpace(text) == "" {
+ return nil, nil
+ }
+ var days []time.Weekday // the previous clause's, for inheritance
+ for _, clause := range reAvailSplit.Split(text, -1) {
+ clause = strings.TrimSpace(clause)
+ if clause == "" {
+ continue
+ }
+ c := newConsumed(clause)
+
+ named, found := takeDays(c)
+ switch {
+ case found:
+ days = named
+ case days == nil:
+ problems = append(problems, fmt.Sprintf("Could not read %q: no day named", clause))
+ }
+
+ ranges, bad := takeRanges(c)
+ problems = append(problems, bad...)
+
+ for c.take(reAvailFiller) != nil {
+ }
+ left := leftover(c)
+ problems = append(problems, left...)
+
+ if len(ranges) == 0 && len(bad) == 0 && len(left) == 0 && found {
+ problems = append(problems, fmt.Sprintf("Could not read %q: no time given", clause))
+ }
+ if !found && days == nil {
+ continue
+ }
+ for _, d := range days {
+ for _, r := range ranges {
+ windows = append(windows, Window{Weekday: d, StartMin: r[0], EndMin: r[1]})
+ }
+ }
+ }
+ return MergeWindows(windows), problems
+}
+
+// takeDays reads the day part off the front of a clause.
+func takeDays(c *consumed) ([]time.Weekday, bool) {
+ if m := c.take(reAvailGroup); m != nil {
+ switch strings.ToLower(spaces.ReplaceAllString(m[1], " ")) {
+ case "weekdays":
+ return []time.Weekday{time.Monday, time.Tuesday, time.Wednesday, time.Thursday, time.Friday}, true
+ case "weekends":
+ return []time.Weekday{time.Saturday, time.Sunday}, true
+ default:
+ return []time.Weekday{time.Sunday, time.Monday, time.Tuesday, time.Wednesday,
+ time.Thursday, time.Friday, time.Saturday}, true
+ }
+ }
+ if m := c.take(reAvailDayRange); m != nil {
+ // Forward modulo 7, so "fri-mon" is four days across the weekend
+ // and never a backwards run.
+ from, to := weekdays[strings.ToLower(m[1])], weekdays[strings.ToLower(m[2])]
+ out := []time.Weekday{from}
+ for d := from; d != to; {
+ d = (d + 1) % 7
+ out = append(out, d)
+ }
+ return out, true
+ }
+ if m := c.take(reAvailDay); m != nil {
+ return []time.Weekday{weekdays[strings.ToLower(m[1])]}, true
+ }
+ return nil, false
+}
+
+// takeRanges reads every time range in the clause. Each good one is a
+// [start, end) pair of minutes; each bad one is a problem quoting it.
+func takeRanges(c *consumed) (ranges [][2]int, problems []string) {
+ for {
+ m := c.take(reAvailRange)
+ if m == nil {
+ return ranges, problems
+ }
+ start, end, ok := readRange(m)
+ if !ok {
+ problems = append(problems, fmt.Sprintf("Could not read %q", strings.TrimSpace(m[0])))
+ continue
+ }
+ ranges = append(ranges, [2]int{start, end})
+ }
+}
+
+// readRange resolves one matched range. The meridiem rule (§3.1): an
+// explicit am/pm on either end applies to both unless the other says
+// otherwise; with none on either end the working-day guess applies.
+func readRange(m []string) (start, end int, ok bool) {
+ sm, em := strings.ToLower(m[3]), strings.ToLower(m[6])
+ inferPM := sm == "" && em == ""
+ start, ok = readClock(m[1], m[2], sm, inferPM, false)
+ if !ok {
+ return 0, 0, false
+ }
+ end, ok = readClock(m[4], m[5], em, inferPM, true)
+ if !ok {
+ return 0, 0, false
+ }
+ // Inheritance is applied AFTER each end is validated as written:
+ // "0-1am" is midnight to one, and the 0 was typed without a
+ // meridiem, so it is checked as a 24-hour figure and only then
+ // told it is in the morning.
+ if sm == "" && em != "" {
+ start = inheritMeridiem(start, em)
+ }
+ if em == "" && sm != "" {
+ end = inheritMeridiem(end, sm)
+ }
+ if end <= start {
+ return 0, 0, false
+ }
+ return start, end, true
+}
+
+// readClock validates RAW components; nothing is clamped.
+//
+// meridiem != "": hour 1..12, minute 0..59; 12am → 0, 12pm → 12.
+// meridiem == "": hour 0..24, minute 0..59; 24 only as 24:00 and only
+// when atEnd; with inferPM, an hour of 1..7 is the
+// afternoon — the HOUR COMPONENT, whatever the minutes,
+// so 12:30-2:30 is 12:30–14:30 and 7:45 is 19:45.
+func readClock(hh, mm, meridiem string, inferPM, atEnd bool) (minutes int, ok bool) {
+ h, err := strconv.Atoi(hh)
+ if err != nil {
+ return 0, false
+ }
+ m := 0
+ if mm != "" {
+ if m, err = strconv.Atoi(mm); err != nil || m > 59 {
+ return 0, false
+ }
+ }
+ switch meridiem {
+ case "am", "pm":
+ if h < 1 || h > 12 {
+ return 0, false
+ }
+ if h == 12 {
+ h = 0
+ }
+ if meridiem == "pm" {
+ h += 12
+ }
+ default:
+ if h > 24 || (h == 24 && (m != 0 || !atEnd)) {
+ return 0, false
+ }
+ if inferPM && h >= 1 && h <= 7 {
+ h += 12
+ }
+ }
+ return h*60 + m, true
+}
+
+// inheritMeridiem applies the other end's am/pm to a clock that was
+// written without one and has already passed the 24-hour check. Only
+// the twelve ambiguous hours move: 0 stays midnight and 13–24 are
+// already unambiguous, so "13-5pm" is 13:00–17:00 as written.
+func inheritMeridiem(minutes int, meridiem string) int {
+ h := minutes / 60
+ switch {
+ case meridiem == "pm" && h >= 1 && h <= 11:
+ return minutes + 12*60
+ case meridiem == "am" && h == 12:
+ return minutes - 12*60
+ }
+ return minutes
+}
+
+// leftover is every run of text nobody claimed, each a problem quoting
+// exactly that run. A run with no letter or digit in it — a stray full
+// stop — is not a token and is let go.
+func leftover(c *consumed) []string {
+ var problems []string
+ var run strings.Builder
+ flush := func() {
+ s := strings.TrimSpace(run.String())
+ run.Reset()
+ if strings.IndexFunc(s, isWordRune) >= 0 {
+ problems = append(problems, fmt.Sprintf("Could not read %q", s))
+ }
+ }
+ for _, r := range c.work {
+ if r == 0 {
+ flush()
+ continue
+ }
+ run.WriteRune(r)
+ }
+ flush()
+ return problems
+}
+
+func isWordRune(r rune) bool { return unicode.IsLetter(r) || unicode.IsDigit(r) }
diff --git a/internal/calendar/avail_describe.go b/internal/calendar/avail_describe.go
new file mode 100644
index 0000000..413ec83
--- /dev/null
+++ b/internal/calendar/avail_describe.go
@@ -0,0 +1,88 @@
+package calendar
+
+import (
+ "fmt"
+ "sort"
+ "strings"
+ "time"
+)
+
+// MergeWindows returns a copy sorted Sunday-first then by start, with
+// windows on the same day that overlap or touch joined into one.
+//
+// Touching counts: "9-12, 12-1" is one stretch of availability, and
+// leaving it as two would make the slot arithmetic offer a 12:00 slot
+// twice, or a 60-minute slot at 11:30 not at all, depending on which
+// window it happened to be looking through. The input is not changed.
+func MergeWindows(ws []Window) []Window {
+ if len(ws) == 0 {
+ return nil
+ }
+ sorted := make([]Window, len(ws))
+ copy(sorted, ws)
+ sort.SliceStable(sorted, func(i, j int) bool {
+ if sorted[i].Weekday != sorted[j].Weekday {
+ return sorted[i].Weekday < sorted[j].Weekday
+ }
+ return sorted[i].StartMin < sorted[j].StartMin
+ })
+ out := []Window{sorted[0]}
+ for _, next := range sorted[1:] {
+ cur := &out[len(out)-1]
+ if next.Weekday == cur.Weekday && next.StartMin <= cur.EndMin {
+ if next.EndMin > cur.EndMin {
+ cur.EndMin = next.EndMin
+ }
+ continue
+ }
+ out = append(out, next)
+ }
+ return out
+}
+
+// DescribeWindows writes the windows out in words, one line per day
+// that has any, Sunday first: "Monday 12:00–15:00, 17:00–19:00".
+//
+// This is what the pane prints, and it is the CONTRACT of the parser
+// in the same way the quick-add pane is: whatever the sentence was
+// read to mean is shown here, in full, before it can be saved.
+func DescribeWindows(ws []Window) []string {
+ byDay := map[time.Weekday][]string{}
+ for _, w := range MergeWindows(ws) {
+ byDay[w.Weekday] = append(byDay[w.Weekday],
+ fmt.Sprintf("%s–%s", clockText(w.StartMin), clockText(w.EndMin)))
+ }
+ var lines []string
+ for d := time.Sunday; d <= time.Saturday; d++ {
+ if spans := byDay[d]; len(spans) > 0 {
+ lines = append(lines, d.String()+" "+strings.Join(spans, ", "))
+ }
+ }
+ return lines
+}
+
+// clockText prints minutes from midnight as HH:MM. 1440 is "24:00":
+// the end of the day, said the way the host typed it, never wrapped to
+// a "00:00" that reads as the start of the same day.
+func clockText(minutes int) string {
+ return fmt.Sprintf("%02d:%02d", minutes/60, minutes%60)
+}
+
+// BookingPaneView is the availability pane: the windows in words and
+// every problem, already formatted so the template does no work.
+type BookingPaneView struct {
+ Lines []string
+ Problems []string
+ Empty bool
+}
+
+// bookingPaneFor renders the sentence for the pane. It is what the
+// live fetch and the scriptless 422 both show, so there is exactly one
+// description of what a sentence was read to mean.
+func bookingPaneFor(text string) BookingPaneView {
+ if strings.TrimSpace(text) == "" {
+ return BookingPaneView{Empty: true}
+ }
+ windows, problems := ParseAvailability(text)
+ return BookingPaneView{Lines: DescribeWindows(windows), Problems: problems}
+}
diff --git a/internal/calendar/clock.go b/internal/calendar/clock.go
index 281b298..03b5e05 100644
--- a/internal/calendar/clock.go
+++ b/internal/calendar/clock.go
@@ -3,6 +3,7 @@ package calendar
import (
"fmt"
"strings"
+ "sync"
"time"
)
@@ -43,24 +44,31 @@ const (
// asks for the same handful of zones hundreds of times, and the parse
// is pure overhead after the first.
//
-// Unbounded on purpose: the key space is the IANA database, about six
-// hundred names, and every one is small. A cache that could evict would
-// need a lock discipline this does not.
-var zoneCache = func() func(string) (*time.Location, error) {
- type entry struct {
- loc *time.Location
- err error
+// A sync.Map, because Zone is called from every request at once and a
+// plain map under concurrent writes is a data race — the race detector
+// caught it the first time a hundred goroutines asked at the same time.
+//
+// It holds SUCCESSES ONLY, which is what makes "unbounded" safe: the
+// key space is then the IANA database, about six hundred small names.
+// Caching failures would let anyone who can put a string in a ?zone=
+// parameter grow the map by one entry per request, forever. A failed
+// load is repeated instead; the cost is a few missed file probes, paid
+// only by whoever is sending garbage.
+var zoneCache sync.Map // name -> *time.Location
+
+func loadZone(name string) (*time.Location, error) {
+ if v, ok := zoneCache.Load(name); ok {
+ return v.(*time.Location), nil
}
- cache := map[string]entry{}
- return func(name string) (*time.Location, error) {
- if e, ok := cache[name]; ok {
- return e.loc, e.err
- }
- loc, err := time.LoadLocation(name)
- cache[name] = entry{loc: loc, err: err}
- return loc, err
+ loc, err := time.LoadLocation(name)
+ if err != nil {
+ return nil, err
}
-}()
+ // Two goroutines loading the same zone at once both store; the
+ // locations are equivalent and the second store is harmless.
+ zoneCache.Store(name, loc)
+ return loc, nil
+}
// Zone resolves an IANA name to a location.
//
@@ -73,7 +81,7 @@ func Zone(name string) (*time.Location, error) {
if name == "" || name == "UTC" {
return time.UTC, nil
}
- loc, err := zoneCache(name)
+ loc, err := loadZone(name)
if err != nil {
return nil, fmt.Errorf("calendar: unknown time zone %q: %w", name, err)
}
diff --git a/internal/calendar/render.go b/internal/calendar/render.go
index f8cb7c7..abb02c8 100644
--- a/internal/calendar/render.go
+++ b/internal/calendar/render.go
@@ -59,22 +59,24 @@ func init() {
// it is parsed into every tree rather than duplicated.
ParseFS(appFS, "templates/layout.html", "templates/_chip.html",
"templates/_quickadd.html", "templates/_quickpane.html",
+ "templates/_bookingpane.html",
"templates/"+name+".html"))
}
}
// fragments is the partials on their own, with no layout around them.
//
-// It exists for the quick-add pane, which is fetched and swapped into a
-// page that is already open. Rendering it through pages[...] would wrap
-// it in the whole shell — a second <html> inside the one on screen.
+// It exists for the panes that are fetched and swapped into a page that
+// is already open — quick add's, and the booking form's. Rendering one
+// through pages[...] would wrap it in the whole shell — a second <html>
+// inside the one on screen.
var fragments = template.Must(
template.Must(template.New("fragments").
Funcs(ui.Funcs(ui.WithIcons(icons.Icon, icons.Assets))).
Funcs(template.FuncMap{"asset": assets.Path}).
Funcs(appFuncs).
ParseFS(ui.Templates(), "*.html")).
- ParseFS(appFS, "templates/_quickpane.html"))
+ ParseFS(appFS, "templates/_quickpane.html", "templates/_bookingpane.html"))
// renderFragment writes one partial, with no shell.
func renderFragment(w http.ResponseWriter, name string, data any) {
diff --git a/internal/calendar/templates/_bookingpane.html b/internal/calendar/templates/_bookingpane.html
new file mode 100644
index 0000000..34d1fba
--- /dev/null
+++ b/internal/calendar/templates/_bookingpane.html
@@ -0,0 +1,25 @@
+{{/* The booking availability pane: what the server read the sentence
+ to mean, and every part it could not read.
+
+ Like the quick-add pane, this is the CONTRACT of the feature. The
+ windows the page will offer are printed here in words, and every
+ problem is listed — all of them, not the first — so the host fixes
+ the sentence once. One template, rendered two ways: inline on the
+ form's first paint and after a scriptless save with problems (422),
+ and as a standalone fragment for the live fetch. */}}
+{{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}}
diff --git a/internal/calendar/testing.go b/internal/calendar/testing.go
index 89f84d7..761f35a 100644
--- a/internal/calendar/testing.go
+++ b/internal/calendar/testing.go
@@ -186,3 +186,16 @@ func (a *App) PersonByAddressForTest(t TB, address string) Person {
}
return p
}
+
+// ZoneCacheSizeForTest counts the zones Zone() has memoised. The test
+// that uses it feeds Zone() a hundred names that do not exist and then
+// asserts the count stayed small: a cache that kept failures is one a
+// stranger can grow.
+func ZoneCacheSizeForTest() int {
+ n := 0
+ zoneCache.Range(func(_, _ any) bool {
+ n++
+ return true
+ })
+ return n
+}
diff --git a/internal/calendar/zones.go b/internal/calendar/zones.go
index 1aa26aa..dc38af5 100644
--- a/internal/calendar/zones.go
+++ b/internal/calendar/zones.go
@@ -74,6 +74,23 @@ var CommonZones = []string{
"Pacific/Honolulu",
}
+// commonZoneSet is CommonZones as a lookup, built once.
+var commonZoneSet = func() map[string]bool {
+ m := make(map[string]bool, len(CommonZones))
+ for _, z := range CommonZones {
+ m[z] = true
+ }
+ return m
+}()
+
+// IsCommonZone reports whether name is on the CommonZones list.
+//
+// It is the bound on a zone a STRANGER may name: the public booking page
+// accepts ?zone= only from this list (plus the host's own zone), so an
+// unauthenticated request can never reach time.LoadLocation with a
+// string of its choosing.
+func IsCommonZone(name string) bool { return commonZoneSet[name] }
+
// etagFor is the feed's cache validator: a hash of exactly the bytes
// being served, so it changes when and only when the file does.
//
diff --git a/internal/calendartest/avail_test.go b/internal/calendartest/avail_test.go
new file mode 100644
index 0000000..8ac5bcd
--- /dev/null
+++ b/internal/calendartest/avail_test.go
@@ -0,0 +1,225 @@
+package calendartest
+
+import (
+ "fmt"
+ "strings"
+ "testing"
+ "time"
+
+ calendar "amadan.net/oficina/calendar/internal/calendar"
+)
+
+// w builds one window from "HH:MM" strings, so a table reads the way
+// a host would write it.
+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 weekdaysOnly(from, to string) []calendar.Window {
+ var out []calendar.Window
+ for d := time.Monday; d <= time.Friday; d++ {
+ out = append(out, w(d, from, to))
+ }
+ return out
+}
+
+// windowsText prints windows one per line so a failing case shows the
+// whole shape rather than the first differing struct.
+func windowsText(ws []calendar.Window) string {
+ var b strings.Builder
+ for _, x := range ws {
+ fmt.Fprintf(&b, "%s %02d:%02d-%02d:%02d\n", x.Weekday.String()[:3],
+ x.StartMin/60, x.StartMin%60, x.EndMin/60, x.EndMin%60)
+ }
+ return b.String()
+}
+
+func TestAvailabilityReadsTheSentence(t *testing.T) {
+ cases := []struct {
+ text string
+ want []calendar.Window
+ }{
+ // Every example in spec §3.1.
+ {"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", weekdaysOnly("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.Sunday, "14:00", "16:00"), w(time.Saturday, "10:30", "13:00")}},
+
+ // Day parts.
+ {"weekends 10-12", []calendar.Window{w(time.Sunday, "10:00", "12:00"), w(time.Saturday, "10:00", "12:00")}},
+ {"daily 9-10", allDays("09:00", "10:00")},
+ {"all days 9-10", allDays("09:00", "10:00")},
+ {"every tuesday 12-2", []calendar.Window{w(time.Tuesday, "12:00", "14:00")}},
+ {"tues 12 to 2", []calendar.Window{w(time.Tuesday, "12:00", "14:00")}},
+ {"Thurs 9-10", []calendar.Window{w(time.Thursday, "09:00", "10:00")}},
+ {"monday to friday 9-10", weekdaysOnly("09:00", "10:00")},
+ // A weekday range walks forward modulo 7, never backwards.
+ {"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")}},
+ {"fri-mon 9-10", []calendar.Window{
+ w(time.Sunday, "09:00", "10:00"), w(time.Monday, "09:00", "10:00"), w(time.Friday, "09:00", "10:00"), w(time.Saturday, "09:00", "10:00")}},
+
+ // Time ranges: every separator, filler, three ranges in a clause.
+ {"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")}},
+ {"monday from 9 until 10", []calendar.Window{w(time.Monday, "09:00", "10:00")}},
+ {"monday 9 till 10", []calendar.Window{w(time.Monday, "09:00", "10:00")}},
+ {"monday 9—10", []calendar.Window{w(time.Monday, "09:00", "10:00")}},
+ {"monday at 9-10 and 11-12", []calendar.Window{w(time.Monday, "09:00", "10:00"), w(time.Monday, "11:00", "12:00")}},
+
+ // Meridiem: one end's am/pm applies to both; none means the
+ // working-day rule on the HOUR component, whatever the minutes.
+ {"monday 9pm-11", []calendar.Window{w(time.Monday, "21:00", "23:00")}},
+ {"monday 9-11am", []calendar.Window{w(time.Monday, "09:00", "11:00")}},
+ {"tuesdays 12:30-2:30", []calendar.Window{w(time.Tuesday, "12:30", "14:30")}},
+ {"monday 7:30-8:30pm", []calendar.Window{w(time.Monday, "19:30", "20:30")}},
+ {"monday 7:45am-8:15", []calendar.Window{w(time.Monday, "07:45", "08:15")}},
+ {"monday 12am-1am", []calendar.Window{w(time.Monday, "00:00", "01:00")}},
+ {"monday 11am-12pm", []calendar.Window{w(time.Monday, "11:00", "12:00")}},
+
+ // Midnight, both ends.
+ {"monday 0-1am", []calendar.Window{w(time.Monday, "00:00", "01:00")}},
+ {"monday 22:00-24:00", []calendar.Window{{Weekday: time.Monday, StartMin: 1320, EndMin: 1440}}},
+ {"monday 13-24", []calendar.Window{{Weekday: time.Monday, StartMin: 780, EndMin: 1440}}},
+
+ // Merging: overlapping and touching become one; output is sorted
+ // Sunday-first, then by start.
+ {"monday 9-12, monday 10-13", []calendar.Window{w(time.Monday, "09:00", "13:00")}},
+ {"monday 9-12, 12-1", []calendar.Window{w(time.Monday, "09:00", "13:00")}},
+ {"monday 12-1, 9-10", []calendar.Window{w(time.Monday, "09:00", "10:00"), w(time.Monday, "12:00", "13:00")}},
+ {"tuesday 9-10, monday 9-10", []calendar.Window{w(time.Monday, "09:00", "10:00"), w(time.Tuesday, "09:00", "10:00")}},
+
+ // Inheritance runs across more than one clause.
+ {"weekdays 9-10; 11-12; 2-3", append(append(weekdaysOnly("09:00", "10:00"), weekdaysOnly("11:00", "12:00")...), weekdaysOnly("14:00", "15:00")...)},
+ }
+ for _, c := range cases {
+ t.Run(c.text, func(t *testing.T) {
+ got, problems := calendar.ParseAvailability(c.text)
+ if len(problems) != 0 {
+ t.Errorf("problems = %q, want none", problems)
+ }
+ // The tables above are written in reading order; the parser
+ // promises Sunday-first then by start, so normalise the
+ // expectation the same way. MergeWindows has its own test.
+ want := calendar.MergeWindows(c.want)
+ if windowsText(got) != windowsText(want) {
+ t.Errorf("windows:\n%s\nwant:\n%s", windowsText(got), windowsText(want))
+ }
+ })
+ }
+}
+
+func TestAvailabilityRefusesWhatItCannotRead(t *testing.T) {
+ cases := []struct{ text, quoted string }{
+ {"mondays 10pm-2", "10pm-2"}, // pm applies to both; 22:00–14:00 is inverted
+ {"mondays 12-3, whenever", "whenever"},
+ {"12-3", "12-3"}, // no day, nothing to inherit
+ {"9pm-11", "9pm-11"},
+ {"mondays 25-26", "25-26"},
+ {"mondays 9:60-10", "9:60-10"},
+ {"mondays 13pm-2pm", "13pm-2pm"},
+ {"mondays 0am-1am", "0am-1am"},
+ {"mondays 24:00-1", "24:00-1"}, // 24:00 only at the END
+ {"mondays 22-24:30", "22-24:30"},
+ {"mondays 9-9", "9-9"}, // empty
+ // The hour-component rule makes 7:45 the evening and leaves 8:15
+ // alone, so this is inverted. Not a bug: the spec's rule, and the
+ // pane quotes it so the host writes "7:45am".
+ {"mondays 7:45-8:15", "7:45-8:15"},
+ {"mondays 9-5 in the office", "in the office"},
+ {"mondays 9-5 mondays", "mondays"},
+ {"mondays", "mondays"}, // a day with no time
+ {"mondays 9-5 100-200", "100-200"},
+ }
+ for _, c := range cases {
+ t.Run(c.text, func(t *testing.T) {
+ _, problems := calendar.ParseAvailability(c.text)
+ if len(problems) == 0 {
+ t.Fatalf("no problems, want one quoting %q", c.quoted)
+ }
+ for _, p := range problems {
+ if strings.Contains(p, `"`+c.quoted+`"`) {
+ return
+ }
+ }
+ t.Errorf("problems = %q, none quotes %q", problems, c.quoted)
+ })
+ }
+}
+
+func TestAvailabilityReportsEveryProblem(t *testing.T) {
+ windows, problems := calendar.ParseAvailability("mondays whenever, tuesdays sometime, wednesdays 9-10")
+ if len(problems) != 2 {
+ t.Errorf("problems = %q, want two", problems)
+ }
+ // What could be read is still returned, so the pane shows both.
+ if want := []calendar.Window{w(time.Wednesday, "09:00", "10:00")}; windowsText(windows) != windowsText(want) {
+ t.Errorf("windows:\n%s\nwant:\n%s", windowsText(windows), windowsText(want))
+ }
+ if _, problems := calendar.ParseAvailability(" "); len(problems) != 0 {
+ t.Errorf("blank text: problems = %q, want none", problems)
+ }
+}
+
+func TestDescribeWindowsGroupsByDay(t *testing.T) {
+ ws, problems := calendar.ParseAvailability("tuesdays 12–2, 5-7, mondays 9-10")
+ if len(problems) != 0 {
+ t.Fatalf("problems = %q", problems)
+ }
+ got := strings.Join(calendar.DescribeWindows(ws), "|")
+ if want := "Monday 09:00–10:00|Tuesday 12:00–14:00, 17:00–19:00"; got != want {
+ t.Errorf("got %q\nwant %q", got, want)
+ }
+
+ // Sunday first, and 24:00 printed as 24:00 rather than wrapped.
+ ws, _ = calendar.ParseAvailability("saturday 22-24; sunday 9-10")
+ got = strings.Join(calendar.DescribeWindows(ws), "|")
+ if want := "Sunday 09:00–10:00|Saturday 22:00–24:00"; got != want {
+ t.Errorf("got %q\nwant %q", got, want)
+ }
+ if lines := calendar.DescribeWindows(nil); len(lines) != 0 {
+ t.Errorf("DescribeWindows(nil) = %q", lines)
+ }
+}
+
+func TestMergeWindowsJoinsOverlapAndTouch(t *testing.T) {
+ in := []calendar.Window{
+ w(time.Tuesday, "09:00", "10:00"),
+ w(time.Monday, "12:00", "13:00"),
+ w(time.Monday, "09:00", "12:00"),
+ w(time.Monday, "11:00", "11:30"),
+ w(time.Monday, "14:00", "15:00"),
+ }
+ got := calendar.MergeWindows(in)
+ want := []calendar.Window{
+ w(time.Monday, "09:00", "13:00"),
+ w(time.Monday, "14:00", "15:00"),
+ w(time.Tuesday, "09:00", "10:00"),
+ }
+ if windowsText(got) != windowsText(want) {
+ t.Errorf("merged:\n%s\nwant:\n%s", windowsText(got), windowsText(want))
+ }
+ if in[0].Weekday != time.Tuesday {
+ t.Error("MergeWindows reordered its input")
+ }
+}
diff --git a/internal/calendartest/zone_test.go b/internal/calendartest/zone_test.go
new file mode 100644
index 0000000..175e7ec
--- /dev/null
+++ b/internal/calendartest/zone_test.go
@@ -0,0 +1,51 @@
+package calendartest
+
+import (
+ "fmt"
+ "sync"
+ "testing"
+
+ calendar "amadan.net/oficina/calendar/internal/calendar"
+)
+
+// TestZoneIsSafeUnderConcurrentGarbage drives Zone() the way the
+// public booking page will: many requests at once, most of them asking
+// for names nobody has ever heard of.
+//
+// Two things must hold. The cache must survive concurrent use — run
+// this under -race — and a name that failed to load must not be kept,
+// or a stranger with a ?zone= parameter grows the map without bound.
+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()
+ if _, err := calendar.Zone(fmt.Sprintf("Nowhere/Town%d", i)); err == nil {
+ t.Errorf("Nowhere/Town%d resolved", i)
+ }
+ if _, err := calendar.Zone("Europe/Dublin"); err != nil {
+ t.Errorf("Europe/Dublin: %v", err)
+ }
+ }(i)
+ }
+ wg.Wait()
+
+ // The bound leaves room for the handful of real zones other tests
+ // in this package load; a hundred garbage names cannot fit inside it.
+ if n, limit := calendar.ZoneCacheSizeForTest(), len(calendar.CommonZones)+5; n > limit {
+ t.Errorf("zone cache holds %d entries after 100 garbage names (limit %d): failures are being cached", n, limit)
+ }
+}
+
+func TestIsCommonZoneKnowsTheList(t *testing.T) {
+ if !calendar.IsCommonZone("Europe/Dublin") {
+ t.Error("Europe/Dublin is not common")
+ }
+ if calendar.IsCommonZone("Nowhere/Town1") {
+ t.Error("Nowhere/Town1 is common")
+ }
+ if calendar.IsCommonZone("") {
+ t.Error("the empty name is common")
+ }
+}