Files
unsupervised-scheduler/docs/features/availability-management.md
T
thatguygriff 171b655bb8
CI / Tests (PHP 8.1) (pull_request) Successful in 56s
CI / Tests (PHP 8.2) (pull_request) Successful in 46s
CI / No Debug Code (pull_request) Successful in 2s
CI / Coding Standards (pull_request) Successful in 3m3s
CI / PHPStan (pull_request) Successful in 2m51s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m50s
CI / Build Plugin Zip (pull_request) Skipped
Stop the availability form failing in silence
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
2026-07-28 23:19:49 -03:00

8.1 KiB
Raw Blame History

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:0016: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's start_of_week option via Availability\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:306: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 by AdminMenu::enqueueAssets()

Tests

  • tests/Unit/Availability/AvailabilityControllerTest.php
  • tests/Unit/Availability/AvailabilityRepositoryTest.php
  • tests/Unit/Availability/AvailabilitySlotTest.php
  • tests/Unit/Availability/AvailabilityEndpointTest.php
  • tests/Unit/Availability/WeekCalendarTest.php
  • tests/Unit/Availability/WindowValidatorTest.php