From 14f43232c957b7fb6808f185eeeeb3932c7bf33f Mon Sep 17 00:00:00 2001 From: James Griffin Date: Wed, 22 Jul 2026 10:13:04 -0300 Subject: [PATCH] Default lessons to a week view on the booking page and in wp-admin The front-end booking calendar now opens in the Week view (anchored to the week of the earliest open slot) with List still available. The Scheduler and My Lessons admin pages gain a week calendar (usc_view/usc_week, bucketed via a new generic WeekCalendar::bucket()) and open in it by default; the original table remains as the List view since it carries the HST / e-transfer forms. Closes #76 Co-Authored-By: Claude Fable 5 --- assets/js/booking.js | 11 ++-- docs/features/lesson-booking.md | 7 ++- src/Availability/WeekCalendar.php | 30 +++++++++ src/Booking/LessonController.php | 28 ++++++++- templates/admin/lessons.php | 60 +++++++++++++++++- tests/Unit/Availability/WeekCalendarTest.php | 26 ++++++++ tests/Unit/Booking/LessonControllerTest.php | 66 ++++++++++++++++++++ 7 files changed, 219 insertions(+), 9 deletions(-) diff --git a/assets/js/booking.js b/assets/js/booking.js index f695cac..2747d5f 100644 --- a/assets/js/booking.js +++ b/assets/js/booking.js @@ -76,9 +76,9 @@ return [...groups.entries()].sort((a, b) => a[0].localeCompare(b[0])); } - // --- calendar view state (list is the default; week keeps its position) --- + // --- calendar view state (week is the default; week keeps its position) --- let allSlots = []; - let view = 'list'; + let view = 'week'; let weekStart = null; const pad = (n) => String(n).padStart(2, '0'); @@ -156,6 +156,10 @@ return; } + // Anchor the week view to the week of the earliest open slot (the API + // returns slots ordered by start), so the first look is never empty. + if (view === 'week' && !weekStart) weekStart = weekStartOf(dayKey(allSlots[0].start_dt)); + slotList.innerHTML = toggleHtml() + (view === 'week' ? weekHtml() : listHtml()); wireCalendarEvents(); } @@ -167,9 +171,6 @@ }); 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(); }); diff --git a/docs/features/lesson-booking.md b/docs/features/lesson-booking.md index fd15845..24930f6 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 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). +1. Student opens the page with the `[us_booking]` shortcode and browses open slots as a weekly calendar (the default, anchored to the week of the earliest open slot) or an agenda list (view toggle with previous/next-week navigation; times shown in 12-hour AM/PM form). 2. Student picks a slot and an **offering** (a 30 or 60-minute private-lesson type). When the slot is tied to an offering the form shows it locked (the student sees exactly what they are booking); otherwise the form presents the instructor's active private-lesson offerings whose duration fits the slot. Every booking requires an offering — a generic slot with no fitting offering cannot be booked online. 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`). @@ -77,6 +77,11 @@ kind `group_class`; see `group-classes.md`. - **Scheduler** (`view_all_lessons` — studio admin / administrators): all upcoming lessons across all instructors - **My Lessons** (`view_own_lessons`): upcoming lessons for the logged-in instructor +Both pages open in a **Week** calendar view by default (`usc_view`/`usc_week` +query params, same pattern as the availability page, bucketed via +`Availability\WeekCalendar`), with the original table available as the **List** +view — the list is where the per-lesson HST and e-transfer edit forms live. + ## Frontend Shortcodes - `[us_booking]` — student calendar + registration flow; requires `book_lesson` capability - `[us_student_login]` — front-end login form for students diff --git a/src/Availability/WeekCalendar.php b/src/Availability/WeekCalendar.php index 37acc38..714c261 100644 --- a/src/Availability/WeekCalendar.php +++ b/src/Availability/WeekCalendar.php @@ -49,6 +49,36 @@ class WeekCalendar { return $days; } + /** + * Bucket arbitrary items into the seven days of the week starting at + * `$weekStart` (`Y-m-d`), using `$dayOf` to extract each item's `Y-m-d` day. + * Every day is present, empty or not, in calendar order. + * + * @template T + * @param list $items + * @param callable(T): string $dayOf + * @return list}> + */ + public static function bucket( string $weekStart, array $items, callable $dayOf ): array { + $start = self::parseDay( $weekStart ) ?? new \DateTimeImmutable( 'today' ); + + $byDay = []; + foreach ( $items as $item ) { + $byDay[ $dayOf( $item ) ][] = $item; + } + + $days = []; + for ( $i = 0; $i < 7; $i++ ) { + $date = $start->modify( '+' . $i . ' days' )->format( 'Y-m-d' ); + $days[] = [ + 'date' => $date, + 'items' => $byDay[ $date ] ?? [], + ]; + } + + return $days; + } + private static function parseDay( string $value ): ?\DateTimeImmutable { $day = \DateTimeImmutable::createFromFormat( '!Y-m-d', $value ); diff --git a/src/Booking/LessonController.php b/src/Booking/LessonController.php index ee87ab2..8a96e64 100644 --- a/src/Booking/LessonController.php +++ b/src/Booking/LessonController.php @@ -6,6 +6,7 @@ namespace Unsupervised\Schedular\Booking; use Unsupervised\Schedular\Auth\RoleManager; use Unsupervised\Schedular\Availability\AvailabilityRepository; use Unsupervised\Schedular\Availability\AvailabilitySlot; +use Unsupervised\Schedular\Availability\WeekCalendar; use Unsupervised\Schedular\Payment\Payment; use Unsupervised\Schedular\Payment\PaymentRepository; use Unsupervised\Schedular\Val; @@ -27,7 +28,7 @@ class LessonController { $rows = array_map( fn( Lesson $lesson ): array => $this->row( $lesson ), $this->repository->findAllUpcoming() ); - include USC_PLUGIN_DIR . 'templates/admin/lessons.php'; + $this->renderLessonsPage( $rows, 'us-scheduler' ); } public function renderInstructorLessons(): void { @@ -39,6 +40,29 @@ class LessonController { $rows = array_map( fn( Lesson $lesson ): array => $this->row( $lesson ), $this->repository->findUpcomingForInstructor( get_current_user_id() ) ); + $this->renderLessonsPage( $rows, 'us-my-lessons' ); + } + + /** + * Render the lessons template with its calendar view state: week (default) + * or list, plus which week the week view shows. + * + * @param list> $rows + */ + private function renderLessonsPage( array $rows, string $pageSlug ): void { + // 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 = 'list' === sanitize_key( Val::string( wp_unslash( $_GET['usc_view'] ?? '' ) ) ) ? 'list' : 'week'; + $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::bucket( $weekStart, $rows, static fn( array $row ): string => Val::string( $row['day'] ) ); + $prevWeek = ( new \DateTimeImmutable( $weekStart ) )->modify( '-7 days' )->format( 'Y-m-d' ); + $nextWeek = ( new \DateTimeImmutable( $weekStart ) )->modify( '+7 days' )->format( 'Y-m-d' ); + $baseUrl = admin_url( 'admin.php?page=' . $pageSlug ); + include USC_PLUGIN_DIR . 'templates/admin/lessons.php'; } @@ -92,6 +116,8 @@ class LessonController { 'student' => $student ? $student->display_name : (string) $lesson->studentId, 'instructor' => $instructor ? $instructor->display_name : (string) $lesson->instructorId, 'time' => $slot ? $this->formatSlotTime( $slot ) : '—', + 'day' => $slot ? substr( $slot->startDt, 0, 10 ) : '', + 'time_short' => $slot ? Val::string( mysql2date( 'g:i A', $slot->startDt ) ) : '—', 'status' => $lesson->status, 'notes' => $lesson->notes ?? '', 'payment_id' => $payment ? (int) $payment->id : 0, diff --git a/templates/admin/lessons.php b/templates/admin/lessons.php index 8b90373..9879211 100644 --- a/templates/admin/lessons.php +++ b/templates/admin/lessons.php @@ -5,12 +5,68 @@ if (! defined('ABSPATH')) { exit; } -/** @var list $rows */ +/** + * @var list $rows + * @var 'list'|'week' $view + * @var string $weekStart + * @var list}> $weekDays + * @var string $prevWeek + * @var string $nextWeek + * @var string $baseUrl + */ ?>

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

