An availability window was stored and served as a single bookable row, so:
A 2026-07-06 09:00–16:00 window with 60-minute lessons showed to students as one "09:00–16:00 (60 min)" entry, and booking it consumed the whole day.
A month-long window (2026-06-01 19:52 – 2026-06-30 19:52) rendered as a nonsense "19:52–19:52" block on a past date — nothing filtered past slots or prevented multi-day windows.
Times rendered in 24-hour form everywhere.
Changes
Slot splitting
AvailabilitySlot::splitByDuration() chunks a window into consecutive lesson-length slots (a trailing remainder shorter than a lesson is dropped); AvailabilityRepository::createFromWindow() persists one row per chunk — used by both the REST endpoint and the admin form, so the existing claim/booking flow is untouched.
Weekly recurrence creates a series per chunk, so "reserve this time weekly" still holds the same hour each week.
Windows must start and end on the same day and fit at least one lesson (400 invalid_window; the admin form is a no-op, matching its handling of other invalid input). POST /availability now always returns {"ids": [...]}.
Past-slot filtering
findAvailable() always floors results at current_time('mysql') — a slot whose start has passed is never returned regardless of query params.
Data migration
Plugin::boot re-runs the Installer when the stored plugin version differs from USC_VERSION (bumped to 1.0.0-rc.2), and the installer runs splitOversizedWindows(): unbooked same-day windows longer than their lesson length are rewritten in place as lesson-length rows. Multi-day legacy rows are left alone (hidden from students by the past filter; deletable from My Availability).
12-hour AM/PM times
Booking page JS, wp-admin availability list, admin student detail, and the Gutenberg editor previews all render g:i A style times.
Weekly calendar views
Student booking page: List | Week toggle; the week view shows seven day columns of bookable time buttons with previous/next-week navigation, defaulting to the week of the earliest open slot.
My Availability: the same toggle via usc_view=week / usc_week=Y-m-d (bookmarkable); the grid shows each day's slots with Booked markers or Delete buttons.
Both honour the site's start_of_week option through the new pure WeekCalendar helper (admin) and the usScheduler JS config (frontend).
Testing
New: AvailabilityEndpointTest (window validation, chunked create), WeekCalendarTest (week resolution, bucketing), plus splitByDuration, createFromWindow, splitOversizedWindows, and past-floor coverage in the existing suites.
composer test (241 tests), composer lint (PHPStan level 10), and composer cs all pass.
Closes #49
## Problem
An availability window was stored and served as a **single bookable row**, so:
- A `2026-07-06 09:00–16:00` window with 60-minute lessons showed to students as one "09:00–16:00 (60 min)" entry, and booking it consumed the whole day.
- A month-long window (`2026-06-01 19:52` – `2026-06-30 19:52`) rendered as a nonsense "19:52–19:52" block on a past date — nothing filtered past slots or prevented multi-day windows.
- Times rendered in 24-hour form everywhere.
## Changes
### Slot splitting
- `AvailabilitySlot::splitByDuration()` chunks a window into consecutive lesson-length slots (a trailing remainder shorter than a lesson is dropped); `AvailabilityRepository::createFromWindow()` persists one row per chunk — used by both the REST endpoint and the admin form, so the existing claim/booking flow is untouched.
- Weekly recurrence creates a series **per chunk**, so "reserve this time weekly" still holds the same hour each week.
- Windows must start and end on the same day and fit at least one lesson (`400 invalid_window`; the admin form is a no-op, matching its handling of other invalid input). `POST /availability` now always returns `{"ids": [...]}`.
### Past-slot filtering
- `findAvailable()` always floors results at `current_time('mysql')` — a slot whose start has passed is never returned regardless of query params.
### Data migration
- `Plugin::boot` re-runs the `Installer` when the stored plugin version differs from `USC_VERSION` (bumped to 1.0.0-rc.2), and the installer runs `splitOversizedWindows()`: unbooked same-day windows longer than their lesson length are rewritten in place as lesson-length rows. Multi-day legacy rows are left alone (hidden from students by the past filter; deletable from My Availability).
### 12-hour AM/PM times
- Booking page JS, wp-admin availability list, admin student detail, and the Gutenberg editor previews all render `g:i A` style times.
### Weekly calendar views
- **Student booking page**: List | Week toggle; the week view shows seven day columns of bookable time buttons with previous/next-week navigation, defaulting to the week of the earliest open slot.
- **My Availability**: the same toggle via `usc_view=week` / `usc_week=Y-m-d` (bookmarkable); the grid shows each day's slots with Booked markers or Delete buttons.
- Both honour the site's `start_of_week` option through the new pure `WeekCalendar` helper (admin) and the `usScheduler` JS config (frontend).
## Testing
- New: `AvailabilityEndpointTest` (window validation, chunked create), `WeekCalendarTest` (week resolution, bucketing), plus `splitByDuration`, `createFromWindow`, `splitOversizedWindows`, and past-floor coverage in the existing suites.
- `composer test` (241 tests), `composer lint` (PHPStan level 10), and `composer cs` all pass.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
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]>
The unanchored dd\( pattern matched the substring in DateTimeImmutable::add(),
failing the check on non-debug code.
Co-Authored-By: Claude Fable 5 <[email protected]>
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Closes #49
Problem
An availability window was stored and served as a single bookable row, so:
2026-07-06 09:00–16:00window with 60-minute lessons showed to students as one "09:00–16:00 (60 min)" entry, and booking it consumed the whole day.2026-06-01 19:52–2026-06-30 19:52) rendered as a nonsense "19:52–19:52" block on a past date — nothing filtered past slots or prevented multi-day windows.Changes
Slot splitting
AvailabilitySlot::splitByDuration()chunks a window into consecutive lesson-length slots (a trailing remainder shorter than a lesson is dropped);AvailabilityRepository::createFromWindow()persists one row per chunk — used by both the REST endpoint and the admin form, so the existing claim/booking flow is untouched.400 invalid_window; the admin form is a no-op, matching its handling of other invalid input).POST /availabilitynow always returns{"ids": [...]}.Past-slot filtering
findAvailable()always floors results atcurrent_time('mysql')— a slot whose start has passed is never returned regardless of query params.Data migration
Plugin::bootre-runs theInstallerwhen the stored plugin version differs fromUSC_VERSION(bumped to 1.0.0-rc.2), and the installer runssplitOversizedWindows(): unbooked same-day windows longer than their lesson length are rewritten in place as lesson-length rows. Multi-day legacy rows are left alone (hidden from students by the past filter; deletable from My Availability).12-hour AM/PM times
g:i Astyle times.Weekly calendar views
usc_view=week/usc_week=Y-m-d(bookmarkable); the grid shows each day's slots with Booked markers or Delete buttons.start_of_weekoption through the new pureWeekCalendarhelper (admin) and theusSchedulerJS config (frontend).Testing
AvailabilityEndpointTest(window validation, chunked create),WeekCalendarTest(week resolution, bucketing), plussplitByDuration,createFromWindow,splitOversizedWindows, and past-floor coverage in the existing suites.composer test(241 tests),composer lint(PHPStan level 10), andcomposer csall pass.🤖 Generated with Claude Code