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"}}

+ +