Explain empty slot lists and minimum-notice gaps - #23
Conversation
… part 1)
Server side plus the shared client helper and the eight locale files. Committed
mid-change so the work is not at risk; the remaining surfaces follow in the next
commit.
Done here:
- internal/slots: a pass can now report the starts the minimum-notice rule
removed (slots.Result.NoticeGap, requested via slots.Extras{NoticeGap: true}
through the new GenerateDetailed). Generate and GenerateWithTaken keep their
signatures and delegate.
Collected during the main walk rather than by a second pass, since the notice
cutoff is evaluated there anyway, and filtered by two rules that keep the
attribution honest: a start already in the past is never reported (it would
have gone with no policy at all, so blaming the policy would put the message
on every event type by dinnertime), and busy intervals are applied on the way
in, so a start a booking took away is never reported either. NoticeGap and
Taken are therefore disjoint - no start can be explained two ways.
- GET /v1/event-types/{slug}/slots gains "min_notice": {minutes, dates}, where
dates are the booker-local days (same YYYY-MM-DD keys the surfaces group slots
by) on which the policy removed an otherwise bookable start. Present whenever
the event type sets a minimum notice, with an empty list when it cost this
range nothing - same "absent vs empty" reasoning as `taken`. Only the day is
sent; the individual withheld times would describe the host's working hours at
a finer grain than the feature needs.
- The translated notice duration ("4 hours") is rendered server-side, where the
resolved locale lives: MinNoticeLabel on the book and manage page data, and
min_notice_label + min_notice_minutes on GET /public for the embed widget. The
/slots call the surfaces make carries no ?lang=, so a label derived from its
response would silently ignore a language override.
- SoleHostName on both page data structs: the host's name when the event type
(or booking) has exactly one, else empty. HostsLabel can be "Alex, Sam & 2
others", which no "%s has no available times" sentence can use grammatically,
so the message drops the name rather than mangling it.
- i18n, all eight locales: no_available_times now takes the date (%s),
no_available_times_host adds the host (%[1]s/%[2]s, indexed so a translation
can reorder), and min_notice_hint states the policy. Non-English values are
LLM drafts, consistent with the project's stated position on them.
- booking-logic.js gains fmt(template, args): %s in order plus indexed %[n]s,
the client half of the contract Go's fmt holds server-side. Deliberately not a
printf. 14 node --test cases pass.
- book.html has the #notice-hint element (outside the slot list, because a day
the policy empties completely is greyed out in the calendar and cannot be
clicked for an explanation).
Not done yet, next commit: book.html's script, manage.html, embed.js, the Go
tests for the engine and the /slots payload, the cross-surface key contract
test, ARCHITECTURE §8, and the CHANGELOG entry.
Verified so far: gofmt -l . empty, go build ./... clean, go test
./internal/i18n/ ./internal/slots/ green (key parity, printf-verb parity and the
CLDR cross-check all pass across the eight locales), node --test on
booking-logic 14/14.
…ode#20, part 2) Completes Calnode#20: the three booker-facing surfaces now say why a day is empty and why the nearest times are missing, and the behaviour is tested and documented. - book.html, manage.html, embed.js: an empty day names the day, and the host when there is exactly one; a day the minimum-notice policy thinned carries the policy line, whether or not later times remain (a day showing 2pm onwards but nothing this morning is the case in the issue). The line also appears in the "pick a day" state, because a day the policy emptied completely is greyed out in the calendar and cannot be clicked for an explanation. - Trap found while wiring the widget: embed.js does NOT load assets/booking-logic.js. EmbedJS serves the embedded file unmodified, so `BookingLogic` is undefined there - the widget already carries its own dowLabels, timeLabel and day grouping for that reason. The module's header comment and ARCHITECTURE §8 both claimed otherwise; both are corrected here, the widget gets its own `fmt` beside its other local helpers, and a test asserts embed.js never calls into BookingLogic so a well-meant de-duplication cannot ship a ReferenceError to a customer's site. (CLAUDE.md makes the same claim; left for the maintainer.) Tests: - internal/slots/notice_gap_test.go - the gap is exactly what min notice removed; carries no host ids; a start already past is not blamed on the policy (the 16:00-with-1-hour-notice case, which would otherwise put the message on every event type by the end of the day); a booked start is not blamed on it either, and NoticeGap/Taken are asserted disjoint; no policy means no gap; the extra output is opt-in and the Generate wrappers still agree; routing modes are respected (a collective start only one host could make is not the policy's fault); the starts are rendered in the booker's timezone. - internal/handler/slots_notice_test.go - the GET /slots payload: dates present and confined to the notice window, min_notice absent with no policy, present with empty dates when the policy cost the range nothing, and keyed to the requested timezone. Phrased relative to the real clock, since computeSlots calls time.Now itself; nothing here only holds during office hours. - booking_surfaces_contract_test.go - all three surfaces reference the new keys and the min_notice field, and en.json actually defines them (Locale.T falls back to the key itself, so a rename would show "min_notice_hint" to a visitor). Docs: ARCHITECTURE §8 gains "Explaining an empty day, and the minimum-notice gap"; CHANGELOG Unreleased entry. Verified: gofmt -l . empty, go vet ./... clean, go test ./... exit 0 (26 packages, no failures), go test ./internal/i18n/ green including same-keys, printf-verb parity and the CLDR date cross-check across all eight locales, node --test on booking-logic 14/14, and node --check on embed.js plus every inline script block of book.html/manage.html (template actions stubbed). No files under frontend/ changed, so the admin-UI visual suite is not implicated.
|
All contributors have signed the CLA. ✅ |
|
I have read the CLA Document and I hereby sign the CLA |
b7198f4 to
8228988
Compare
There was a problem hiding this comment.
ℹ️ No critical issues — two rough edges worth a look.
Reviewed changes in this run: full diff of PR #23 (empty-day copy + min-notice gap explanations across booking surfaces).
- Slot engine NoticeGap —
GenerateDetailed/Extrascollect min-notice-withheld starts in the main walk; past and busy starts excluded; routing applied;Generatewrappers unchanged. - Slots + public API —
min_notice: {minutes, dates}on/slots; server-renderedmin_notice_label/SoleHostNameon book, manage, and/public. - Three surfaces — empty-day host/date messages and notice hints on thinned days and pick-a-day; embed keeps a local
fmtand a regression test againstBookingLogic. - i18n + tests — three keys in all eight locales; engine, handler, and three-surface contract coverage.
ℹ️ Pick-a-day notice is one-shot after the first day pick
On book.html, renderNoticeHint only shows when !pickedDate. Once any day is chosen, the standalone hint stays hidden for the rest of the session, and a day the policy emptied completely remains grey and unclickable — so that explanation is no longer reachable. Manage has the same shape once a day is chosen. Fine if the primary case is a thinned day (notice still appears in fillSlots); if fully emptied greys are common, consider keeping a non-list notice visible whenever noticeDates is non-empty and the open day is not already showing the line.
Technical details
# Pick-a-day notice lifetime
## Affected sites
- `internal/handler/templates/book.html` — `renderNoticeHint` (`show = !pickedDate && …`)
- `internal/handler/templates/manage.html` — `renderPickDayHint` early-returns when `pickedDate`
- `internal/handler/embed.js` — notice in pick state only when `!st.day`
## Required outcome
- A visitor who first picks an unaffected later day can still learn why a fully emptied near day is grey, without a full page reload.
## Suggested approach (optional)
- Keep the standalone hint visible when `noticeDates` has entries and the selected day is absent from that set (or always show it under the calendar).Grok | 𝕏
Review catch on Calnode#23. The test asked for a window starting tomorrow with a one-minute minimum notice and asserted that nothing was withheld. But seedNoticeEventType opens availability at 00:00, so tomorrow's first slot is midnight, and in the last minute of a UTC day the cutoff lands past it (now 23:59:01 → cutoff 00:00:01). That slot is then correctly withheld, dates is non-empty, and the test fails — roughly 59 seconds a day, for a reason unrelated to what it is asserting. Starts the window at +2 days instead. Every candidate slot is then at least 24 hours beyond any cutoff a one-minute policy can produce, whatever the clock says, which is the property the file header promises for all of these. Verified: TestGetSlots_minNoticeDatesEmptyWhenThePolicyCostThisRangeNothing and TestGetSlots_minNoticeDatesUseTheRequestedTimezone both pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Thanks — the flake is real and I have pushed the fix ( You were right about the mechanism: On the pick-a-day notice being one-shot: you have read it correctly, and it was a deliberate I would rather not fix that by keeping the banner permanently visible, since it would sit above |
Review catch on Calnode#23. The test asked for a window starting tomorrow with a one-minute minimum notice and asserted that nothing was withheld. But seedNoticeEventType opens availability at 00:00, so tomorrow's first slot is midnight, and in the last minute of a UTC day the cutoff lands past it (now 23:59:01 → cutoff 00:00:01). That slot is then correctly withheld, dates is non-empty, and the test fails — roughly 59 seconds a day, for a reason unrelated to what it is asserting. Starts the window at +2 days instead. Every candidate slot is then at least 24 hours beyond any cutoff a one-minute policy can produce, whatever the clock says, which is the property the file header promises for all of these. Verified: TestGetSlots_minNoticeDatesEmptyWhenThePolicyCostThisRangeNothing and TestGetSlots_minNoticeDatesUseTheRequestedTimezone both pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
a612b68 to
db378bc
Compare
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes since the prior pullfrog review on 8228988: one test-only commit that stabilizes the empty-dates notice assertion.
- Clock-safe empty-dates window —
TestGetSlots_minNoticeDatesEmptyWhenThePolicyCostThisRangeNothingnow queries+2/+4days instead of+1/+3, with a comment documenting why a 1-minute notice can still withhold tomorrow’s midnight slot at the end of a UTC day.
Prior flaky-test thread resolved. No product or runtime behavior changed in this delta.
Grok | 𝕏

Fixes #20
Two silences on the booker-facing surfaces, both now answered on all three of them
(
book.html,manage.html,embed.js).1. An empty day says which day it is
no_available_timesnow takes the date, and a newno_available_times_hostadds the host:The host is named only when there is exactly one — the event type's single host, or on
the manage page the booking's single assigned host.
HostsLabelcan beAlex, Sam & 2 others, which cannot be the subject of that sentence in any of the eightlocales without a plural verb no key can supply, so the surfaces fall back to the
date-only form instead of mangling it.
SoleHostNameon both page-data structs carriesthat decision server-side.
2. The minimum-notice policy explains itself
When
min_notice_minutesis what removed the nearest starts, the surfaces say so:(The issue suggests "Bookings require 4 hours' notice"; the possessive does not survive
composing the duration from the existing
duration_*keys, so this is the same statementin plainer words. It is one locale key if you would rather have different wording.)
It renders on the day the policy actually thinned, whether or not later times remain —
a day showing 2pm onwards but nothing this morning is exactly the reported case — and
also in the "pick a day" state, because a day the policy emptied completely is greyed
out in the calendar and so can never be clicked for an explanation.
Where the decision is made
Server-side, in the engine that already knows it, rather than re-derived in three front
ends.
slots.GenerateDetailed(req, slots.Extras{NoticeGap: true})reports the starts thenotice rule removed, collected during the main walk (the cutoff is evaluated there anyway)
rather than by a second pass like
taken.GenerateandGenerateWithTakenkeep theirsignatures and delegate.
Two exclusions are what make the answer honest rather than merely plausible:
all. Blaming the policy for it would put the message on every event type by dinnertime.
in, so
NoticeGapandTakenare structurally disjoint and no start is ever explainedtwo ways.
Routing rules are applied to the withheld starts too, so a
collectivestart that onlyone host could have made is not blamed on the notice policy either.
API
GET /v1/event-types/{slug}/slotsgains:datesare booker-localYYYY-MM-DDkeys, matching what the surfaces already group slotsby. Only the day travels: the individual withheld times would describe the host's working
hours at a finer grain than the feature needs. The field is absent when the event type
sets no minimum notice and present with an empty
dateswhen it sets one that costthis range nothing — the same distinction
takendraws, for the same reason.GET /v1/event-types/{slug}/publicgainsmin_notice_minutesandmin_notice_label, andthe two page-data structs gain
MinNoticeLabel. The translated duration is renderedserver-side via the existing
durationLabelbecause the/slotscall the surfaces makecarries no
?lang=— a label built from that response would silently ignore a languageoverride.
minutesstill travels for a client with no label of its own.Translations
Three keys across all eight locales:
no_available_times(now takes%s),no_available_times_host(%[1]s/%[2]s, indexed so a translation can put the datefirst — German and Swedish do), and
min_notice_hint. The non-English values are LLMdrafts in the register of their neighbours, consistent with the project's stated position
on them; a native speaker's corrections are welcome and cheap to apply.
One thing worth knowing: embed.js does not load booking-logic.js
assets/booking-logic.js's own header said it was "prepended to embed.js (soBookingLogicis a page global)", and ARCHITECTURE §8 said all three surfaces share it.Neither is true:
EmbedJSserves the embedded file unmodified, soBookingLogicisundefined inside the widget — which is why the widget already carries its own
dowLabels,timeLabeland day grouping. WiringBookingLogic.fmtinto it would have thrown aReferenceErroron customers' sites and nothing in the tree would have caught it.So: the widget gets its own
fmtbeside its other local helpers, the module header andARCHITECTURE §8 are corrected, and
TestEmbedJSDoesNotDependOnBookingLogicfails on anynon-comment reference to
BookingLogicinembed.js, so a well-meant de-duplicationcannot ship that bug.
CLAUDE.mdmakes the same claim; I have left it alone as it is yourfile.
Testing
go test ./...green (26 packages);gofmt -l .empty;go vet ./...clean;go test ./internal/i18n/green, including same-keys, printf-verb parity and the CLDR datecross-check across all eight locales;
node --test internal/handler/assets/booking-logic.test.js14/14. I also ran
node --checkoverembed.jsand over every inline script block of bothtemplates with the template actions stubbed. Nothing under
frontend/changed, so theadmin-UI visual suite is not implicated.
New tests:
internal/slots/notice_gap_test.go— the gap is exactly what min notice removed and thesame starts are absent from
Free; it carries no host ids; a start already in the pastis not blamed on the policy (the 16:00-with-one-hour-notice case); a booked start is not
either, with
NoticeGap/Takenasserted disjoint; no policy means no gap; the output isopt-in and the
Generatewrappers still agree; routing modes are respected; the startsare rendered in the booker's timezone.
internal/handler/slots_notice_test.go— the/slotspayload: dates present andconfined to the notice window,
min_noticeabsent with no policy, present with emptydateswhen the policy cost the range nothing, and keyed to the requested timezone.Every assertion is phrased relative to the real clock, since
computeSlotscallstime.Nowitself — nothing here holds only during office hours.booking_surfaces_contract_test.go— extends your existing three-surface net: all threereference the new keys and the
min_noticefield, anden.jsonactually defines them(
Locale.Tfalls back to the key itself, so a rename would showmin_notice_hintto avisitor).
Not included
clickable-but-empty, which would have needed a new day-cell state in the shared
booking.cssand a third meaning for the "available" class. The explanation is reachablein the pick-a-day state instead.
includeTaken: falsetoday andthe same reasoning applies here: an agent needs bookable times, not prose about why
others are missing.