From 2983284b9f81fe85fee243b54689db2deaa7acf9 Mon Sep 17 00:00:00 2001
From: Sean Dean <254259913+distronode-com@users.noreply.github.com>
Date: Fri, 4 Sep 2026 03:37:11 -0400
Subject: [PATCH 1/3] feat(booking): explain empty days and minimum-notice gaps
(#20, part 1)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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.
---
internal/handler/assets/booking-logic.js | 24 ++++
internal/handler/assets/booking-logic.test.js | 27 ++++
internal/handler/book.go | 71 ++++++++---
internal/handler/manage_handler.go | 36 ++++--
internal/handler/slots_handler.go | 74 +++++++++--
internal/handler/templates/book.html | 5 +
internal/i18n/locales/de.json | 4 +-
internal/i18n/locales/en.json | 4 +-
internal/i18n/locales/es.json | 4 +-
internal/i18n/locales/fr.json | 4 +-
internal/i18n/locales/it.json | 4 +-
internal/i18n/locales/nl.json | 4 +-
internal/i18n/locales/pt.json | 4 +-
internal/i18n/locales/sv.json | 4 +-
internal/slots/generate.go | 116 ++++++++++++++----
15 files changed, 318 insertions(+), 67 deletions(-)
diff --git a/internal/handler/assets/booking-logic.js b/internal/handler/assets/booking-logic.js
index 0358b30..bb087aa 100644
--- a/internal/handler/assets/booking-logic.js
+++ b/internal/handler/assets/booking-logic.js
@@ -113,6 +113,29 @@
function addMonths(d, n) { return new Date(d.getFullYear(), d.getMonth() + n, 1); }
function daysInMonth(year, month) { return new Date(year, month + 1, 0).getDate(); }
+ // fmt — argument substitution for the translated strings the booking surfaces render
+ // themselves, so the three of them don't each grow their own. Supports exactly the two
+ // forms the locale files use for these keys: plain %s, taken in order, and the indexed
+ // %[n]s that lets a translation reorder its arguments ("%[1]s has no available times on
+ // %[2]s" is date-first in several languages). Server-side, Go's fmt does this job; this
+ // is the client half of the same contract.
+ //
+ // Deliberately not a printf. Accepting %d without implementing number formatting would
+ // be worse than not claiming to: the keys these surfaces substitute carry %s only, and
+ // internal/i18n's verb-parity test holds every locale to English's verbs.
+ //
+ // A missing argument renders as an empty string rather than leaving "%s" on screen —
+ // visibly wrong copy beats a literal format verb in front of a customer.
+ function fmt(template, args) {
+ var list = args || [];
+ var next = 0;
+ return String(template).replace(/%(?:\[(\d+)\])?s/g, function (_match, index) {
+ var pick = index ? Number(index) - 1 : next++;
+ var value = list[pick];
+ return value === undefined || value === null ? '' : String(value);
+ });
+ }
+
// NOTE: there is deliberately no host-label helper here. Each surface builds its own
// (hostsLabel in book.go for the server-rendered page, in book.html's script for the
// post-slot-pick rewrite, and in embed.js), because the label needs the resolved locale's
@@ -128,6 +151,7 @@
groupSlotsByDay: groupSlotsByDay,
mergeDaySlots: mergeDaySlots,
bookableDayKeys: bookableDayKeys,
+ fmt: fmt,
formatTime: formatTime,
formatDay: formatDay,
dowIndex: dowIndex,
diff --git a/internal/handler/assets/booking-logic.test.js b/internal/handler/assets/booking-logic.test.js
index 71ffda2..6710f4d 100644
--- a/internal/handler/assets/booking-logic.test.js
+++ b/internal/handler/assets/booking-logic.test.js
@@ -87,3 +87,30 @@ test('bookableDayKeys omits a day whose slots are all taken', () => {
const freeByDay = { '2026-06-15': [{ start: 'x' }], '2026-06-16': [] };
assert.deepEqual(B.bookableDayKeys(freeByDay).sort(), ['2026-06-15']);
});
+
+test('fmt substitutes %s in order', () => {
+ assert.equal(B.fmt('No available times on %s.', ['Monday, 15 June']), 'No available times on Monday, 15 June.');
+ assert.equal(B.fmt('%s has no available times on %s.', ['Alex', 'Monday']), 'Alex has no available times on Monday.');
+ assert.equal(B.fmt('Bookings must be made at least %s in advance.', ['4 hours']),
+ 'Bookings must be made at least 4 hours in advance.');
+});
+
+test('fmt honours indexed %[n]s, so a translation can reorder its arguments', () => {
+ // German and Swedish put the date before the verb; the locale files are allowed to
+ // reorder as long as the verbs match English (internal/i18n's parity test).
+ assert.equal(B.fmt('%[2]s: %[1]s hat keine Termine.', ['Alex', 'Montag']), 'Montag: Alex hat keine Termine.');
+ // An index may repeat an argument, and mixing forms keeps the sequential counter
+ // independent of the indexed reads.
+ assert.equal(B.fmt('%[1]s / %[1]s / %s', ['a', 'b']), 'a / a / a');
+});
+
+test('fmt leaves no format verb on screen when an argument is missing', () => {
+ assert.equal(B.fmt('No available times on %s.', []), 'No available times on .');
+ assert.equal(B.fmt('No available times on %s.'), 'No available times on .');
+ assert.equal(B.fmt('%[3]s missing', ['a']), ' missing');
+});
+
+test('fmt leaves a string with no verbs untouched', () => {
+ assert.equal(B.fmt('No available times.', ['unused']), 'No available times.');
+ assert.equal(B.fmt('Inga lediga tider.'), 'Inga lediga tider.');
+});
diff --git a/internal/handler/book.go b/internal/handler/book.go
index ff64123..cb5ef2b 100644
--- a/internal/handler/book.go
+++ b/internal/handler/book.go
@@ -53,12 +53,22 @@ type bookPageData struct {
AvatarURL string
Hosts []hostDisplay // faces for the info panel (1 = single, >1 = group stack)
HostsLabel string // "Alex, Sam & 2 others" for the group case
- LocationLabel string
- PriceLabel string // formatted price (e.g. "$50.00"); empty for free events
- PriceCents int // raw price for the dataLayer conversion value (0 = free)
- Currency string // ISO 4217, lowercase
- MaxFutureDays int
- Questions []bookQuestion
+ // SoleHostName is the host's name when this event type has exactly one, and "" when
+ // it has several. It is what lets an empty day read "Alex has no available times on
+ // …": a group label ("Alex, Sam & 2 others") in that sentence would need a plural
+ // verb no translation key can supply, so the message drops the name instead (#20).
+ SoleHostName string
+ // MinNoticeLabel is the translated minimum-notice duration ("4 hours"), or "" when the
+ // event type sets none. Rendered here rather than derived by the page's JS because the
+ // server already knows the resolved locale — the /slots call the page makes carries no
+ // ?lang=, so a label built from its response would silently ignore a language override.
+ MinNoticeLabel string
+ LocationLabel string
+ PriceLabel string // formatted price (e.g. "$50.00"); empty for free events
+ PriceCents int // raw price for the dataLayer conversion value (0 = free)
+ Currency string // ISO 4217, lowercase
+ MaxFutureDays int
+ Questions []bookQuestion
// AssistantEnabled shows the conversational-booking chat panel when the LLM layer is on.
AssistantEnabled bool
// AssistantDisclosure is the persistent AI-disclosure notice on the chat panel (Art. 50(1)).
@@ -197,6 +207,28 @@ func durationLabel(minutes int, loc *i18n.Locale) string {
return fmt.Sprintf(loc.T("duration_hr_min"), h, m)
}
+// noticeLabel renders an event type's minimum notice as a translated duration ("4
+// hours"), or "" when there is no such policy and so nothing to explain.
+//
+// It reuses durationLabel rather than introducing notice-specific plural keys: the
+// booking surfaces already label durations that way, and a second set of plural forms in
+// every locale would be more strings to keep in step for no gain.
+func noticeLabel(minNoticeMinutes int, loc *i18n.Locale) string {
+ if minNoticeMinutes <= 0 {
+ return ""
+ }
+ return durationLabel(minNoticeMinutes, loc)
+}
+
+// soleHostName returns the host's name when the event type has exactly one, else "".
+// See bookPageData.SoleHostName for why a group deliberately yields nothing.
+func soleHostName(hosts []hostDisplay) string {
+ if len(hosts) != 1 {
+ return ""
+ }
+ return hosts[0].Name
+}
+
var mdRenderer = goldmark.New(
goldmark.WithExtensions(extension.Strikethrough),
goldmark.WithRendererOptions(html.WithHardWraps()),
@@ -285,18 +317,18 @@ func (h *Handler) PublicEventType(w http.ResponseWriter, r *http.Request) {
var (
etID, name, description, locType, locValue string
hostName, avatarURL, routingMode, currency string
- durMins, maxDays, priceCents int
+ durMins, maxDays, minNotice, priceCents int
msgGreeting sql.NullString
)
err := h.db.QueryRowContext(r.Context(), `
SELECT et.id, et.name, COALESCE(et.description, ''),
et.duration_minutes, et.location_type, COALESCE(et.location_value, ''),
- et.max_future_days, et.routing_mode, u.name, COALESCE(u.avatar_url, ''),
+ et.max_future_days, et.min_notice_minutes, et.routing_mode, u.name, COALESCE(u.avatar_url, ''),
et.price_cents, et.currency, et.msg_greeting
FROM event_types et
JOIN users u ON u.id = et.user_id
WHERE et.slug = ? AND et.is_active = 1 AND et.is_public = 1`,
- slug).Scan(&etID, &name, &description, &durMins, &locType, &locValue, &maxDays, &routingMode, &hostName, &avatarURL, &priceCents, ¤cy, &msgGreeting)
+ slug).Scan(&etID, &name, &description, &durMins, &locType, &locValue, &maxDays, &minNotice, &routingMode, &hostName, &avatarURL, &priceCents, ¤cy, &msgGreeting)
if errors.Is(err, sql.ErrNoRows) {
h.writeError(w, http.StatusNotFound, "event type not found")
return
@@ -355,10 +387,16 @@ func (h *Handler) PublicEventType(w http.ResponseWriter, r *http.Request) {
// doesn't have to rebuild it from duration_minutes (it used to hardcode " min",
// which both skipped translation and disagreed with the pages for >= 60 min).
// duration_minutes stays for clients that want the raw number.
- "duration_label": durationLabel(durMins, loc),
- "location_type": locType,
- "location_label": locationLabel(locType, locValue, loc),
- "max_future_days": maxDays,
+ "duration_label": durationLabel(durMins, loc),
+ "location_type": locType,
+ "location_label": locationLabel(locType, locValue, loc),
+ "max_future_days": maxDays,
+ // min_notice_label is the translated minimum-notice duration ("4 hours"), empty
+ // when the event type sets none. The widget needs it here because the /slots call
+ // it makes later carries no language of its own, and min_notice_minutes alone
+ // would leave it rebuilding a plural-aware label the server already has (#20).
+ "min_notice_minutes": minNotice,
+ "min_notice_label": noticeLabel(minNotice, loc),
"assistant_enabled": h.getLLM() != nil,
"assistant_greeting": assistantGreeting(msgGreeting, loc),
"price_cents": priceCents,
@@ -384,6 +422,7 @@ func (h *Handler) BookPage(w http.ResponseWriter, r *http.Request) {
locType string
locValue string
maxDays int
+ minNotice int
hostName string
avatarURL string
routingMode string
@@ -394,12 +433,12 @@ func (h *Handler) BookPage(w http.ResponseWriter, r *http.Request) {
err := h.db.QueryRowContext(r.Context(), `
SELECT et.id, et.name, COALESCE(et.description, ''),
et.duration_minutes, et.location_type, COALESCE(et.location_value, ''),
- et.max_future_days, et.routing_mode, u.name, COALESCE(u.avatar_url, ''),
+ et.max_future_days, et.min_notice_minutes, et.routing_mode, u.name, COALESCE(u.avatar_url, ''),
et.price_cents, et.currency, et.msg_greeting
FROM event_types et
JOIN users u ON u.id = et.user_id
WHERE et.slug = ? AND et.is_active = 1 AND et.is_public = 1`,
- slug).Scan(&etID, &name, &description, &durMins, &locType, &locValue, &maxDays, &routingMode, &hostName, &avatarURL, &priceCents, ¤cy, &msgGreeting)
+ slug).Scan(&etID, &name, &description, &durMins, &locType, &locValue, &maxDays, &minNotice, &routingMode, &hostName, &avatarURL, &priceCents, ¤cy, &msgGreeting)
if errors.Is(err, sql.ErrNoRows) {
http.Error(w, "Page not found", http.StatusNotFound)
@@ -470,6 +509,8 @@ func (h *Handler) BookPage(w http.ResponseWriter, r *http.Request) {
AvatarURL: hosts[0].AvatarURL,
Hosts: hosts,
HostsLabel: hostsLabel(hosts, loc),
+ SoleHostName: soleHostName(hosts),
+ MinNoticeLabel: noticeLabel(minNotice, loc),
LocationLabel: locationLabel(locType, locValue, loc),
PriceLabel: formatPrice(priceCents, currency),
Locale: loc.Code,
diff --git a/internal/handler/manage_handler.go b/internal/handler/manage_handler.go
index 401d1ff..412df06 100644
--- a/internal/handler/manage_handler.go
+++ b/internal/handler/manage_handler.go
@@ -26,13 +26,21 @@ var manageTmpl = template.Must(template.Must(template.New("manage").Funcs(templa
}).Parse(sharedPartialsSrc)).Parse(manageTmplSrc))
type managePageData struct {
- Token string
- BookingID string
- EventTypeName string
- EventTypeSlug string
- HostName string
- HostInitial string
- AvatarURL string
+ Token string
+ BookingID string
+ EventTypeName string
+ EventTypeSlug string
+ HostName string
+ HostInitial string
+ AvatarURL string
+ // SoleHostName is the host's name when this booking has exactly one, else "" — see
+ // bookPageData.SoleHostName. HostName can be a group label ("Alex, Sam & 2 others"),
+ // which no "%s has no available times" sentence can use grammatically.
+ SoleHostName string
+ // MinNoticeLabel is the translated minimum-notice duration ("4 hours") of the event
+ // type being rescheduled, or "" when it sets none. Reschedule goes through the same
+ // /slots endpoint as booking, so the same policy hides the same nearest times (#20).
+ MinNoticeLabel string
DurationLabel string
LocationLabel string
PriceLabel string // empty on manage → the eventMeta partial omits the price row
@@ -86,14 +94,14 @@ func (h *Handler) ManagePage(w http.ResponseWriter, r *http.Request) {
}
var etName, etSlug, locType, locValue string
- var durMins, maxDays int
+ var durMins, maxDays, minNotice int
var hostName string
if err := h.db.QueryRowContext(r.Context(), `
- SELECT et.name, et.slug, et.duration_minutes, et.max_future_days,
+ SELECT et.name, et.slug, et.duration_minutes, et.max_future_days, et.min_notice_minutes,
et.location_type, COALESCE(et.location_value,''), u.name
FROM event_types et JOIN users u ON u.id = et.user_id
WHERE et.id = ?`, b.EventTypeID).
- Scan(&etName, &etSlug, &durMins, &maxDays, &locType, &locValue, &hostName); err != nil {
+ Scan(&etName, &etSlug, &durMins, &maxDays, &minNotice, &locType, &locValue, &hostName); err != nil {
h.logger.ErrorContext(r.Context(), "manage page: load event type", "error", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
@@ -103,13 +111,17 @@ func (h *Handler) ManagePage(w http.ResponseWriter, r *http.Request) {
// (round-robin/Group route elsewhere). Falls back to the owner name above if
// no booking_hosts rows exist. The avatar uses the primary host.
loc := h.resolveLocale(r)
- var hostInitial, avatarURL string
+ var hostInitial, avatarURL, soleHost string
if hosts := h.displayHostsForBooking(r.Context(), b.ID); len(hosts) > 0 {
hostName = hostsLabel(hosts, loc)
hostInitial = hosts[0].Initial
avatarURL = hosts[0].AvatarURL
+ if len(hosts) == 1 {
+ soleHost = hosts[0].Name
+ }
} else {
hostInitial = firstRune(hostName)
+ soleHost = hostName // the event-type owner: one person, so nameable
}
var orgTZ string
@@ -128,6 +140,8 @@ func (h *Handler) ManagePage(w http.ResponseWriter, r *http.Request) {
HostName: hostName,
HostInitial: hostInitial,
AvatarURL: avatarURL,
+ SoleHostName: soleHost,
+ MinNoticeLabel: noticeLabel(minNotice, loc),
DurationLabel: durationLabel(durMins, loc),
LocationLabel: locationLabel(locType, locValue, loc),
MaxFutureDays: maxDays,
diff --git a/internal/handler/slots_handler.go b/internal/handler/slots_handler.go
index 45e8c99..f13636c 100644
--- a/internal/handler/slots_handler.go
+++ b/internal/handler/slots_handler.go
@@ -31,6 +31,14 @@ type slotsResult struct {
// being nil does not work, because converting an empty result yields an empty
// non-nil slice.
ShowsTaken bool
+ // MinNoticeMinutes is the event type's minimum-notice policy; 0 = none.
+ MinNoticeMinutes int
+ // MinNoticeDates are the days (YYYY-MM-DD, in the requested timezone, so they match
+ // the keys the booking surfaces group slots by) on which that policy removed at
+ // least one start that was otherwise bookable. It is the only thing a surface needs
+ // to decide whether to explain a thin or empty day, and it says nothing about which
+ // times or which host - see slots.Result.NoticeGap.
+ MinNoticeDates []string
}
// Sentinel errors from computeSlots, so non-HTTP callers (the MCP tools) can map
@@ -75,6 +83,21 @@ func (h *Handler) GetSlots(w http.ResponseWriter, r *http.Request) {
if res.ShowsTaken {
body["taken"] = res.Taken
}
+ // Present whenever the event type sets a minimum notice, with an empty `dates` when
+ // the policy happened to remove nothing in this range. Same reasoning as `taken`: a
+ // client can tell "there is no such policy" from "the policy cost you nothing here",
+ // and only the second is worth explaining. `minutes` lets a client that has no
+ // localized label of its own still say something.
+ if res.MinNoticeMinutes > 0 {
+ dates := res.MinNoticeDates
+ if dates == nil {
+ dates = []string{}
+ }
+ body["min_notice"] = map[string]any{
+ "minutes": res.MinNoticeMinutes,
+ "dates": dates,
+ }
+ }
h.writeJSON(w, http.StatusOK, body)
}
@@ -126,8 +149,13 @@ func (h *Handler) computeSlots(ctx context.Context, slug, tzName, fromStr, toStr
}
if len(pool) == 0 {
// No bookable hosts (e.g. all archived, or a round-robin with no rotation
- // members) — offer nothing rather than erroring.
- return slotsResult{Slots: []slotJSON{}, Hosts: map[string]map[string]string{}}, nil
+ // members) — offer nothing rather than erroring. The notice policy still travels,
+ // so the response shape doesn't depend on how the day turned out.
+ return slotsResult{
+ Slots: []slotJSON{},
+ Hosts: map[string]map[string]string{},
+ MinNoticeMinutes: et.MinNoticeMinutes,
+ }, nil
}
// Load each host's availability concurrently. The slow part is the Google
@@ -178,12 +206,7 @@ func (h *Handler) computeSlots(ctx context.Context, slug, tzName, fromStr, toStr
// opted in. GenerateWithTaken walks the range a second time with busy ignored, so
// it is not free, and it returns exactly the information the default must withhold.
showsTaken := includeTaken && et.ShowTakenSlots
- var result, takenSlots []slots.Slot
- if showsTaken {
- result, takenSlots, err = slots.GenerateWithTaken(req)
- } else {
- result, err = slots.Generate(req)
- }
+ result, err := slots.GenerateDetailed(req, slots.Extras{Taken: showsTaken, NoticeGap: true})
if err != nil {
return slotsResult{}, fmt.Errorf("slots generate: %w", err)
}
@@ -196,13 +219,40 @@ func (h *Handler) computeSlots(ctx context.Context, slug, tzName, fromStr, toStr
poolIDs[i] = ph.id
}
return slotsResult{
- Slots: toSlotJSON(result),
- Taken: toSlotJSON(takenSlots),
- Hosts: h.hostDisplayMap(ctx, poolIDs),
- ShowsTaken: showsTaken,
+ Slots: toSlotJSON(result.Free),
+ Taken: toSlotJSON(result.Taken),
+ Hosts: h.hostDisplayMap(ctx, poolIDs),
+ ShowsTaken: showsTaken,
+ MinNoticeMinutes: et.MinNoticeMinutes,
+ MinNoticeDates: noticeDates(result.NoticeGap),
}, nil
}
+// noticeDates reduces the starts the minimum-notice rule withheld to the distinct days
+// they fall on, ascending. The engine renders those starts in the booker's timezone, so
+// formatting them here yields the same YYYY-MM-DD keys the booking surfaces group their
+// slots by.
+//
+// A day is all a surface needs to explain the gap, and it is all that is sent: the
+// individual withheld times would describe the host's working hours at a finer grain than
+// the feature requires.
+func noticeDates(gap []slots.Slot) []string {
+ if len(gap) == 0 {
+ return nil
+ }
+ out := make([]string, 0, 2) // the notice window spans a day or two at most
+ seen := make(map[string]bool, 2)
+ for _, s := range gap {
+ day := s.Start.Format("2006-01-02")
+ if seen[day] {
+ continue
+ }
+ seen[day] = true
+ out = append(out, day)
+ }
+ return out
+}
+
// toSlotJSON renders engine slots for the wire. Taken slots carry no host ids, so
// host_ids is simply absent for them rather than invented.
func toSlotJSON(in []slots.Slot) []slotJSON {
diff --git a/internal/handler/templates/book.html b/internal/handler/templates/book.html
index 353a95b..ef5f3f7 100644
--- a/internal/handler/templates/book.html
+++ b/internal/handler/templates/book.html
@@ -166,6 +166,11 @@
{{.Name}}
{{call .T "select_day_hint"}}
+
+
diff --git a/internal/i18n/locales/de.json b/internal/i18n/locales/de.json
index 92b01f3..16bcd78 100644
--- a/internal/i18n/locales/de.json
+++ b/internal/i18n/locales/de.json
@@ -25,9 +25,11 @@
"booking_failed_error": "Die Buchung ist fehlgeschlagen. Bitte versuchen Sie es erneut.",
"network_error": "Verbindungsfehler. Bitte prüfen Sie Ihre Internetverbindung.",
"loading": "Wird geladen…",
- "no_available_times": "Keine Termine verfügbar.",
+ "no_available_times": "Am %s sind keine Termine verfügbar.",
+ "no_available_times_host": "%[1]s hat am %[2]s keine Termine verfügbar.",
"slot_taken": "bereits gebucht",
"all_times_taken": "Alle Zeiten an diesem Tag sind gebucht.",
+ "min_notice_hint": "Buchungen müssen mindestens %s im Voraus erfolgen.",
"could_not_load_times": "Termine konnten nicht geladen werden. Bitte versuchen Sie es erneut.",
"times_shown_in": "Zeiten angezeigt in ",
"payment_received": "Zahlung erhalten",
diff --git a/internal/i18n/locales/en.json b/internal/i18n/locales/en.json
index 4163418..81e5eab 100644
--- a/internal/i18n/locales/en.json
+++ b/internal/i18n/locales/en.json
@@ -25,9 +25,11 @@
"booking_failed_error": "Booking failed. Please try again.",
"network_error": "Network error. Please check your connection.",
"loading": "Loading…",
- "no_available_times": "No available times.",
+ "no_available_times": "No available times on %s.",
+ "no_available_times_host": "%[1]s has no available times on %[2]s.",
"slot_taken": "already booked",
"all_times_taken": "Every time on this day is booked.",
+ "min_notice_hint": "Bookings must be made at least %s in advance.",
"could_not_load_times": "Could not load times. Please try again.",
"times_shown_in": "Times shown in ",
"payment_received": "Payment received",
diff --git a/internal/i18n/locales/es.json b/internal/i18n/locales/es.json
index debc1be..2ed8904 100644
--- a/internal/i18n/locales/es.json
+++ b/internal/i18n/locales/es.json
@@ -25,9 +25,11 @@
"booking_failed_error": "No se pudo completar la reserva. Inténtalo de nuevo.",
"network_error": "Error de conexión. Comprueba tu conexión a internet.",
"loading": "Cargando…",
- "no_available_times": "No hay horarios disponibles.",
+ "no_available_times": "No hay horarios disponibles el %s.",
+ "no_available_times_host": "%[1]s no tiene horarios disponibles el %[2]s.",
"slot_taken": "ya reservado",
"all_times_taken": "Todos los horarios de este día están reservados.",
+ "min_notice_hint": "Las reservas deben hacerse con al menos %s de antelación.",
"could_not_load_times": "No se pudieron cargar los horarios. Inténtalo de nuevo.",
"times_shown_in": "Horarios mostrados en ",
"payment_received": "Pago recibido",
diff --git a/internal/i18n/locales/fr.json b/internal/i18n/locales/fr.json
index f9e2ab2..ea9843b 100644
--- a/internal/i18n/locales/fr.json
+++ b/internal/i18n/locales/fr.json
@@ -25,9 +25,11 @@
"booking_failed_error": "La réservation a échoué. Veuillez réessayer.",
"network_error": "Erreur de connexion. Vérifiez votre connexion internet.",
"loading": "Chargement…",
- "no_available_times": "Aucun créneau disponible.",
+ "no_available_times": "Aucun créneau disponible le %s.",
+ "no_available_times_host": "%[1]s n'a aucun créneau disponible le %[2]s.",
"slot_taken": "déjà réservé",
"all_times_taken": "Tous les horaires de cette journée sont réservés.",
+ "min_notice_hint": "Les réservations doivent être effectuées au moins %s à l'avance.",
"could_not_load_times": "Impossible de charger les créneaux. Veuillez réessayer.",
"times_shown_in": "Horaires affichés en ",
"payment_received": "Paiement reçu",
diff --git a/internal/i18n/locales/it.json b/internal/i18n/locales/it.json
index 153f207..020a5c4 100644
--- a/internal/i18n/locales/it.json
+++ b/internal/i18n/locales/it.json
@@ -25,9 +25,11 @@
"booking_failed_error": "Prenotazione non riuscita. Riprovi.",
"network_error": "Errore di connessione. Controlli la connessione a internet.",
"loading": "Caricamento…",
- "no_available_times": "Nessun orario disponibile.",
+ "no_available_times": "Nessun orario disponibile il %s.",
+ "no_available_times_host": "%[1]s non ha orari disponibili il %[2]s.",
"slot_taken": "già prenotato",
"all_times_taken": "Tutti gli orari di questo giorno sono prenotati.",
+ "min_notice_hint": "Le prenotazioni devono essere effettuate almeno %s in anticipo.",
"could_not_load_times": "Impossibile caricare gli orari. Riprovi.",
"times_shown_in": "Orari mostrati in ",
"payment_received": "Pagamento ricevuto",
diff --git a/internal/i18n/locales/nl.json b/internal/i18n/locales/nl.json
index d1957a5..b450526 100644
--- a/internal/i18n/locales/nl.json
+++ b/internal/i18n/locales/nl.json
@@ -25,9 +25,11 @@
"booking_failed_error": "Boeken is mislukt. Probeer het opnieuw.",
"network_error": "Verbindingsfout. Controleer je internetverbinding.",
"loading": "Laden…",
- "no_available_times": "Geen tijden beschikbaar.",
+ "no_available_times": "Geen tijden beschikbaar op %s.",
+ "no_available_times_host": "%[1]s heeft geen tijden beschikbaar op %[2]s.",
"slot_taken": "al geboekt",
"all_times_taken": "Alle tijden op deze dag zijn geboekt.",
+ "min_notice_hint": "Boekingen moeten minstens %s vooraf worden gemaakt.",
"could_not_load_times": "Kon de tijden niet laden. Probeer het opnieuw.",
"times_shown_in": "Tijden weergegeven in ",
"payment_received": "Betaling ontvangen",
diff --git a/internal/i18n/locales/pt.json b/internal/i18n/locales/pt.json
index 0df31b8..51bbe75 100644
--- a/internal/i18n/locales/pt.json
+++ b/internal/i18n/locales/pt.json
@@ -25,9 +25,11 @@
"booking_failed_error": "A marcação falhou. Tente novamente.",
"network_error": "Erro de ligação. Verifique a sua ligação à internet.",
"loading": "A carregar…",
- "no_available_times": "Sem horários disponíveis.",
+ "no_available_times": "Sem horários disponíveis em %s.",
+ "no_available_times_host": "%[1]s não tem horários disponíveis em %[2]s.",
"slot_taken": "já reservado",
"all_times_taken": "Todos os horários deste dia estão reservados.",
+ "min_notice_hint": "As reservas devem ser feitas com pelo menos %s de antecedência.",
"could_not_load_times": "Não foi possível carregar os horários. Tente novamente.",
"times_shown_in": "Horários apresentados em ",
"payment_received": "Pagamento recebido",
diff --git a/internal/i18n/locales/sv.json b/internal/i18n/locales/sv.json
index d167675..3f9afe0 100644
--- a/internal/i18n/locales/sv.json
+++ b/internal/i18n/locales/sv.json
@@ -25,9 +25,11 @@
"booking_failed_error": "Bokningen misslyckades. Försök igen.",
"network_error": "Anslutningsfel. Kontrollera din internetanslutning.",
"loading": "Läser in…",
- "no_available_times": "Inga lediga tider.",
+ "no_available_times": "Inga lediga tider den %s.",
+ "no_available_times_host": "%[1]s har inga lediga tider den %[2]s.",
"slot_taken": "redan bokad",
"all_times_taken": "Alla tider den här dagen är bokade.",
+ "min_notice_hint": "Bokningar måste göras minst %s i förväg.",
"could_not_load_times": "Det gick inte att läsa in tiderna. Försök igen.",
"times_shown_in": "Tider visas i ",
"payment_received": "Betalning mottagen",
diff --git a/internal/slots/generate.go b/internal/slots/generate.go
index 407f145..7f6fb09 100644
--- a/internal/slots/generate.go
+++ b/internal/slots/generate.go
@@ -56,8 +56,8 @@ type Request struct {
// Generate runs the slot-generation algorithm (§9) and returns bookable slots
// rendered in the booker's timezone, ordered by start time.
func Generate(req Request) ([]Slot, error) {
- free, _, err := generate(req, false)
- return free, err
+ res, err := generate(req, Extras{})
+ return res.Free, err
}
// GenerateWithTaken runs the same algorithm and additionally reports the starts a
@@ -84,7 +84,53 @@ func Generate(req Request) ([]Slot, error) {
// would say which specific person is busy, which is more than the feature needs to
// disclose.
func GenerateWithTaken(req Request) (free, taken []Slot, err error) {
- return generate(req, true)
+ res, err := generate(req, Extras{Taken: true})
+ return res.Free, res.Taken, err
+}
+
+// Extras selects the optional secondary outputs of a pass. Each one costs work, so
+// every caller says what it needs (see GenerateDetailed).
+type Extras struct {
+ // Taken asks for the starts a booking or calendar conflict removed. Costs a second
+ // walk of the range.
+ Taken bool
+ // NoticeGap asks for the starts the minimum-notice rule removed. Free when the event
+ // type sets no minimum notice, and otherwise one extra map plus a routing pass over
+ // the handful of starts involved — it is collected during the main walk.
+ NoticeGap bool
+}
+
+// Result is one pass's output: the bookable slots, plus whichever secondary outputs the
+// caller asked for.
+type Result struct {
+ // Free is the bookable slots, ordered by start time.
+ Free []Slot
+ // Taken is the starts a booking or calendar conflict removed (see GenerateWithTaken).
+ Taken []Slot
+ // NoticeGap is the starts the minimum-notice rule removed: they are inside the host's
+ // working hours, nothing is booked over them, and the routing mode could have been
+ // satisfied — they are simply too soon. It exists so a booking surface can say WHY
+ // the nearest times are missing instead of leaving the visitor to guess, which is the
+ // single most common "why can't I see those times" question (#20).
+ //
+ // Two exclusions make the answer honest rather than merely plausible:
+ //
+ // - A start already in the past is never reported. It would have been dropped with
+ // no notice policy at all, so attributing it to the policy would be a lie that
+ // puts the message on every event type by dinnertime.
+ // - A start a booking took away is never reported, because busy intervals are
+ // applied on the way in. It follows that NoticeGap and Taken are disjoint, so a
+ // surface can render both without ever explaining one start two ways.
+ //
+ // Like Taken, these carry no HostIDs: the caller needs the time, not who was free.
+ NoticeGap []Slot
+}
+
+// GenerateDetailed is Generate with the secondary outputs in Extras. One call so a
+// surface that wants both (an event type showing taken times AND setting a minimum
+// notice) doesn't walk the range twice for them.
+func GenerateDetailed(req Request, want Extras) (Result, error) {
+ return generate(req, want)
}
// params holds the derived scalars every pass needs, computed once.
@@ -118,54 +164,72 @@ func newParams(req Request) params {
return p
}
-func generate(req Request, wantTaken bool) (free, taken []Slot, err error) {
+func generate(req Request, want Extras) (Result, error) {
if req.Event.DurationMinutes <= 0 {
- return nil, nil, fmt.Errorf("slots: DurationMinutes must be positive")
+ return Result{}, fmt.Errorf("slots: DurationMinutes must be positive")
}
if req.Event.SlotIntervalMinutes <= 0 {
- return nil, nil, fmt.Errorf("slots: SlotIntervalMinutes must be positive")
+ return Result{}, fmt.Errorf("slots: SlotIntervalMinutes must be positive")
}
if req.BookerTZ == nil {
- return nil, nil, fmt.Errorf("slots: BookerTZ must not be nil")
+ return Result{}, fmt.Errorf("slots: BookerTZ must not be nil")
}
for i, h := range req.Hosts {
if h.Location == nil {
- return nil, nil, fmt.Errorf("slots: Hosts[%d] (%s) Location must not be nil", i, h.HostID)
+ return Result{}, fmt.Errorf("slots: Hosts[%d] (%s) Location must not be nil", i, h.HostID)
}
}
p := newParams(req)
- perStart, err := hostsByStart(req, p, true)
+ // Collected during the main walk rather than by a second pass: the notice cutoff is
+ // evaluated there anyway, so the starts it drops are already in hand. nil switches
+ // the collection off, which is also what happens when the event type sets no minimum
+ // notice — there is then no policy to attribute anything to.
+ var belowNotice map[time.Time]map[string]bool
+ if want.NoticeGap && req.Event.MinNoticeMinutes > 0 {
+ belowNotice = make(map[time.Time]map[string]bool)
+ }
+
+ perStart, err := hostsByStart(req, p, true, belowNotice)
if err != nil {
- return nil, nil, err
+ return Result{}, err
}
- free = offer(req, p, perStart)
- if !wantTaken {
- return free, nil, nil
+ res := Result{Free: offer(req, p, perStart)}
+
+ // Run the same routing rules over the withheld starts, so only the ones that really
+ // would have been offered are reported: a start no host pool could satisfy is not the
+ // notice policy's fault.
+ for _, s := range offer(req, p, belowNotice) {
+ res.NoticeGap = append(res.NoticeGap, Slot{Start: s.Start, End: s.End})
+ }
+
+ if !want.Taken {
+ return res, nil
}
// The same walk with every host's busy list ignored: what the calendar would offer
// if nothing were booked.
- perStartIgnoringBusy, err := hostsByStart(req, p, false)
+ perStartIgnoringBusy, err := hostsByStart(req, p, false, nil)
if err != nil {
- return nil, nil, err
+ return Result{}, err
}
- offered := make(map[time.Time]bool, len(free))
- for _, s := range free {
+ offered := make(map[time.Time]bool, len(res.Free))
+ for _, s := range res.Free {
offered[s.Start] = true
}
for _, s := range offer(req, p, perStartIgnoringBusy) {
if !offered[s.Start] {
- taken = append(taken, Slot{Start: s.Start, End: s.End})
+ res.Taken = append(res.Taken, Slot{Start: s.Start, End: s.End})
}
}
- return free, taken, nil
+ return res, nil
}
// hostsByStart walks every candidate start in the range and records which hosts have it
// free. applyBusy=false ignores the busy lists entirely, which is how the "nothing is
-// booked" comparison pass is built.
-func hostsByStart(req Request, p params, applyBusy bool) (map[time.Time]map[string]bool, error) {
+// booked" comparison pass is built. A non-nil belowNotice collects the starts the
+// minimum-notice rule removed, in the same shape.
+func hostsByStart(req Request, p params, applyBusy bool, belowNotice map[time.Time]map[string]bool) (map[time.Time]map[string]bool, error) {
perStart := make(map[time.Time]map[string]bool)
for d := p.dateFrom; !d.After(p.dateTo); d = d.AddDate(0, 0, 1) {
@@ -189,6 +253,16 @@ func hostsByStart(req Request, p params, applyBusy bool) (map[time.Time]map[stri
t := alignUp(f.Start, p.interval)
for ; !t.Add(p.dur).After(f.End); t = t.Add(p.interval) {
if t.Before(p.minNotice) {
+ // !t.Before(req.Now) is what keeps the attribution honest: a start
+ // in the past would have gone with no notice policy at all, so
+ // blaming the policy for it would put the explanation on every
+ // event type by the end of the working day.
+ if belowNotice != nil && !t.Before(req.Now) {
+ if belowNotice[t] == nil {
+ belowNotice[t] = make(map[string]bool)
+ }
+ belowNotice[t][host.HostID] = true
+ }
continue
}
if t.After(p.maxFuture) {
From 8228988a3b81bb31b9895102404992480457f609 Mon Sep 17 00:00:00 2001
From: Sean Dean <254259913+distronode-com@users.noreply.github.com>
Date: Fri, 4 Sep 2026 03:48:33 -0400
Subject: [PATCH 2/3] feat(booking): render the empty-day and min-notice
explanations (#20, part 2)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Completes #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.
---
CHANGELOG.md | 17 ++
docs/ARCHITECTURE.md | 45 +++-
internal/handler/assets/booking-logic.js | 16 +-
.../handler/booking_surfaces_contract_test.go | 64 +++++
internal/handler/embed.js | 79 +++++-
internal/handler/slots_notice_test.go | 153 +++++++++++
internal/handler/taken_slots_test.go | 7 +
internal/handler/templates/book.html | 51 +++-
internal/handler/templates/manage.html | 59 +++-
internal/slots/notice_gap_test.go | 253 ++++++++++++++++++
10 files changed, 726 insertions(+), 18 deletions(-)
create mode 100644 internal/handler/slots_notice_test.go
create mode 100644 internal/slots/notice_gap_test.go
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1445c11..cc199d9 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,6 +11,23 @@ exact tag (`ghcr.io/calnode/calnode:0.1.0`) if you need stability between upgrad
## [Unreleased]
+### Added
+- **Empty days and minimum-notice gaps now explain themselves** on all three booking
+ surfaces (booking page, manage/reschedule page, embed widget). Closes
+ [#20](https://github.com/Calnode/calnode/issues/20).
+
+ A day with nothing on it names the day, and the host when the event type has exactly
+ one, instead of the bare "No available times." that never said whether another day would
+ help. And when `min_notice_minutes` is what removed the nearest starts, the surfaces say
+ so rather than leaving the visitor to guess - the most common "why can't I see those
+ times".
+
+ The engine decides that, not the front ends: `GET /slots` gains
+ `min_notice: {minutes, dates}` listing the booker-local days the policy actually cost
+ something. A start that is simply in the past, one a booking took away, and one no host
+ pool could satisfy are all excluded, so the explanation never appears attached to the
+ wrong cause. Three new/changed keys in all eight locales.
+
## [0.8.0] - 2026-09-03
### Added
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index e81e86c..f9659d1 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -319,13 +319,56 @@ in discussion #14, issue #19.
- **`taken` is absent, not empty, when off** - a client must distinguish "does not show
taken times" from "opted in, nothing booked today".
- **Client side:** `mergeDaySlots` and `bookableDayKeys` in the shared
- `assets/booking-logic.js`, so all three surfaces share one implementation. Free and
+ `assets/booking-logic.js`, inlined into book.html and manage.html. **The embed widget
+ does not load that module** - `EmbedJS` serves `embed.js` as standalone bytes, so
+ `BookingLogic` is undefined inside it and it carries its own copies of the helpers it
+ needs (a test asserts it never calls into `BookingLogic`). Free and
taken stay separate on the wire and are combined only for display. `bookableDayKeys`
exists for a specific trap: once taken slots are grouped by day too, a fully booked
day still produces a key, and using those keys for the calendar would advertise it as
having something available. Fully booked days *are* still openable, deliberately - a
list of struck-through times explains itself better than a dead date.
+### Explaining an empty day, and the minimum-notice gap
+
+Two silences on the booker-facing surfaces, issue #20. A day with nothing on it used to
+render a bare "No available times.", which does not say whether another day would help;
+and `min_notice_minutes` removes the nearest starts with nothing left behind to explain
+them - the most common "why can't I see those times".
+
+- **The empty-day message names the day, and the host when there is one.**
+ `no_available_times` takes the date; `no_available_times_host` adds the host. The host
+ is named only when the event type (or, on manage, the booking) has exactly ONE -
+ `HostsLabel` can be "Alex, Sam & 2 others", which cannot be the subject of that
+ sentence in any shipped locale. `SoleHostName` on both page data structs is empty
+ otherwise, and the surfaces fall back to the date-only form.
+- **The notice gap is computed server-side, in the engine that already knows it.**
+ `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), not by a second pass like `taken`.
+- **Two exclusions keep the attribution honest.** A start already in the past is never
+ reported - it would have gone with no policy at all, and 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, and no start is ever explained two ways. Routing rules
+ are applied to the withheld starts too, so a start no host pool could satisfy is not
+ blamed on the policy.
+- **The wire carries days, not times.** `GET /slots` returns
+ `min_notice: {minutes, dates}`, where `dates` are booker-local `YYYY-MM-DD` keys
+ matching what the surfaces group slots by. Sending the individual withheld times would
+ describe the host's working hours at a finer grain than the feature needs. Absent when
+ the event type sets no minimum notice, present-with-empty-`dates` when it set one that
+ cost this range nothing - the same distinction `taken` draws.
+- **The label is server-rendered.** `MinNoticeLabel` on the book/manage page data and
+ `min_notice_label` on `GET /public` (for the widget), both via `durationLabel`, because
+ the `/slots` call the surfaces make carries no `?lang=` - a label derived from its
+ response would silently ignore a language override. `minutes` still travels for clients
+ with no label of their own.
+- **Where it renders.** On the day itself when that day lost starts, whether or not times
+ remain (a day showing 2pm onwards but nothing this morning is exactly the case in the
+ issue), and *also* 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.
+
---
## 9. Booking lifecycle
diff --git a/internal/handler/assets/booking-logic.js b/internal/handler/assets/booking-logic.js
index bb087aa..a9b7bc8 100644
--- a/internal/handler/assets/booking-logic.js
+++ b/internal/handler/assets/booking-logic.js
@@ -1,8 +1,14 @@
-// booking-logic.js — the PURE date/slot/format logic shared by the THREE booking surfaces
-// (book.html, manage.html, embed.js), so a change is made once instead of three times. No DOM.
-// Served inlined into the book/manage Go templates and prepended to embed.js (so `BookingLogic`
-// is a page global), and require()-able by the node tests (booking-logic.test.js). Same UMD
-// pattern as room-logic.js — no build step, stays framework-free.
+// booking-logic.js — the PURE date/slot/format logic shared by book.html and manage.html, so a
+// change is made once instead of twice. No DOM.
+// Served inlined into the book/manage Go templates, and require()-able by the node tests
+// (booking-logic.test.js). Same UMD pattern as room-logic.js — no build step, stays
+// framework-free.
+//
+// NOT loaded by embed.js. The widget is served as its own standalone file
+// (internal/handler/embed_handler.go serves the embedded bytes unmodified), so `BookingLogic`
+// is undefined inside it and it carries its own copies of the few helpers it needs — see the
+// comments on its dowLabels and fmt. Anything added here that all three surfaces need has to be
+// mirrored there deliberately.
(function (root, factory) {
if (typeof module === 'object' && module.exports) module.exports = factory();
else root.BookingLogic = factory();
diff --git a/internal/handler/booking_surfaces_contract_test.go b/internal/handler/booking_surfaces_contract_test.go
index 2dd38d7..ff5e79a 100644
--- a/internal/handler/booking_surfaces_contract_test.go
+++ b/internal/handler/booking_surfaces_contract_test.go
@@ -63,3 +63,67 @@ func TestBookingSurfacesShareStructuralHooks(t *testing.T) {
}
}
}
+
+// TestBookingSurfacesExplainEmptyDaysAndMinNotice is the same safety net for the strings
+// and payload field that answer "why can't I see those times" (#20). All three surfaces
+// have to name the day on an empty one, name the host when there is exactly one, and
+// surface the minimum-notice policy when that is what removed the nearest starts — and
+// each does it in its own separately-authored code, so forgetting one is silent.
+func TestBookingSurfacesExplainEmptyDaysAndMinNotice(t *testing.T) {
+ var bookBuf, manageBuf bytes.Buffer
+ if err := bookTmpl.Execute(&bookBuf, bookPageData{T: i18n.Default().T}); err != nil {
+ t.Fatalf("book render: %v", err)
+ }
+ if err := manageTmpl.Execute(&manageBuf, managePageData{T: i18n.Default().T}); err != nil {
+ t.Fatalf("manage render: %v", err)
+ }
+ surfaces := map[string]string{
+ "book.html": bookBuf.String(),
+ "manage.html": manageBuf.String(),
+ "embed.js": string(embedJS),
+ }
+
+ required := []string{
+ "no_available_times", // the empty-day message, which now names the date
+ "no_available_times_host", // its "
has no available times on " form
+ "min_notice_hint", // the minimum-notice explanation
+ "min_notice", // the GET /slots field saying which days it applied to
+ }
+ for _, key := range required {
+ for name, src := range surfaces {
+ if !strings.Contains(src, key) {
+ t.Errorf("%q missing from %s — an empty or thinned day there will not explain "+
+ "itself; add it to all three surfaces (#20)", key, name)
+ }
+ }
+ }
+
+ // Every locale must actually carry the keys the surfaces look up. i18n's own key-parity
+ // test compares locales against English; this checks English has them at all, so a
+ // renamed key can't leave three surfaces rendering their own key names at visitors.
+ en := i18n.Default()
+ for _, key := range []string{"no_available_times", "no_available_times_host", "min_notice_hint"} {
+ if en.T(key) == key {
+ t.Errorf("locale key %q is missing from en.json — Locale.T falls back to the key "+
+ "itself, so the booking page would show %q to a visitor", key, key)
+ }
+ }
+}
+
+// TestEmbedJSDoesNotDependOnBookingLogic pins the trap that the shared module's own header
+// used to get wrong: embed.js is served as standalone bytes (EmbedJS writes the embedded
+// file unmodified), so `BookingLogic` does not exist inside the widget. It carries its own
+// copies of the few helpers it needs. A well-meant de-duplication onto BookingLogic would
+// throw a ReferenceError on a customer's site, where nothing here would see it.
+func TestEmbedJSDoesNotDependOnBookingLogic(t *testing.T) {
+ for i, line := range strings.Split(string(embedJS), "\n") {
+ code := strings.TrimSpace(line)
+ if strings.HasPrefix(code, "//") { // comments may reference it by name
+ continue
+ }
+ if strings.Contains(code, "BookingLogic") {
+ t.Errorf("embed.js:%d calls into BookingLogic, which is not loaded in the widget: %s",
+ i+1, code)
+ }
+ }
+}
diff --git a/internal/handler/embed.js b/internal/handler/embed.js
index 4a3c1de..ce0556d 100644
--- a/internal/handler/embed.js
+++ b/internal/handler/embed.js
@@ -38,6 +38,20 @@
// t: pure lookup, not a method — mirrors internal/i18n.Locale.T (falls back to the
// key itself if the string table hasn't loaded yet or the key is missing).
function t(i18n, key) { return (i18n && i18n[key]) || key; }
+ // fmt: argument substitution for the translated strings that carry one — %s in order,
+ // plus the indexed %[n]s a translation may use to reorder. Mirrors BookingLogic.fmt in
+ // internal/handler/assets/booking-logic.js; this widget does NOT load that module (see
+ // dowLabels below), so the two must be kept in step. Deliberately not a printf: the keys
+ // substituted here carry %s only.
+ function fmt(template, args) {
+ var list = args || [];
+ var next = 0;
+ return String(template).replace(/%(?:\[(\d+)\])?s/g, function (_match, index) {
+ var pick = index ? Number(index) - 1 : next++;
+ var value = list[pick];
+ return value === undefined || value === null ? '' : String(value);
+ });
+ }
// dowLabels: Monday-first weekday header labels via Intl, matching
// BookingLogic.dowLabels in booking-logic.js (not literally shared code — this widget
// doesn't import that module — but the same approach, replacing what used to be a
@@ -176,7 +190,7 @@
this.root.appendChild(el('style', { text: STYLE }));
this.wrap = el('div', { class: 'wrap' });
this.root.appendChild(this.wrap);
- this.state = { month: startOfMonth(new Date()), slotsByDay: {}, day: null, view: 'pick', slot: null };
+ this.state = { month: startOfMonth(new Date()), slotsByDay: {}, noticeDates: [], day: null, view: 'pick', slot: null };
this.narrow = false;
this.cw = 9999;
this.descExpanded = false;
@@ -242,6 +256,10 @@
(by[dayKey(s.start)] = by[dayKey(s.start)] || []).push(s);
});
this.state.slotsByDay = by;
+ // Days the minimum-notice policy took starts away from, so an empty or thin day
+ // can say why instead of leaving the visitor to guess (#20). Server-side these are
+ // already in TZ, so they match dayKey's output.
+ this.state.noticeDates = (r.min_notice && r.min_notice.dates) || [];
// Capture the id→host map so the header can narrow to a slot's actual host once
// one is picked. Avatar URLs come back relative; make them absolute (the widget
// runs cross-origin to the Calnode instance).
@@ -251,7 +269,7 @@
var m = r.hosts[id] || {}, av = m.avatar_url || '';
hm[id] = { name: m.name || '', avatar_url: av && av.charAt(0) === '/' ? BASE + av : av };
});
- } catch (e) { this.state.slotsByDay = {}; }
+ } catch (e) { this.state.slotsByDay = {}; this.state.noticeDates = []; }
}
infoPane() {
@@ -444,9 +462,35 @@
return el('section', { class: 'cal-col' }, [nav, grid, el('p', { class: 'tz-label', text: t(this.i18n, 'times_shown_in') + TZ })]);
}
+ // noticeHint — the minimum-notice explanation, or '' when the event type sets none.
+ // The label is translated server-side and arrives on /public: the /slots call carries
+ // no language of its own, and rebuilding a plural-aware duration here would duplicate
+ // what the server already knows (#20).
+ noticeHint() {
+ var label = this.info && this.info.min_notice_label;
+ return label ? fmt(t(this.i18n, 'min_notice_hint'), [label]) : '';
+ }
+
+ // emptyDayText — "Alex has no available times on Mon, 15 Jun", or the date-only form
+ // when the event type has several hosts (a group label cannot be the subject of that
+ // sentence in any of the shipped locales).
+ emptyDayText(dayKeyStr) {
+ var hosts = (this.info && this.info.hosts) || [];
+ // 'T00:00:00' (not the bare date) so the browser reads it as local midnight rather
+ // than UTC, which would name the previous day west of Greenwich.
+ var label = new Intl.DateTimeFormat(this.locale || [], {
+ weekday: 'long', month: 'long', day: 'numeric'
+ }).format(new Date(dayKeyStr + 'T00:00:00'));
+ if (hosts.length === 1 && hosts[0].name) {
+ return fmt(t(this.i18n, 'no_available_times_host'), [hosts[0].name, label]);
+ }
+ return fmt(t(this.i18n, 'no_available_times'), [label]);
+ }
+
rightPane() {
var self = this, st = this.state;
var inner;
+ var notice = this.noticeHint();
if (st.view === 'form') inner = this.formView(st.slot);
else if (st.view === 'confirm') inner = this.confirmView(st.slot);
else if (st.day) {
@@ -469,16 +513,43 @@
b.addEventListener('click', function () { self.state.slot = s; self.state.view = 'form'; self.render(); });
listEl.appendChild(b);
});
+ if (!list.length) {
+ // Name the day, and the host when there is one: a bare "No available times."
+ // never said whether another day would help.
+ listEl.appendChild(el('p', { class: 'hint', text: this.emptyDayText(st.day) }));
+ }
if (list.length && !list.some(function (s) { return !s.taken; })) {
listEl.appendChild(el('p', { class: 'hint', text: t(self.i18n, 'all_times_taken') }));
}
- inner = el('div', {}, [el('p', { class: 'slots-header', text: list[0] ? shortDay(list[0].start, self.locale) : '' }), listEl]);
+ // Only on a day the policy actually thinned - including one it emptied, and one
+ // that still shows later times, which is the commonest "why can't I see those
+ // times" case.
+ if (notice && st.noticeDates && st.noticeDates.indexOf(st.day) !== -1) {
+ listEl.appendChild(el('p', { class: 'hint', text: notice }));
+ }
+ inner = el('div', {}, [el('p', { class: 'slots-header', text: list[0] ? shortDay(list[0].start, self.locale) : this.dayHeader(st.day) }), listEl]);
} else {
- inner = el('p', { class: 'hint', text: t(this.i18n, 'select_day_hint') });
+ // Before a day is chosen. The notice line belongs here as well as in the list: a
+ // day the policy emptied completely is greyed out in the calendar, so this is the
+ // only place the explanation can be reached.
+ var kids = [el('p', { class: 'hint', text: t(this.i18n, 'select_day_hint') })];
+ if (notice && st.noticeDates && st.noticeDates.length) {
+ kids.push(el('p', { class: 'hint', text: notice }));
+ }
+ inner = el('div', {}, kids);
}
return el('section', { class: 'right-col' }, [inner]);
}
+ // dayHeader — the selected day's short label when no slot is available to derive it
+ // from, so an empty day still gets the same header as a full one.
+ dayHeader(dayKeyStr) {
+ if (!dayKeyStr) return '';
+ return new Intl.DateTimeFormat(this.locale || [], {
+ weekday: 'short', month: 'short', day: 'numeric'
+ }).format(new Date(dayKeyStr + 'T00:00:00'));
+ }
+
formView(slot) {
var self = this;
var back = el('button', { class: 'back-btn', html: SVG_BACK + ' ' + t(this.i18n, 'back') });
diff --git a/internal/handler/slots_notice_test.go b/internal/handler/slots_notice_test.go
new file mode 100644
index 0000000..048df0b
--- /dev/null
+++ b/internal/handler/slots_notice_test.go
@@ -0,0 +1,153 @@
+package handler_test
+
+import (
+ "database/sql"
+ "testing"
+ "time"
+
+ "github.com/calnode/calnode/internal/uid"
+)
+
+// The minimum-notice policy hides the nearest starts and leaves nothing behind to explain
+// them (#20). GET /slots therefore reports the policy and the days it actually cost
+// something, which is what lets the three booking surfaces say so without re-deriving it.
+//
+// These tests run against the real clock, because computeSlots calls time.Now itself.
+// Every assertion is therefore phrased relative to now rather than against a fixture
+// date — a test that only holds between 09:00 and 17:00 would be worse than none.
+
+type slotsPayload = slotsBody
+
+// seedNoticeEventType inserts a bookable event type whose single host is available around
+// the clock every day, so the only thing that can remove a start is the notice policy.
+func seedNoticeEventType(t *testing.T, database *sql.DB, ownerID, slug string, minNoticeMinutes int) {
+ t.Helper()
+ etID := uid.New()
+ if _, err := database.Exec(`
+ INSERT INTO event_types (id, user_id, slug, name, duration_minutes, slot_interval_minutes,
+ min_notice_minutes, max_future_days, is_active, is_public)
+ VALUES (?, ?, ?, 'Intro Call', 30, 30, ?, 0, 1, 1)`,
+ etID, ownerID, slug, minNoticeMinutes); err != nil {
+ t.Fatalf("seed event type: %v", err)
+ }
+ if _, err := database.Exec(`
+ INSERT INTO event_type_hosts (id, event_type_id, user_id, role, priority)
+ VALUES (?, ?, ?, 'required', 0)`, uid.New(), etID, ownerID); err != nil {
+ t.Fatalf("seed host: %v", err)
+ }
+ for day := 0; day < 7; day++ {
+ if _, err := database.Exec(`
+ INSERT INTO availability_rules (id, user_id, day_of_week, start_time, end_time)
+ VALUES (?, ?, ?, '00:00', '23:59')`, uid.New(), ownerID, day); err != nil {
+ t.Fatalf("seed availability day %d: %v", day, err)
+ }
+ }
+}
+
+func TestGetSlots_reportsTheDaysMinimumNoticeEmptied(t *testing.T) {
+ h, database, _, ownerID := setupWorkspaceWithDB(t)
+ seedNoticeEventType(t, database, ownerID, "notice-call", 24*60)
+
+ now := time.Now().UTC()
+ from := now.Format("2006-01-02")
+ to := now.AddDate(0, 0, 2).Format("2006-01-02")
+ got := getSlots(t, h, "notice-call", "?from="+from+"&to="+to+"&tz=UTC")
+
+ if got.MinNotice == nil {
+ t.Fatal("min_notice is absent for an event type that sets one")
+ }
+ if got.MinNotice.Minutes != 24*60 {
+ t.Errorf("min_notice.minutes: got %d; want %d", got.MinNotice.Minutes, 24*60)
+ }
+ if len(got.MinNotice.Dates) == 0 {
+ t.Fatal("min_notice.dates is empty, but a 24-hour notice on an all-hours calendar must have hidden today's remaining starts")
+ }
+ // The notice window is 24 hours from now, so it can only touch today and tomorrow.
+ allowed := map[string]bool{
+ from: true,
+ now.AddDate(0, 0, 1).Format("2006-01-02"): true,
+ }
+ for _, d := range got.MinNotice.Dates {
+ if !allowed[d] {
+ t.Errorf("min_notice.dates contains %q, which is outside the 24-hour notice window (today/tomorrow)", d)
+ }
+ }
+ // And the policy really is in force: nothing bookable inside the window.
+ cutoff := now.Add(24 * time.Hour)
+ if len(got.Slots) == 0 {
+ t.Fatal("no slots at all; the fixture is meant to leave later days bookable")
+ }
+ for _, s := range got.Slots {
+ start, err := time.Parse(time.RFC3339, s.Start)
+ if err != nil {
+ t.Fatalf("parse slot start %q: %v", s.Start, err)
+ }
+ if start.Before(cutoff) {
+ t.Errorf("slot at %s is inside the 24-hour notice window", s.Start)
+ }
+ }
+}
+
+func TestGetSlots_omitsMinNoticeWhenThereIsNoPolicy(t *testing.T) {
+ // Absent rather than zeroed, so a client can tell "no such policy" from "the policy
+ // cost you nothing here" — the same distinction `taken` draws.
+ h, database, _, ownerID := setupWorkspaceWithDB(t)
+ seedNoticeEventType(t, database, ownerID, "open-call", 0)
+
+ now := time.Now().UTC()
+ got := getSlots(t, h, "open-call",
+ "?from="+now.Format("2006-01-02")+"&to="+now.AddDate(0, 0, 1).Format("2006-01-02")+"&tz=UTC")
+ if got.MinNotice != nil {
+ t.Errorf("min_notice present for an event type with no minimum notice: %+v", *got.MinNotice)
+ }
+}
+
+func TestGetSlots_minNoticeDatesEmptyWhenThePolicyCostThisRangeNothing(t *testing.T) {
+ // A one-minute notice, asked about days that start tomorrow: the policy exists and is
+ // reported, but it took nothing away in this window, so nothing should be explained.
+ h, database, _, ownerID := setupWorkspaceWithDB(t)
+ seedNoticeEventType(t, database, ownerID, "tiny-notice", 1)
+
+ now := time.Now().UTC()
+ from := now.AddDate(0, 0, 1).Format("2006-01-02")
+ to := now.AddDate(0, 0, 3).Format("2006-01-02")
+ got := getSlots(t, h, "tiny-notice", "?from="+from+"&to="+to+"&tz=UTC")
+
+ if got.MinNotice == nil {
+ t.Fatal("min_notice is absent for an event type that sets one")
+ }
+ if len(got.MinNotice.Dates) != 0 {
+ t.Errorf("min_notice.dates: got %v; want empty (the window starts tomorrow)", got.MinNotice.Dates)
+ }
+}
+
+func TestGetSlots_minNoticeDatesUseTheRequestedTimezone(t *testing.T) {
+ // The surfaces group slots by day in the tz they asked for, so these keys have to be
+ // in that tz too or the explanation lands on the wrong day. Auckland is up to 13 hours
+ // ahead of UTC, so its "today" differs from UTC's for half the clock.
+ h, database, _, ownerID := setupWorkspaceWithDB(t)
+ seedNoticeEventType(t, database, ownerID, "tz-call", 24*60)
+
+ akl, err := time.LoadLocation("Pacific/Auckland")
+ if err != nil {
+ t.Skipf("Pacific/Auckland unavailable: %v", err)
+ }
+ now := time.Now().UTC()
+ from := now.AddDate(0, 0, -1).Format("2006-01-02")
+ to := now.AddDate(0, 0, 2).Format("2006-01-02")
+ got := getSlots(t, h, "tz-call", "?from="+from+"&to="+to+"&tz=Pacific%2FAuckland")
+
+ if got.MinNotice == nil || len(got.MinNotice.Dates) == 0 {
+ t.Fatal("expected min_notice dates for a 24-hour notice on an all-hours calendar")
+ }
+ nowAKL := time.Now().In(akl)
+ allowed := map[string]bool{
+ nowAKL.Format("2006-01-02"): true,
+ nowAKL.AddDate(0, 0, 1).Format("2006-01-02"): true,
+ }
+ for _, d := range got.MinNotice.Dates {
+ if !allowed[d] {
+ t.Errorf("min_notice.dates contains %q; want Auckland-local today/tomorrow (%v)", d, allowed)
+ }
+ }
+}
diff --git a/internal/handler/taken_slots_test.go b/internal/handler/taken_slots_test.go
index 007d200..c7fa165 100644
--- a/internal/handler/taken_slots_test.go
+++ b/internal/handler/taken_slots_test.go
@@ -23,6 +23,13 @@ type slotsBody struct {
Start string `json:"start"`
HostIDs []string `json:"host_ids"`
} `json:"taken"`
+ // MinNotice is a pointer for the same reason: absent means the event type sets no
+ // minimum notice, present-but-empty means it does and this range lost nothing to it
+ // (see slots_notice_test.go).
+ MinNotice *struct {
+ Minutes int `json:"minutes"`
+ Dates []string `json:"dates"`
+ } `json:"min_notice"`
}
func getSlots(t *testing.T, h interface {
diff --git a/internal/handler/templates/book.html b/internal/handler/templates/book.html
index ef5f3f7..63b21db 100644
--- a/internal/handler/templates/book.html
+++ b/internal/handler/templates/book.html
@@ -267,6 +267,10 @@ {{call .T "booking_confirmed"}}
const PRICE_CENTS = {{.PriceCents}};
const CURRENCY = "{{.Currency}}";
const EVENT_NAME = {{.Name}};
+ // "" when the event type has several hosts (a group label can't be the subject of
+ // "%s has no available times"), and "" when it sets no minimum notice.
+ const SOLE_HOST = {{.SoleHostName}};
+ const MIN_NOTICE_LABEL = {{.MinNoticeLabel}};
const LOCALE = "{{.Locale}}";
const I18N = window.__CALNODE_I18N || {};
function t(key) { return I18N[key] || key; } // key itself as a last-resort fallback, matching Locale.T server-side
@@ -281,6 +285,7 @@ {{call .T "booking_confirmed"}}
let availabilityLoaded = false; // false = render optimistically (assume all days open)
let slotsByDay = {}; // "YYYY-MM-DD" -> [slot]; cached from the month fetch
let bookableDates = new Set(); // days with >=1 FREE slot; drives the empty-day hint
+ let noticeDates = new Set(); // days the minimum-notice policy took starts away from
const $ = id => document.getElementById(id);
const pad = n => String(n).padStart(2, '0');
@@ -450,13 +455,18 @@ {{call .T "booking_confirmed"}}
// the two sets are identical, so nothing changes for everyone else.
availableDates = new Set(Object.keys(slotsByDay));
bookableDates = new Set(BookingLogic.bookableDayKeys(freeByDay));
+ // Days the minimum-notice policy took starts away from. Already in the selected
+ // timezone server-side, so these keys line up with slotsByDay's.
+ noticeDates = new Set((data.min_notice && data.min_notice.dates) || []);
} catch {
slotsByDay = {};
availableDates = new Set();
bookableDates = new Set();
+ noticeDates = new Set();
}
availabilityLoaded = true;
renderCal();
+ renderNoticeHint();
}
function renderCal() {
@@ -519,9 +529,37 @@ {{call .T "booking_confirmed"}}
});
// ── Date pick ─────────────────────────────────────────────────────────────
- function fillSlots(list) {
+ // The minimum-notice policy is invisible by construction: it removes the nearest starts
+ // and leaves nothing in their place. These two helpers put it back (#20).
+ function noticeHintText() {
+ return MIN_NOTICE_LABEL ? BookingLogic.fmt(t('min_notice_hint'), [MIN_NOTICE_LABEL]) : '';
+ }
+ // The standalone hint, shown before a day is chosen. Needed because a day the policy
+ // emptied completely is greyed out and cannot be clicked for the in-list explanation;
+ // once a day IS chosen, fillSlots says it in context instead.
+ function renderNoticeHint() {
+ const el = $('notice-hint');
+ if (!el) return;
+ const text = noticeHintText();
+ const show = !pickedDate && noticeDates.size > 0 && text !== '';
+ el.textContent = show ? text : '';
+ el.classList.toggle('hidden', !show);
+ }
+
+ function fillSlots(list, ds) {
+ // Only when this specific day lost starts to the policy - saying it on a day the
+ // policy did not touch would be noise attached to the wrong cause.
+ const noticeLine = noticeDates.has(ds) ? noticeHintText() : '';
+ const noticeHTML = noticeLine ? `${esc(noticeLine)}
` : '';
if (!slotsCache.length) {
- list.innerHTML = `${t('no_available_times')}
`;
+ // Name the day, and the host when there is exactly one. A bare "No available times."
+ // never told the visitor whether another day would help, which is the whole
+ // complaint in #20.
+ const dayLabel = fmtDate(pickedDate);
+ const msg = SOLE_HOST
+ ? BookingLogic.fmt(t('no_available_times_host'), [SOLE_HOST, dayLabel])
+ : BookingLogic.fmt(t('no_available_times'), [dayLabel]);
+ list.innerHTML = `${esc(msg)}
` + noticeHTML;
return;
}
// Taken entries render as disabled buttons so they keep the same box and rhythm as
@@ -537,6 +575,9 @@ {{call .T "booking_confirmed"}}
if (slotsCache.length && !slotsCache.some(s => !s.taken)) {
list.insertAdjacentHTML('beforeend', `${t('all_times_taken')}
`);
}
+ // Appended after a non-empty list too: a day showing 2pm onwards but nothing this
+ // morning is exactly the "why can't I see those times" case.
+ if (noticeHTML) list.insertAdjacentHTML('beforeend', noticeHTML);
}
async function pickDate(ds, y, m, d) {
@@ -545,6 +586,7 @@ {{call .T "booking_confirmed"}}
restoreDefaultHosts();
renderCal();
$('empty-hint').classList.add('hidden');
+ renderNoticeHint(); // a day is chosen now, so the standalone hint stands down
showView('slots-view');
setStep('right');
$('slots-date').textContent = fmtDate(pickedDate);
@@ -553,7 +595,7 @@ {{call .T "booking_confirmed"}}
// Instant: the month fetch already cached this day's slots.
if (slotsByDay[ds]) {
slotsCache = slotsByDay[ds];
- fillSlots(list);
+ fillSlots(list, ds);
return;
}
// Fallback (clicked before the month load finished): fetch just this day.
@@ -563,8 +605,9 @@ {{call .T "booking_confirmed"}}
if (!r.ok) throw new Error(r.status);
const data = await r.json();
Object.assign(hostMeta, data.hosts || {});
+ ((data.min_notice && data.min_notice.dates) || []).forEach(k => noticeDates.add(k));
slotsCache = BookingLogic.mergeDaySlots(data.slots, data.taken);
- fillSlots(list);
+ fillSlots(list, ds);
} catch {
list.innerHTML = `${t('could_not_load_times')}
`;
}
diff --git a/internal/handler/templates/manage.html b/internal/handler/templates/manage.html
index 1581cf6..adce09a 100644
--- a/internal/handler/templates/manage.html
+++ b/internal/handler/templates/manage.html
@@ -266,6 +266,10 @@ {{call .T "booking_cancelled_title"}}
const MAX_DAYS = {{.MaxFutureDays}};
const CURRENT_ISO = {{.CurrentStartISO}};
const ORG_TZ = {{.OrganizerTZ}};
+ // "" when this booking has several hosts (a group label can't be the subject of
+ // "%s has no available times"), and "" when the event type sets no minimum notice.
+ const SOLE_HOST = {{.SoleHostName}};
+ const MIN_NOTICE_LABEL = {{.MinNoticeLabel}};
const LOCALE = "{{.Locale}}";
const I18N = window.__CALNODE_I18N || {};
function t(key) { return I18N[key] || key; } // key itself as a last-resort fallback, matching Locale.T server-side
@@ -280,6 +284,7 @@ {{call .T "booking_cancelled_title"}}
let availableDates = new Set();
let availabilityLoaded = false; // false = render optimistically (assume all days open)
let slotsByDay = {}; // "YYYY-MM-DD" -> [slot]; cached from the month fetch
+ let noticeDates = new Set(); // days the minimum-notice policy took starts away from
const $ = id => document.getElementById(id);
const pad = n => String(n).padStart(2, '0');
@@ -346,12 +351,18 @@ {{call .T "booking_cancelled_title"}}
// event type shows taken times, a fully booked day is still worth opening so the
// struck-through list explains itself. Identical sets when it does not.
availableDates = new Set(Object.keys(slotsByDay));
+ // Days the minimum-notice policy took starts away from. Already in the selected
+ // timezone server-side, so these keys line up with slotsByDay's. A reschedule goes
+ // through the same /slots endpoint, so the same policy hides the same times.
+ noticeDates = new Set((data.min_notice && data.min_notice.dates) || []);
} catch {
slotsByDay = {};
availableDates = new Set();
+ noticeDates = new Set();
}
availabilityLoaded = true;
renderCal(); // grey out days that came back with no slots
+ renderPickDayHint();
}
function renderCal() {
@@ -408,9 +419,45 @@ {{call .T "booking_cancelled_title"}}
pickDate(btn.dataset.ds, +btn.dataset.y, +btn.dataset.m, +btn.dataset.d);
});
- function fillSlots(list) {
+ // ── Why times are missing ─────────────────────────────────────────────────
+ // Built with textContent rather than innerHTML: the host's name and the date are the
+ // only interpolated values on this page's hints, and this page has no escaping helper.
+ function hintP(text) {
+ const p = document.createElement('p');
+ p.className = 'hint';
+ p.textContent = text;
+ return p;
+ }
+ function noticeHintText() {
+ return MIN_NOTICE_LABEL ? BookingLogic.fmt(t('min_notice_hint'), [MIN_NOTICE_LABEL]) : '';
+ }
+ // The "pick a day" state, re-rendered after the month lands so it can carry the
+ // minimum-notice explanation. Needed because a day the policy emptied completely is
+ // greyed out and cannot be clicked for the in-list version (#20).
+ function renderPickDayHint() {
+ if (pickedDate) return; // a day is chosen; fillSlots owns the list now
+ const list = $('slots-list');
+ if (!list) return;
+ const notice = noticeDates.size > 0 ? noticeHintText() : '';
+ list.innerHTML = '';
+ list.appendChild(hintP(t('pick_day_hint')));
+ if (notice) list.appendChild(hintP(notice));
+ }
+
+ function fillSlots(list, ds) {
+ // Only when this specific day lost starts to the policy - saying it on a day the
+ // policy did not touch would be noise attached to the wrong cause.
+ const notice = noticeDates.has(ds) ? noticeHintText() : '';
if (!slotsCache.length) {
- list.innerHTML = `${t('no_available_times')}
`;
+ // Name the day, and the host when there is exactly one. A bare "No available times."
+ // never told the visitor whether another day would help (#20).
+ const dayLabel = fmtDate(pickedDate);
+ const msg = SOLE_HOST
+ ? BookingLogic.fmt(t('no_available_times_host'), [SOLE_HOST, dayLabel])
+ : BookingLogic.fmt(t('no_available_times'), [dayLabel]);
+ list.innerHTML = '';
+ list.appendChild(hintP(msg));
+ if (notice) list.appendChild(hintP(notice));
return;
}
// Taken entries are disabled buttons: same box as a bookable slot, announced as
@@ -422,6 +469,9 @@ {{call .T "booking_cancelled_title"}}
if (slotsCache.length && !slotsCache.some(s => !s.taken)) {
list.insertAdjacentHTML('beforeend', `${t('all_times_taken')}
`);
}
+ // Appended after a non-empty list too: a day showing 2pm onwards but nothing this
+ // morning is exactly the "why can't I see those times" case.
+ if (notice) list.appendChild(hintP(notice));
}
async function pickDate(ds, y, m, d) {
@@ -435,7 +485,7 @@ {{call .T "booking_cancelled_title"}}
// Instant: the month fetch already cached this day's slots.
if (slotsByDay[ds]) {
slotsCache = slotsByDay[ds];
- fillSlots(list);
+ fillSlots(list, ds);
return;
}
// Fallback (clicked before the month load finished): fetch just this day.
@@ -444,10 +494,11 @@ {{call .T "booking_cancelled_title"}}
const r = await fetch(`/v1/event-types/${SLUG}/slots?from=${ds}&to=${ds}&tz=${encodeURIComponent(TZ)}`);
if (!r.ok) throw new Error(r.status);
const data = await r.json();
+ ((data.min_notice && data.min_notice.dates) || []).forEach(k => noticeDates.add(k));
slotsCache = BookingLogic.mergeDaySlots(
(data.slots || []).filter(s => s.start !== CURRENT_ISO),
(data.taken || []).filter(s => s.start !== CURRENT_ISO));
- fillSlots(list);
+ fillSlots(list, ds);
} catch {
list.innerHTML = `${t('could_not_load_times')}
`;
}
diff --git a/internal/slots/notice_gap_test.go b/internal/slots/notice_gap_test.go
new file mode 100644
index 0000000..0dbab4c
--- /dev/null
+++ b/internal/slots/notice_gap_test.go
@@ -0,0 +1,253 @@
+package slots_test
+
+import (
+ "testing"
+ "time"
+
+ "github.com/calnode/calnode/internal/slots"
+)
+
+// The minimum-notice rule removes the starts nearest to now and leaves nothing behind to
+// explain them, which is the most common "why can't I see those times" question (#20).
+// GenerateDetailed reports those starts so a booking surface can say so — but only when
+// the policy is genuinely the reason, which is what most of these tests pin.
+
+// noticeReq is a wide-open Monday for one host, with now during that Monday morning.
+func noticeReq(minNotice int, now time.Time, busy ...slots.Interval) slots.Request {
+ date := utcDate(2026, 6, 15) // a Monday
+ return slots.Request{
+ Event: slots.EventConfig{
+ DurationMinutes: 30,
+ SlotIntervalMinutes: 30,
+ MinNoticeMinutes: minNotice,
+ RoutingMode: "fixed",
+ MaxFutureDays: 30,
+ },
+ Hosts: []slots.HostAvailability{singleHost("h1", time.UTC, monRules("09:00", "17:00"), busy...)},
+ DateFrom: date,
+ DateTo: date,
+ BookerTZ: time.UTC,
+ Now: now,
+ }
+}
+
+func TestGenerateDetailed_noticeGapIsExactlyWhatMinNoticeRemoved(t *testing.T) {
+ // 09:00, and bookings need 2 hours' notice: 09:00-10:30 are gone, 11:00 is the first
+ // bookable start.
+ res, err := slots.GenerateDetailed(noticeReq(120, utcTime(2026, 6, 15, 9, 0, 0)), slots.Extras{NoticeGap: true})
+ if err != nil {
+ t.Fatalf("GenerateDetailed: %v", err)
+ }
+ want := []time.Time{
+ utcTime(2026, 6, 15, 9, 0, 0),
+ utcTime(2026, 6, 15, 9, 30, 0),
+ utcTime(2026, 6, 15, 10, 0, 0),
+ utcTime(2026, 6, 15, 10, 30, 0),
+ }
+ got := startTimes(res.NoticeGap)
+ if len(got) != len(want) {
+ t.Fatalf("NoticeGap starts: got %v; want %v", got, want)
+ }
+ for i := range want {
+ if !got[i].Equal(want[i]) {
+ t.Errorf("NoticeGap[%d]: got %v; want %v", i, got[i], want[i])
+ }
+ }
+ // And the same starts are absent from Free: the two together account for the day.
+ for _, s := range res.Free {
+ if s.Start.UTC().Before(utcTime(2026, 6, 15, 11, 0, 0)) {
+ t.Errorf("Free contains %v, which min notice should have removed", s.Start)
+ }
+ }
+ if len(res.Free) == 0 {
+ t.Error("Free is empty; the fixture is meant to leave the afternoon bookable")
+ }
+}
+
+func TestGenerateDetailed_noticeGapCarriesNoHostIDs(t *testing.T) {
+ // Same reasoning as taken slots: the caller needs the time, and naming who was free
+ // at a time nobody can book says more than the feature needs to.
+ res, err := slots.GenerateDetailed(noticeReq(120, utcTime(2026, 6, 15, 9, 0, 0)), slots.Extras{NoticeGap: true})
+ if err != nil {
+ t.Fatalf("GenerateDetailed: %v", err)
+ }
+ if len(res.NoticeGap) == 0 {
+ t.Fatal("fixture produced no notice gap")
+ }
+ for _, s := range res.NoticeGap {
+ if len(s.HostIDs) != 0 {
+ t.Errorf("NoticeGap slot at %v names hosts %v", s.Start, s.HostIDs)
+ }
+ if !s.End.Equal(s.Start.Add(30 * time.Minute)) {
+ t.Errorf("NoticeGap slot at %v has End %v; want start+duration", s.Start, s.End)
+ }
+ }
+}
+
+func TestGenerateDetailed_startsAlreadyPastAreNotBlamedOnTheNoticePolicy(t *testing.T) {
+ // 16:00 with 60 minutes' notice. 09:00-15:30 are gone because they are in the PAST,
+ // not because of the policy: they would have gone with no policy at all. Only 16:00
+ // and 16:30 fall inside the notice window.
+ //
+ // Getting this wrong would put "bookings must be made 1 hour in advance" on every
+ // event type by the end of the working day, which is worse than saying nothing.
+ res, err := slots.GenerateDetailed(noticeReq(60, utcTime(2026, 6, 15, 16, 0, 0)), slots.Extras{NoticeGap: true})
+ if err != nil {
+ t.Fatalf("GenerateDetailed: %v", err)
+ }
+ want := []time.Time{
+ utcTime(2026, 6, 15, 16, 0, 0),
+ utcTime(2026, 6, 15, 16, 30, 0),
+ }
+ got := startTimes(res.NoticeGap)
+ if len(got) != len(want) {
+ t.Fatalf("NoticeGap starts: got %v; want %v (only the starts inside the notice window)", got, want)
+ }
+ for i := range want {
+ if !got[i].Equal(want[i]) {
+ t.Errorf("NoticeGap[%d]: got %v; want %v", i, got[i], want[i])
+ }
+ }
+}
+
+func TestGenerateDetailed_aBookedStartIsNotBlamedOnTheNoticePolicy(t *testing.T) {
+ // 09:00, 2 hours' notice, and 10:00-11:00 is already booked. 10:00 and 10:30 are gone
+ // twice over; the honest answer is that they were taken, so they must not appear in
+ // the notice gap. Busy intervals are applied on the way in, which is what makes
+ // NoticeGap and Taken disjoint.
+ req := noticeReq(120, utcTime(2026, 6, 15, 9, 0, 0), busyUTC(10, 0, 11, 0, utcDate(2026, 6, 15)))
+ res, err := slots.GenerateDetailed(req, slots.Extras{NoticeGap: true, Taken: true})
+ if err != nil {
+ t.Fatalf("GenerateDetailed: %v", err)
+ }
+ for _, s := range res.NoticeGap {
+ st := s.Start.UTC()
+ if st.Equal(utcTime(2026, 6, 15, 10, 0, 0)) || st.Equal(utcTime(2026, 6, 15, 10, 30, 0)) {
+ t.Errorf("booked start %v reported as withheld by the notice policy", st)
+ }
+ }
+ // 09:00 and 09:30 are still the policy's doing.
+ if len(res.NoticeGap) != 2 {
+ t.Errorf("NoticeGap: got %v; want the two unbooked starts inside the notice window", startTimes(res.NoticeGap))
+ }
+ // Nothing may be explained twice.
+ inNotice := map[time.Time]bool{}
+ for _, s := range res.NoticeGap {
+ inNotice[s.Start.UTC()] = true
+ }
+ for _, s := range res.Taken {
+ if inNotice[s.Start.UTC()] {
+ t.Errorf("start %v is reported as both taken and withheld by the notice policy", s.Start)
+ }
+ }
+}
+
+func TestGenerateDetailed_noNoticePolicyMeansNoNoticeGap(t *testing.T) {
+ // Nothing to attribute: with no policy, the starts before now are simply past.
+ res, err := slots.GenerateDetailed(noticeReq(0, utcTime(2026, 6, 15, 12, 0, 0)), slots.Extras{NoticeGap: true})
+ if err != nil {
+ t.Fatalf("GenerateDetailed: %v", err)
+ }
+ if len(res.NoticeGap) != 0 {
+ t.Errorf("NoticeGap with min_notice=0: got %v; want none", startTimes(res.NoticeGap))
+ }
+}
+
+func TestGenerateDetailed_noticeGapIsOptIn(t *testing.T) {
+ // The default Extras asks for nothing, and Generate/GenerateWithTaken keep behaving
+ // exactly as before.
+ req := noticeReq(120, utcTime(2026, 6, 15, 9, 0, 0))
+ res, err := slots.GenerateDetailed(req, slots.Extras{})
+ if err != nil {
+ t.Fatalf("GenerateDetailed: %v", err)
+ }
+ if len(res.NoticeGap) != 0 {
+ t.Errorf("NoticeGap without Extras.NoticeGap: got %v; want none", startTimes(res.NoticeGap))
+ }
+ if len(res.Taken) != 0 {
+ t.Errorf("Taken without Extras.Taken: got %v; want none", startTimes(res.Taken))
+ }
+ free, err := slots.Generate(req)
+ if err != nil {
+ t.Fatalf("Generate: %v", err)
+ }
+ if len(free) != len(res.Free) {
+ t.Errorf("Generate returned %d slots; GenerateDetailed returned %d — the wrappers must agree",
+ len(free), len(res.Free))
+ }
+}
+
+func TestGenerateDetailed_noticeGapRespectsTheRoutingMode(t *testing.T) {
+ // Collective: a slot is only bookable when BOTH hosts are free. h2 starts at 11:00, so
+ // the 09:00-10:30 starts were never bookable by anyone — the notice policy is not what
+ // removed them, and reporting them would explain a gap with the wrong cause.
+ date := utcDate(2026, 6, 15)
+ req := slots.Request{
+ Event: slots.EventConfig{
+ DurationMinutes: 30,
+ SlotIntervalMinutes: 30,
+ MinNoticeMinutes: 240, // 09:00 + 4h → nothing before 13:00 is bookable
+ RoutingMode: "collective",
+ MaxFutureDays: 30,
+ },
+ Hosts: []slots.HostAvailability{
+ singleHost("h1", time.UTC, monRules("09:00", "17:00")),
+ singleHost("h2", time.UTC, monRules("11:00", "17:00")),
+ },
+ DateFrom: date,
+ DateTo: date,
+ BookerTZ: time.UTC,
+ Now: utcTime(2026, 6, 15, 9, 0, 0),
+ }
+ res, err := slots.GenerateDetailed(req, slots.Extras{NoticeGap: true})
+ if err != nil {
+ t.Fatalf("GenerateDetailed: %v", err)
+ }
+ for _, s := range res.NoticeGap {
+ if s.Start.UTC().Before(utcTime(2026, 6, 15, 11, 0, 0)) {
+ t.Errorf("start %v reported as withheld by the notice policy, but h2 does not work then", s.Start)
+ }
+ }
+ // 11:00 through 12:30 were bookable but for the notice.
+ if got := len(res.NoticeGap); got != 4 {
+ t.Errorf("NoticeGap: got %v; want the four collective starts inside the notice window",
+ startTimes(res.NoticeGap))
+ }
+}
+
+func TestGenerateDetailed_noticeGapIsRenderedInTheBookerTimezone(t *testing.T) {
+ // The handler formats these into YYYY-MM-DD day keys the booking surfaces match
+ // against, so they must land in the booker's timezone like Free and Taken do.
+ ny := mustLoc(t, "America/New_York")
+ date := utcDate(2026, 6, 15)
+ req := slots.Request{
+ Event: slots.EventConfig{
+ DurationMinutes: 30,
+ SlotIntervalMinutes: 30,
+ MinNoticeMinutes: 120,
+ RoutingMode: "fixed",
+ MaxFutureDays: 30,
+ },
+ Hosts: []slots.HostAvailability{singleHost("h1", time.UTC, monRules("09:00", "17:00"))},
+ DateFrom: date,
+ DateTo: date,
+ BookerTZ: ny,
+ Now: utcTime(2026, 6, 15, 9, 0, 0),
+ }
+ res, err := slots.GenerateDetailed(req, slots.Extras{NoticeGap: true})
+ if err != nil {
+ t.Fatalf("GenerateDetailed: %v", err)
+ }
+ if len(res.NoticeGap) == 0 {
+ t.Fatal("fixture produced no notice gap")
+ }
+ for _, s := range res.NoticeGap {
+ if s.Start.Location().String() != ny.String() {
+ t.Errorf("NoticeGap start %v is in %s; want %s", s.Start, s.Start.Location(), ny)
+ }
+ }
+ // 09:00 UTC on 2026-06-15 is 05:00 in New York, so the day key is still the 15th.
+ if got := res.NoticeGap[0].Start.Format("2006-01-02 15:04"); got != "2026-06-15 05:00" {
+ t.Errorf("first NoticeGap start: got %q; want %q", got, "2026-06-15 05:00")
+ }
+}
From db378bc2df936fbecd4b4e2cec620a988890596a Mon Sep 17 00:00:00 2001
From: Sean Dean <254259913+distronode-com@users.noreply.github.com>
Date: Fri, 4 Sep 2026 04:26:29 -0400
Subject: [PATCH 3/3] test(slots): make the empty-dates notice assertion
clock-safe
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Review catch on #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)
---
internal/handler/slots_notice_test.go | 17 +++++++++++++----
1 file changed, 13 insertions(+), 4 deletions(-)
diff --git a/internal/handler/slots_notice_test.go b/internal/handler/slots_notice_test.go
index 048df0b..cf0365a 100644
--- a/internal/handler/slots_notice_test.go
+++ b/internal/handler/slots_notice_test.go
@@ -103,14 +103,23 @@ func TestGetSlots_omitsMinNoticeWhenThereIsNoPolicy(t *testing.T) {
}
func TestGetSlots_minNoticeDatesEmptyWhenThePolicyCostThisRangeNothing(t *testing.T) {
- // A one-minute notice, asked about days that start tomorrow: the policy exists and is
- // reported, but it took nothing away in this window, so nothing should be explained.
+ // A one-minute notice, asked about days that start the day after tomorrow: the policy
+ // exists and is reported, but it took nothing away in this window, so nothing should
+ // be explained.
+ //
+ // ⚠️ The window deliberately starts at +2 days, not +1. 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 pushes the cutoff past it (now 23:59:01 → cutoff
+ // 00:00:01), withholding that slot and making dates non-empty. That is a real ~59
+ // seconds a day where this test would fail for a reason unrelated to what it asserts.
+ // Starting a full day later puts every candidate slot at least 24 hours beyond any
+ // cutoff a one-minute policy can produce, whatever the clock says.
h, database, _, ownerID := setupWorkspaceWithDB(t)
seedNoticeEventType(t, database, ownerID, "tiny-notice", 1)
now := time.Now().UTC()
- from := now.AddDate(0, 0, 1).Format("2006-01-02")
- to := now.AddDate(0, 0, 3).Format("2006-01-02")
+ from := now.AddDate(0, 0, 2).Format("2006-01-02")
+ to := now.AddDate(0, 0, 4).Format("2006-01-02")
got := getSlots(t, h, "tiny-notice", "?from="+from+"&to="+to+"&tz=UTC")
if got.MinNotice == nil {