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 `
+
');
+ 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