From edcacae816ea42282d43dbe7a98d546794da589b Mon Sep 17 00:00:00 2001 From: James Griffin Date: Tue, 28 Jul 2026 12:27:37 -0300 Subject: [PATCH] Filter booking calendar slots by available lesson type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Not every open time can be booked as every private-lesson type: a slot tied to an offering takes that offering only, and a generic slot only takes types whose length fits. Students had no way to see that before clicking a time. The booking calendar now carries a lesson-type filter — a checkbox per active private-lesson type, fetched once from GET /offerings?kind=private_lesson. Ticking types narrows the calendar to the times bookable as one of them and re-anchors the week view on the earliest match. The registration form's Lesson type picker is narrowed the same way, and a lone remaining type is pre-selected with its intake questions loaded. Bookability is decided by offeringFitsSlot(), the client-side mirror of the rule POST /bookings enforces; the filter is a browsing aid and the server still validates every booking. No ticks means no filter, and the whole control is hidden when the studio offers fewer than two private-lesson types. Closes #117 Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 1 + assets/css/frontend.css | 30 ++++ assets/js/booking.js | 188 ++++++++++++++++++----- docs/features/availability-management.md | 2 + docs/features/lesson-booking.md | 26 +++- 5 files changed, 204 insertions(+), 43 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bd308fa..52a2295 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ each change under the current top section as you work. ## [1.2.2] ### Added +- The booking calendar now has a **lesson type** filter above it, so a student browsing open times can narrow them to the types they actually want. Because not every open time can be booked as every private-lesson type — some times are tied to a specific type, others only take types of a matching length — the filter shows just the times bookable as the ticked types, and re-anchors the week view on the earliest one so it never opens on an empty week. Picking one of those times narrows the **Lesson type** picker on the registration form to the same list, and when only one type is left it is chosen automatically with its questions loaded. Tick nothing (or use **Show all types**) to see every open time as before. The filter is hidden when the studio only offers one private-lesson type. - The **Group Classes** block can now be pinned to a single class, under **Classes shown → Class** in the block sidebar (shortcode: `[us_group_classes offering="…"]`). Pick a class and the block shows only that one, so it can be embedded on a page that describes the class. In this mode the class's own description is left out to avoid repeating the page copy — the card shows the schedule, instructor, price, enrolment deadline and the enrol/withdraw controls. Leaving it on **All classes** keeps the full browsable catalog with descriptions. - The **Student Registration** block can now send students onward to a page of your choosing once they finish registering. Its **After email confirmation** panel is now **After registration**: the page you pick there is where the link shown to a newly registered student points — the "Sign in to your account" link after they confirm their email, and a "Continue to your account" link for an invited student, who is signed in immediately. A new **Redirect automatically** option takes them straight there instead of showing the link. Registration errors are never skipped — a failed sign-up and an expired confirmation link still show their message on the page, as does the "check your email to confirm your address" step. The redirect needs a page to be chosen; with none set, students see the link (or, for invited students, just the confirmation) as before. diff --git a/assets/css/frontend.css b/assets/css/frontend.css index ecbcced..cdd288d 100644 --- a/assets/css/frontend.css +++ b/assets/css/frontend.css @@ -117,6 +117,36 @@ color: #8a6d1a; } +.us-type-filter { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px 16px; + margin-bottom: 12px; + padding: 8px 12px; + border: 1px solid #eee; + border-radius: 4px; +} + +.us-type-filter-heading { + font-weight: 600; +} + +.us-type-filter-choice { + display: inline-flex; + align-items: center; + gap: 6px; +} + +.us-type-filter-clear { + margin-left: auto; + padding: 4px 12px; + border: 1px solid #ccc; + border-radius: 4px; + background: transparent; + cursor: pointer; +} + .us-view-toggle { display: flex; gap: 8px; diff --git a/assets/js/booking.js b/assets/js/booking.js index d19752f..35e4ee2 100644 --- a/assets/js/booking.js +++ b/assets/js/booking.js @@ -81,6 +81,43 @@ let view = 'week'; let weekStart = null; + // Every active private-lesson type the student may book, across instructors. + let catalog = []; + + // Lesson types the student has filtered the calendar down to; empty means + // "no filter" — every open slot is shown. + const selectedTypeIds = new Set(); + + // Whether an offering can be booked into a slot — the client-side mirror of + // the rule `POST /bookings` enforces: a slot tied to an offering takes that + // offering only, and a generic slot takes any of its instructor's types + // whose length fits. + function offeringFitsSlot(offering, slot) { + if (Number(offering.instructor_id) !== Number(slot.instructor_id)) return false; + + const tiedId = Number(slot.offering_id) || 0; + if (tiedId) return Number(offering.id) === tiedId; + + return !offering.duration_minutes + || Number(offering.duration_minutes) === Number(slot.duration_minutes); + } + + const filterActive = () => selectedTypeIds.size > 0; + + const typeSelected = (offering) => !filterActive() || selectedTypeIds.has(Number(offering.id)); + + // The lesson types this slot could be booked as, honouring the filter. + function slotChoices(slot) { + return catalog.filter((o) => offeringFitsSlot(o, slot) && typeSelected(o)); + } + + // With a filter set, a slot is only shown when one of the chosen lesson + // types can actually be booked into it. + function visibleSlots() { + if (!filterActive()) return allSlots; + return allSlots.filter((slot) => slotChoices(slot).length > 0); + } + const pad = (n) => String(n).padStart(2, '0'); const toKey = (d) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; @@ -106,9 +143,40 @@ `; } + // "Piano Lesson (30 min)" — the instructor's name is only worth the space + // when the catalog spans more than one of them. + function filterLabel(offering) { + const duration = offering.duration_minutes ? ` (${offering.duration_minutes} min)` : ''; + const instructors = new Set(catalog.map((o) => Number(o.instructor_id))); + const who = instructors.size > 1 && offering.instructor_name + ? ` — ${offering.instructor_name}` + : ''; + return `${offering.title}${duration}${who}`; + } + + // Lesson-type filter. Pointless with a single bookable type, so it is only + // rendered once there is a choice to make. + function filterHtml() { + if (catalog.length < 2) return ''; + + const choices = catalog.map((o) => ` + + `).join(''); + + return ` +
+ Lesson type + ${choices} + ${filterActive() ? '' : ''} +
`; + } + // Agenda-style calendar: available slots grouped by day. - function listHtml() { - return groupByDay(allSlots).map(([key, daySlots]) => ` + function listHtml(slots) { + return groupByDay(slots).map(([key, daySlots]) => `

${escHtml(dayLabel(key))}

${daySlots.map((slot) => ` @@ -122,8 +190,8 @@ } // Weekly calendar: seven day columns with a bookable button per slot. - function weekHtml() { - const byDay = new Map(groupByDay(allSlots)); + function weekHtml(slots) { + const byDay = new Map(groupByDay(slots)); const days = [...Array(7).keys()].map((i) => addDays(weekStart, i)); const columns = days.map((key) => { @@ -151,19 +219,51 @@ } function render() { - if (!allSlots.length) { - slotList.innerHTML = '

No available lesson slots at this time.

'; + const slots = visibleSlots(); + + if (!slots.length) { + slotList.innerHTML = filterHtml() + (allSlots.length + ? '

No open times match the selected lesson types.

' + : '

No available lesson slots at this time.

'); + wireFilterEvents(); return; } - // Anchor the week view to the week of the earliest open slot (the API - // returns slots ordered by start), so the first look is never empty. - if (view === 'week' && !weekStart) weekStart = weekStartOf(dayKey(allSlots[0].start_dt)); + // Anchor the week view to the week of the earliest matching slot (the + // API returns slots ordered by start), so the first look is never empty. + if (view === 'week' && !weekStart) weekStart = weekStartOf(dayKey(slots[0].start_dt)); - slotList.innerHTML = toggleHtml() + (view === 'week' ? weekHtml() : listHtml()); + slotList.innerHTML = filterHtml() + toggleHtml() + (view === 'week' ? weekHtml(slots) : listHtml(slots)); + wireFilterEvents(); wireCalendarEvents(); } + function wireFilterEvents() { + slotList.querySelectorAll('.us-type-filter-option').forEach((input) => { + input.addEventListener('change', () => { + const id = Number(input.value); + if (input.checked) { + selectedTypeIds.add(id); + } else { + selectedTypeIds.delete(id); + } + // The nearest matching time may be weeks away, so re-anchor the + // week view instead of leaving the student on an empty week. + weekStart = null; + render(); + }); + }); + + const clear = document.getElementById('us-type-filter-clear'); + if (clear) { + clear.addEventListener('click', () => { + selectedTypeIds.clear(); + weekStart = null; + render(); + }); + } + } + function wireCalendarEvents() { document.getElementById('us-view-list').addEventListener('click', () => { view = 'list'; @@ -211,20 +311,6 @@
`; } - // Active private-lesson offerings per instructor, so revisiting the - // registration form does not refetch the same catalog. - const offeringCache = new Map(); - - function instructorOfferings(instructorId) { - if (offeringCache.has(instructorId)) { - return Promise.resolve(offeringCache.get(instructorId)); - } - return apiFetch(`offerings?instructor_id=${instructorId}&kind=private_lesson`).then((list) => { - offeringCache.set(instructorId, list); - return list; - }); - } - // "Piano Lesson (60 min — $50.00 CAD)" / "Trial Lesson (Free)" function offeringLabel(o) { const duration = o.duration_minutes ? `${o.duration_minutes} min — ` : ''; @@ -237,13 +323,8 @@ function openRegistration(slot) { clearError(); - Promise.all([ - instructorOfferings(Number(slot.instructor_id)), - apiFetch('policies?scope=booking'), - ]) - .then(([offerings, policies]) => { - renderRegistration(slot, offerings, policies); - }) + apiFetch('policies?scope=booking') + .then((policies) => renderRegistration(slot, policies)) .catch((err) => showError(err.message)); } @@ -258,6 +339,19 @@

`; } + + // Only one type is left to book this slot as — usually because the + // filter narrowed it down — so it is chosen for the student. + if (choices.length === 1) { + return ` +

