Skip to content

Explain empty slot lists and minimum-notice gaps - #23

Open
distronode-com wants to merge 3 commits into
Calnode:mainfrom
distronode-com:feat/explain-empty-slots
Open

Explain empty slot lists and minimum-notice gaps#23
distronode-com wants to merge 3 commits into
Calnode:mainfrom
distronode-com:feat/explain-empty-slots

Conversation

@distronode-com

Copy link
Copy Markdown

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_times now takes the date, and a new no_available_times_host adds the host:

Alex has no available times on Monday, 15 June.

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. HostsLabel can be
Alex, Sam & 2 others, which cannot be the subject of that sentence in any of the eight
locales without a plural verb no key can supply, so the surfaces fall back to the
date-only form instead of mangling it. SoleHostName on both page-data structs carries
that decision server-side.

2. The minimum-notice policy explains itself

When min_notice_minutes is what removed the nearest starts, the surfaces say so:

Bookings must be made at least 4 hours in advance.

(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 statement
in 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 the
notice rule removed, collected during the main walk (the cutoff is evaluated there anyway)
rather than by a second pass like taken. Generate and GenerateWithTaken keep their
signatures and delegate.

Two exclusions are what make the answer honest rather than merely plausible:

  • A start already in the past is never reported. It would have gone with no policy at
    all. Blaming the policy for it would put the message on every event type by dinnertime.
  • A start a booking took away is never reported. Busy intervals are applied on the way
    in, so NoticeGap and Taken are structurally disjoint and no start is ever explained
    two ways.

Routing rules are applied to the withheld starts too, so a collective start that only
one host could have made is not blamed on the notice policy either.

API

GET /v1/event-types/{slug}/slots gains:

"min_notice": { "minutes": 240, "dates": ["2026-06-15"] }

dates are booker-local YYYY-MM-DD keys, matching what the surfaces already group slots
by. 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 dates when it sets one that cost
this range nothing — the same distinction taken draws, for the same reason.

GET /v1/event-types/{slug}/public gains min_notice_minutes and min_notice_label, and
the two page-data structs gain MinNoticeLabel. The translated duration is rendered
server-side via the existing durationLabel because the /slots call the surfaces make
carries no ?lang= — a label built from that response would silently ignore a language
override. minutes still 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 date
first — German and Swedish do), and min_notice_hint. The non-English values are LLM
drafts 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 (so
BookingLogic is a page global)", and ARCHITECTURE §8 said all three surfaces share it.
Neither is true: EmbedJS serves the embedded file unmodified, so BookingLogic is
undefined inside the widget — which is why the widget already carries its own dowLabels,
timeLabel and day grouping. Wiring BookingLogic.fmt into it would have thrown a
ReferenceError on customers' sites and nothing in the tree would have caught it.

So: the widget gets its own fmt beside its other local helpers, the module header and
ARCHITECTURE §8 are corrected, and TestEmbedJSDoesNotDependOnBookingLogic fails on any
non-comment reference to BookingLogic in embed.js, so a well-meant de-duplication
cannot ship that bug. CLAUDE.md makes the same claim; I have left it alone as it is your
file.

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 date
cross-check across all eight locales; node --test internal/handler/assets/booking-logic.test.js
14/14. I also ran node --check over embed.js and over every inline script block of both
templates with the template actions stubbed. Nothing under frontend/ changed, so the
admin-UI visual suite is not implicated.

New tests:

  • internal/slots/notice_gap_test.go — the gap is exactly what min notice removed and the
    same starts are absent from Free; it carries no host ids; a start already in the past
    is not blamed on the policy (the 16:00-with-one-hour-notice case); a booked start is not
    either, with NoticeGap/Taken asserted disjoint; no policy means no gap; the output is
    opt-in and the Generate wrappers still agree; routing modes are respected; the starts
    are rendered in the booker's timezone.
  • internal/handler/slots_notice_test.go — the /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.
    Every assertion is phrased relative to the real clock, since computeSlots calls
    time.Now itself — nothing here holds only during office hours.
  • booking_surfaces_contract_test.go — extends your existing three-surface net: all three
    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).

Not included

  • No CSS. A day the policy emptied stays greyed out in the calendar rather than becoming
    clickable-but-empty, which would have needed a new day-cell state in the shared
    booking.css and a third meaning for the "available" class. The explanation is reachable
    in the pick-a-day state instead.
  • The booking assistant and MCP are unchanged. They pass includeTaken: false today and
    the same reasoning applies here: an agent needs bookable times, not prose about why
    others are missing.

… 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.
@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

All contributors have signed the CLA. ✅
Posted by the CLA Assistant Lite bot.

@distronode-com

Copy link
Copy Markdown
Author

I have read the CLA Document and I hereby sign the CLA

@distronode-com
distronode-com force-pushed the feat/explain-empty-slots branch from b7198f4 to 8228988 Compare September 4, 2026 08:01

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ℹ️ 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 NoticeGapGenerateDetailed / Extras collect min-notice-withheld starts in the main walk; past and busy starts excluded; routing applied; Generate wrappers unchanged.
  • Slots + public APImin_notice: {minutes, dates} on /slots; server-rendered min_notice_label / SoleHostName on 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 fmt and a regression test against BookingLogic.
  • 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).

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run | Using Grok𝕏

Comment thread internal/handler/slots_notice_test.go Outdated
distronode-com pushed a commit to distronode-com/district-scheduler that referenced this pull request Sep 4, 2026
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>
@distronode-com

Copy link
Copy Markdown
Author

Thanks — the flake is real and I have pushed the fix (a612b68).

You were right about the mechanism: seedNoticeEventType opens availability at 00:00, so
tomorrow's first slot is midnight, and in the last minute of a UTC day a one-minute notice puts
the cutoff past it. About 59 seconds a day where the test fails for a reason unrelated to what
it asserts. The window now starts at +2 days, so every candidate slot is at least 24 hours
beyond any cutoff that policy can produce, whatever the clock says.

On the pick-a-day notice being one-shot: you have read it correctly, and it was a deliberate
choice I should have written down. The hint is suppressed once a day is picked because the
thinned-day case already carries its own line inside fillSlots, and repeating a standalone
banner above it read as duplicated. The gap you have found is the fully-emptied day, which
stays grey with no explanation reachable after the first pick.

I would rather not fix that by keeping the banner permanently visible, since it would sit above
days it does not apply to. The better shape is probably making a fully-emptied notice day
clickable and letting the empty-day message explain itself, which is a slightly larger change to
day-cell state than this PR should carry. Happy to do it here if you would prefer it in one go,
or to leave a follow-up issue and keep this PR to the copy it set out to add. Your call.

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>
@distronode-com
distronode-com force-pushed the feat/explain-empty-slots branch from a612b68 to db378bc Compare September 4, 2026 08:26

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ 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 windowTestGetSlots_minNoticeDatesEmptyWhenThePolicyCostThisRangeNothing now queries +2/+4 days 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.

Pullfrog  | View workflow run | Using Grok𝕏

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Booking page: explain empty slot lists and minimum-notice gaps

1 participant