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;
|
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). */
|
/* Shown only in block-editor previews (see BlockPreview). */
|
||||||
.us-editor-note {
|
.us-editor-note {
|
||||||
font-size: 0.85em;
|
font-size: 0.85em;
|
||||||
|
|||||||
+109
-11
@@ -43,7 +43,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
const dayKey = (dt) => String(dt).slice(0, 10);
|
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) {
|
function dayLabel(key) {
|
||||||
const date = new Date(key + 'T00:00:00');
|
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) {
|
function groupByDay(slots) {
|
||||||
const groups = new Map();
|
const groups = new Map();
|
||||||
slots.forEach((slot) => {
|
slots.forEach((slot) => {
|
||||||
@@ -63,14 +75,39 @@
|
|||||||
return [...groups.entries()].sort((a, b) => a[0].localeCompare(b[0]));
|
return [...groups.entries()].sort((a, b) => a[0].localeCompare(b[0]));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Agenda-style calendar: available slots grouped by day.
|
// --- calendar view state (list is the default; week keeps its position) ---
|
||||||
function renderSlots(slots) {
|
let allSlots = [];
|
||||||
if (!slots.length) {
|
let view = 'list';
|
||||||
slotList.innerHTML = '<p>No available lesson slots at this time.</p>';
|
let weekStart = null;
|
||||||
return;
|
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
slotList.innerHTML = groupByDay(slots).map(([key, daySlots]) => `
|
// 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">
|
<div class="us-day">
|
||||||
<h3 class="us-day-heading">${escHtml(dayLabel(key))}</h3>
|
<h3 class="us-day-heading">${escHtml(dayLabel(key))}</h3>
|
||||||
${daySlots.map((slot) => `
|
${daySlots.map((slot) => `
|
||||||
@@ -81,10 +118,68 @@
|
|||||||
`).join('')}
|
`).join('')}
|
||||||
</div>
|
</div>
|
||||||
`).join('');
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
slotList.querySelectorAll('.us-book-btn').forEach((btn) => {
|
// Weekly calendar: seven day columns with a bookable button per slot.
|
||||||
const slot = slots.find((s) => String(s.id) === btn.dataset.slotId);
|
function weekHtml() {
|
||||||
btn.addEventListener('click', () => openRegistration(slot));
|
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';
|
slotList.style.display = 'block';
|
||||||
confirm.style.display = 'none';
|
confirm.style.display = 'none';
|
||||||
apiFetch('availability')
|
apiFetch('availability')
|
||||||
.then(renderSlots)
|
.then((slots) => {
|
||||||
|
allSlots = slots;
|
||||||
|
render();
|
||||||
|
})
|
||||||
.catch((err) => showError(err.message));
|
.catch((err) => showError(err.message));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# Feature: Availability Management
|
# Feature: Availability Management
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
Instructors define date/time windows during which they are available for private lessons. Students book from these windows. Windows carry a lesson length and may be generated as a weekly-recurring series.
|
Instructors define same-day date/time windows during which they are available for private lessons. On save, a window is split into consecutive lesson-length slots (09:00–16:00 with 60-minute lessons becomes seven rows), each independently bookable by students. Windows may be generated as a weekly-recurring series.
|
||||||
|
|
||||||
## Data Model — `{prefix}us_availability`
|
## Data Model — `{prefix}us_availability`
|
||||||
|
|
||||||
@@ -11,31 +11,43 @@ Instructors define date/time windows during which they are available for private
|
|||||||
| `instructor_id` | BIGINT UNSIGNED | WordPress user ID |
|
| `instructor_id` | BIGINT UNSIGNED | WordPress user ID |
|
||||||
| `offering_id` | BIGINT UNSIGNED | Nullable FK → `us_offerings.id` (private-lesson type) |
|
| `offering_id` | BIGINT UNSIGNED | Nullable FK → `us_offerings.id` (private-lesson type) |
|
||||||
| `start_dt` | DATETIME | Slot start — stored as `Y-m-d H:i:s` |
|
| `start_dt` | DATETIME | Slot start — stored as `Y-m-d H:i:s` |
|
||||||
| `end_dt` | DATETIME | Slot end — stored as `Y-m-d H:i:s` |
|
| `end_dt` | DATETIME | Slot end — always `start_dt + duration_minutes` |
|
||||||
| `duration_minutes` | SMALLINT | Lesson length the window accommodates (e.g. 30, 60) |
|
| `duration_minutes` | SMALLINT | Lesson length (e.g. 30, 60) |
|
||||||
| `is_booked` | TINYINT(1) | 0 = available, 1 = booked |
|
| `is_booked` | TINYINT(1) | 0 = available, 1 = booked |
|
||||||
| `recurrence_group` | BIGINT UNSIGNED | Nullable — weekly-recurring windows share one group id |
|
| `recurrence_group` | BIGINT UNSIGNED | Nullable — weekly-recurring windows share one group id |
|
||||||
| `created_at` | DATETIME | Insertion time |
|
| `created_at` | DATETIME | Insertion time |
|
||||||
|
|
||||||
A window's `duration_minutes` is matched against the offering a student picks: a
|
A slot's `duration_minutes` is matched against the offering a student picks: a
|
||||||
30-minute private offering can only be booked into a window whose
|
30-minute private offering can only be booked into a slot whose
|
||||||
`duration_minutes` accommodates it.
|
`duration_minutes` accommodates it.
|
||||||
|
|
||||||
|
## Window Splitting
|
||||||
|
`AvailabilitySlot::splitByDuration()` chunks a submitted window into consecutive
|
||||||
|
`duration_minutes` slots; `AvailabilityRepository::createFromWindow()` persists
|
||||||
|
one row per chunk. A trailing remainder shorter than the lesson length is
|
||||||
|
dropped. Windows must start and end on the same day and fit at least one lesson
|
||||||
|
(REST responds `400 invalid_window` otherwise; the admin form is a no-op).
|
||||||
|
`AvailabilityRepository::splitOversizedWindows()` is a data migration (run by
|
||||||
|
`Installer` on activation or version change) that rewrites pre-split rows.
|
||||||
|
|
||||||
## Weekly-Recurring Windows
|
## Weekly-Recurring Windows
|
||||||
Instructors may generate a window weekly across a date range. Each occurrence is a
|
Instructors may generate a window weekly across a date range. Each lesson-length
|
||||||
separate row sharing one `recurrence_group` id, so a recurring set can be added or
|
chunk becomes its own weekly series: occurrences of the same time-of-day share
|
||||||
removed together while individual occurrences are still booked independently.
|
one `recurrence_group` id, so a recurring set can be added or removed together
|
||||||
|
while individual occurrences are still booked independently.
|
||||||
|
|
||||||
## Admin Interface
|
## Admin Interface
|
||||||
Instructors access **My Availability** in wp-admin (`?page=us-availability`).
|
Instructors access **My Availability** in wp-admin (`?page=us-availability`).
|
||||||
- Add a slot: provide start/end datetime, duration, and (optionally) a linked private-lesson offering
|
- Add availability: provide a same-day start/end window, lesson length, and (optionally) a linked private-lesson offering
|
||||||
- Add a weekly series: provide the weekday/time plus a date range
|
- Add a weekly series: tick weekly repeat and choose the number of weeks
|
||||||
- Delete a slot: only allowed if `is_booked = 0`
|
- Delete a slot: only allowed if `is_booked = 0`
|
||||||
|
- Current slots can be shown as a **list** or a **weekly calendar** (`usc_view=week`, navigated with `usc_week=Y-m-d`); the grid honours the site's `start_of_week` option via `Availability\WeekCalendar`
|
||||||
|
|
||||||
## Public Calendar
|
## Public Calendar
|
||||||
The front-end booking shortcode renders a month/week calendar of open windows,
|
The front-end booking shortcode renders open slots from `GET /availability`
|
||||||
populated from `GET /availability`. Students can filter by instructor and by
|
either as an agenda-style list grouped by day or as a **weekly calendar** with
|
||||||
offering/duration before selecting a slot to register for.
|
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).
|
||||||
|
|
||||||
## REST API
|
## REST API
|
||||||
| Method | Endpoint | Permission |
|
| Method | Endpoint | Permission |
|
||||||
@@ -45,19 +57,27 @@ offering/duration before selecting a slot to register for.
|
|||||||
| `DELETE` | `/wp-json/us-scheduler/v1/availability/{id}` | `manage_availability` + slot owner |
|
| `DELETE` | `/wp-json/us-scheduler/v1/availability/{id}` | `manage_availability` + slot owner |
|
||||||
|
|
||||||
`GET` supports query params: `instructor_id`, `offering_id`, `duration_minutes`, `from` (datetime), `to` (datetime).
|
`GET` supports query params: `instructor_id`, `offering_id`, `duration_minutes`, `from` (datetime), `to` (datetime).
|
||||||
|
Slots whose start has already passed are never returned.
|
||||||
|
|
||||||
`POST` validates `start_dt`/`end_dt` (admin form and REST alike) via
|
`POST` validates `start_dt`/`end_dt` (admin form and REST alike) via
|
||||||
`AvailabilitySlot::normalizeDateTime()`: the canonical `Y-m-d H:i[:s]` and HTML
|
`AvailabilitySlot::normalizeDateTime()`: the canonical `Y-m-d H:i[:s]` and HTML
|
||||||
`datetime-local` (`Y-m-d\TH:i[:s]`) forms are normalised to `Y-m-d H:i:s`;
|
`datetime-local` (`Y-m-d\TH:i[:s]`) forms are normalised to `Y-m-d H:i:s`;
|
||||||
anything else — or an end not after the start — is rejected (REST responds
|
anything else — or an end not after the start — is rejected (REST responds
|
||||||
`400 invalid_datetime`; the admin form is a no-op).
|
`400 invalid_datetime`; the admin form is a no-op). A valid window is stored as
|
||||||
|
lesson-length slots and `201` returns `{ "ids": [...] }` for every row created.
|
||||||
|
|
||||||
|
Times are displayed in 12-hour AM/PM form in the booking calendar and wp-admin
|
||||||
|
lists.
|
||||||
|
|
||||||
## Implementation
|
## Implementation
|
||||||
- Repository: `Unsupervised\Schedular\Availability\AvailabilityRepository`
|
- Repository: `Unsupervised\Schedular\Availability\AvailabilityRepository`
|
||||||
- Model: `Unsupervised\Schedular\Availability\AvailabilitySlot`
|
- Model: `Unsupervised\Schedular\Availability\AvailabilitySlot`
|
||||||
|
- Week bucketing: `Unsupervised\Schedular\Availability\WeekCalendar`
|
||||||
- Admin controller: `Unsupervised\Schedular\Availability\AvailabilityController`
|
- Admin controller: `Unsupervised\Schedular\Availability\AvailabilityController`
|
||||||
- REST endpoint: `Unsupervised\Schedular\Availability\AvailabilityEndpoint`
|
- REST endpoint: `Unsupervised\Schedular\Availability\AvailabilityEndpoint`
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
- `tests/Unit/Availability/AvailabilityRepositoryTest.php`
|
- `tests/Unit/Availability/AvailabilityRepositoryTest.php`
|
||||||
- `tests/Unit/Availability/AvailabilitySlotTest.php`
|
- `tests/Unit/Availability/AvailabilitySlotTest.php`
|
||||||
|
- `tests/Unit/Availability/AvailabilityEndpointTest.php`
|
||||||
|
- `tests/Unit/Availability/WeekCalendarTest.php`
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ Students register for a private lesson by choosing an offering, picking a time (
|
|||||||
| `created_at` | DATETIME | Insertion time |
|
| `created_at` | DATETIME | Insertion time |
|
||||||
|
|
||||||
## Registration Flow
|
## Registration Flow
|
||||||
1. Student opens the page with the `[us_booking]` shortcode and browses the calendar.
|
1. Student opens the page with the `[us_booking]` shortcode and browses open slots as an agenda list or a weekly calendar (view toggle with previous/next-week navigation; times shown in 12-hour AM/PM form).
|
||||||
2. Student picks an **offering** (a 30 or 60-minute private-lesson type) and a slot.
|
2. Student picks an **offering** (a 30 or 60-minute private-lesson type) and a slot.
|
||||||
3. For a `weekly` reservation, the same weekday/time is held for the rest of the offering's term.
|
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`).
|
4. Student answers the offering's questions (`GET /offerings/{id}/questions`).
|
||||||
|
|||||||
@@ -29,6 +29,18 @@ class AvailabilityController {
|
|||||||
$slots = $this->repository->findByInstructor( $instructorId );
|
$slots = $this->repository->findByInstructor( $instructorId );
|
||||||
$offeringChoices = $this->offerings->findAll( $instructorId, Offering::KIND_PRIVATE_LESSON, true );
|
$offeringChoices = $this->offerings->findAll( $instructorId, Offering::KIND_PRIVATE_LESSON, true );
|
||||||
|
|
||||||
|
// View-state query params only (which view, which week) — nothing is
|
||||||
|
// mutated from them, so no nonce applies.
|
||||||
|
// phpcs:disable WordPress.Security.NonceVerification.Recommended
|
||||||
|
$view = 'week' === sanitize_key( Val::string( wp_unslash( $_GET['usc_view'] ?? '' ) ) ) ? 'week' : 'list';
|
||||||
|
$requestedWeek = sanitize_text_field( Val::string( wp_unslash( $_GET['usc_week'] ?? '' ) ) );
|
||||||
|
// phpcs:enable WordPress.Security.NonceVerification.Recommended
|
||||||
|
|
||||||
|
$weekStart = WeekCalendar::weekStart( $requestedWeek, Val::int( get_option( 'start_of_week', 1 ) ), current_time( 'Y-m-d' ) );
|
||||||
|
$weekDays = WeekCalendar::days( $weekStart, $slots );
|
||||||
|
$prevWeek = ( new \DateTimeImmutable( $weekStart ) )->modify( '-7 days' )->format( 'Y-m-d' );
|
||||||
|
$nextWeek = ( new \DateTimeImmutable( $weekStart ) )->modify( '+7 days' )->format( 'Y-m-d' );
|
||||||
|
|
||||||
include USC_PLUGIN_DIR . 'templates/admin/availability.php';
|
include USC_PLUGIN_DIR . 'templates/admin/availability.php';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,14 +70,16 @@ class AvailabilityController {
|
|||||||
$startDt = AvailabilitySlot::normalizeDateTime( sanitize_text_field( Val::string( wp_unslash( $_POST['start_dt'] ?? '' ) ) ) );
|
$startDt = AvailabilitySlot::normalizeDateTime( sanitize_text_field( Val::string( wp_unslash( $_POST['start_dt'] ?? '' ) ) ) );
|
||||||
$endDt = AvailabilitySlot::normalizeDateTime( sanitize_text_field( Val::string( wp_unslash( $_POST['end_dt'] ?? '' ) ) ) );
|
$endDt = AvailabilitySlot::normalizeDateTime( sanitize_text_field( Val::string( wp_unslash( $_POST['end_dt'] ?? '' ) ) ) );
|
||||||
|
|
||||||
if ( null === $startDt || null === $endDt || $endDt <= $startDt ) {
|
// A window must start and end on the same day (weekly repeat covers longer
|
||||||
|
// ranges) and fit at least one lesson; it is stored as lesson-length slots.
|
||||||
|
if ( null === $startDt || null === $endDt || $endDt <= $startDt || substr( $startDt, 0, 10 ) !== substr( $endDt, 0, 10 ) ) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$offeringId = absint( Val::int( $_POST['offering_id'] ?? 0 ) );
|
$offeringId = absint( Val::int( $_POST['offering_id'] ?? 0 ) );
|
||||||
$duration = absint( Val::int( $_POST['duration_minutes'] ?? 0 ) );
|
$duration = absint( Val::int( $_POST['duration_minutes'] ?? 0 ) );
|
||||||
|
|
||||||
$slot = new AvailabilitySlot(
|
$window = new AvailabilitySlot(
|
||||||
instructorId: $instructorId,
|
instructorId: $instructorId,
|
||||||
startDt: $startDt,
|
startDt: $startDt,
|
||||||
endDt: $endDt,
|
endDt: $endDt,
|
||||||
@@ -73,12 +87,10 @@ class AvailabilityController {
|
|||||||
offeringId: $offeringId > 0 ? $offeringId : null,
|
offeringId: $offeringId > 0 ? $offeringId : null,
|
||||||
);
|
);
|
||||||
|
|
||||||
if ( 'weekly' === sanitize_key( Val::string( wp_unslash( $_POST['recurrence'] ?? 'single' ) ) ) ) {
|
$recurrence = sanitize_key( Val::string( wp_unslash( $_POST['recurrence'] ?? 'single' ) ) );
|
||||||
$this->repository->createWeeklySeries( $slot, absint( Val::int( $_POST['weeks'] ?? 1 ) ) );
|
$weeks = absint( Val::int( $_POST['weeks'] ?? 1 ) );
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->repository->insert( $slot );
|
$this->repository->createFromWindow( $window, 'weekly' === $recurrence, $weeks );
|
||||||
// phpcs:enable WordPress.Security.NonceVerification.Missing
|
// phpcs:enable WordPress.Security.NonceVerification.Missing
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -133,7 +133,11 @@ class AvailabilityEndpoint {
|
|||||||
return new \WP_Error( 'invalid_datetime', __( 'Provide a valid start and end, with the end after the start.', 'unsupervised-schedular' ), [ 'status' => 400 ] );
|
return new \WP_Error( 'invalid_datetime', __( 'Provide a valid start and end, with the end after the start.', 'unsupervised-schedular' ), [ 'status' => 400 ] );
|
||||||
}
|
}
|
||||||
|
|
||||||
$slot = new AvailabilitySlot(
|
if ( substr( $startDt, 0, 10 ) !== substr( $endDt, 0, 10 ) ) {
|
||||||
|
return new \WP_Error( 'invalid_window', __( 'Availability must start and end on the same day. Use the weekly repeat to cover multiple weeks.', 'unsupervised-schedular' ), [ 'status' => 400 ] );
|
||||||
|
}
|
||||||
|
|
||||||
|
$window = new AvailabilitySlot(
|
||||||
instructorId: $instructorId,
|
instructorId: $instructorId,
|
||||||
startDt: $startDt,
|
startDt: $startDt,
|
||||||
endDt: $endDt,
|
endDt: $endDt,
|
||||||
@@ -141,15 +145,17 @@ class AvailabilityEndpoint {
|
|||||||
offeringId: $offeringId > 0 ? $offeringId : null,
|
offeringId: $offeringId > 0 ? $offeringId : null,
|
||||||
);
|
);
|
||||||
|
|
||||||
if ( 'weekly' === $request->get_param( 'recurrence' ) ) {
|
if ( [] === $window->splitByDuration() ) {
|
||||||
$ids = $this->repository->createWeeklySeries( $slot, absint( Val::int( $request->get_param( 'weeks' ) ) ) );
|
return new \WP_Error( 'invalid_window', __( 'The availability window is shorter than the lesson length.', 'unsupervised-schedular' ), [ 'status' => 400 ] );
|
||||||
|
|
||||||
return new \WP_REST_Response( [ 'ids' => $ids ], 201 );
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$id = $this->repository->insert( $slot );
|
$ids = $this->repository->createFromWindow(
|
||||||
|
$window,
|
||||||
|
'weekly' === $request->get_param( 'recurrence' ),
|
||||||
|
absint( Val::int( $request->get_param( 'weeks' ) ) )
|
||||||
|
);
|
||||||
|
|
||||||
return new \WP_REST_Response( [ 'id' => $id ], 201 );
|
return new \WP_REST_Response( [ 'ids' => $ids ], 201 );
|
||||||
}
|
}
|
||||||
|
|
||||||
public function delete( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error {
|
public function delete( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error {
|
||||||
|
|||||||
@@ -30,6 +30,26 @@ class AvailabilityRepository {
|
|||||||
return $this->db->insert_id;
|
return $this->db->insert_id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persist an availability window as individually bookable lesson-length slots.
|
||||||
|
* The window is split into consecutive `duration_minutes` chunks; each chunk
|
||||||
|
* becomes its own row (and, when weekly, its own weekly series) so students can
|
||||||
|
* book any open lesson-length block within the window.
|
||||||
|
*
|
||||||
|
* @return list<int> Inserted slot IDs.
|
||||||
|
*/
|
||||||
|
public function createFromWindow( AvailabilitySlot $window, bool $weekly = false, int $weeks = 1 ): array {
|
||||||
|
$ids = [];
|
||||||
|
|
||||||
|
foreach ( $window->splitByDuration() as $slot ) {
|
||||||
|
$ids = $weekly
|
||||||
|
? array_merge( $ids, $this->createWeeklySeries( $slot, $weeks ) )
|
||||||
|
: [ ...$ids, $this->insert( $slot ) ];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $ids;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a weekly-recurring series from a template slot. Each occurrence is a
|
* Create a weekly-recurring series from a template slot. Each occurrence is a
|
||||||
* separate row one week apart, all sharing a `recurrence_group` (the id of the
|
* separate row one week apart, all sharing a `recurrence_group` (the id of the
|
||||||
@@ -87,8 +107,10 @@ class AvailabilityRepository {
|
|||||||
* @return list<AvailabilitySlot>
|
* @return list<AvailabilitySlot>
|
||||||
*/
|
*/
|
||||||
public function findAvailable( int $instructorId = 0, int $offeringId = 0, int $durationMinutes = 0, string $from = '', string $to = '' ): array {
|
public function findAvailable( int $instructorId = 0, int $offeringId = 0, int $durationMinutes = 0, string $from = '', string $to = '' ): array {
|
||||||
$where = [ 'is_booked = 0' ];
|
// A slot whose start has passed can no longer be booked, so it is never
|
||||||
$params = [];
|
// "available" regardless of the requested range.
|
||||||
|
$where = [ 'is_booked = 0', 'start_dt >= %s' ];
|
||||||
|
$params = [ current_time( 'mysql' ) ];
|
||||||
|
|
||||||
if ( $instructorId > 0 ) {
|
if ( $instructorId > 0 ) {
|
||||||
$where[] = 'instructor_id = %d';
|
$where[] = 'instructor_id = %d';
|
||||||
@@ -189,6 +211,47 @@ class AvailabilityRepository {
|
|||||||
return 1 === $updated;
|
return 1 === $updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One-time upgrade for rows created before windows were split on save: a
|
||||||
|
* window stored as a single row (e.g. 09:00–16:00 with 60-minute lessons)
|
||||||
|
* showed to students as one giant slot. Rewrites every unbooked same-day
|
||||||
|
* window longer than its lesson length as lesson-length rows: the original
|
||||||
|
* row is trimmed to the first chunk (keeping its id and any recurrence
|
||||||
|
* group), and the remaining chunks are inserted as one-off rows.
|
||||||
|
*/
|
||||||
|
public function splitOversizedWindows(): void {
|
||||||
|
$rows = $this->db->get_results(
|
||||||
|
$this->db->prepare(
|
||||||
|
'SELECT * FROM %i
|
||||||
|
WHERE is_booked = 0
|
||||||
|
AND DATE(start_dt) = DATE(end_dt)
|
||||||
|
AND TIMESTAMPDIFF(MINUTE, start_dt, end_dt) > duration_minutes',
|
||||||
|
$this->table
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
foreach ( $rows ?? [] as $row ) {
|
||||||
|
$window = AvailabilitySlot::fromRow( $row );
|
||||||
|
$chunks = $window->splitByDuration();
|
||||||
|
|
||||||
|
if ( [] === $chunks ) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->db->update(
|
||||||
|
$this->table,
|
||||||
|
[ 'end_dt' => $chunks[0]->endDt ],
|
||||||
|
[ 'id' => $window->id ],
|
||||||
|
[ '%s' ],
|
||||||
|
[ '%d' ]
|
||||||
|
);
|
||||||
|
|
||||||
|
foreach ( array_slice( $chunks, 1 ) as $chunk ) {
|
||||||
|
$this->insert( $chunk );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete an unbooked slot. Returns false if the slot is already booked.
|
* Delete an unbooked slot. Returns false if the slot is already booked.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -37,6 +37,42 @@ class AvailabilitySlot {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Split this window into consecutive lesson-length slots: 09:00–16:00 with
|
||||||
|
* 60-minute lessons yields seven bookable slots. A trailing remainder shorter
|
||||||
|
* than the lesson length is dropped, and an empty list is returned when the
|
||||||
|
* window cannot fit a single lesson.
|
||||||
|
*
|
||||||
|
* @return list<self>
|
||||||
|
*/
|
||||||
|
public function splitByDuration(): array {
|
||||||
|
if ( $this->durationMinutes <= 0 ) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$end = new \DateTimeImmutable( $this->endDt );
|
||||||
|
$step = new \DateInterval( 'PT' . $this->durationMinutes . 'M' );
|
||||||
|
|
||||||
|
$cursor = new \DateTimeImmutable( $this->startDt );
|
||||||
|
$chunkEnd = $cursor->add( $step );
|
||||||
|
|
||||||
|
$slots = [];
|
||||||
|
while ( $chunkEnd <= $end ) {
|
||||||
|
$slots[] = new self(
|
||||||
|
instructorId: $this->instructorId,
|
||||||
|
startDt: $cursor->format( 'Y-m-d H:i:s' ),
|
||||||
|
endDt: $chunkEnd->format( 'Y-m-d H:i:s' ),
|
||||||
|
durationMinutes: $this->durationMinutes,
|
||||||
|
offeringId: $this->offeringId,
|
||||||
|
);
|
||||||
|
|
||||||
|
$cursor = $chunkEnd;
|
||||||
|
$chunkEnd = $cursor->add( $step );
|
||||||
|
}
|
||||||
|
|
||||||
|
return $slots;
|
||||||
|
}
|
||||||
|
|
||||||
public static function fromRow( \stdClass $row ): self {
|
public static function fromRow( \stdClass $row ): self {
|
||||||
return new self(
|
return new self(
|
||||||
instructorId: Val::int( $row->instructor_id ),
|
instructorId: Val::int( $row->instructor_id ),
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Unsupervised\Schedular\Availability;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure helpers for the weekly calendar views: resolving which week to show and
|
||||||
|
* bucketing slots into that week's seven days.
|
||||||
|
*/
|
||||||
|
class WeekCalendar {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a requested week anchor to the date of the first day of its week.
|
||||||
|
* `$requested` may be any date (`Y-m-d`) inside the wanted week; anything
|
||||||
|
* unparseable falls back to `$today`. `$startOfWeek` follows WordPress's
|
||||||
|
* `start_of_week` option (0 = Sunday … 6 = Saturday).
|
||||||
|
*/
|
||||||
|
public static function weekStart( string $requested, int $startOfWeek, string $today ): string {
|
||||||
|
$anchor = self::parseDay( $requested ) ?? self::parseDay( $today ) ?? new \DateTimeImmutable( 'today' );
|
||||||
|
$shift = ( (int) $anchor->format( 'w' ) - $startOfWeek + 7 ) % 7;
|
||||||
|
|
||||||
|
return $anchor->modify( '-' . $shift . ' days' )->format( 'Y-m-d' );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bucket slots into the seven days of the week starting at `$weekStart`
|
||||||
|
* (`Y-m-d`). Every day is present, empty or not, in calendar order.
|
||||||
|
*
|
||||||
|
* @param list<AvailabilitySlot> $slots
|
||||||
|
* @return list<array{date: string, slots: list<AvailabilitySlot>}>
|
||||||
|
*/
|
||||||
|
public static function days( string $weekStart, array $slots ): array {
|
||||||
|
$start = self::parseDay( $weekStart ) ?? new \DateTimeImmutable( 'today' );
|
||||||
|
|
||||||
|
$byDay = [];
|
||||||
|
foreach ( $slots as $slot ) {
|
||||||
|
$byDay[ substr( $slot->startDt, 0, 10 ) ][] = $slot;
|
||||||
|
}
|
||||||
|
|
||||||
|
$days = [];
|
||||||
|
for ( $i = 0; $i < 7; $i++ ) {
|
||||||
|
$date = $start->modify( '+' . $i . ' days' )->format( 'Y-m-d' );
|
||||||
|
$days[] = [
|
||||||
|
'date' => $date,
|
||||||
|
'slots' => $byDay[ $date ] ?? [],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $days;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function parseDay( string $value ): ?\DateTimeImmutable {
|
||||||
|
$day = \DateTimeImmutable::createFromFormat( '!Y-m-d', $value );
|
||||||
|
|
||||||
|
return false !== $day && $day->format( 'Y-m-d' ) === $value ? $day : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,14 +20,14 @@ class BlockPreview {
|
|||||||
[
|
[
|
||||||
'label' => __( 'Monday', 'unsupervised-schedular' ),
|
'label' => __( 'Monday', 'unsupervised-schedular' ),
|
||||||
'slots' => [
|
'slots' => [
|
||||||
[ '16:00–16:30', 30 ],
|
[ '4:00 PM–4:30 PM', 30 ],
|
||||||
[ '16:30–17:00', 30 ],
|
[ '4:30 PM–5:00 PM', 30 ],
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'label' => __( 'Wednesday', 'unsupervised-schedular' ),
|
'label' => __( 'Wednesday', 'unsupervised-schedular' ),
|
||||||
'slots' => [
|
'slots' => [
|
||||||
[ '17:00–17:45', 45 ],
|
[ '5:00 PM–5:45 PM', 45 ],
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
@@ -63,7 +63,7 @@ class BlockPreview {
|
|||||||
'<div id="us-group-app">%s<div id="us-group-list"><div class="us-class"><h3>%s</h3><p>%s</p><p>%s</p><p>25.00 CAD</p><button type="button" class="us-enrol-btn" disabled>%s</button></div></div></div>',
|
'<div id="us-group-app">%s<div id="us-group-list"><div class="us-class"><h3>%s</h3><p>%s</p><p>%s</p><p>25.00 CAD</p><button type="button" class="us-enrol-btn" disabled>%s</button></div></div></div>',
|
||||||
self::note( __( 'Editor preview — students see live group classes on the published page.', 'unsupervised-schedular' ) ),
|
self::note( __( 'Editor preview — students see live group classes on the published page.', 'unsupervised-schedular' ) ),
|
||||||
esc_html__( 'Beginner Group Class', 'unsupervised-schedular' ),
|
esc_html__( 'Beginner Group Class', 'unsupervised-schedular' ),
|
||||||
esc_html__( 'Saturdays 10:00–11:00', 'unsupervised-schedular' ),
|
esc_html__( 'Saturdays 10:00 AM–11:00 AM', 'unsupervised-schedular' ),
|
||||||
esc_html__( 'A sample class shown so the page can be styled.', 'unsupervised-schedular' ),
|
esc_html__( 'A sample class shown so the page can be styled.', 'unsupervised-schedular' ),
|
||||||
esc_html__( 'Enrol', 'unsupervised-schedular' )
|
esc_html__( 'Enrol', 'unsupervised-schedular' )
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,11 +4,13 @@ declare(strict_types=1);
|
|||||||
namespace Unsupervised\Schedular;
|
namespace Unsupervised\Schedular;
|
||||||
|
|
||||||
use Unsupervised\Schedular\Auth\RoleManager;
|
use Unsupervised\Schedular\Auth\RoleManager;
|
||||||
|
use Unsupervised\Schedular\Availability\AvailabilityRepository;
|
||||||
|
|
||||||
class Installer {
|
class Installer {
|
||||||
|
|
||||||
public function run(): void {
|
public function run(): void {
|
||||||
$this->createTables();
|
$this->createTables();
|
||||||
|
$this->migrateData();
|
||||||
( new RoleManager() )->createRoles();
|
( new RoleManager() )->createRoles();
|
||||||
flush_rewrite_rules();
|
flush_rewrite_rules();
|
||||||
update_option( 'us_schedular_version', USC_VERSION );
|
update_option( 'us_schedular_version', USC_VERSION );
|
||||||
@@ -27,4 +29,13 @@ class Installer {
|
|||||||
dbDelta( $sql );
|
dbDelta( $sql );
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function migrateData(): void {
|
||||||
|
global $wpdb;
|
||||||
|
if ( ! $wpdb instanceof \wpdb ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
( new AvailabilityRepository( $wpdb ) )->splitOversizedWindows();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,13 @@ class Plugin {
|
|||||||
if ( ! $wpdb instanceof \wpdb ) {
|
if ( ! $wpdb instanceof \wpdb ) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Re-run install steps when the plugin files were updated without a fresh
|
||||||
|
// activation (e.g. a deploy), so schema and data migrations still apply.
|
||||||
|
if ( get_option( 'us_schedular_version' ) !== USC_VERSION ) {
|
||||||
|
( new Installer() )->run();
|
||||||
|
}
|
||||||
|
|
||||||
$availability = new AvailabilityRepository( $wpdb );
|
$availability = new AvailabilityRepository( $wpdb );
|
||||||
$bookings = new BookingRepository( $wpdb );
|
$bookings = new BookingRepository( $wpdb );
|
||||||
$offerings = new OfferingRepository( $wpdb );
|
$offerings = new OfferingRepository( $wpdb );
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ class ShortcodeRegistrar {
|
|||||||
'restUrl' => rest_url( 'us-scheduler/v1/' ),
|
'restUrl' => rest_url( 'us-scheduler/v1/' ),
|
||||||
'nonce' => wp_create_nonce( 'wp_rest' ),
|
'nonce' => wp_create_nonce( 'wp_rest' ),
|
||||||
'stripeKey' => $settings->publishableKey(),
|
'stripeKey' => $settings->publishableKey(),
|
||||||
|
'startOfWeek' => Val::int( get_option( 'start_of_week', 1 ) ),
|
||||||
];
|
];
|
||||||
|
|
||||||
// Attach the shared config to the payment helper so it is defined before the
|
// Attach the shared config to the payment helper so it is defined before the
|
||||||
|
|||||||
@@ -8,12 +8,33 @@ if (! defined('ABSPATH')) {
|
|||||||
/**
|
/**
|
||||||
* @var list<\Unsupervised\Schedular\Availability\AvailabilitySlot> $slots
|
* @var list<\Unsupervised\Schedular\Availability\AvailabilitySlot> $slots
|
||||||
* @var list<\Unsupervised\Schedular\Offering\Offering> $offeringChoices
|
* @var list<\Unsupervised\Schedular\Offering\Offering> $offeringChoices
|
||||||
|
* @var 'list'|'week' $view
|
||||||
|
* @var string $weekStart
|
||||||
|
* @var list<array{date: string, slots: list<\Unsupervised\Schedular\Availability\AvailabilitySlot>}> $weekDays
|
||||||
|
* @var string $prevWeek
|
||||||
|
* @var string $nextWeek
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
$baseUrl = admin_url('admin.php?page=us-availability');
|
||||||
|
|
||||||
|
$deleteForm = static function (\Unsupervised\Schedular\Availability\AvailabilitySlot $slot): void {
|
||||||
|
?>
|
||||||
|
<form method="post" style="display:inline;">
|
||||||
|
<?php wp_nonce_field('usc_availability_action'); ?>
|
||||||
|
<input type="hidden" name="usc_action" value="delete">
|
||||||
|
<input type="hidden" name="slot_id" value="<?php echo esc_attr((string) $slot->id); ?>">
|
||||||
|
<button type="submit" class="button button-small button-link-delete">
|
||||||
|
<?php esc_html_e('Delete', 'unsupervised-schedular'); ?>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<?php
|
||||||
|
};
|
||||||
?>
|
?>
|
||||||
<div class="wrap">
|
<div class="wrap">
|
||||||
<h1><?php esc_html_e('My Availability', 'unsupervised-schedular'); ?></h1>
|
<h1><?php esc_html_e('My Availability', 'unsupervised-schedular'); ?></h1>
|
||||||
|
|
||||||
<h2><?php esc_html_e('Add Slot', 'unsupervised-schedular'); ?></h2>
|
<h2><?php esc_html_e('Add Availability', 'unsupervised-schedular'); ?></h2>
|
||||||
|
<p><?php esc_html_e('The window must start and end on the same day. It is split into bookable slots of the chosen lesson length — for example, 9:00 AM–4:00 PM with 60-minute lessons creates seven slots.', 'unsupervised-schedular'); ?></p>
|
||||||
<form method="post">
|
<form method="post">
|
||||||
<?php wp_nonce_field('usc_availability_action'); ?>
|
<?php wp_nonce_field('usc_availability_action'); ?>
|
||||||
<input type="hidden" name="usc_action" value="add">
|
<input type="hidden" name="usc_action" value="add">
|
||||||
@@ -56,12 +77,63 @@ if (! defined('ABSPATH')) {
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
<?php submit_button(esc_html__('Add Slot', 'unsupervised-schedular')); ?>
|
<?php submit_button(esc_html__('Add Availability', 'unsupervised-schedular')); ?>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<h2><?php esc_html_e('Current Slots', 'unsupervised-schedular'); ?></h2>
|
<h2><?php esc_html_e('Current Slots', 'unsupervised-schedular'); ?></h2>
|
||||||
|
|
||||||
<?php if (empty($slots)) : ?>
|
<ul class="subsubsub" style="margin-bottom:12px;">
|
||||||
|
<li>
|
||||||
|
<a href="<?php echo esc_url($baseUrl); ?>" <?php echo 'list' === $view ? 'class="current"' : ''; ?>><?php esc_html_e('List', 'unsupervised-schedular'); ?></a> |
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="<?php echo esc_url(add_query_arg('usc_view', 'week', $baseUrl)); ?>" <?php echo 'week' === $view ? 'class="current"' : ''; ?>><?php esc_html_e('Week', 'unsupervised-schedular'); ?></a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<div class="clear"></div>
|
||||||
|
|
||||||
|
<?php if ('week' === $view) : ?>
|
||||||
|
<p>
|
||||||
|
<a class="button" href="<?php echo esc_url(add_query_arg(['usc_view' => 'week', 'usc_week' => $prevWeek], $baseUrl)); ?>">‹ <?php esc_html_e('Previous week', 'unsupervised-schedular'); ?></a>
|
||||||
|
<strong style="margin:0 12px;">
|
||||||
|
<?php
|
||||||
|
/* translators: %s: date of the first day of the displayed week */
|
||||||
|
echo esc_html(sprintf(__('Week of %s', 'unsupervised-schedular'), (string) mysql2date('M j, Y', $weekStart)));
|
||||||
|
?>
|
||||||
|
</strong>
|
||||||
|
<a class="button" href="<?php echo esc_url(add_query_arg(['usc_view' => 'week', 'usc_week' => $nextWeek], $baseUrl)); ?>"><?php esc_html_e('Next week', 'unsupervised-schedular'); ?> ›</a>
|
||||||
|
</p>
|
||||||
|
<table class="wp-list-table widefat fixed">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<?php foreach ($weekDays as $day) : ?>
|
||||||
|
<th><?php echo esc_html((string) mysql2date('D M j', $day['date'])); ?></th>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<?php foreach ($weekDays as $day) : ?>
|
||||||
|
<td style="vertical-align:top;">
|
||||||
|
<?php if (empty($day['slots'])) : ?>
|
||||||
|
<span aria-hidden="true">—</span>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php foreach ($day['slots'] as $slot) : ?>
|
||||||
|
<p style="margin:0 0 8px;">
|
||||||
|
<?php echo esc_html((string) mysql2date('g:i A', $slot->startDt) . '–' . (string) mysql2date('g:i A', $slot->endDt)); ?><br>
|
||||||
|
<?php if ($slot->isBooked) : ?>
|
||||||
|
<em><?php esc_html_e('Booked', 'unsupervised-schedular'); ?></em>
|
||||||
|
<?php else : ?>
|
||||||
|
<?php $deleteForm($slot); ?>
|
||||||
|
<?php endif; ?>
|
||||||
|
</p>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</td>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<?php elseif (empty($slots)) : ?>
|
||||||
<p><?php esc_html_e('No availability slots configured.', 'unsupervised-schedular'); ?></p>
|
<p><?php esc_html_e('No availability slots configured.', 'unsupervised-schedular'); ?></p>
|
||||||
<?php else : ?>
|
<?php else : ?>
|
||||||
<table class="wp-list-table widefat fixed striped">
|
<table class="wp-list-table widefat fixed striped">
|
||||||
@@ -77,20 +149,13 @@ if (! defined('ABSPATH')) {
|
|||||||
<tbody>
|
<tbody>
|
||||||
<?php foreach ($slots as $slot) : ?>
|
<?php foreach ($slots as $slot) : ?>
|
||||||
<tr>
|
<tr>
|
||||||
<td><?php echo esc_html($slot->startDt); ?></td>
|
<td><?php echo esc_html((string) mysql2date('M j, Y g:i A', $slot->startDt)); ?></td>
|
||||||
<td><?php echo esc_html($slot->endDt); ?></td>
|
<td><?php echo esc_html((string) mysql2date('M j, Y g:i A', $slot->endDt)); ?></td>
|
||||||
<td><?php echo esc_html((string) $slot->durationMinutes . ' min'); ?></td>
|
<td><?php echo esc_html((string) $slot->durationMinutes . ' min'); ?></td>
|
||||||
<td><?php echo $slot->isBooked ? esc_html__('Booked', 'unsupervised-schedular') : esc_html__('Available', 'unsupervised-schedular'); ?></td>
|
<td><?php echo $slot->isBooked ? esc_html__('Booked', 'unsupervised-schedular') : esc_html__('Available', 'unsupervised-schedular'); ?></td>
|
||||||
<td>
|
<td>
|
||||||
<?php if (! $slot->isBooked) : ?>
|
<?php if (! $slot->isBooked) : ?>
|
||||||
<form method="post" style="display:inline;">
|
<?php $deleteForm($slot); ?>
|
||||||
<?php wp_nonce_field('usc_availability_action'); ?>
|
|
||||||
<input type="hidden" name="usc_action" value="delete">
|
|
||||||
<input type="hidden" name="slot_id" value="<?php echo esc_attr((string) $slot->id); ?>">
|
|
||||||
<button type="submit" class="button button-small button-link-delete">
|
|
||||||
<?php esc_html_e('Delete', 'unsupervised-schedular'); ?>
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ $renderLessons = static function (array $rows): void {
|
|||||||
<tbody>
|
<tbody>
|
||||||
<?php foreach ($rows as $row) : ?>
|
<?php foreach ($rows as $row) : ?>
|
||||||
<tr>
|
<tr>
|
||||||
<td><?php echo esc_html($row['start_dt'] !== '' ? $row['start_dt'] : '—'); ?></td>
|
<td><?php echo esc_html($row['start_dt'] !== '' ? (string) mysql2date('M j, Y g:i A', $row['start_dt']) : '—'); ?></td>
|
||||||
<td><?php echo esc_html($row['offering']); ?></td>
|
<td><?php echo esc_html($row['offering']); ?></td>
|
||||||
<td><?php echo esc_html($row['instructor']); ?></td>
|
<td><?php echo esc_html($row['instructor']); ?></td>
|
||||||
<td><?php echo esc_html($row['status']); ?></td>
|
<td><?php echo esc_html($row['status']); ?></td>
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Unsupervised\Schedular\Tests\Unit\Availability;
|
||||||
|
|
||||||
|
use Brain\Monkey\Functions;
|
||||||
|
use Mockery;
|
||||||
|
use Unsupervised\Schedular\Availability\AvailabilityEndpoint;
|
||||||
|
use Unsupervised\Schedular\Availability\AvailabilityRepository;
|
||||||
|
use Unsupervised\Schedular\Availability\AvailabilitySlot;
|
||||||
|
use Unsupervised\Schedular\Offering\OfferingRepository;
|
||||||
|
use Unsupervised\Schedular\Tests\Unit\TestCase;
|
||||||
|
|
||||||
|
class AvailabilityEndpointTest extends TestCase
|
||||||
|
{
|
||||||
|
private AvailabilityRepository $repository;
|
||||||
|
private OfferingRepository $offerings;
|
||||||
|
private AvailabilityEndpoint $endpoint;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
|
||||||
|
Functions\when('absint')->alias(static fn ($v): int => abs((int) $v));
|
||||||
|
Functions\when('get_current_user_id')->justReturn(5);
|
||||||
|
|
||||||
|
$this->repository = Mockery::mock(AvailabilityRepository::class);
|
||||||
|
$this->offerings = Mockery::mock(OfferingRepository::class);
|
||||||
|
$this->endpoint = new AvailabilityEndpoint($this->repository, $this->offerings);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCreateRejectsWindowSpanningMultipleDays(): void
|
||||||
|
{
|
||||||
|
$this->repository->shouldNotReceive('createFromWindow');
|
||||||
|
|
||||||
|
$request = new \WP_REST_Request([
|
||||||
|
'start_dt' => '2026-06-01 19:52:00',
|
||||||
|
'end_dt' => '2026-06-30 19:52:00',
|
||||||
|
'duration_minutes' => 60,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$result = $this->endpoint->create($request);
|
||||||
|
|
||||||
|
self::assertInstanceOf(\WP_Error::class, $result);
|
||||||
|
self::assertSame('invalid_window', $result->get_error_code());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCreateRejectsWindowShorterThanLessonLength(): void
|
||||||
|
{
|
||||||
|
$this->repository->shouldNotReceive('createFromWindow');
|
||||||
|
|
||||||
|
$request = new \WP_REST_Request([
|
||||||
|
'start_dt' => '2026-07-06 09:00:00',
|
||||||
|
'end_dt' => '2026-07-06 09:30:00',
|
||||||
|
'duration_minutes' => 60,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$result = $this->endpoint->create($request);
|
||||||
|
|
||||||
|
self::assertInstanceOf(\WP_Error::class, $result);
|
||||||
|
self::assertSame('invalid_window', $result->get_error_code());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCreateStoresWindowAsLessonLengthSlots(): void
|
||||||
|
{
|
||||||
|
$this->repository->shouldReceive('createFromWindow')
|
||||||
|
->once()
|
||||||
|
->with(
|
||||||
|
Mockery::on(static function (AvailabilitySlot $window): bool {
|
||||||
|
return $window->instructorId === 5
|
||||||
|
&& $window->startDt === '2026-07-06 09:00:00'
|
||||||
|
&& $window->endDt === '2026-07-06 16:00:00'
|
||||||
|
&& $window->durationMinutes === 60;
|
||||||
|
}),
|
||||||
|
false,
|
||||||
|
1
|
||||||
|
)
|
||||||
|
->andReturn([1, 2, 3, 4, 5, 6, 7]);
|
||||||
|
|
||||||
|
$request = new \WP_REST_Request([
|
||||||
|
'start_dt' => '2026-07-06 09:00:00',
|
||||||
|
'end_dt' => '2026-07-06 16:00:00',
|
||||||
|
'duration_minutes' => 60,
|
||||||
|
'recurrence' => 'single',
|
||||||
|
'weeks' => 1,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$result = $this->endpoint->create($request);
|
||||||
|
|
||||||
|
self::assertInstanceOf(\WP_REST_Response::class, $result);
|
||||||
|
self::assertSame(201, $result->get_status());
|
||||||
|
self::assertSame(['ids' => [1, 2, 3, 4, 5, 6, 7]], $result->get_data());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCreateWeeklyPassesRecurrenceThrough(): void
|
||||||
|
{
|
||||||
|
$this->repository->shouldReceive('createFromWindow')
|
||||||
|
->once()
|
||||||
|
->with(Mockery::type(AvailabilitySlot::class), true, 4)
|
||||||
|
->andReturn([1, 2, 3, 4]);
|
||||||
|
|
||||||
|
$request = new \WP_REST_Request([
|
||||||
|
'start_dt' => '2026-07-06 09:00:00',
|
||||||
|
'end_dt' => '2026-07-06 10:00:00',
|
||||||
|
'duration_minutes' => 60,
|
||||||
|
'recurrence' => 'weekly',
|
||||||
|
'weeks' => 4,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$result = $this->endpoint->create($request);
|
||||||
|
|
||||||
|
self::assertInstanceOf(\WP_REST_Response::class, $result);
|
||||||
|
self::assertSame(['ids' => [1, 2, 3, 4]], $result->get_data());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -147,11 +147,16 @@ class AvailabilityRepositoryTest extends TestCase
|
|||||||
self::assertFalse($this->repo->delete(1));
|
self::assertFalse($this->repo->delete(1));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testFindAvailableWithNoFiltersPreparesTableOnly(): void
|
public function testFindAvailableWithNoFiltersExcludesPastSlots(): void
|
||||||
{
|
{
|
||||||
|
Functions\when('current_time')->justReturn('2026-07-05 12:00:00');
|
||||||
|
|
||||||
$this->db->shouldReceive('prepare')
|
$this->db->shouldReceive('prepare')
|
||||||
->once()
|
->once()
|
||||||
->with(Mockery::pattern('/WHERE is_booked = 0/'), ['wp_us_availability'])
|
->with(
|
||||||
|
Mockery::pattern('/WHERE is_booked = 0 AND start_dt >= %s/'),
|
||||||
|
['wp_us_availability', '2026-07-05 12:00:00']
|
||||||
|
)
|
||||||
->andReturn('SELECT ...');
|
->andReturn('SELECT ...');
|
||||||
|
|
||||||
$this->db->shouldReceive('get_results')
|
$this->db->shouldReceive('get_results')
|
||||||
@@ -166,6 +171,8 @@ class AvailabilityRepositoryTest extends TestCase
|
|||||||
|
|
||||||
public function testFindAvailableWithInstructorFilterPreparesQuery(): void
|
public function testFindAvailableWithInstructorFilterPreparesQuery(): void
|
||||||
{
|
{
|
||||||
|
Functions\when('current_time')->justReturn('2026-07-05 12:00:00');
|
||||||
|
|
||||||
$this->db->shouldReceive('prepare')
|
$this->db->shouldReceive('prepare')
|
||||||
->once()
|
->once()
|
||||||
->with(Mockery::pattern('/instructor_id = %d/'), Mockery::any())
|
->with(Mockery::pattern('/instructor_id = %d/'), Mockery::any())
|
||||||
@@ -178,11 +185,13 @@ class AvailabilityRepositoryTest extends TestCase
|
|||||||
|
|
||||||
public function testFindAvailableWithOfferingAndDurationFilters(): void
|
public function testFindAvailableWithOfferingAndDurationFilters(): void
|
||||||
{
|
{
|
||||||
|
Functions\when('current_time')->justReturn('2026-07-05 12:00:00');
|
||||||
|
|
||||||
$this->db->shouldReceive('prepare')
|
$this->db->shouldReceive('prepare')
|
||||||
->once()
|
->once()
|
||||||
->with(
|
->with(
|
||||||
Mockery::pattern('/offering_id = %d AND duration_minutes = %d/'),
|
Mockery::pattern('/offering_id = %d AND duration_minutes = %d/'),
|
||||||
Mockery::on(static fn (array $p): bool => $p === ['wp_us_availability', 8, 30])
|
Mockery::on(static fn (array $p): bool => $p === ['wp_us_availability', '2026-07-05 12:00:00', 8, 30])
|
||||||
)
|
)
|
||||||
->andReturn('SELECT ...');
|
->andReturn('SELECT ...');
|
||||||
|
|
||||||
@@ -191,6 +200,118 @@ class AvailabilityRepositoryTest extends TestCase
|
|||||||
$this->repo->findAvailable(offeringId: 8, durationMinutes: 30);
|
$this->repo->findAvailable(offeringId: 8, durationMinutes: 30);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testCreateFromWindowInsertsOneRowPerLessonLengthChunk(): void
|
||||||
|
{
|
||||||
|
Functions\when('current_time')->justReturn('2026-07-05 12:00:00');
|
||||||
|
|
||||||
|
$captured = [];
|
||||||
|
$ids = [21, 22, 23];
|
||||||
|
|
||||||
|
$this->db->shouldReceive('insert')
|
||||||
|
->times(3)
|
||||||
|
->andReturnUsing(function (string $table, array $data) use (&$captured, &$ids): void {
|
||||||
|
$captured[] = [$data['start_dt'], $data['end_dt']];
|
||||||
|
$this->db->insert_id = array_shift($ids);
|
||||||
|
});
|
||||||
|
|
||||||
|
$window = new AvailabilitySlot(5, '2026-07-06 09:00:00', '2026-07-06 12:00:00', 60);
|
||||||
|
$result = $this->repo->createFromWindow($window);
|
||||||
|
|
||||||
|
self::assertSame([21, 22, 23], $result);
|
||||||
|
self::assertSame(
|
||||||
|
[
|
||||||
|
['2026-07-06 09:00:00', '2026-07-06 10:00:00'],
|
||||||
|
['2026-07-06 10:00:00', '2026-07-06 11:00:00'],
|
||||||
|
['2026-07-06 11:00:00', '2026-07-06 12:00:00'],
|
||||||
|
],
|
||||||
|
$captured
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCreateFromWindowWeeklyCreatesASeriesPerChunk(): void
|
||||||
|
{
|
||||||
|
Functions\when('current_time')->justReturn('2026-07-05 12:00:00');
|
||||||
|
|
||||||
|
$captured = [];
|
||||||
|
$ids = [30, 31, 40, 41];
|
||||||
|
|
||||||
|
// Two chunks × two weeks: each chunk becomes its own weekly series.
|
||||||
|
$this->db->shouldReceive('insert')
|
||||||
|
->times(4)
|
||||||
|
->andReturnUsing(function (string $table, array $data) use (&$captured, &$ids): void {
|
||||||
|
$captured[] = $data['start_dt'];
|
||||||
|
$this->db->insert_id = array_shift($ids);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Each series back-fills its first row with its own recurrence group.
|
||||||
|
$this->db->shouldReceive('update')
|
||||||
|
->once()
|
||||||
|
->with('wp_us_availability', ['recurrence_group' => 30], ['id' => 30], ['%d'], ['%d']);
|
||||||
|
$this->db->shouldReceive('update')
|
||||||
|
->once()
|
||||||
|
->with('wp_us_availability', ['recurrence_group' => 40], ['id' => 40], ['%d'], ['%d']);
|
||||||
|
|
||||||
|
$window = new AvailabilitySlot(5, '2026-07-06 09:00:00', '2026-07-06 11:00:00', 60);
|
||||||
|
$result = $this->repo->createFromWindow($window, weekly: true, weeks: 2);
|
||||||
|
|
||||||
|
self::assertSame([30, 31, 40, 41], $result);
|
||||||
|
self::assertSame(
|
||||||
|
[
|
||||||
|
'2026-07-06 09:00:00',
|
||||||
|
'2026-07-13 09:00:00',
|
||||||
|
'2026-07-06 10:00:00',
|
||||||
|
'2026-07-13 10:00:00',
|
||||||
|
],
|
||||||
|
$captured
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSplitOversizedWindowsTrimsRowAndInsertsRemainingChunks(): void
|
||||||
|
{
|
||||||
|
Functions\when('current_time')->justReturn('2026-07-05 12:00:00');
|
||||||
|
|
||||||
|
$row = (object) [
|
||||||
|
'id' => '9',
|
||||||
|
'instructor_id' => '5',
|
||||||
|
'offering_id' => null,
|
||||||
|
'start_dt' => '2026-07-06 09:00:00',
|
||||||
|
'end_dt' => '2026-07-06 12:00:00',
|
||||||
|
'duration_minutes' => '60',
|
||||||
|
'is_booked' => '0',
|
||||||
|
'recurrence_group' => null,
|
||||||
|
];
|
||||||
|
|
||||||
|
$this->db->shouldReceive('prepare')
|
||||||
|
->once()
|
||||||
|
->with(Mockery::pattern('/TIMESTAMPDIFF\(MINUTE, start_dt, end_dt\) > duration_minutes/'), 'wp_us_availability')
|
||||||
|
->andReturn('SELECT ...');
|
||||||
|
$this->db->shouldReceive('get_results')->once()->with('SELECT ...')->andReturn([$row]);
|
||||||
|
|
||||||
|
// The original row is trimmed to the first lesson-length chunk.
|
||||||
|
$this->db->shouldReceive('update')
|
||||||
|
->once()
|
||||||
|
->with('wp_us_availability', ['end_dt' => '2026-07-06 10:00:00'], ['id' => 9], ['%s'], ['%d']);
|
||||||
|
|
||||||
|
// The remaining two chunks become new rows.
|
||||||
|
$inserted = [];
|
||||||
|
$this->db->shouldReceive('insert')
|
||||||
|
->times(2)
|
||||||
|
->andReturnUsing(function (string $table, array $data) use (&$inserted): void {
|
||||||
|
$inserted[] = [$data['start_dt'], $data['end_dt']];
|
||||||
|
$this->db->insert_id = 50;
|
||||||
|
});
|
||||||
|
|
||||||
|
$this->repo->splitOversizedWindows();
|
||||||
|
|
||||||
|
self::assertSame(
|
||||||
|
[
|
||||||
|
['2026-07-06 10:00:00', '2026-07-06 11:00:00'],
|
||||||
|
['2026-07-06 11:00:00', '2026-07-06 12:00:00'],
|
||||||
|
],
|
||||||
|
$inserted
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
public function testFindByInstructorReturnsSlots(): void
|
public function testFindByInstructorReturnsSlots(): void
|
||||||
{
|
{
|
||||||
$row = (object) [
|
$row = (object) [
|
||||||
|
|||||||
@@ -84,6 +84,51 @@ class AvailabilitySlotTest extends TestCase
|
|||||||
self::assertNull(AvailabilitySlot::normalizeDateTime("2026-04-01 09:00:00'); DROP TABLE x;--"));
|
self::assertNull(AvailabilitySlot::normalizeDateTime("2026-04-01 09:00:00'); DROP TABLE x;--"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testSplitByDurationChunksWindowIntoLessonLengthSlots(): void
|
||||||
|
{
|
||||||
|
$window = new AvailabilitySlot(5, '2026-07-06 09:00:00', '2026-07-06 12:00:00', 60, 8);
|
||||||
|
|
||||||
|
$slots = $window->splitByDuration();
|
||||||
|
|
||||||
|
self::assertCount(3, $slots);
|
||||||
|
self::assertSame('2026-07-06 09:00:00', $slots[0]->startDt);
|
||||||
|
self::assertSame('2026-07-06 10:00:00', $slots[0]->endDt);
|
||||||
|
self::assertSame('2026-07-06 11:00:00', $slots[2]->startDt);
|
||||||
|
self::assertSame('2026-07-06 12:00:00', $slots[2]->endDt);
|
||||||
|
|
||||||
|
// Each chunk keeps the window's instructor, duration, and offering.
|
||||||
|
self::assertSame(5, $slots[1]->instructorId);
|
||||||
|
self::assertSame(60, $slots[1]->durationMinutes);
|
||||||
|
self::assertSame(8, $slots[1]->offeringId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSplitByDurationDropsRemainderShorterThanALesson(): void
|
||||||
|
{
|
||||||
|
$window = new AvailabilitySlot(5, '2026-07-06 09:00:00', '2026-07-06 16:30:00', 60);
|
||||||
|
|
||||||
|
$slots = $window->splitByDuration();
|
||||||
|
|
||||||
|
self::assertCount(7, $slots);
|
||||||
|
self::assertSame('2026-07-06 16:00:00', $slots[6]->endDt);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSplitByDurationReturnsEmptyWhenWindowTooShort(): void
|
||||||
|
{
|
||||||
|
$window = new AvailabilitySlot(5, '2026-07-06 09:00:00', '2026-07-06 09:45:00', 60);
|
||||||
|
|
||||||
|
self::assertSame([], $window->splitByDuration());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSplitByDurationHandlesThirtyMinuteLessons(): void
|
||||||
|
{
|
||||||
|
$window = new AvailabilitySlot(5, '2026-07-06 09:00:00', '2026-07-06 10:30:00', 30);
|
||||||
|
|
||||||
|
$slots = $window->splitByDuration();
|
||||||
|
|
||||||
|
self::assertCount(3, $slots);
|
||||||
|
self::assertSame('2026-07-06 09:30:00', $slots[0]->endDt);
|
||||||
|
}
|
||||||
|
|
||||||
public function testToArrayContainsExpectedKeys(): void
|
public function testToArrayContainsExpectedKeys(): void
|
||||||
{
|
{
|
||||||
$slot = new AvailabilitySlot(1, '2026-04-01 09:00:00', '2026-04-01 10:00:00', 30, 8, false, null, 10);
|
$slot = new AvailabilitySlot(1, '2026-04-01 09:00:00', '2026-04-01 10:00:00', 30, 8, false, null, 10);
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Unsupervised\Schedular\Tests\Unit\Availability;
|
||||||
|
|
||||||
|
use Unsupervised\Schedular\Availability\AvailabilitySlot;
|
||||||
|
use Unsupervised\Schedular\Availability\WeekCalendar;
|
||||||
|
use Unsupervised\Schedular\Tests\Unit\TestCase;
|
||||||
|
|
||||||
|
class WeekCalendarTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testWeekStartShiftsBackToConfiguredStartOfWeek(): void
|
||||||
|
{
|
||||||
|
// 2026-07-08 is a Wednesday; with Monday (1) starts, the week begins Jul 6.
|
||||||
|
self::assertSame('2026-07-06', WeekCalendar::weekStart('2026-07-08', 1, '2026-01-01'));
|
||||||
|
|
||||||
|
// With Sunday (0) starts, the same Wednesday's week begins Jul 5.
|
||||||
|
self::assertSame('2026-07-05', WeekCalendar::weekStart('2026-07-08', 0, '2026-01-01'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testWeekStartIsIdempotentWhenAnchorIsAlreadyTheStart(): void
|
||||||
|
{
|
||||||
|
self::assertSame('2026-07-06', WeekCalendar::weekStart('2026-07-06', 1, '2026-01-01'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testWeekStartFallsBackToTodayForInvalidInput(): void
|
||||||
|
{
|
||||||
|
// 2026-07-05 is a Sunday; with Monday starts, its week began Jun 29.
|
||||||
|
self::assertSame('2026-06-29', WeekCalendar::weekStart('', 1, '2026-07-05'));
|
||||||
|
self::assertSame('2026-06-29', WeekCalendar::weekStart('not-a-date', 1, '2026-07-05'));
|
||||||
|
self::assertSame('2026-06-29', WeekCalendar::weekStart('2026-13-40', 1, '2026-07-05'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testDaysReturnsSevenBucketsWithSlotsOnTheirDates(): void
|
||||||
|
{
|
||||||
|
$monday = new AvailabilitySlot(5, '2026-07-06 09:00:00', '2026-07-06 10:00:00', 60, null, false, null, 1);
|
||||||
|
$mondayTwo = new AvailabilitySlot(5, '2026-07-06 10:00:00', '2026-07-06 11:00:00', 60, null, false, null, 2);
|
||||||
|
$thursday = new AvailabilitySlot(5, '2026-07-09 14:00:00', '2026-07-09 15:00:00', 60, null, false, null, 3);
|
||||||
|
$nextWeek = new AvailabilitySlot(5, '2026-07-13 09:00:00', '2026-07-13 10:00:00', 60, null, false, null, 4);
|
||||||
|
|
||||||
|
$days = WeekCalendar::days('2026-07-06', [$monday, $mondayTwo, $thursday, $nextWeek]);
|
||||||
|
|
||||||
|
self::assertCount(7, $days);
|
||||||
|
self::assertSame('2026-07-06', $days[0]['date']);
|
||||||
|
self::assertSame('2026-07-12', $days[6]['date']);
|
||||||
|
|
||||||
|
self::assertCount(2, $days[0]['slots']);
|
||||||
|
self::assertSame(3, $days[3]['slots'][0]->id);
|
||||||
|
self::assertSame([], $days[1]['slots']);
|
||||||
|
|
||||||
|
// A slot outside the week is not bucketed anywhere.
|
||||||
|
$ids = array_merge(...array_map(
|
||||||
|
static fn (array $day): array => array_map(static fn (AvailabilitySlot $s): ?int => $s->id, $day['slots']),
|
||||||
|
$days
|
||||||
|
));
|
||||||
|
self::assertNotContains(4, $ids);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testDaysCrossesMonthBoundary(): void
|
||||||
|
{
|
||||||
|
$days = WeekCalendar::days('2026-06-29', []);
|
||||||
|
|
||||||
|
self::assertSame('2026-06-29', $days[0]['date']);
|
||||||
|
self::assertSame('2026-07-05', $days[6]['date']);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
* Plugin Name: Unsupervised Scheduler
|
* Plugin Name: Unsupervised Scheduler
|
||||||
* Plugin URI: https://unsupervised.ca
|
* Plugin URI: https://unsupervised.ca
|
||||||
* Description: Instructor/student lesson scheduling for WordPress.
|
* Description: Instructor/student lesson scheduling for WordPress.
|
||||||
* Version: 1.0.0-rc.1
|
* Version: 1.0.0-rc.2
|
||||||
* Requires at least: 6.2
|
* Requires at least: 6.2
|
||||||
* Requires PHP: 8.1
|
* Requires PHP: 8.1
|
||||||
* Author: Unsupervised
|
* Author: Unsupervised
|
||||||
@@ -19,7 +19,7 @@ if (! defined('ABSPATH')) {
|
|||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
define('USC_VERSION', '1.0.0-rc.1');
|
define('USC_VERSION', '1.0.0-rc.2');
|
||||||
define('USC_PLUGIN_FILE', __FILE__);
|
define('USC_PLUGIN_FILE', __FILE__);
|
||||||
define('USC_PLUGIN_DIR', plugin_dir_path(__FILE__));
|
define('USC_PLUGIN_DIR', plugin_dir_path(__FILE__));
|
||||||
define('USC_PLUGIN_URL', plugin_dir_url(__FILE__));
|
define('USC_PLUGIN_URL', plugin_dir_url(__FILE__));
|
||||||
|
|||||||
Reference in New Issue
Block a user