+ +

`; + } + return `

`; } - function renderRegistration(slot, offerings, policies) { + function renderRegistration(slot, policies) { const tiedId = Number(slot.offering_id) || 0; - const tied = tiedId ? offerings.find((o) => Number(o.id) === tiedId) : null; + const tied = tiedId ? catalog.find((o) => Number(o.id) === tiedId) : null; - // Generic slots offer every lesson type that fits the slot's length. - const choices = tiedId - ? [] - : offerings.filter((o) => !o.duration_minutes || Number(o.duration_minutes) === Number(slot.duration_minutes)); + // Generic slots offer every lesson type that fits the slot — narrowed to + // the filtered types when the student has set a filter. + const choices = tiedId ? [] : slotChoices(slot); if (!tiedId && !choices.length) { // The server rejects offering-less bookings, so without a matching @@ -309,8 +402,10 @@ `; // The intake questions belong to the selected offering, so they follow - // the picker instead of being fixed at render time. - let selectedId = tiedId; + // the picker instead of being fixed at render time. A tied slot — or a + // lone remaining type — is already decided, so its questions load + // straight away. + let selectedId = tiedId || (choices.length === 1 ? Number(choices[0].id) : 0); let questions = []; const questionsBox = document.getElementById('us-questions'); @@ -467,13 +562,24 @@ confirm.style.display = 'block'; } + // The private-lesson catalog drives both the filter and the registration + // form's lesson-type picker, and it does not change while the student + // browses — so it is fetched once and kept. + function loadCatalog() { + if (catalog.length) return Promise.resolve(catalog); + return apiFetch('offerings?kind=private_lesson').then((list) => { + catalog = list; + return catalog; + }); + } + function loadSlots() { clearError(); slotList.style.display = 'block'; confirm.style.display = 'none'; loadMyLessons(); - apiFetch('availability') - .then((slots) => { + Promise.all([apiFetch('availability'), loadCatalog()]) + .then(([slots]) => { allSlots = slots; render(); }) diff --git a/docs/features/availability-management.md b/docs/features/availability-management.md index 0839711..ed0d69c 100644 --- a/docs/features/availability-management.md +++ b/docs/features/availability-management.md @@ -49,6 +49,8 @@ The front-end booking shortcode renders open slots from `GET /availability` either as an agenda-style list grouped by day or as a **weekly calendar** with previous/next-week navigation (toggle rendered by `assets/js/booking.js`; the site's `start_of_week` option is passed through the `usScheduler` JS config). +Both views can be narrowed to the slots bookable as chosen private-lesson types +with the lesson-type filter — see `lesson-booking.md`. ## REST API | Method | Endpoint | Permission | diff --git a/docs/features/lesson-booking.md b/docs/features/lesson-booking.md index 9785d25..bfc4a79 100644 --- a/docs/features/lesson-booking.md +++ b/docs/features/lesson-booking.md @@ -20,8 +20,8 @@ Students register for a private lesson by choosing an offering, picking a time ( | `created_at` | DATETIME | Insertion time | ## Registration Flow -1. Student opens the page with the `[us_booking]` shortcode and browses open slots as a weekly calendar (the default, anchored to the week of the earliest open slot) or an agenda list (view toggle with previous/next-week navigation; times shown in 12-hour AM/PM form). -2. Student picks a slot and an **offering** (a 30 or 60-minute private-lesson type). When the slot is tied to an offering the form shows it locked (the student sees exactly what they are booking); otherwise the form presents the instructor's active private-lesson offerings whose duration fits the slot. Every booking requires an offering — a generic slot with no fitting offering cannot be booked online. +1. Student opens the page with the `[us_booking]` shortcode and browses open slots as a weekly calendar (the default, anchored to the week of the earliest open slot) or an agenda list (view toggle with previous/next-week navigation; times shown in 12-hour AM/PM form). A **lesson-type filter** above the calendar narrows the open times to those bookable as the chosen types (see **Lesson-Type Filter**). +2. Student picks a slot and an **offering** (a 30 or 60-minute private-lesson type). When the slot is tied to an offering the form shows it locked (the student sees exactly what they are booking); otherwise the form presents the instructor's active private-lesson offerings whose duration fits the slot, narrowed to the filtered types. When exactly one type remains it is pre-selected (its intake questions load immediately). Every booking requires an offering — a generic slot with no fitting offering cannot be booked online. 3. For a `weekly` reservation, the same weekday/time is held for the rest of the offering's term. 4. Student answers the offering's questions (`GET /offerings/{id}/questions`). 5. Student accepts the current published policy versions (`GET /policies`) — required to continue. @@ -31,6 +31,28 @@ Students register for a private lesson by choosing an offering, picking a time ( 9. Instructor sees the booking under **My Lessons** and may update status via `PATCH /bookings/{id}/status`. 10. The booking page also shows the student their upcoming lessons (`GET /bookings`) — each with the booked offering's name and length, when it happens, a per-lesson status badge (pending payment / confirmed), and a **Cancel** button. Only the soonest five are shown; a **Show all** control reveals the rest. `GET /bookings` includes `offering_title` and `duration_minutes` for each lesson so the list needs no extra request. +## Lesson-Type Filter +Not every open slot can be booked as every private-lesson type — a slot tied to +an offering takes that offering only, and a generic slot only takes types whose +length fits. The booking calendar therefore carries a lesson-type filter above +the view toggle: a checkbox per active private-lesson type (from +`GET /offerings?kind=private_lesson`, fetched once per page load), showing the +instructor's name alongside the title when the catalog spans more than one +instructor. The filter is hidden when there is only one bookable type. + +Ticking one or more types narrows the calendar to the slots bookable as one of +them; no ticks means no filter. Picking a filtered slot narrows the registration +form's **Lesson type** picker the same way, and when exactly one type remains it +is pre-selected and its intake questions load immediately. Changing the filter +re-anchors the week view on the earliest matching slot, so the student never +lands on an empty week. **Show all types** clears the filter. + +Bookability is decided client-side by `offeringFitsSlot()` in +`assets/js/booking.js` — the mirror of the rule `POST /bookings` enforces (same +instructor, the tied offering when there is one, otherwise a matching +`duration_minutes`). The filter is a browsing aid only: the server re-checks +every booking regardless. + ## Cancellation Students cancel their own lessons via `POST /bookings/{id}/cancel` (idempotent). Cancelling marks the lesson `cancelled`, frees the availability slot for -- 2.54.0