diff --git a/assets/css/frontend.css b/assets/css/frontend.css index 27f1a91..7a540e4 100644 --- a/assets/css/frontend.css +++ b/assets/css/frontend.css @@ -34,6 +34,83 @@ 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). */ .us-editor-note { font-size: 0.85em; diff --git a/assets/js/booking.js b/assets/js/booking.js index aeb9303..2c3acac 100644 --- a/assets/js/booking.js +++ b/assets/js/booking.js @@ -43,7 +43,13 @@ } 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) { 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) { const groups = new Map(); slots.forEach((slot) => { @@ -63,14 +75,39 @@ return [...groups.entries()].sort((a, b) => a[0].localeCompare(b[0])); } - // Agenda-style calendar: available slots grouped by day. - function renderSlots(slots) { - if (!slots.length) { - slotList.innerHTML = '

No available lesson slots at this time.

'; - return; - } + // --- calendar view state (list is the default; week keeps its position) --- + let allSlots = []; + let view = 'list'; + let weekStart = null; - 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 ` +
+ + +
`; + } + + // Agenda-style calendar: available slots grouped by day. + function listHtml() { + return groupByDay(allSlots).map(([key, daySlots]) => `

${escHtml(dayLabel(key))}

${daySlots.map((slot) => ` @@ -81,10 +118,68 @@ `).join('')}
`).join(''); + } - slotList.querySelectorAll('.us-book-btn').forEach((btn) => { - const slot = slots.find((s) => String(s.id) === btn.dataset.slotId); - btn.addEventListener('click', () => openRegistration(slot)); + // Weekly calendar: seven day columns with a bookable button per slot. + function weekHtml() { + 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) => ` + + `).join(''); + + return ` +
+

${escHtml(shortDayLabel(key))}

+ ${buttons || ''} +
`; + }).join(''); + + return ` +
+ + Week of ${escHtml(shortDayLabel(weekStart))} + +
+
${columns}
`; + } + + function render() { + if (!allSlots.length) { + slotList.innerHTML = '

No available lesson slots at this time.

'; + 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'; confirm.style.display = 'none'; apiFetch('availability') - .then(renderSlots) + .then((slots) => { + allSlots = slots; + render(); + }) .catch((err) => showError(err.message)); } diff --git a/docs/features/availability-management.md b/docs/features/availability-management.md index 67fd52a..95d44d0 100644 --- a/docs/features/availability-management.md +++ b/docs/features/availability-management.md @@ -1,7 +1,7 @@ # Feature: Availability Management ## 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: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` @@ -11,31 +11,43 @@ Instructors define date/time windows during which they are available for private | `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 — stored as `Y-m-d H:i:s` | -| `duration_minutes` | SMALLINT | Lesson length the window accommodates (e.g. 30, 60) | +| `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 window's `duration_minutes` is matched against the offering a student picks: a -30-minute private offering can only be booked into a window whose +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 +(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 -Instructors may generate a window weekly across a date range. Each occurrence is a -separate row sharing one `recurrence_group` id, so a recurring set can be added or -removed together while individual occurrences are still booked independently. +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 a slot: provide start/end datetime, duration, and (optionally) a linked private-lesson offering -- Add a weekly series: provide the weekday/time plus a date range +- 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` +- 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 -The front-end booking shortcode renders a month/week calendar of open windows, -populated from `GET /availability`. Students can filter by instructor and by -offering/duration before selecting a slot to register for. +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). ## REST API | 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 | `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 `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`; 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 - 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` ## Tests - `tests/Unit/Availability/AvailabilityRepositoryTest.php` - `tests/Unit/Availability/AvailabilitySlotTest.php` +- `tests/Unit/Availability/AvailabilityEndpointTest.php` +- `tests/Unit/Availability/WeekCalendarTest.php` diff --git a/docs/features/lesson-booking.md b/docs/features/lesson-booking.md index a22ef4c..14763e3 100644 --- a/docs/features/lesson-booking.md +++ b/docs/features/lesson-booking.md @@ -20,7 +20,7 @@ Students register for a private lesson by choosing an offering, picking a time ( | `created_at` | DATETIME | Insertion time | ## 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. 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`). diff --git a/src/Availability/AvailabilityController.php b/src/Availability/AvailabilityController.php index 43df163..840bfed 100644 --- a/src/Availability/AvailabilityController.php +++ b/src/Availability/AvailabilityController.php @@ -29,6 +29,18 @@ class AvailabilityController { $slots = $this->repository->findByInstructor( $instructorId ); $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'; } @@ -58,14 +70,16 @@ class AvailabilityController { $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'] ?? '' ) ) ) ); - 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; } $offeringId = absint( Val::int( $_POST['offering_id'] ?? 0 ) ); $duration = absint( Val::int( $_POST['duration_minutes'] ?? 0 ) ); - $slot = new AvailabilitySlot( + $window = new AvailabilitySlot( instructorId: $instructorId, startDt: $startDt, endDt: $endDt, @@ -73,12 +87,10 @@ class AvailabilityController { offeringId: $offeringId > 0 ? $offeringId : null, ); - if ( 'weekly' === sanitize_key( Val::string( wp_unslash( $_POST['recurrence'] ?? 'single' ) ) ) ) { - $this->repository->createWeeklySeries( $slot, absint( Val::int( $_POST['weeks'] ?? 1 ) ) ); - return; - } + $recurrence = sanitize_key( Val::string( wp_unslash( $_POST['recurrence'] ?? 'single' ) ) ); + $weeks = absint( Val::int( $_POST['weeks'] ?? 1 ) ); - $this->repository->insert( $slot ); + $this->repository->createFromWindow( $window, 'weekly' === $recurrence, $weeks ); // phpcs:enable WordPress.Security.NonceVerification.Missing } } diff --git a/src/Availability/AvailabilityEndpoint.php b/src/Availability/AvailabilityEndpoint.php index ef25fd5..cc596bc 100644 --- a/src/Availability/AvailabilityEndpoint.php +++ b/src/Availability/AvailabilityEndpoint.php @@ -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 ] ); } - $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, startDt: $startDt, endDt: $endDt, @@ -141,15 +145,17 @@ class AvailabilityEndpoint { offeringId: $offeringId > 0 ? $offeringId : null, ); - if ( 'weekly' === $request->get_param( 'recurrence' ) ) { - $ids = $this->repository->createWeeklySeries( $slot, absint( Val::int( $request->get_param( 'weeks' ) ) ) ); - - return new \WP_REST_Response( [ 'ids' => $ids ], 201 ); + if ( [] === $window->splitByDuration() ) { + return new \WP_Error( 'invalid_window', __( 'The availability window is shorter than the lesson length.', 'unsupervised-schedular' ), [ 'status' => 400 ] ); } - $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 { diff --git a/src/Availability/AvailabilityRepository.php b/src/Availability/AvailabilityRepository.php index 18b5a13..b685de4 100644 --- a/src/Availability/AvailabilityRepository.php +++ b/src/Availability/AvailabilityRepository.php @@ -30,6 +30,26 @@ class AvailabilityRepository { 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 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 * separate row one week apart, all sharing a `recurrence_group` (the id of the @@ -87,8 +107,10 @@ class AvailabilityRepository { * @return list */ public function findAvailable( int $instructorId = 0, int $offeringId = 0, int $durationMinutes = 0, string $from = '', string $to = '' ): array { - $where = [ 'is_booked = 0' ]; - $params = []; + // A slot whose start has passed can no longer be booked, so it is never + // "available" regardless of the requested range. + $where = [ 'is_booked = 0', 'start_dt >= %s' ]; + $params = [ current_time( 'mysql' ) ]; if ( $instructorId > 0 ) { $where[] = 'instructor_id = %d'; @@ -189,6 +211,47 @@ class AvailabilityRepository { 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:00–16: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. */ diff --git a/src/Availability/AvailabilitySlot.php b/src/Availability/AvailabilitySlot.php index 66b729c..999127d 100644 --- a/src/Availability/AvailabilitySlot.php +++ b/src/Availability/AvailabilitySlot.php @@ -37,6 +37,42 @@ class AvailabilitySlot { return null; } + /** + * Split this window into consecutive lesson-length slots: 09:00–16: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 + */ + 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 { return new self( instructorId: Val::int( $row->instructor_id ), diff --git a/src/Availability/WeekCalendar.php b/src/Availability/WeekCalendar.php new file mode 100644 index 0000000..37acc38 --- /dev/null +++ b/src/Availability/WeekCalendar.php @@ -0,0 +1,57 @@ +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 $slots + * @return list}> + */ + 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; + } +} diff --git a/src/BlockPreview.php b/src/BlockPreview.php index 1fc6e13..cc8173a 100644 --- a/src/BlockPreview.php +++ b/src/BlockPreview.php @@ -20,14 +20,14 @@ class BlockPreview { [ 'label' => __( 'Monday', 'unsupervised-schedular' ), 'slots' => [ - [ '16:00–16:30', 30 ], - [ '16:30–17:00', 30 ], + [ '4:00 PM–4:30 PM', 30 ], + [ '4:30 PM–5:00 PM', 30 ], ], ], [ 'label' => __( 'Wednesday', 'unsupervised-schedular' ), 'slots' => [ - [ '17:00–17:45', 45 ], + [ '5:00 PM–5:45 PM', 45 ], ], ], ]; @@ -63,7 +63,7 @@ class BlockPreview { '
%s

%s

%s

%s

25.00 CAD

', self::note( __( 'Editor preview — students see live group classes on the published page.', 'unsupervised-schedular' ) ), esc_html__( 'Beginner Group Class', 'unsupervised-schedular' ), - esc_html__( 'Saturdays 10:00–11:00', 'unsupervised-schedular' ), + esc_html__( 'Saturdays 10:00 AM–11:00 AM', 'unsupervised-schedular' ), esc_html__( 'A sample class shown so the page can be styled.', 'unsupervised-schedular' ), esc_html__( 'Enrol', 'unsupervised-schedular' ) ); diff --git a/src/Installer.php b/src/Installer.php index 566113b..ffa0380 100644 --- a/src/Installer.php +++ b/src/Installer.php @@ -4,11 +4,13 @@ declare(strict_types=1); namespace Unsupervised\Schedular; use Unsupervised\Schedular\Auth\RoleManager; +use Unsupervised\Schedular\Availability\AvailabilityRepository; class Installer { public function run(): void { $this->createTables(); + $this->migrateData(); ( new RoleManager() )->createRoles(); flush_rewrite_rules(); update_option( 'us_schedular_version', USC_VERSION ); @@ -27,4 +29,13 @@ class Installer { dbDelta( $sql ); } } + + private function migrateData(): void { + global $wpdb; + if ( ! $wpdb instanceof \wpdb ) { + return; + } + + ( new AvailabilityRepository( $wpdb ) )->splitOversizedWindows(); + } } diff --git a/src/Plugin.php b/src/Plugin.php index fb5a59b..cf8be22 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -36,6 +36,13 @@ class Plugin { if ( ! $wpdb instanceof \wpdb ) { 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 ); $bookings = new BookingRepository( $wpdb ); $offerings = new OfferingRepository( $wpdb ); diff --git a/src/ShortcodeRegistrar.php b/src/ShortcodeRegistrar.php index 7a2bb15..761b034 100644 --- a/src/ShortcodeRegistrar.php +++ b/src/ShortcodeRegistrar.php @@ -45,9 +45,10 @@ class ShortcodeRegistrar { wp_register_script( 'us-scheduler-payment', USC_PLUGIN_URL . 'assets/js/payment.js', $paymentDeps, USC_VERSION, true ); $data = [ - 'restUrl' => rest_url( 'us-scheduler/v1/' ), - 'nonce' => wp_create_nonce( 'wp_rest' ), - 'stripeKey' => $settings->publishableKey(), + 'restUrl' => rest_url( 'us-scheduler/v1/' ), + 'nonce' => wp_create_nonce( 'wp_rest' ), + '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 diff --git a/templates/admin/availability.php b/templates/admin/availability.php index 6921b8c..ab28013 100644 --- a/templates/admin/availability.php +++ b/templates/admin/availability.php @@ -8,12 +8,33 @@ if (! defined('ABSPATH')) { /** * @var list<\Unsupervised\Schedular\Availability\AvailabilitySlot> $slots * @var list<\Unsupervised\Schedular\Offering\Offering> $offeringChoices + * @var 'list'|'week' $view + * @var string $weekStart + * @var list}> $weekDays + * @var string $prevWeek + * @var string $nextWeek */ + +$baseUrl = admin_url('admin.php?page=us-availability'); + +$deleteForm = static function (\Unsupervised\Schedular\Availability\AvailabilitySlot $slot): void { + ?> +
+ + + + +
+

-

+

+

@@ -56,12 +77,63 @@ if (! defined('ABSPATH')) { - +

- +
    +
  • + > | +
  • +
  • + > +
  • +
+
+ + +

+ + + + + +

+ + + + + + + + + + + + + + + +
+ + + + +

+ startDt) . '–' . (string) mysql2date('g:i A', $slot->endDt)); ?>
+ isBooked) : ?> + + + + +

+ +
+

@@ -77,20 +149,13 @@ if (! defined('ABSPATH')) { - - + + diff --git a/templates/admin/student-detail.php b/templates/admin/student-detail.php index d8ddd31..8a1415f 100644 --- a/templates/admin/student-detail.php +++ b/templates/admin/student-detail.php @@ -34,7 +34,7 @@ $renderLessons = static function (array $rows): void { - + diff --git a/tests/Unit/Availability/AvailabilityEndpointTest.php b/tests/Unit/Availability/AvailabilityEndpointTest.php new file mode 100644 index 0000000..a00438b --- /dev/null +++ b/tests/Unit/Availability/AvailabilityEndpointTest.php @@ -0,0 +1,115 @@ +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()); + } +} diff --git a/tests/Unit/Availability/AvailabilityRepositoryTest.php b/tests/Unit/Availability/AvailabilityRepositoryTest.php index 7fe1274..001693b 100644 --- a/tests/Unit/Availability/AvailabilityRepositoryTest.php +++ b/tests/Unit/Availability/AvailabilityRepositoryTest.php @@ -147,11 +147,16 @@ class AvailabilityRepositoryTest extends TestCase 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') ->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 ...'); $this->db->shouldReceive('get_results') @@ -166,6 +171,8 @@ class AvailabilityRepositoryTest extends TestCase public function testFindAvailableWithInstructorFilterPreparesQuery(): void { + Functions\when('current_time')->justReturn('2026-07-05 12:00:00'); + $this->db->shouldReceive('prepare') ->once() ->with(Mockery::pattern('/instructor_id = %d/'), Mockery::any()) @@ -178,11 +185,13 @@ class AvailabilityRepositoryTest extends TestCase public function testFindAvailableWithOfferingAndDurationFilters(): void { + Functions\when('current_time')->justReturn('2026-07-05 12:00:00'); + $this->db->shouldReceive('prepare') ->once() ->with( 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 ...'); @@ -191,6 +200,118 @@ class AvailabilityRepositoryTest extends TestCase $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 { $row = (object) [ diff --git a/tests/Unit/Availability/AvailabilitySlotTest.php b/tests/Unit/Availability/AvailabilitySlotTest.php index 12ccf20..7f9d17f 100644 --- a/tests/Unit/Availability/AvailabilitySlotTest.php +++ b/tests/Unit/Availability/AvailabilitySlotTest.php @@ -84,6 +84,51 @@ class AvailabilitySlotTest extends TestCase 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 { $slot = new AvailabilitySlot(1, '2026-04-01 09:00:00', '2026-04-01 10:00:00', 30, 8, false, null, 10); diff --git a/tests/Unit/Availability/WeekCalendarTest.php b/tests/Unit/Availability/WeekCalendarTest.php new file mode 100644 index 0000000..84cf2e9 --- /dev/null +++ b/tests/Unit/Availability/WeekCalendarTest.php @@ -0,0 +1,66 @@ +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']); + } +} diff --git a/unsupervised-schedular.php b/unsupervised-schedular.php index b36863b..3b6b932 100644 --- a/unsupervised-schedular.php +++ b/unsupervised-schedular.php @@ -3,7 +3,7 @@ * Plugin Name: Unsupervised Scheduler * Plugin URI: https://unsupervised.ca * 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 PHP: 8.1 * Author: Unsupervised @@ -19,7 +19,7 @@ if (! defined('ABSPATH')) { 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_DIR', plugin_dir_path(__FILE__)); define('USC_PLUGIN_URL', plugin_dir_url(__FILE__));
startDt); ?>endDt); ?>startDt)); ?>endDt)); ?> durationMinutes . ' min'); ?> isBooked ? esc_html__('Booked', 'unsupervised-schedular') : esc_html__('Available', 'unsupervised-schedular'); ?> isBooked) : ?> -
- - - - -
+