Default lessons to a week view on the booking page and in wp-admin
CI / Tests (PHP 8.2) (pull_request) Successful in 1m15s
CI / Tests (PHP 8.1) (pull_request) Successful in 1m16s
CI / No Debug Code (pull_request) Successful in 2s
CI / Coding Standards (pull_request) Successful in 3m15s
CI / PHPStan (pull_request) Successful in 3m14s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m37s
CI / Build Plugin Zip (pull_request) Skipped

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 <[email protected]>
This commit is contained in:
2026-07-22 10:13:04 -03:00
co-authored by Claude Fable 5
parent 05d1728248
commit 14f43232c9
7 changed files with 219 additions and 9 deletions
+6 -5
View File
@@ -76,9 +76,9 @@
return [...groups.entries()].sort((a, b) => a[0].localeCompare(b[0])); 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 allSlots = [];
let view = 'list'; let view = 'week';
let weekStart = null; let weekStart = null;
const pad = (n) => String(n).padStart(2, '0'); const pad = (n) => String(n).padStart(2, '0');
@@ -156,6 +156,10 @@
return; 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()); slotList.innerHTML = toggleHtml() + (view === 'week' ? weekHtml() : listHtml());
wireCalendarEvents(); wireCalendarEvents();
} }
@@ -167,9 +171,6 @@
}); });
document.getElementById('us-view-week').addEventListener('click', () => { document.getElementById('us-view-week').addEventListener('click', () => {
view = 'week'; 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(); render();
}); });
+6 -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 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. 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. 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`).
@@ -77,6 +77,11 @@ kind `group_class`; see `group-classes.md`.
- **Scheduler** (`view_all_lessons` — studio admin / administrators): all upcoming lessons across all instructors - **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 - **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 ## Frontend Shortcodes
- `[us_booking]` — student calendar + registration flow; requires `book_lesson` capability - `[us_booking]` — student calendar + registration flow; requires `book_lesson` capability
- `[us_student_login]` — front-end login form for students - `[us_student_login]` — front-end login form for students
+30
View File
@@ -49,6 +49,36 @@ class WeekCalendar {
return $days; 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<T> $items
* @param callable(T): string $dayOf
* @return list<array{date: string, items: list<T>}>
*/
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 { private static function parseDay( string $value ): ?\DateTimeImmutable {
$day = \DateTimeImmutable::createFromFormat( '!Y-m-d', $value ); $day = \DateTimeImmutable::createFromFormat( '!Y-m-d', $value );
+27 -1
View File
@@ -6,6 +6,7 @@ namespace Unsupervised\Schedular\Booking;
use Unsupervised\Schedular\Auth\RoleManager; use Unsupervised\Schedular\Auth\RoleManager;
use Unsupervised\Schedular\Availability\AvailabilityRepository; use Unsupervised\Schedular\Availability\AvailabilityRepository;
use Unsupervised\Schedular\Availability\AvailabilitySlot; use Unsupervised\Schedular\Availability\AvailabilitySlot;
use Unsupervised\Schedular\Availability\WeekCalendar;
use Unsupervised\Schedular\Payment\Payment; use Unsupervised\Schedular\Payment\Payment;
use Unsupervised\Schedular\Payment\PaymentRepository; use Unsupervised\Schedular\Payment\PaymentRepository;
use Unsupervised\Schedular\Val; use Unsupervised\Schedular\Val;
@@ -27,7 +28,7 @@ class LessonController {
$rows = array_map( fn( Lesson $lesson ): array => $this->row( $lesson ), $this->repository->findAllUpcoming() ); $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 { 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() ) ); $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<array<string, mixed>> $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'; include USC_PLUGIN_DIR . 'templates/admin/lessons.php';
} }
@@ -92,6 +116,8 @@ class LessonController {
'student' => $student ? $student->display_name : (string) $lesson->studentId, 'student' => $student ? $student->display_name : (string) $lesson->studentId,
'instructor' => $instructor ? $instructor->display_name : (string) $lesson->instructorId, 'instructor' => $instructor ? $instructor->display_name : (string) $lesson->instructorId,
'time' => $slot ? $this->formatSlotTime( $slot ) : '—', '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, 'status' => $lesson->status,
'notes' => $lesson->notes ?? '', 'notes' => $lesson->notes ?? '',
'payment_id' => $payment ? (int) $payment->id : 0, 'payment_id' => $payment ? (int) $payment->id : 0,
+58 -2
View File
@@ -5,12 +5,68 @@ if (! defined('ABSPATH')) {
exit; exit;
} }
/** @var list<array{student: string, instructor: string, time: string, status: string, notes: string, payment_id: int, currency: string, amount: float, tax_rate: float, tax_amount: float, total: float, etransfer_email: string, etransfer_editable: bool, tax_editable: bool}> $rows */ /**
* @var list<array{student: string, instructor: string, time: string, day: string, time_short: string, status: string, notes: string, payment_id: int, currency: string, amount: float, tax_rate: float, tax_amount: float, total: float, etransfer_email: string, etransfer_editable: bool, tax_editable: bool}> $rows
* @var 'list'|'week' $view
* @var string $weekStart
* @var list<array{date: string, items: list<array{student: string, time_short: string, status: string}>}> $weekDays
* @var string $prevWeek
* @var string $nextWeek
* @var string $baseUrl
*/
?> ?>
<div class="wrap"> <div class="wrap">
<h1><?php esc_html_e('Lessons', 'unsupervised-schedular'); ?></h1> <h1><?php esc_html_e('Lessons', 'unsupervised-schedular'); ?></h1>
<?php if (empty($rows)) : ?> <ul class="subsubsub" style="margin-bottom:12px;">
<li>
<a href="<?php echo esc_url($baseUrl); ?>" <?php echo 'week' === $view ? 'class="current"' : ''; ?>><?php esc_html_e('Week', 'unsupervised-schedular'); ?></a> |
</li>
<li>
<a href="<?php echo esc_url(add_query_arg('usc_view', 'list', $baseUrl)); ?>" <?php echo 'list' === $view ? 'class="current"' : ''; ?>><?php esc_html_e('List', '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_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_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['items'])) : ?>
<span aria-hidden="true">—</span>
<?php endif; ?>
<?php foreach ($day['items'] as $item) : ?>
<p style="margin:0 0 8px;">
<strong><?php echo esc_html($item['time_short']); ?></strong><br>
<?php echo esc_html($item['student']); ?><br>
<em><?php echo esc_html($item['status']); ?></em>
</p>
<?php endforeach; ?>
</td>
<?php endforeach; ?>
</tr>
</tbody>
</table>
<?php elseif (empty($rows)) : ?>
<p><?php esc_html_e('No upcoming lessons.', 'unsupervised-schedular'); ?></p> <p><?php esc_html_e('No upcoming lessons.', 'unsupervised-schedular'); ?></p>
<?php else : ?> <?php else : ?>
<table class="wp-list-table widefat fixed striped"> <table class="wp-list-table widefat fixed striped">
@@ -63,4 +63,30 @@ class WeekCalendarTest extends TestCase
self::assertSame('2026-06-29', $days[0]['date']); self::assertSame('2026-06-29', $days[0]['date']);
self::assertSame('2026-07-05', $days[6]['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'));
}
} }
@@ -30,16 +30,26 @@ class LessonControllerTest extends TestCase
$this->controller = new LessonController($this->bookings, $this->payments, $this->availability); $this->controller = new LessonController($this->bookings, $this->payments, $this->availability);
$_POST = []; $_POST = [];
$_GET = [];
Functions\when('current_user_can')->justReturn(true); Functions\when('current_user_can')->justReturn(true);
Functions\when('get_userdata')->justReturn(false); Functions\when('get_userdata')->justReturn(false);
Functions\when('mysql2date')->alias( Functions\when('mysql2date')->alias(
static fn (string $format, string $date) => date($format, (int) strtotime($date)) 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 public function testAdminDashboardShowsSlotDateTimeInsteadOfSlotId(): void
{ {
$_GET['usc_view'] = 'list';
$lesson = new Lesson(slotId: 10, studentId: 5, instructorId: 3, id: 1); $lesson = new Lesson(slotId: 10, studentId: 5, instructorId: 3, id: 1);
$slot = new AvailabilitySlot( $slot = new AvailabilitySlot(
instructorId: 3, instructorId: 3,
@@ -60,6 +70,8 @@ class LessonControllerTest extends TestCase
public function testSlotCrossingMidnightRepeatsTheDateOnTheEndTime(): void public function testSlotCrossingMidnightRepeatsTheDateOnTheEndTime(): void
{ {
$_GET['usc_view'] = 'list';
$lesson = new Lesson(slotId: 11, studentId: 5, instructorId: 3, id: 2); $lesson = new Lesson(slotId: 11, studentId: 5, instructorId: 3, id: 2);
$slot = new AvailabilitySlot( $slot = new AvailabilitySlot(
instructorId: 3, instructorId: 3,
@@ -90,6 +102,7 @@ class LessonControllerTest extends TestCase
public function testInstructorLessonsShowSlotDateTime(): void public function testInstructorLessonsShowSlotDateTime(): void
{ {
$_GET['usc_view'] = 'list';
Functions\when('get_current_user_id')->justReturn(3); Functions\when('get_current_user_id')->justReturn(3);
$lesson = new Lesson(slotId: 12, studentId: 5, instructorId: 3, id: 4); $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 PM3:00 PM', $html); self::assertStringContainsString('Aug 1, 2026 2:00 PM3: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 private function render(): string
{ {
ob_start(); ob_start();