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
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:
@@ -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
@@ -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">‹ Previous week</button>
|
||||
<strong class="us-week-label">Week of ${escHtml(shortDayLabel(weekStart))}</strong>
|
||||
<button type="button" id="us-week-next">Next week ›</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));
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user