Filter booking calendar slots by available lesson type
CI / Tests (PHP 8.1) (pull_request) Successful in 49s
CI / Tests (PHP 8.2) (pull_request) Successful in 51s
CI / No Debug Code (pull_request) Successful in 2s
CI / Coding Standards (pull_request) Successful in 2m51s
CI / PHPStan (pull_request) Successful in 2m59s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m37s
CI / Build Plugin Zip (pull_request) Skipped

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 <[email protected]>
This commit is contained in:
2026-07-28 12:27:37 -03:00
co-authored by Claude Opus 5
parent 17487cde46
commit edcacae816
5 changed files with 204 additions and 43 deletions
+1
View File
@@ -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.
+30
View File
@@ -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;
+147 -41
View File
@@ -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 @@
</div>`;
}
// "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) => `
<label class="us-type-filter-choice">
<input type="checkbox" class="us-type-filter-option" value="${o.id}" ${selectedTypeIds.has(Number(o.id)) ? 'checked' : ''}>
${escHtml(filterLabel(o))}
</label>
`).join('');
return `
<div class="us-type-filter" role="group" aria-label="Filter by lesson type">
<span class="us-type-filter-heading">Lesson type</span>
${choices}
${filterActive() ? '<button type="button" id="us-type-filter-clear" class="us-type-filter-clear">Show all types</button>' : ''}
</div>`;
}
// 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]) => `
<div class="us-day">
<h3 class="us-day-heading">${escHtml(dayLabel(key))}</h3>
${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 = '<p>No available lesson slots at this time.</p>';
const slots = visibleSlots();
if (!slots.length) {
slotList.innerHTML = filterHtml() + (allSlots.length
? '<p>No open times match the selected lesson types.</p>'
: '<p>No available lesson slots at this time.</p>');
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 @@
</div>`;
}
// 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 @@
<select id="us-offering" disabled><option>${escHtml(label)}</option></select></label>
</p>`;
}
// 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 `
<p class="us-offering">
<label>Lesson type<br>
<select id="us-offering" required>
<option value="${choices[0].id}" selected>${escHtml(offeringLabel(choices[0]))}</option>
</select></label>
</p>`;
}
return `
<p class="us-offering">
<label>Lesson type<br>
@@ -268,14 +362,13 @@
</p>`;
}
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 @@
</div>`;
// 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();
})
+2
View File
@@ -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 |
+24 -2
View File
@@ -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