Split availability windows into bookable lesson-length slots with weekly calendar views
CI / Build Plugin Zip (pull_request) Has been skipped
CI / Tests (PHP 8.2) (pull_request) Successful in 45s
CI / PHPStan (pull_request) Successful in 2m48s
CI / Tests (PHP 8.1) (pull_request) Successful in 41s
CI / No Debug Code (pull_request) Failing after 2s
CI / Coding Standards (pull_request) Successful in 52s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m36s

Availability windows were stored and served as a single bookable row, so a
9:00 AM-4:00 PM window showed to students as one giant slot and booking it
consumed the whole day; past and multi-day windows also leaked into the
booking page as nonsense entries.

- Split windows into consecutive lesson-length slots on save (REST and admin
  form); each chunk is independently bookable and weekly recurrence creates a
  series per chunk so "reserve this time weekly" holds the same hour each week
- Reject windows spanning multiple days or shorter than the lesson length
  (400 invalid_window)
- Never return slots whose start has passed from GET /availability
- Migrate pre-split rows: Plugin::boot re-runs the Installer on version change
  and AvailabilityRepository::splitOversizedWindows() rewrites unbooked
  same-day oversized windows in place
- Display all times in 12-hour AM/PM form (booking page, wp-admin lists,
  editor previews)
- Add a List | Week view toggle to the student booking page and the
  instructor availability page, with previous/next-week navigation honouring
  the site's start_of_week option (new WeekCalendar helper)

Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
2026-07-05 16:00:47 -03:00
co-authored by Claude Fable 5
parent 66f308e1da
commit b7d5e3039e
20 changed files with 869 additions and 69 deletions
+77
View File
@@ -34,6 +34,83 @@
margin-top: 8px;
}
.us-view-toggle {
display: flex;
gap: 8px;
margin-bottom: 12px;
}
.us-view-toggle button {
padding: 6px 16px;
border: 1px solid #ccc;
border-radius: 4px;
background: transparent;
cursor: pointer;
}
.us-view-toggle button.us-active {
background: #333;
border-color: #333;
color: #fff;
}
.us-week-nav {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
margin-bottom: 12px;
}
.us-week-nav button {
padding: 6px 12px;
border: 1px solid #ccc;
border-radius: 4px;
background: transparent;
cursor: pointer;
}
.us-week-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 8px;
}
.us-week-day {
border: 1px solid #ddd;
border-radius: 4px;
padding: 8px;
min-height: 90px;
}
.us-week-day-heading {
margin: 0 0 8px;
font-size: 0.85em;
text-align: center;
}
.us-week-slot {
display: block;
width: 100%;
margin-bottom: 6px;
}
.us-week-empty {
display: block;
text-align: center;
opacity: 0.4;
}
@media (max-width: 640px) {
.us-week-grid {
grid-template-columns: 1fr;
}
.us-week-day {
min-height: 0;
}
}
/* Shown only in block-editor previews (see BlockPreview). */
.us-editor-note {
font-size: 0.85em;
+110 -12
View File
@@ -43,7 +43,13 @@
}
const dayKey = (dt) => String(dt).slice(0, 10);
const timeOf = (dt) => String(dt).slice(11, 16);
// "2026-07-06 14:30:00" → "2:30 PM"
function timeOf(dt) {
const hours = Number(String(dt).slice(11, 13));
const minutes = String(dt).slice(14, 16);
return `${hours % 12 || 12}:${minutes} ${hours < 12 ? 'AM' : 'PM'}`;
}
function dayLabel(key) {
const date = new Date(key + 'T00:00:00');
@@ -53,6 +59,12 @@
});
}
function shortDayLabel(key) {
const date = new Date(key + 'T00:00:00');
if (Number.isNaN(date.getTime())) return key;
return date.toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' });
}
function groupByDay(slots) {
const groups = new Map();
slots.forEach((slot) => {
@@ -63,14 +75,39 @@
return [...groups.entries()].sort((a, b) => a[0].localeCompare(b[0]));
}
// Agenda-style calendar: available slots grouped by day.
function renderSlots(slots) {
if (!slots.length) {
slotList.innerHTML = '<p>No available lesson slots at this time.</p>';
return;
}
// --- calendar view state (list is the default; week keeps its position) ---
let allSlots = [];
let view = 'list';
let weekStart = null;
slotList.innerHTML = groupByDay(slots).map(([key, daySlots]) => `
const pad = (n) => String(n).padStart(2, '0');
const toKey = (d) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
function addDays(key, days) {
const date = new Date(key + 'T00:00:00');
date.setDate(date.getDate() + days);
return toKey(date);
}
// First day of the week containing `key`, honouring the site's
// start-of-week setting (0 = Sunday … 6 = Saturday).
function weekStartOf(key) {
const startOfWeek = Number(usScheduler.startOfWeek) || 0;
const date = new Date(key + 'T00:00:00');
return addDays(key, -((date.getDay() - startOfWeek + 7) % 7));
}
function toggleHtml() {
return `
<div class="us-view-toggle" role="group" aria-label="Calendar view">
<button type="button" id="us-view-list" class="${view === 'list' ? 'us-active' : ''}">List</button>
<button type="button" id="us-view-week" class="${view === 'week' ? 'us-active' : ''}">Week</button>
</div>`;
}
// Agenda-style calendar: available slots grouped by day.
function listHtml() {
return groupByDay(allSlots).map(([key, daySlots]) => `
<div class="us-day">
<h3 class="us-day-heading">${escHtml(dayLabel(key))}</h3>
${daySlots.map((slot) => `
@@ -81,10 +118,68 @@
`).join('')}
</div>
`).join('');
}
slotList.querySelectorAll('.us-book-btn').forEach((btn) => {
const slot = slots.find((s) => String(s.id) === btn.dataset.slotId);
btn.addEventListener('click', () => openRegistration(slot));
// Weekly calendar: seven day columns with a bookable button per slot.
function weekHtml() {
const byDay = new Map(groupByDay(allSlots));
const days = [...Array(7).keys()].map((i) => addDays(weekStart, i));
const columns = days.map((key) => {
const daySlots = byDay.get(key) || [];
const buttons = daySlots.map((slot) => `
<button data-slot-id="${slot.id}" class="us-book-btn us-week-slot" title="${escHtml(String(slot.duration_minutes))} min">
${escHtml(timeOf(slot.start_dt))}
</button>
`).join('');
return `
<div class="us-week-day">
<h4 class="us-week-day-heading">${escHtml(shortDayLabel(key))}</h4>
${buttons || '<span class="us-week-empty" aria-hidden="true">—</span>'}
</div>`;
}).join('');
return `
<div class="us-week-nav">
<button type="button" id="us-week-prev">&lsaquo; Previous week</button>
<strong class="us-week-label">Week of ${escHtml(shortDayLabel(weekStart))}</strong>
<button type="button" id="us-week-next">Next week &rsaquo;</button>
</div>
<div class="us-week-grid">${columns}</div>`;
}
function render() {
if (!allSlots.length) {
slotList.innerHTML = '<p>No available lesson slots at this time.</p>';
return;
}
slotList.innerHTML = toggleHtml() + (view === 'week' ? weekHtml() : listHtml());
wireCalendarEvents();
}
function wireCalendarEvents() {
document.getElementById('us-view-list').addEventListener('click', () => {
view = 'list';
render();
});
document.getElementById('us-view-week').addEventListener('click', () => {
view = 'week';
// Default to the week of the earliest open slot (the API returns
// slots ordered by start), so the first look is never empty.
if (!weekStart) weekStart = weekStartOf(dayKey(allSlots[0].start_dt));
render();
});
const prev = document.getElementById('us-week-prev');
const next = document.getElementById('us-week-next');
if (prev) prev.addEventListener('click', () => { weekStart = addDays(weekStart, -7); render(); });
if (next) next.addEventListener('click', () => { weekStart = addDays(weekStart, 7); render(); });
slotList.querySelectorAll('.us-book-btn[data-slot-id]').forEach((btn) => {
const slot = allSlots.find((s) => String(s.id) === btn.dataset.slotId);
if (slot) btn.addEventListener('click', () => openRegistration(slot));
});
}
@@ -195,7 +290,10 @@
slotList.style.display = 'block';
confirm.style.display = 'none';
apiFetch('availability')
.then(renderSlots)
.then((slots) => {
allSlots = slots;
render();
})
.catch((err) => showError(err.message));
}