# 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'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: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 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`