+ + + + + +

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

+
+
+ +

+ +
+

diff --git a/tests/Unit/Availability/WeekCalendarTest.php b/tests/Unit/Availability/WeekCalendarTest.php index 84cf2e9..687c7f7 100644 --- a/tests/Unit/Availability/WeekCalendarTest.php +++ b/tests/Unit/Availability/WeekCalendarTest.php @@ -63,4 +63,30 @@ class WeekCalendarTest extends TestCase self::assertSame('2026-06-29', $days[0]['date']); self::assertSame('2026-07-05', $days[6]['date']); } + + public function testBucketGroupsItemsByExtractedDay(): void + { + $items = [ + ['day' => '2026-07-06', 'label' => 'a'], + ['day' => '2026-07-06', 'label' => 'b'], + ['day' => '2026-07-09', 'label' => 'c'], + ['day' => '2026-07-13', 'label' => 'outside'], + ['day' => '', 'label' => 'dayless'], + ]; + + $days = WeekCalendar::bucket('2026-07-06', $items, static fn (array $i): string => $i['day']); + + self::assertCount(7, $days); + self::assertSame('2026-07-06', $days[0]['date']); + self::assertSame('2026-07-12', $days[6]['date']); + + self::assertSame(['a', 'b'], array_column($days[0]['items'], 'label')); + self::assertSame(['c'], array_column($days[3]['items'], 'label')); + self::assertSame([], $days[1]['items']); + + // Items outside the week (or with no day) are not bucketed anywhere. + $labels = array_merge(...array_column($days, 'items')); + self::assertNotContains('outside', array_column($labels, 'label')); + self::assertNotContains('dayless', array_column($labels, 'label')); + } } diff --git a/tests/Unit/Booking/LessonControllerTest.php b/tests/Unit/Booking/LessonControllerTest.php index 3ced1b8..a290953 100644 --- a/tests/Unit/Booking/LessonControllerTest.php +++ b/tests/Unit/Booking/LessonControllerTest.php @@ -30,16 +30,26 @@ class LessonControllerTest extends TestCase $this->controller = new LessonController($this->bookings, $this->payments, $this->availability); $_POST = []; + $_GET = []; Functions\when('current_user_can')->justReturn(true); Functions\when('get_userdata')->justReturn(false); Functions\when('mysql2date')->alias( static fn (string $format, string $date) => date($format, (int) strtotime($date)) ); + Functions\when('wp_unslash')->returnArg(); + Functions\when('sanitize_text_field')->returnArg(); + Functions\when('sanitize_key')->alias(static fn ($v) => strtolower((string) $v)); + Functions\when('get_option')->justReturn(1); + Functions\when('current_time')->justReturn('2026-07-06'); + Functions\when('admin_url')->alias(static fn (string $path) => 'https://example.test/wp-admin/' . $path); + Functions\when('add_query_arg')->alias(static fn ($key, $value, $url) => $url . '&' . $key . '=' . $value); } public function testAdminDashboardShowsSlotDateTimeInsteadOfSlotId(): void { + $_GET['usc_view'] = 'list'; + $lesson = new Lesson(slotId: 10, studentId: 5, instructorId: 3, id: 1); $slot = new AvailabilitySlot( instructorId: 3, @@ -60,6 +70,8 @@ class LessonControllerTest extends TestCase public function testSlotCrossingMidnightRepeatsTheDateOnTheEndTime(): void { + $_GET['usc_view'] = 'list'; + $lesson = new Lesson(slotId: 11, studentId: 5, instructorId: 3, id: 2); $slot = new AvailabilitySlot( instructorId: 3, @@ -90,6 +102,7 @@ class LessonControllerTest extends TestCase public function testInstructorLessonsShowSlotDateTime(): void { + $_GET['usc_view'] = 'list'; Functions\when('get_current_user_id')->justReturn(3); $lesson = new Lesson(slotId: 12, studentId: 5, instructorId: 3, id: 4); @@ -110,6 +123,59 @@ class LessonControllerTest extends TestCase self::assertStringContainsString('Aug 1, 2026 2:00 PM–3:00 PM', $html); } + public function testDefaultsToWeekViewWithLessonInItsDay(): void + { + $lesson = new Lesson(slotId: 10, studentId: 5, instructorId: 3, id: 1); + $slot = new AvailabilitySlot( + instructorId: 3, + startDt: '2026-07-08 09:00:00', + endDt: '2026-07-08 10:00:00', + id: 10 + ); + + $this->bookings->shouldReceive('findAllUpcoming')->once()->andReturn([$lesson]); + $this->availability->shouldReceive('findById')->once()->with(10)->andReturn($slot); + + $html = $this->render(); + + // Week of Monday 2026-07-06 (start_of_week = 1, today = 2026-07-06). + self::assertStringContainsString('Week of Jul 6, 2026', $html); + self::assertStringContainsString('Wed Jul 8', $html); + self::assertStringContainsString('9:00 AM', $html); + // The list table is not rendered in week view. + self::assertStringNotContainsString('Date/Time', $html); + } + + public function testWeekViewHonoursRequestedWeek(): void + { + $_GET['usc_week'] = '2026-08-01'; + + $this->bookings->shouldReceive('findAllUpcoming')->once()->andReturn([]); + + $html = $this->render(); + + // 2026-08-01 is a Saturday; its Monday-start week begins 2026-07-27. + self::assertStringContainsString('Week of Jul 27, 2026', $html); + } + + public function testLessonOutsideDisplayedWeekIsNotShown(): void + { + $lesson = new Lesson(slotId: 10, studentId: 5, instructorId: 3, id: 1); + $slot = new AvailabilitySlot( + instructorId: 3, + startDt: '2026-09-01 09:00:00', + endDt: '2026-09-01 10:00:00', + id: 10 + ); + + $this->bookings->shouldReceive('findAllUpcoming')->once()->andReturn([$lesson]); + $this->availability->shouldReceive('findById')->once()->with(10)->andReturn($slot); + + $html = $this->render(); + + self::assertStringNotContainsString('9:00 AM', $html); + } + private function render(): string { ob_start();