Adding availability for 5:30-6:00 PM with the lesson length left on its 60-minute default saved nothing and said nothing. A window is stored as consecutive lesson-length slots, so one that fits no lesson splits into none: splitByDuration() returned [], createFromWindow() inserted nothing, and addSlot() discarded the result and re-rendered the page unchanged. The REST endpoint already rejected that window with a 400. The admin form checked the same rules separately, and its copy was both laxer and mute — an unreadable date, an end before the start, and a two-day window were bare `return`s, and it never checked offering ownership at all, so a crafted POST could tie a slot to another instructor's offering and inherit their price and payment routing. Both callers now go through WindowValidator, which returns the window or a WP_Error explaining the refusal. The endpoint returns that error as is; the page renders its message as a notice. handleFormAction returns a [notice, error] pair so deletes report themselves too, and a successful add says how many slots it created. Two failures could also go unnoticed underneath: wpdb::insert's result was ignored, and insert_id still holds the previous statement's id after a failed write, so a failure looked like a success — and could become the recurrence group of a weekly series, orphaning every later occurrence. weeks was unbounded server-side despite the form's max=52. availability-admin.js narrows the lesson-length choices to those that fit the window and blocks submission when none do, which is what makes the original mistake hard to repeat. It is a convenience: the server validates regardless. Closes #130
8.1 KiB
Feature: Availability Management
Overview
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
| Column | Type | Notes |
|---|---|---|
id |
BIGINT UNSIGNED | Primary key |
instructor_id |
BIGINT UNSIGNED | WordPress user ID |
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 |
end_dt |
DATETIME | Slot end — always start_dt + duration_minutes |
duration_minutes |
SMALLINT | Lesson length (e.g. 30, 60) |
is_booked |
TINYINT(1) | 0 = available, 1 = booked |
recurrence_group |
BIGINT UNSIGNED | Nullable — weekly-recurring windows share one group id |
created_at |
DATETIME | Insertion time |
A slot's duration_minutes is matched against the offering a student picks: a
30-minute private offering can only be booked into a slot whose
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;
both the REST endpoint and the admin form reject one that does not, with a
message saying so (see REST API below).
AvailabilityRepository::splitOversizedWindows() is a data migration (run by
Installer on activation or version change) that rewrites pre-split rows.
Weekly-Recurring Windows
Instructors may generate a window weekly across a date range. Each lesson-length
chunk becomes its own weekly series: occurrences of the same time-of-day share
one recurrence_group id, so a recurring set can be added or removed together
while individual occurrences are still booked independently.
Admin Interface
Instructors access My Availability in wp-admin (?page=us-availability).
- Add availability: provide a same-day start/end window, lesson length, and (optionally) a linked private-lesson offering
- Add a weekly series: tick weekly repeat and choose the number of weeks
- Delete a slot: only allowed if
is_booked = 0 - Bulk delete: the list view has a checkbox per unbooked slot (with a select-all header checkbox) and a Delete selected button (
usc_action=bulk_delete,slot_ids[]); each id is ownership-checked, and booked slots are refused at the repository level - Current slots can be shown as a weekly calendar (the default, navigated with
usc_week=Y-m-d) or a list (usc_view=list); the grid honours the site'sstart_of_weekoption viaAvailability\WeekCalendar
Feedback
Every submitted action reports its outcome as a wp-admin notice — a success
notice naming the number of slots created or deleted, or an error explaining the
refusal. AvailabilityController::handleFormAction() returns a
[$notice, $error] pair that templates/admin/availability.php renders.
This matters because the form used to fail silently: a window shorter than the chosen lesson length splits into no slots, so nothing was written, nothing was said, and the page simply reloaded. Submitting 5:30–6:00 PM with the length select on its 60-minute default was the reported case. Invalid datetimes, an end before the start, a window spanning two days, and an offering belonging to another instructor were all silent in the same way.
Lesson-length choices
assets/js/availability-admin.js (enqueued by AdminMenu::enqueueAssets() on
this screen only) hides any lesson length longer than the entered window, falls
back to the longest one that still fits when the current pick is hidden, and
disables the submit button when nothing fits. It is a convenience, not a
guarantee — the server validates the same window regardless. The choices come
from AvailabilitySlot::DURATION_CHOICES.
Public Calendar
The front-end booking shortcode renders open slots from GET /availability
either as an agenda-style list grouped by day or as a weekly calendar with
previous/next-week navigation (toggle rendered by assets/js/booking.js; the
site's start_of_week option is passed through the usScheduler JS config).
Both views can be narrowed to the slots bookable as chosen private-lesson types
with the Show Only lesson-type filter — see lesson-booking.md.
REST API
| Method | Endpoint | Permission |
|---|---|---|
GET |
/wp-json/us-scheduler/v1/availability |
book_lesson |
POST |
/wp-json/us-scheduler/v1/availability |
manage_availability |
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).
Slots whose start has already passed are never returned.
POST runs every submitted window — admin form and REST alike — through
Availability\WindowValidator, which returns either the window ready to persist
or a WP_Error. The REST endpoint returns that error directly (its status
data makes it a 400); the admin screen shows get_error_message() in a notice.
Sharing one validator is deliberate: the two paths previously checked the same
rules separately, and the admin copy was both laxer (no offering-ownership
check) and mute (a bare return on every rejection).
| Rejection | Code |
|---|---|
| Start or end not a real datetime | invalid_datetime |
| End at or before the start | invalid_datetime |
| Window spans two days | invalid_window |
| Window shorter than the lesson length (so it holds no slots) | invalid_window |
| Offering missing, or owned by another instructor | invalid_offering |
start_dt/end_dt are normalised by AvailabilitySlot::normalizeDateTime():
the canonical Y-m-d H:i[:s] and HTML datetime-local (Y-m-d\TH:i[:s]) forms
become Y-m-d H:i:s; anything else is rejected. A valid window is stored as
lesson-length slots and 201 returns { "ids": [...] } for every row created.
weeks is clamped to AvailabilitySlot::MAX_WEEKLY_OCCURRENCES in the
repository, so the form's max cannot be bypassed by posting directly. A write
that fails entirely returns 500 not_saved rather than a 201 listing no ids.
Times are displayed in 12-hour AM/PM form in the booking calendar and wp-admin lists.
Implementation
- Repository:
Unsupervised\Schedular\Availability\AvailabilityRepository - Model:
Unsupervised\Schedular\Availability\AvailabilitySlot - Week bucketing:
Unsupervised\Schedular\Availability\WeekCalendar - Admin controller:
Unsupervised\Schedular\Availability\AvailabilityController - REST endpoint:
Unsupervised\Schedular\Availability\AvailabilityEndpoint - Shared window validation:
Unsupervised\Schedular\Availability\WindowValidator - Admin form script:
assets/js/availability-admin.js, enqueued byAdminMenu::enqueueAssets()
Tests
tests/Unit/Availability/AvailabilityControllerTest.phptests/Unit/Availability/AvailabilityRepositoryTest.phptests/Unit/Availability/AvailabilitySlotTest.phptests/Unit/Availability/AvailabilityEndpointTest.phptests/Unit/Availability/WeekCalendarTest.phptests/Unit/Availability/WindowValidatorTest.php