Merge pull request 'Split availability windows into bookable lesson-length slots; weekly calendar views; 12-hour times' (#50) from feature/slot-splitting-and-week-view into main
CI / PHPStan (push) Successful in 1m42s
CI / Tests (PHP 8.2) (push) Successful in 40s
CI / Tests (PHP 8.1) (push) Successful in 56s
CI / No Debug Code (push) Successful in 3s
CI / Tests (PHP 8.3) (push) Successful in 34s
CI / Coding Standards (push) Successful in 2m45s
CI / Build Plugin Zip (push) Successful in 1m14s

Reviewed-on: #50
This commit was merged in pull request #50.
This commit is contained in:
2026-07-05 19:14:50 +00:00
21 changed files with 871 additions and 70 deletions
+2 -1
View File
@@ -97,7 +97,8 @@ jobs:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Check for debug statements - name: Check for debug statements
run: | run: |
if grep -rn --include="*.php" -E "(var_dump|var_export|print_r|error_log|dd\(|dump\()" src/; then # \b keeps method calls like DateTimeImmutable::add() from matching dd(.
if grep -rn --include="*.php" -E "\b(var_dump|var_export|print_r|error_log|dd|dump)\s*\(" src/; then
echo "Debug code found in src/ — please remove before merging." echo "Debug code found in src/ — please remove before merging."
exit 1 exit 1
fi fi
+77
View File
@@ -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;
+110 -12
View File
@@ -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;
}
slotList.innerHTML = groupByDay(slots).map(([key, daySlots]) => ` 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);
}
// 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">&lsaquo; Previous week</button>
<strong class="us-week-label">Week of ${escHtml(shortDayLabel(weekStart))}</strong>
<button type="button" id="us-week-next">Next week &rsaquo;</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));
} }
+34 -14
View File
@@ -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: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` ## 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`
+1 -1
View File
@@ -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`).
+19 -7
View File
@@ -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
} }
} }
+13 -7
View File
@@ -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 {
+65 -2
View File
@@ -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:0016: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.
*/ */
+36
View File
@@ -37,6 +37,42 @@ class AvailabilitySlot {
return null; return null;
} }
/**
* Split this window into consecutive lesson-length slots: 09:0016: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 ),
+57
View File
@@ -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;
}
}
+4 -4
View File
@@ -20,14 +20,14 @@ class BlockPreview {
[ [
'label' => __( 'Monday', 'unsupervised-schedular' ), 'label' => __( 'Monday', 'unsupervised-schedular' ),
'slots' => [ 'slots' => [
[ '16:0016:30', 30 ], [ '4:00 PM4:30 PM', 30 ],
[ '16:3017:00', 30 ], [ '4:30 PM5:00 PM', 30 ],
], ],
], ],
[ [
'label' => __( 'Wednesday', 'unsupervised-schedular' ), 'label' => __( 'Wednesday', 'unsupervised-schedular' ),
'slots' => [ 'slots' => [
[ '17:0017:45', 45 ], [ '5:00 PM5: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:0011:00', 'unsupervised-schedular' ), esc_html__( 'Saturdays 10:00 AM11: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' )
); );
+11
View File
@@ -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();
}
} }
+7
View File
@@ -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 );
+4 -3
View File
@@ -45,9 +45,10 @@ class ShortcodeRegistrar {
wp_register_script( 'us-scheduler-payment', USC_PLUGIN_URL . 'assets/js/payment.js', $paymentDeps, USC_VERSION, true ); wp_register_script( 'us-scheduler-payment', USC_PLUGIN_URL . 'assets/js/payment.js', $paymentDeps, USC_VERSION, true );
$data = [ $data = [
'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
+78 -13
View File
@@ -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 AM4: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)); ?>">&lsaquo; <?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'); ?> &rsaquo;</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>
+1 -1
View File
@@ -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']);
}
}
+2 -2
View File
@@ -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__));