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
+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();
})