Default lessons to a week view on the booking page and in wp-admin #80
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<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 {
|
||||
$day = \DateTimeImmutable::createFromFormat( '!Y-m-d', $value );
|
||||
|
||||
|
||||
@@ -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<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';
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -5,12 +5,68 @@ if (! defined('ABSPATH')) {
|
||||
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">
|
||||
<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)); ?>">‹ <?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'); ?> ›</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>
|
||||
<?php else : ?>
|
||||
<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-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);
|
||||
|
||||
$_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();
|
||||
|
||||
Reference in New Issue
Block a user