Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
45 changes: 44 additions & 1 deletion docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
40 changes: 35 additions & 5 deletions internal/handler/assets/booking-logic.js
Original file line number Diff line number Diff line change
@@ -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();
Expand Down Expand Up @@ -113,6 +119,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
Expand All @@ -128,6 +157,7 @@
groupSlotsByDay: groupSlotsByDay,
mergeDaySlots: mergeDaySlots,
bookableDayKeys: bookableDayKeys,
fmt: fmt,
formatTime: formatTime,
formatDay: formatDay,
dowIndex: dowIndex,
Expand Down
27 changes: 27 additions & 0 deletions internal/handler/assets/booking-logic.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.');
});
71 changes: 56 additions & 15 deletions internal/handler/book.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)).
Expand Down Expand Up @@ -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()),
Expand Down Expand Up @@ -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, &currency, &msgGreeting)
slug).Scan(&etID, &name, &description, &durMins, &locType, &locValue, &maxDays, &minNotice, &routingMode, &hostName, &avatarURL, &priceCents, &currency, &msgGreeting)
if errors.Is(err, sql.ErrNoRows) {
h.writeError(w, http.StatusNotFound, "event type not found")
return
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand All @@ -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, &currency, &msgGreeting)
slug).Scan(&etID, &name, &description, &durMins, &locType, &locValue, &maxDays, &minNotice, &routingMode, &hostName, &avatarURL, &priceCents, &currency, &msgGreeting)

if errors.Is(err, sql.ErrNoRows) {
http.Error(w, "Page not found", http.StatusNotFound)
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading