Merge pull request 'Group-class scheduling, instructor assignment, and details/invite management' (#98) from feature/group-class-scheduling into main
CI / Coding Standards (push) Successful in 2m51s
CI / PHPStan (push) Successful in 2m55s
CI / Tests (PHP 8.3) (push) Successful in 2m37s
CI / Tests (PHP 8.2) (push) Successful in 37s
CI / Tests (PHP 8.1) (push) Successful in 51s
CI / No Debug Code (push) Successful in 2s
CI / Build Plugin Zip (push) Successful in 2m47s

Reviewed-on: #98
This commit was merged in pull request #98.
This commit is contained in:
2026-07-23 20:34:16 +00:00
23 changed files with 1355 additions and 207 deletions
+3
View File
@@ -18,9 +18,12 @@ each change under the current top section as you work.
- Instructor group-class roster view under **My Lessons**.
- Cancellation cutoff that limits how close to a lesson a student can cancel.
- Studio-defined account-registration questions collected during student sign-up.
- Group classes now carry a specific class time (alongside the date and duration), and studio admins can assign the teaching instructor. Assigning an instructor clears their open booking slots at the class time and flags any already-booked lesson that clashes.
- Students see who teaches each group class and when it meets on the enrolment page.
### Changed
- Plugin metadata links now point at Unsupervised and the Gitea repository.
- The instructor **My Group Classes** view is now a summary of classes with enrolment counts; each class links through to a per-class **details page** (class schedule, roster, and — for invite-only classes — the controls to add or invite students), rather than listing every student inline. Managing who is in an invite-only class is now done from that details page. The studio-admin **Group Classes** page is likewise a per-class summary that links through to the same details page, so a studio admin — including an owner-operator who also teaches — can view any class's roster and manage its invite-only membership.
## [1.0.0]
+16 -1
View File
@@ -89,6 +89,20 @@
return `${formatDate(o.term_start)} ${formatDate(o.term_end)} (${sessions} weekly sessions)`;
}
// Format a stored H:i(:s) class time as a friendly local-clock label.
function timeLabel(o) {
if (!o.class_time) return '';
const [h, m] = o.class_time.split(':').map(Number);
const d = new Date();
d.setHours(h, m, 0, 0);
return d.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' });
}
// The "when" line combines the date (or date range) with the class time.
function whenLabel(o) {
return [termLabel(o), timeLabel(o)].filter(Boolean).join(' · ');
}
function renderClasses(offerings, enrolledOfferingIds) {
let groups = offerings.filter((o) => o.kind === 'group_class');
if (singleOfferingId) {
@@ -104,7 +118,8 @@
list.innerHTML = groups.map((o) => `
<div class="us-class">
<h3>${escHtml(o.title)}</h3>
${termLabel(o) ? `<p>${escHtml(termLabel(o))}</p>` : ''}
${whenLabel(o) ? `<p class="us-class-when">${escHtml(whenLabel(o))}</p>` : ''}
${o.instructor_name ? `<p class="us-class-instructor">With ${escHtml(o.instructor_name)}</p>` : ''}
${o.schedule_note ? `<p>${escHtml(o.schedule_note)}</p>` : ''}
${o.description ? `<p>${escHtml(o.description)}</p>` : ''}
<p>${escHtml(Number(o.price).toFixed(2))} ${escHtml(o.currency)}</p>
+32 -13
View File
@@ -17,11 +17,17 @@ A group class can be marked **invite-only** (`us_offerings.access_mode = invite_
| `payment_id` | BIGINT UNSIGNED | Nullable FK → `us_payments.id` |
| `enrolled_at` | DATETIME | Insertion time |
## Class Dates
A group class offering carries `term_start`/`term_end` (see `offerings.md`):
one-off classes end the day they start; weekly classes run a set number of
sessions. The class card on the enrolment page shows the date or date range
with the session count.
## Class Dates, Time, and Instructor
A group class offering carries `term_start`/`term_end` plus a `class_time` and an
owning `instructor_id` (see `offerings.md`): one-off classes end the day they
start; weekly classes run a set number of sessions, all at `class_time`. The class
card on the enrolment page shows **when** the class meets (the date or date range
plus the start time) and **who** teaches it (the assigned instructor's display
name, surfaced as `instructor_name` on the `GET /offerings` response).
Assigning an instructor to a scheduled class removes that instructor's open
booking slots at the class time and flags any already-booked lesson that clashes;
see **Instructor assignment** in `offerings.md`.
## Enrolment Flow
The class list is loaded together with the student's own enrolments
@@ -100,19 +106,32 @@ class becomes enrollable for them — they choose whether to enrol.
| `created_at` | DATETIME | Insertion time |
## Admin Interface
- **Group Classes** (`view_all_lessons` / studio admin): all active enrolments across instructors
- **My Lessons → My Group Classes** (`view_own_lessons` / instructor): the instructor's
own group classes, each showing its active-enrolment count against capacity and a
per-class roster of enrolled students with enrolment and payment status. Invite-only
classes additionally list who has been invited but not yet enrolled and carry the
add/make-available/invite-by-email controls (nonce-checked `usc_action` POSTs, scoped to
the owning instructor)
- **Group Classes** (`view_all_lessons` / studio admin): a per-class summary across
instructors — each class with its instructor, when it meets, and its active-enrolment
count against capacity (not a flat list of individual student enrolments). Selecting a
class (`?class_id=<id>`) opens the same per-class **details page** described below, so a
studio admin — including an owner-operator who also teaches, for whom the instructor
**My Group Classes** menu is hidden — can view any class's roster and manage invite-only
membership from here. Invite actions are permitted for the class's own instructor or any
`view_all_lessons` studio admin.
- **My Lessons → My Group Classes** (`view_own_lessons` / instructor): a summary of the
instructor's own group classes — each with when it meets and its active-enrolment count
against capacity, plus a **View details** link (**View & invite** for invite-only
classes). Selecting a class (`?class_id=<id>`, scoped to the owning instructor) opens its
**details page**: a class-details panel (when, instructor, enrolled/capacity, duration,
price, schedule note, description, status), the roster of enrolled students with enrolment
and payment status, and — for invite-only classes — an **Invite & enrol students** section
listing who has been invited but not yet enrolled alongside the add/make-available/
invite-by-email controls (nonce-checked `usc_action` POSTs, scoped to the owning
instructor). Managing who is in an invite-only class is therefore done entirely from this
page. The summary (`templates/admin/my-group-classes.php`) and the details page
(`templates/admin/my-group-class-detail.php`) are separate templates.
## Implementation
- Repository: `Unsupervised\Schedular\GroupClass\EnrollmentRepository` (`countActiveForOffering`/`hasActiveEnrollment` enforce capacity and prevent duplicates)
- Access grants: `Unsupervised\Schedular\GroupClass\GroupAccess` + `GroupAccessRepository` (`hasGrant`, `findGrantedOfferingIds`, `markEnrolled`, `linkStudentByEmail`)
- Model: `Unsupervised\Schedular\GroupClass\Enrollment`
- Admin controller: `Unsupervised\Schedular\GroupClass\GroupClassController``renderPage` (studio admin, `view_all_lessons`) and `renderInstructorPage` (instructor, `view_own_lessons`)
- Admin controller: `Unsupervised\Schedular\GroupClass\GroupClassController``renderPage` (studio admin per-class summary, `view_all_lessons`) and `renderInstructorPage` (instructor summary + `?class_id` roster detail, `view_own_lessons`)
- REST endpoint: `Unsupervised\Schedular\GroupClass\EnrollmentEndpoint`
- Frontend: `Unsupervised\Schedular\GroupClass\GroupClassPage` (`[us_group_classes]` shortcode; `offering="…"` restricts it to a single class for embedding on a dedicated page — the block equivalent is the `offeringId` attribute)
- Reuses `Registration\RegistrationGate` (intake answers + booking-scoped policy acceptance, type `enrollment`)
+28 -2
View File
@@ -20,6 +20,7 @@ An offering is anything a student can register for: a private-lesson type (30 or
| `capacity` | SMALLINT | Group only — max enrolments; NULL for private |
| `term_start` | DATE | Group / term offerings — first day; NULL otherwise |
| `term_end` | DATE | Group / term offerings — last day; NULL otherwise |
| `class_time` | TIME | Group only — time of day each session starts; NULL otherwise |
| `schedule_note` | VARCHAR(191) | Group only — human-readable schedule, e.g. "Tuesdays 4:00pm"|
| `cancellation_cutoff_hours` | SMALLINT UNSIGNED | Optional per-offering cancellation cutoff in hours; NULL inherits the studio default (see `cancellation-cutoff.md`) |
| `access_mode` | VARCHAR(20) | `public` (listed in the catalog) or `invite_only` (group classes hidden from the catalog — see `group-classes.md`) |
@@ -40,6 +41,28 @@ sessions** (`term_end = term_start + (N1) weeks`, computed by
student-facing class card shows the date (one-off) or the date range with the
weekly session count.
## Class Time and Sessions
A group class also carries `class_time` — the time of day each session starts —
validated by `Offering::normalizeTime()` (strict `H:i`/`H:i:s`; garbage leaves it
NULL). `class_time` + `term_start`/`term_end` + `duration_minutes` together define
the concrete session windows: `Offering::sessionWindows()` returns one
`{start, end}` per session (weekly across the term, or a single window for a
one-off), and returns an empty list unless date, time, and a positive duration are
all set. These windows drive availability reconciliation (see **Instructor
assignment** below and `group-classes.md`).
## Instructor assignment
Every offering has an owning `instructor_id`. A studio admin
(`manage_instructors`) sees an **Instructor** picker on the offering form and may
assign a group class to any instructor; a plain instructor never sees the picker
and always owns the classes they create (the posted value is ignored for them, and
updates never reassign the owner otherwise). When a group class is saved with an
assigned instructor and a full schedule, `Offering\ClassSlotReconciler` clears that
instructor's **open** availability slots overlapping each session so students can't
book them, and reports any **already-booked** lesson that clashes as a conflict for
the studio to resolve by hand (a booked lesson is never deleted). The result is
surfaced as an admin notice after saving.
## Admin Interface
Studio admin and instructors manage offerings under **Offerings** in wp-admin.
- Studio admin (`manage_offerings`) manages offerings for any instructor.
@@ -61,11 +84,14 @@ Studio admin and instructors manage offerings under **Offerings** in wp-admin.
## Implementation
- Repository: `Unsupervised\Schedular\Offering\OfferingRepository`
- Model: `Unsupervised\Schedular\Offering\Offering`
- Model: `Unsupervised\Schedular\Offering\Offering` (`normalizeTime`, `sessionWindows`)
- Admin controller: `Unsupervised\Schedular\Offering\OfferingController`
- REST endpoint: `Unsupervised\Schedular\Offering\OfferingEndpoint`
- REST endpoint: `Unsupervised\Schedular\Offering\OfferingEndpoint` (public listing includes `instructor_name`)
- Availability reconciliation: `Unsupervised\Schedular\Offering\ClassSlotReconciler` (uses `Availability\AvailabilityRepository::findOverlapping`)
## Tests
- `tests/Unit/Offering/OfferingControllerTest.php`
- `tests/Unit/Offering/OfferingRepositoryTest.php`
- `tests/Unit/Offering/OfferingTest.php`
- `tests/Unit/Offering/OfferingEndpointTest.php`
- `tests/Unit/Offering/ClassSlotReconcilerTest.php`
+2 -1
View File
@@ -20,6 +20,7 @@ use Unsupervised\Schedular\Booking\LessonController;
use Unsupervised\Schedular\GroupClass\EnrollmentRepository;
use Unsupervised\Schedular\GroupClass\GroupAccessRepository;
use Unsupervised\Schedular\GroupClass\GroupClassController;
use Unsupervised\Schedular\Offering\ClassSlotReconciler;
use Unsupervised\Schedular\Offering\OfferingController;
use Unsupervised\Schedular\Offering\OfferingRepository;
use Unsupervised\Schedular\Payment\BillingMethodResolver;
@@ -57,7 +58,7 @@ class AdminMenu {
public function __construct( AvailabilityRepository $availability, BookingRepository $bookings, OfferingRepository $offerings, QuestionRepository $questions, AnswerRepository $answers, PolicyRepository $policies, PolicyVersionRepository $policyVersions, PolicyService $policyService, AcceptanceRepository $acceptances, InviteRepository $invites, EnrollmentRepository $enrollments, GroupAccessRepository $groupAccess, StudioSettings $settings, PaymentRepository $payments, PaymentService $paymentService, BillingMethodResolver $resolver, RegistrationMailer $registrationMailer ) {
$this->availabilityController = new AvailabilityController( $availability, $offerings );
$this->lessonController = new LessonController( $bookings, $payments, $availability );
$this->offeringController = new OfferingController( $offerings );
$this->offeringController = new OfferingController( $offerings, new ClassSlotReconciler( $availability ) );
$this->questionController = new QuestionController( $questions, $offerings );
$this->policyController = new PolicyController( $policies, $policyVersions, $policyService );
$this->registrationController = new RegistrationController( $invites );
@@ -181,6 +181,28 @@ class AvailabilityRepository {
return array_map( AvailabilitySlot::fromRow( ... ), $rows ?? [] );
}
/**
* An instructor's slots (booked and unbooked) that overlap a time window —
* they share any time with the half-open interval [$start, $end). Used when a
* group class is scheduled to find the private-booking slots that collide with
* it, so open ones can be cleared and booked ones flagged as conflicts.
*
* @return list<AvailabilitySlot>
*/
public function findOverlapping( int $instructorId, string $start, string $end ): array {
$rows = $this->db->get_results(
$this->db->prepare(
'SELECT * FROM %i WHERE instructor_id = %d AND start_dt < %s AND end_dt > %s ORDER BY start_dt ASC',
$this->table,
$instructorId,
$end,
$start
)
);
return array_map( AvailabilitySlot::fromRow( ... ), $rows ?? [] );
}
public function findById( int $id ): ?AvailabilitySlot {
$row = $this->db->get_row(
$this->db->prepare( 'SELECT * FROM %i WHERE id = %d', $this->table, $id )
+174 -48
View File
@@ -27,33 +27,76 @@ class GroupClassController {
private RegistrationMailer $mailer,
) {}
/**
* Studio-admin overview: every group class across instructors as a summary —
* who teaches it, when it meets, and how full it is — rather than a flat list
* of individual student enrolments. Selecting a class (`?class_id=<id>`) opens
* the same per-class details page instructors use, so a studio admin (including
* an owner-operator who also teaches) can view any class's roster and manage
* invite-only membership from here.
*/
public function renderPage(): void {
if ( ! current_user_can( RoleManager::CAP_VIEW_ALL_LESSONS ) ) {
wp_die( esc_html__( 'You do not have permission to view group classes.', 'unsupervised-schedular' ) );
}
$notice = '';
if ( isset( $_POST['usc_action'] ) && check_admin_referer( 'usc_group_action' ) ) {
$notice = $this->handleFormAction( get_current_user_id() );
}
$offerings = $this->offerings->findAll( 0, Offering::KIND_GROUP_CLASS );
$baseUrl = admin_url( 'admin.php?page=us-group-classes' );
// View-state query param only (which class to drill into) — nothing is
// mutated from it, so no nonce applies.
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
$classId = absint( Val::int( $_GET['class_id'] ?? 0 ) );
$current = null;
foreach ( $offerings as $offering ) {
if ( $offering->id === $classId ) {
$current = $offering;
break;
}
}
if ( null !== $current ) {
// Enrolments are looked up by the class's own instructor; classDetail
// filters them down to this offering.
$class = $this->classDetail( $current, $this->enrollments->findByInstructor( $current->instructorId ) );
$students = $this->studentOptions();
include USC_PLUGIN_DIR . 'templates/admin/my-group-class-detail.php';
return;
}
$rows = array_map(
function ( Enrollment $enrollment ): array {
$offering = $this->offerings->findById( $enrollment->offeringId );
$student = get_userdata( $enrollment->studentId );
function ( Offering $offering ): array {
$instructor = get_userdata( $offering->instructorId );
return [
'student' => $student ? $student->display_name : (string) $enrollment->studentId,
'offering' => $offering ? $offering->title : (string) $enrollment->offeringId,
'status' => $enrollment->status,
'id' => $offering->id,
'title' => $offering->title,
'instructor' => $instructor ? $instructor->display_name : (string) $offering->instructorId,
'when' => $this->whenLabel( $offering ),
'capacity' => $offering->capacity,
'enrolled' => $this->enrollments->countActiveForOffering( (int) $offering->id ),
'invite_only' => $offering->isInviteOnly(),
];
},
$this->enrollments->findAllActive()
$offerings
);
include USC_PLUGIN_DIR . 'templates/admin/group-classes.php';
}
/**
* Instructor view: their own group classes with per-class rosters. Each class
* shows its enrolment count against capacity plus a roster of enrolled
* students with enrolment and payment status. Invite-only classes also carry
* controls to add, grant access to, or email-invite students.
* Instructor view. By default a summary of the instructor's own group classes
* — each with when it meets and how many are enrolled — rather than a dump of
* every roster. A `class_id` query param drills into one class to show its
* roster of enrolled students and, for invite-only classes, the controls to
* add, grant access to, or email-invite students.
*/
public function renderInstructorPage(): void {
if ( ! current_user_can( RoleManager::CAP_VIEW_LESSONS ) ) {
@@ -67,50 +110,127 @@ class GroupClassController {
$notice = $this->handleFormAction( $instructorId );
}
$offerings = $this->offerings->findAll( $instructorId, Offering::KIND_GROUP_CLASS );
$enrollments = $this->enrollments->findByInstructor( $instructorId );
// View-state query param only (which class to drill into) — nothing is
// mutated from it, so no nonce applies.
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
$classId = absint( Val::int( $_GET['class_id'] ?? 0 ) );
$current = null;
foreach ( $offerings as $offering ) {
if ( $offering->id === $classId ) {
$current = $offering;
break;
}
}
if ( null !== $current ) {
$baseUrl = admin_url( 'admin.php?page=us-my-group-classes' );
$class = $this->classDetail( $current, $enrollments );
$students = $this->studentOptions();
include USC_PLUGIN_DIR . 'templates/admin/my-group-class-detail.php';
return;
}
$classes = array_map(
function ( Offering $offering ) use ( $enrollments ): array {
$roster = [];
$enrolled = 0;
foreach ( $enrollments as $enrollment ) {
if ( $enrollment->offeringId !== $offering->id ) {
continue;
}
if ( Enrollment::STATUS_ACTIVE === $enrollment->status ) {
++$enrolled;
}
$student = get_userdata( $enrollment->studentId );
$payment = null !== $enrollment->paymentId ? $this->payments->findById( $enrollment->paymentId ) : null;
$roster[] = [
'student' => $student ? $student->display_name : (string) $enrollment->studentId,
'status' => $enrollment->status,
'payment' => $payment?->status,
];
}
return [
'id' => $offering->id,
'title' => $offering->title,
'capacity' => $offering->capacity,
'enrolled' => $enrolled,
'invite_only' => $offering->isInviteOnly(),
'roster' => $roster,
'invited' => $offering->isInviteOnly() ? $this->pendingInvites( (int) $offering->id ) : [],
];
},
$this->offerings->findAll( $instructorId, Offering::KIND_GROUP_CLASS )
fn( Offering $offering ): array => $this->classSummary( $offering, $enrollments ),
$offerings
);
$students = $this->studentOptions();
$baseUrl = admin_url( 'admin.php?page=us-my-group-classes' );
include USC_PLUGIN_DIR . 'templates/admin/my-group-classes.php';
}
/**
* Summary row for one class in the instructor overview: its identity, when it
* meets, and how many active enrolments it holds against capacity.
*
* @param list<Enrollment> $enrollments
* @return array{id: int|null, title: string, when: string, capacity: int|null, enrolled: int, invite_only: bool}
*/
private function classSummary( Offering $offering, array $enrollments ): array {
$enrolled = 0;
foreach ( $enrollments as $enrollment ) {
if ( $enrollment->offeringId === $offering->id && Enrollment::STATUS_ACTIVE === $enrollment->status ) {
++$enrolled;
}
}
return [
'id' => $offering->id,
'title' => $offering->title,
'when' => $this->whenLabel( $offering ),
'capacity' => $offering->capacity,
'enrolled' => $enrolled,
'invite_only' => $offering->isInviteOnly(),
];
}
/**
* Full details for one class: the summary fields, the class's own settings
* (instructor, price, duration, description, schedule, active state), the
* roster of enrolled students (with enrolment and payment status), and — for
* invite-only classes — the list of people invited but not yet enrolled.
*
* @param list<Enrollment> $enrollments
* @return array{id: int|null, title: string, when: string, capacity: int|null, enrolled: int, invite_only: bool, instructor: string, price: float, currency: string, duration: int|null, description: string|null, schedule_note: string|null, active: bool, roster: list<array{student: string, status: string, payment: string|null}>, invited: list<array{who: string, kind: string}>}
*/
private function classDetail( Offering $offering, array $enrollments ): array {
$roster = [];
foreach ( $enrollments as $enrollment ) {
if ( $enrollment->offeringId !== $offering->id ) {
continue;
}
$student = get_userdata( $enrollment->studentId );
$payment = null !== $enrollment->paymentId ? $this->payments->findById( $enrollment->paymentId ) : null;
$roster[] = [
'student' => $student ? $student->display_name : (string) $enrollment->studentId,
'status' => $enrollment->status,
'payment' => $payment?->status,
];
}
$instructor = get_userdata( $offering->instructorId );
return $this->classSummary( $offering, $enrollments ) + [
'instructor' => $instructor ? $instructor->display_name : (string) $offering->instructorId,
'price' => $offering->price,
'currency' => $offering->currency,
'duration' => $offering->durationMinutes,
'description' => $offering->description,
'schedule_note' => $offering->scheduleNote,
'active' => $offering->isActive,
'roster' => $roster,
'invited' => $offering->isInviteOnly() ? $this->pendingInvites( (int) $offering->id ) : [],
];
}
/**
* Human-readable "when" label for a class: the class date (or weekly date
* range) and, when set, the start time. Empty when the class has no date.
*/
private function whenLabel( Offering $offering ): string {
if ( null === $offering->termStart ) {
return '';
}
$label = null === $offering->termEnd || $offering->termEnd === $offering->termStart
? (string) mysql2date( 'M j, Y', $offering->termStart )
: (string) mysql2date( 'M j, Y', $offering->termStart ) . ' ' . (string) mysql2date( 'M j, Y', $offering->termEnd );
if ( null !== $offering->classTime ) {
$label .= ' · ' . (string) mysql2date( 'g:i a', $offering->termStart . ' ' . $offering->classTime );
}
return $label;
}
/**
* Pending (not-yet-enrolled) access grants for an invite-only class, shown so
* the instructor can see who has been invited but has not enrolled yet.
@@ -143,7 +263,10 @@ class GroupClassController {
/**
* Handle a posted management action, returning a status notice for display.
* Every action is scoped to a group class the current instructor owns.
* The action is scoped to a group class the current instructor owns, unless
* the caller is a studio admin (`view_all_lessons`) — who may manage any
* instructor's class, since the studio-admin Group Classes page reaches the
* same controls for every class.
*/
private function handleFormAction( int $instructorId ): string {
// Nonce is verified by the caller before this method runs.
@@ -152,7 +275,10 @@ class GroupClassController {
$offeringId = absint( Val::int( $_POST['offering_id'] ?? 0 ) );
$offering = $offeringId > 0 ? $this->offerings->findById( $offeringId ) : null;
if ( null === $offering || $offering->instructorId !== $instructorId || Offering::KIND_GROUP_CLASS !== $offering->kind ) {
$ownsOrManagesAll = null !== $offering
&& ( $offering->instructorId === $instructorId || current_user_can( RoleManager::CAP_VIEW_ALL_LESSONS ) );
if ( null === $offering || ! $ownsOrManagesAll || Offering::KIND_GROUP_CLASS !== $offering->kind ) {
return esc_html__( 'That group class was not found.', 'unsupervised-schedular' );
}
+57
View File
@@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Offering;
use Unsupervised\Schedular\Availability\AvailabilityRepository;
/**
* Keeps an instructor's open availability out of the way of the group classes
* they teach. When a group class is scheduled (an assigned instructor plus a
* date, time, and duration), each session occupies the instructor: any open
* private-booking slot that overlaps a session is removed so students cannot
* book the instructor at the class time, and any already-booked slot that
* overlaps is reported as a conflict for the studio to resolve by hand — a
* booked lesson is never silently deleted.
*/
class ClassSlotReconciler {
public function __construct( private AvailabilityRepository $availability ) {}
/**
* Reconcile the assigned instructor's availability with the class schedule.
*
* @return array{removed: int, conflicts: list<string>} The number of open
* slots cleared, and the start datetime (`Y-m-d H:i:s`) of each booked
* slot that still clashes with a session.
*/
public function reconcile( Offering $offering ): array {
if ( Offering::KIND_GROUP_CLASS !== $offering->kind || $offering->instructorId <= 0 ) {
return [
'removed' => 0,
'conflicts' => [],
];
}
$removed = 0;
$conflicts = [];
foreach ( $offering->sessionWindows() as $window ) {
foreach ( $this->availability->findOverlapping( $offering->instructorId, $window['start'], $window['end'] ) as $slot ) {
if ( $slot->isBooked ) {
$conflicts[] = $slot->startDt;
continue;
}
if ( null !== $slot->id && $this->availability->delete( $slot->id ) ) {
++$removed;
}
}
}
return [
'removed' => $removed,
'conflicts' => $conflicts,
];
}
}
+66
View File
@@ -53,6 +53,7 @@ class Offering {
public readonly ?int $capacity = null,
public readonly ?string $termStart = null,
public readonly ?string $termEnd = null,
public readonly ?string $classTime = null,
public readonly ?string $scheduleNote = null,
public readonly ?string $etransferEmail = null,
public readonly ?int $cancellationCutoffHours = null,
@@ -90,6 +91,69 @@ class Offering {
return ( new \DateTimeImmutable( $termStart ) )->modify( '+' . ( 7 * $weeks ) . ' days' )->format( 'Y-m-d' );
}
/**
* Normalise a submitted time-of-day to canonical `H:i:s`, or null when it is
* not a real time. Accepts the HTML `time` form (`H:i`, optionally with
* seconds); anything else is rejected so garbage never reaches the TIME column.
*/
public static function normalizeTime( string $value ): ?string {
foreach ( [ 'H:i:s', 'H:i' ] as $format ) {
$time = \DateTimeImmutable::createFromFormat( '!' . $format, $value );
if ( false !== $time && $time->format( $format ) === $value ) {
return $time->format( 'H:i:s' );
}
}
return null;
}
/**
* The concrete start/end datetimes of every session of this group class,
* derived from the class date(s), the class time, and the duration. A weekly
* class yields one window per week from `term_start` through `term_end`; a
* one-off class yields a single window. Returns an empty list unless the
* schedule is fully specified (date, time, and a positive duration), so it can
* never fabricate a session window from partial data.
*
* @return list<array{start: string, end: string}>
*/
public function sessionWindows(): array {
if (
null === $this->termStart
|| null === $this->classTime
|| null === $this->durationMinutes
|| $this->durationMinutes <= 0
) {
return [];
}
$first = \DateTimeImmutable::createFromFormat( '!Y-m-d H:i:s', $this->termStart . ' ' . $this->classTime );
if ( false === $first ) {
return [];
}
$lastDay = null !== $this->termEnd ? $this->termEnd : $this->termStart;
$step = new \DateInterval( 'PT' . $this->durationMinutes . 'M' );
$windows = [];
$cursor = $first;
$cursorDay = $cursor->format( 'Y-m-d' );
// Cap the walk at ten years of weeks so a term_end before term_start (or a
// bad value) can never spin into an unbounded loop.
for ( $i = 0; $i < 520 && $cursorDay <= $lastDay; $i++ ) {
$windows[] = [
'start' => $cursor->format( 'Y-m-d H:i:s' ),
'end' => $cursor->add( $step )->format( 'Y-m-d H:i:s' ),
];
$cursor = $cursor->modify( '+7 days' );
$cursorDay = $cursor->format( 'Y-m-d' );
}
return $windows;
}
public static function fromRow( \stdClass $row ): self {
return new self(
instructorId: Val::int( $row->instructor_id ),
@@ -104,6 +168,7 @@ class Offering {
capacity: Val::intOrNull( $row->capacity ),
termStart: Val::stringOrNull( $row->term_start ),
termEnd: Val::stringOrNull( $row->term_end ),
classTime: Val::stringOrNull( $row->class_time ?? null ),
scheduleNote: Val::stringOrNull( $row->schedule_note ),
etransferEmail: Val::stringOrNull( $row->etransfer_email ),
cancellationCutoffHours: Val::intOrNull( $row->cancellation_cutoff_hours ),
@@ -137,6 +202,7 @@ class Offering {
'capacity' => $this->capacity,
'term_start' => $this->termStart,
'term_end' => $this->termEnd,
'class_time' => $this->classTime,
'schedule_note' => $this->scheduleNote,
'cancellation_cutoff_hours' => $this->cancellationCutoffHours,
'access_mode' => $this->accessMode,
+120 -9
View File
@@ -8,7 +8,10 @@ use Unsupervised\Schedular\Val;
class OfferingController {
public function __construct( private OfferingRepository $repository ) {}
public function __construct(
private OfferingRepository $repository,
private ClassSlotReconciler $reconciler,
) {}
public function renderPage(): void {
if ( ! current_user_can( RoleManager::CAP_MANAGE_OFFERINGS ) ) {
@@ -18,10 +21,15 @@ class OfferingController {
$instructorId = get_current_user_id();
$manageAll = current_user_can( RoleManager::CAP_MANAGE_INSTRUCTORS );
$notice = '';
if ( isset( $_POST['usc_action'] ) && check_admin_referer( 'usc_offering_action' ) ) {
$this->handleFormAction( $instructorId, $manageAll );
$notice = $this->handleFormAction( $instructorId, $manageAll );
}
// Studio admins may assign any instructor to a class; a plain instructor
// only ever creates classes for themselves, so the picker is theirs alone.
$instructors = $manageAll ? $this->instructorOptions() : [];
// View-state query param only (which offering the form is editing) —
// nothing is mutated from it, so no nonce applies.
// phpcs:disable WordPress.Security.NonceVerification.Recommended
@@ -43,15 +51,22 @@ class OfferingController {
include USC_PLUGIN_DIR . 'templates/admin/offerings.php';
}
private function handleFormAction( int $instructorId, bool $manageAll ): void {
/**
* Process the posted add/update/delete action, returning a status notice for
* display (e.g. how many booking slots a scheduled class cleared, or that a
* booked lesson clashes with it). An empty string means nothing to report.
*/
private function handleFormAction( int $instructorId, bool $manageAll ): string {
// Nonce is verified by the caller (renderPage) before this method runs.
// phpcs:disable WordPress.Security.NonceVerification.Missing
$action = sanitize_key( Val::string( wp_unslash( $_POST['usc_action'] ?? '' ) ) );
if ( 'add' === $action ) {
$offering = $this->offeringFromPost( $instructorId );
$offering = $this->offeringFromPost( $instructorId, $manageAll );
if ( null !== $offering ) {
$this->repository->insert( $offering );
return $this->reconcileNotice( $offering );
}
}
@@ -60,9 +75,11 @@ class OfferingController {
if ( $offeringId > 0 ) {
$existing = $this->repository->findById( $offeringId );
if ( $existing && ( $manageAll || $existing->instructorId === $instructorId ) ) {
$offering = $this->offeringFromPost( $instructorId, $existing );
$offering = $this->offeringFromPost( $instructorId, $manageAll, $existing );
if ( null !== $offering ) {
$this->repository->update( $offeringId, $offering );
return $this->reconcileNotice( $offering );
}
}
}
@@ -78,15 +95,86 @@ class OfferingController {
}
}
// phpcs:enable WordPress.Security.NonceVerification.Missing
return '';
}
/**
* Clear the assigned instructor's open booking slots that collide with a
* scheduled group class and describe the result, warning about any booked
* lesson that clashes (which the studio must resolve by hand).
*/
private function reconcileNotice( Offering $offering ): string {
if ( Offering::KIND_GROUP_CLASS !== $offering->kind ) {
return '';
}
$result = $this->reconciler->reconcile( $offering );
$parts = [];
if ( $result['removed'] > 0 ) {
$parts[] = sprintf(
/* translators: %d: number of open booking slots removed. */
_n(
'%d open booking slot was removed to hold the class time.',
'%d open booking slots were removed to hold the class time.',
$result['removed'],
'unsupervised-schedular'
),
$result['removed']
);
}
foreach ( $result['conflicts'] as $startDt ) {
$parts[] = sprintf(
/* translators: %s: date and time of the already-booked lesson that clashes. */
esc_html__( 'Conflict: a lesson is already booked at %s during this class.', 'unsupervised-schedular' ),
(string) mysql2date( 'M j, Y g:i a', $startDt )
);
}
return implode( ' ', $parts );
}
/**
* Registered instructors offered in the assignment select, by display name.
*
* @return list<array{id: int, name: string}>
*/
private function instructorOptions(): array {
$users = array_filter(
get_users(
[
'role' => RoleManager::INSTRUCTOR,
'orderby' => 'display_name',
'order' => 'ASC',
]
),
static fn( mixed $u ): bool => $u instanceof \WP_User
);
return array_values(
array_map(
static fn( \WP_User $u ): array => [
'id' => (int) $u->ID,
'name' => '' !== (string) $u->display_name ? (string) $u->display_name : (string) $u->user_email,
],
$users
)
);
}
/**
* Build an offering from the submitted add/edit form, or null when the
* submission is invalid. When `$existing` is given the result is an edit:
* it keeps the existing id, owner, and currency so an update can never
* reassign an offering to whoever happens to submit the form.
* it keeps the existing id and currency so an update can never rewrite those
* from whoever submits the form.
*
* The owning instructor normally stays fixed (the creator on add, the existing
* owner on edit). A studio admin (`$manageAll`) may instead assign the class to
* any instructor via the picker; a blank or absent choice keeps the default.
*/
private function offeringFromPost( int $instructorId, ?Offering $existing = null ): ?Offering {
private function offeringFromPost( int $instructorId, bool $manageAll, ?Offering $existing = null ): ?Offering {
// Nonce is verified by the caller (renderPage) before this method runs.
// phpcs:disable WordPress.Security.NonceVerification.Missing
$title = sanitize_text_field( Val::string( wp_unslash( $_POST['title'] ?? '' ) ) );
@@ -119,8 +207,10 @@ class OfferingController {
$termEnd = 'weekly' === $recurrence ? Offering::weeklyTermEnd( $termStart, $sessions ) : $termStart;
}
$classTime = Offering::normalizeTime( sanitize_text_field( Val::string( wp_unslash( $_POST['class_time'] ?? '' ) ) ) );
return new Offering(
instructorId: null !== $existing ? $existing->instructorId : $instructorId,
instructorId: $this->resolveInstructorId( $instructorId, $manageAll, $existing ),
kind: $kind,
title: $title,
price: max( 0.0, (float) sanitize_text_field( Val::string( wp_unslash( $_POST['price'] ?? '0' ) ) ) ),
@@ -132,6 +222,7 @@ class OfferingController {
capacity: $capacity > 0 ? $capacity : null,
termStart: $termStart,
termEnd: $termEnd,
classTime: $classTime,
scheduleNote: $this->nullableText( sanitize_text_field( Val::string( wp_unslash( $_POST['schedule_note'] ?? '' ) ) ) ),
etransferEmail: $this->nullableText( sanitize_email( Val::string( wp_unslash( $_POST['etransfer_email'] ?? '' ) ) ) ),
cancellationCutoffHours: $cutoffHours,
@@ -142,6 +233,26 @@ class OfferingController {
// phpcs:enable WordPress.Security.NonceVerification.Missing
}
/**
* The instructor the offering should belong to. A studio admin may reassign it
* via the posted `class_instructor_id`; otherwise it stays with the existing
* owner (edit) or the current user (add). A plain instructor can never change
* the owner, so the posted value is ignored unless `$manageAll` is set.
*/
private function resolveInstructorId( int $instructorId, bool $manageAll, ?Offering $existing ): int {
$fallback = null !== $existing ? $existing->instructorId : $instructorId;
if ( ! $manageAll ) {
return $fallback;
}
// Nonce is verified by the caller (renderPage) before this method runs.
// phpcs:ignore WordPress.Security.NonceVerification.Missing
$posted = absint( Val::int( $_POST['class_instructor_id'] ?? 0 ) );
return $posted > 0 ? $posted : $fallback;
}
private function nullableText( string $value ): ?string {
return '' === $value ? null : $value;
}
+19 -2
View File
@@ -77,8 +77,25 @@ class OfferingEndpoint {
$offerings[] = $granted;
}
// Public listing: omit the private e-transfer destination email.
return new \WP_REST_Response( array_map( fn( Offering $o ) => $o->toArray( includeEtransferEmail: false ), $offerings ), 200 );
// Public listing: omit the private e-transfer destination email, and
// attach the assigned instructor's display name so the front end can show
// students who teaches each class.
return new \WP_REST_Response( array_map( [ $this, 'present' ], $offerings ), 200 );
}
/**
* A public-facing offering array with the assigned instructor's display name
* added (empty when the instructor account no longer exists).
*
* @return array<string, mixed>
*/
private function present( Offering $offering ): array {
$out = $offering->toArray( includeEtransferEmail: false );
$user = get_userdata( $offering->instructorId );
$out['instructor_name'] = $user instanceof \WP_User ? $user->display_name : '';
return $out;
}
/**
+3 -2
View File
@@ -14,12 +14,12 @@ class OfferingRepository {
/**
* Column formats aligned to {@see columns()} (instructor_id, kind, title,
* description, duration_minutes, price, currency, billing_mode, allow_weekly,
* capacity, term_start, term_end, schedule_note, etransfer_email,
* capacity, term_start, term_end, class_time, schedule_note, etransfer_email,
* cancellation_cutoff_hours, access_mode, is_active).
*
* @var list<string>
*/
private const COLUMN_FORMATS = [ '%d', '%s', '%s', '%s', '%d', '%f', '%s', '%s', '%d', '%d', '%s', '%s', '%s', '%s', '%d', '%s', '%d' ];
private const COLUMN_FORMATS = [ '%d', '%s', '%s', '%s', '%d', '%f', '%s', '%s', '%d', '%d', '%s', '%s', '%s', '%s', '%s', '%d', '%s', '%d' ];
public function insert( Offering $offering ): int {
$this->db->insert(
@@ -60,6 +60,7 @@ class OfferingRepository {
'capacity' => $offering->capacity,
'term_start' => $offering->termStart,
'term_end' => $offering->termEnd,
'class_time' => $offering->classTime,
'schedule_note' => $offering->scheduleNote,
'etransfer_email' => $offering->etransferEmail,
'cancellation_cutoff_hours' => $offering->cancellationCutoffHours,
+1
View File
@@ -63,6 +63,7 @@ class Schema {
capacity SMALLINT UNSIGNED DEFAULT NULL,
term_start DATE DEFAULT NULL,
term_end DATE DEFAULT NULL,
class_time TIME DEFAULT NULL,
schedule_note VARCHAR(191) DEFAULT NULL,
etransfer_email VARCHAR(191) DEFAULT NULL,
cancellation_cutoff_hours SMALLINT UNSIGNED DEFAULT NULL,
+35 -8
View File
@@ -5,29 +5,56 @@ if (! defined('ABSPATH')) {
exit;
}
/** @var list<array{student: string, offering: string, status: string}> $rows */
/**
* @var list<array{id: int|null, title: string, instructor: string, when: string, capacity: int|null, enrolled: int, invite_only: bool}> $rows
* @var string $notice
* @var string $baseUrl
*/
?>
<div class="wrap">
<h1><?php esc_html_e('Group Classes', 'unsupervised-schedular'); ?></h1>
<p class="description"><?php esc_html_e('Active enrolments across all group classes.', 'unsupervised-schedular'); ?></p>
<p class="description"><?php esc_html_e('Every group class across instructors. Select one for its details, roster, and — for invite-only classes — to invite or add students.', 'unsupervised-schedular'); ?></p>
<?php if ('' !== $notice) : ?>
<div class="notice notice-info is-dismissible"><p><?php echo esc_html($notice); ?></p></div>
<?php endif; ?>
<?php if (empty($rows)) : ?>
<p><?php esc_html_e('No active enrolments.', 'unsupervised-schedular'); ?></p>
<p><?php esc_html_e('No group classes.', 'unsupervised-schedular'); ?></p>
<?php else : ?>
<table class="wp-list-table widefat fixed striped">
<thead>
<tr>
<th><?php esc_html_e('Student', 'unsupervised-schedular'); ?></th>
<th><?php esc_html_e('Class', 'unsupervised-schedular'); ?></th>
<th><?php esc_html_e('Status', 'unsupervised-schedular'); ?></th>
<th><?php esc_html_e('Instructor', 'unsupervised-schedular'); ?></th>
<th><?php esc_html_e('When', 'unsupervised-schedular'); ?></th>
<th><?php esc_html_e('Enrolled', 'unsupervised-schedular'); ?></th>
<th><?php esc_html_e('Actions', 'unsupervised-schedular'); ?></th>
</tr>
</thead>
<tbody>
<?php foreach ($rows as $row) : ?>
<tr>
<td><?php echo esc_html($row['student']); ?></td>
<td><?php echo esc_html($row['offering']); ?></td>
<td><?php echo esc_html($row['status']); ?></td>
<td>
<?php echo esc_html($row['title']); ?>
<?php if ($row['invite_only']) : ?>
<span class="dashicons dashicons-lock" title="<?php esc_attr_e('Invite only', 'unsupervised-schedular'); ?>"></span>
<?php endif; ?>
</td>
<td><?php echo esc_html($row['instructor']); ?></td>
<td><?php echo '' !== $row['when'] ? esc_html($row['when']) : '&mdash;'; ?></td>
<td>
<?php
if (null === $row['capacity']) {
echo esc_html((string) $row['enrolled']);
} else {
echo esc_html($row['enrolled'] . ' / ' . $row['capacity']);
}
?>
</td>
<td>
<a class="button button-small" href="<?php echo esc_url(add_query_arg('class_id', (int) $row['id'], $baseUrl)); ?>"><?php echo $row['invite_only'] ? esc_html__('View & invite', 'unsupervised-schedular') : esc_html__('View details', 'unsupervised-schedular'); ?></a>
</td>
</tr>
<?php endforeach; ?>
</tbody>
+189
View File
@@ -0,0 +1,189 @@
<?php
declare(strict_types=1);
if (! defined('ABSPATH')) {
exit;
}
/**
* @var array{id: int|null, title: string, when: string, capacity: int|null, enrolled: int, invite_only: bool, instructor: string, price: float, currency: string, duration: int|null, description: string|null, schedule_note: string|null, active: bool, roster: list<array{student: string, status: string, payment: string|null}>, invited: list<array{who: string, kind: string}>} $class
* @var list<array{id: int, name: string}> $students
* @var string $notice
* @var string $baseUrl
*/
?>
<div class="wrap">
<h1>
<?php echo esc_html($class['title']); ?>
<?php if ($class['invite_only']) : ?>
<span class="dashicons dashicons-lock" title="<?php esc_attr_e('Invite only', 'unsupervised-schedular'); ?>"></span>
<?php endif; ?>
</h1>
<p>
<a href="<?php echo esc_url($baseUrl); ?>">&larr; <?php esc_html_e('Back to my group classes', 'unsupervised-schedular'); ?></a>
</p>
<?php if ('' !== $notice) : ?>
<div class="notice notice-info is-dismissible"><p><?php echo esc_html($notice); ?></p></div>
<?php endif; ?>
<h2><?php esc_html_e('Class details', 'unsupervised-schedular'); ?></h2>
<table class="widefat striped" style="max-width:40em;">
<tbody>
<tr>
<th scope="row"><?php esc_html_e('When', 'unsupervised-schedular'); ?></th>
<td><?php echo '' !== $class['when'] ? esc_html($class['when']) : '&mdash;'; ?></td>
</tr>
<tr>
<th scope="row"><?php esc_html_e('Instructor', 'unsupervised-schedular'); ?></th>
<td><?php echo esc_html($class['instructor']); ?></td>
</tr>
<tr>
<th scope="row"><?php esc_html_e('Enrolled', 'unsupervised-schedular'); ?></th>
<td>
<?php
if (null === $class['capacity']) {
printf(
/* translators: %d: number of enrolled students. */
esc_html__('%d enrolled', 'unsupervised-schedular'),
(int) $class['enrolled']
);
} else {
printf(
/* translators: 1: number of enrolled students, 2: class capacity. */
esc_html__('%1$d / %2$d enrolled', 'unsupervised-schedular'),
(int) $class['enrolled'],
(int) $class['capacity']
);
}
?>
</td>
</tr>
<?php if (null !== $class['duration']) : ?>
<tr>
<th scope="row"><?php esc_html_e('Duration', 'unsupervised-schedular'); ?></th>
<td>
<?php
printf(
/* translators: %d: session length in minutes. */
esc_html__('%d min', 'unsupervised-schedular'),
(int) $class['duration']
);
?>
</td>
</tr>
<?php endif; ?>
<tr>
<th scope="row"><?php esc_html_e('Price', 'unsupervised-schedular'); ?></th>
<td><?php echo esc_html(number_format($class['price'], 2) . ' ' . $class['currency']); ?></td>
</tr>
<?php if (null !== $class['schedule_note'] && '' !== $class['schedule_note']) : ?>
<tr>
<th scope="row"><?php esc_html_e('Schedule note', 'unsupervised-schedular'); ?></th>
<td><?php echo esc_html($class['schedule_note']); ?></td>
</tr>
<?php endif; ?>
<?php if (null !== $class['description'] && '' !== $class['description']) : ?>
<tr>
<th scope="row"><?php esc_html_e('Description', 'unsupervised-schedular'); ?></th>
<td><?php echo esc_html($class['description']); ?></td>
</tr>
<?php endif; ?>
<tr>
<th scope="row"><?php esc_html_e('Status', 'unsupervised-schedular'); ?></th>
<td>
<?php if ($class['active']) : ?>
<?php esc_html_e('Open for registration', 'unsupervised-schedular'); ?>
<?php else : ?>
<?php esc_html_e('Closed', 'unsupervised-schedular'); ?>
<?php endif; ?>
<?php if ($class['invite_only']) : ?>
&middot; <?php esc_html_e('Invite only', 'unsupervised-schedular'); ?>
<?php endif; ?>
</td>
</tr>
</tbody>
</table>
<h2><?php esc_html_e('Enrolled students', 'unsupervised-schedular'); ?></h2>
<?php if (empty($class['roster'])) : ?>
<p><?php esc_html_e('No enrolments yet.', 'unsupervised-schedular'); ?></p>
<?php else : ?>
<table class="wp-list-table widefat fixed striped">
<thead>
<tr>
<th><?php esc_html_e('Student', 'unsupervised-schedular'); ?></th>
<th><?php esc_html_e('Enrolment', 'unsupervised-schedular'); ?></th>
<th><?php esc_html_e('Payment', 'unsupervised-schedular'); ?></th>
</tr>
</thead>
<tbody>
<?php foreach ($class['roster'] as $entry) : ?>
<tr>
<td><?php echo esc_html($entry['student']); ?></td>
<td><?php echo esc_html($entry['status']); ?></td>
<td><?php echo esc_html($entry['payment'] ?? '—'); ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
<?php if ($class['invite_only']) : ?>
<h2><?php esc_html_e('Invite &amp; enrol students', 'unsupervised-schedular'); ?></h2>
<p class="description"><?php esc_html_e('This class is invite only, so students join only when you add or invite them here.', 'unsupervised-schedular'); ?></p>
<?php if (! empty($class['invited'])) : ?>
<h3><?php esc_html_e('Invited (not yet enrolled)', 'unsupervised-schedular'); ?></h3>
<ul class="ul-disc">
<?php foreach ($class['invited'] as $invitee) : ?>
<li><?php echo esc_html($invitee['who'] . ' — ' . $invitee['kind']); ?></li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
<div class="us-group-invite-controls" style="display:flex; flex-wrap:wrap; gap:2em; margin:1em 0 2em;">
<form method="post">
<?php wp_nonce_field('usc_group_action'); ?>
<input type="hidden" name="offering_id" value="<?php echo esc_attr((string) $class['id']); ?>">
<h4><?php esc_html_e('Add students directly', 'unsupervised-schedular'); ?></h4>
<p class="description"><?php esc_html_e('Enrols them now with a pending payment.', 'unsupervised-schedular'); ?></p>
<select name="student_ids[]" multiple size="5" style="min-width:16em;">
<?php foreach ($students as $student) : ?>
<option value="<?php echo esc_attr((string) $student['id']); ?>"><?php echo esc_html($student['name']); ?></option>
<?php endforeach; ?>
</select>
<p>
<button type="submit" name="usc_action" value="add_direct" class="button"><?php esc_html_e('Add to class', 'unsupervised-schedular'); ?></button>
</p>
</form>
<form method="post">
<?php wp_nonce_field('usc_group_action'); ?>
<input type="hidden" name="offering_id" value="<?php echo esc_attr((string) $class['id']); ?>">
<h4><?php esc_html_e('Make available to students', 'unsupervised-schedular'); ?></h4>
<p class="description"><?php esc_html_e('Grants access so they can enrol themselves.', 'unsupervised-schedular'); ?></p>
<select name="student_ids[]" multiple size="5" style="min-width:16em;">
<?php foreach ($students as $student) : ?>
<option value="<?php echo esc_attr((string) $student['id']); ?>"><?php echo esc_html($student['name']); ?></option>
<?php endforeach; ?>
</select>
<p>
<button type="submit" name="usc_action" value="grant_access" class="button"><?php esc_html_e('Grant access', 'unsupervised-schedular'); ?></button>
</p>
</form>
<form method="post">
<?php wp_nonce_field('usc_group_action'); ?>
<input type="hidden" name="offering_id" value="<?php echo esc_attr((string) $class['id']); ?>">
<h4><?php esc_html_e('Invite by email', 'unsupervised-schedular'); ?></h4>
<p class="description"><?php esc_html_e('For someone without an account yet.', 'unsupervised-schedular'); ?></p>
<input type="email" name="email" class="regular-text" placeholder="<?php esc_attr_e('[email protected]', 'unsupervised-schedular'); ?>">
<p>
<button type="submit" name="usc_action" value="invite_email" class="button"><?php esc_html_e('Send invite', 'unsupervised-schedular'); ?></button>
</p>
</form>
</div>
<?php endif; ?>
</div>
+38 -106
View File
@@ -6,14 +6,14 @@ if (! defined('ABSPATH')) {
}
/**
* @var list<array{id: int|null, title: string, capacity: int|null, enrolled: int, invite_only: bool, roster: list<array{student: string, status: string, payment: string|null}>, invited: list<array{who: string, kind: string}>}> $classes
* @var list<array{id: int, name: string}> $students
* @var list<array{id: int|null, title: string, when: string, capacity: int|null, enrolled: int, invite_only: bool}> $classes
* @var string $notice
* @var string $baseUrl
*/
?>
<div class="wrap">
<h1><?php esc_html_e('My Group Classes', 'unsupervised-schedular'); ?></h1>
<p class="description"><?php esc_html_e('Your group classes and their rosters.', 'unsupervised-schedular'); ?></p>
<p class="description"><?php esc_html_e('Your group classes. Select one for its details, roster, and — for invite-only classes — to invite or add students.', 'unsupervised-schedular'); ?></p>
<?php if ('' !== $notice) : ?>
<div class="notice notice-info is-dismissible"><p><?php echo esc_html($notice); ?></p></div>
@@ -22,108 +22,40 @@ if (! defined('ABSPATH')) {
<?php if (empty($classes)) : ?>
<p><?php esc_html_e('You have no group classes.', 'unsupervised-schedular'); ?></p>
<?php else : ?>
<?php foreach ($classes as $class) : ?>
<h2>
<?php echo esc_html($class['title']); ?>
<?php if ($class['invite_only']) : ?>
<span class="dashicons dashicons-lock" title="<?php esc_attr_e('Invite only', 'unsupervised-schedular'); ?>"></span>
<?php endif; ?>
<span class="count">
<?php
if (null === $class['capacity']) {
printf(
/* translators: %d: number of enrolled students. */
esc_html__('(%d enrolled)', 'unsupervised-schedular'),
(int) $class['enrolled']
);
} else {
printf(
/* translators: 1: number of enrolled students, 2: class capacity. */
esc_html__('(%1$d / %2$d enrolled)', 'unsupervised-schedular'),
(int) $class['enrolled'],
(int) $class['capacity']
);
}
?>
</span>
</h2>
<?php if (empty($class['roster'])) : ?>
<p><?php esc_html_e('No enrolments yet.', 'unsupervised-schedular'); ?></p>
<?php else : ?>
<table class="wp-list-table widefat fixed striped">
<thead>
<tr>
<th><?php esc_html_e('Student', 'unsupervised-schedular'); ?></th>
<th><?php esc_html_e('Enrolment', 'unsupervised-schedular'); ?></th>
<th><?php esc_html_e('Payment', 'unsupervised-schedular'); ?></th>
</tr>
</thead>
<tbody>
<?php foreach ($class['roster'] as $entry) : ?>
<tr>
<td><?php echo esc_html($entry['student']); ?></td>
<td><?php echo esc_html($entry['status']); ?></td>
<td><?php echo esc_html($entry['payment'] ?? '—'); ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
<?php if ($class['invite_only']) : ?>
<?php if (! empty($class['invited'])) : ?>
<h4><?php esc_html_e('Invited (not yet enrolled)', 'unsupervised-schedular'); ?></h4>
<ul class="ul-disc">
<?php foreach ($class['invited'] as $invitee) : ?>
<li><?php echo esc_html($invitee['who'] . ' — ' . $invitee['kind']); ?></li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
<div class="us-group-invite-controls" style="display:flex; flex-wrap:wrap; gap:2em; margin:1em 0 2em;">
<form method="post">
<?php wp_nonce_field('usc_group_action'); ?>
<input type="hidden" name="offering_id" value="<?php echo esc_attr((string) $class['id']); ?>">
<h4><?php esc_html_e('Add students directly', 'unsupervised-schedular'); ?></h4>
<p class="description"><?php esc_html_e('Enrols them now with a pending payment.', 'unsupervised-schedular'); ?></p>
<select name="student_ids[]" multiple size="5" style="min-width:16em;">
<?php foreach ($students as $student) : ?>
<option value="<?php echo esc_attr((string) $student['id']); ?>"><?php echo esc_html($student['name']); ?></option>
<?php endforeach; ?>
</select>
<p>
<button type="submit" name="usc_action" value="add_direct" class="button"><?php esc_html_e('Add to class', 'unsupervised-schedular'); ?></button>
</p>
</form>
<form method="post">
<?php wp_nonce_field('usc_group_action'); ?>
<input type="hidden" name="offering_id" value="<?php echo esc_attr((string) $class['id']); ?>">
<h4><?php esc_html_e('Make available to students', 'unsupervised-schedular'); ?></h4>
<p class="description"><?php esc_html_e('Grants access so they can enrol themselves.', 'unsupervised-schedular'); ?></p>
<select name="student_ids[]" multiple size="5" style="min-width:16em;">
<?php foreach ($students as $student) : ?>
<option value="<?php echo esc_attr((string) $student['id']); ?>"><?php echo esc_html($student['name']); ?></option>
<?php endforeach; ?>
</select>
<p>
<button type="submit" name="usc_action" value="grant_access" class="button"><?php esc_html_e('Grant access', 'unsupervised-schedular'); ?></button>
</p>
</form>
<form method="post">
<?php wp_nonce_field('usc_group_action'); ?>
<input type="hidden" name="offering_id" value="<?php echo esc_attr((string) $class['id']); ?>">
<h4><?php esc_html_e('Invite by email', 'unsupervised-schedular'); ?></h4>
<p class="description"><?php esc_html_e('For someone without an account yet.', 'unsupervised-schedular'); ?></p>
<input type="email" name="email" class="regular-text" placeholder="<?php esc_attr_e('[email protected]', 'unsupervised-schedular'); ?>">
<p>
<button type="submit" name="usc_action" value="invite_email" class="button"><?php esc_html_e('Send invite', 'unsupervised-schedular'); ?></button>
</p>
</form>
</div>
<?php endif; ?>
<?php endforeach; ?>
<table class="wp-list-table widefat fixed striped">
<thead>
<tr>
<th><?php esc_html_e('Class', 'unsupervised-schedular'); ?></th>
<th><?php esc_html_e('When', 'unsupervised-schedular'); ?></th>
<th><?php esc_html_e('Enrolled', 'unsupervised-schedular'); ?></th>
<th><?php esc_html_e('Actions', 'unsupervised-schedular'); ?></th>
</tr>
</thead>
<tbody>
<?php foreach ($classes as $class) : ?>
<tr>
<td>
<?php echo esc_html($class['title']); ?>
<?php if ($class['invite_only']) : ?>
<span class="dashicons dashicons-lock" title="<?php esc_attr_e('Invite only', 'unsupervised-schedular'); ?>"></span>
<?php endif; ?>
</td>
<td><?php echo '' !== $class['when'] ? esc_html($class['when']) : '&mdash;'; ?></td>
<td>
<?php
if (null === $class['capacity']) {
echo esc_html((string) $class['enrolled']);
} else {
echo esc_html($class['enrolled'] . ' / ' . $class['capacity']);
}
?>
</td>
<td>
<a class="button button-small" href="<?php echo esc_url(add_query_arg('class_id', (int) $class['id'], $baseUrl)); ?>"><?php echo $class['invite_only'] ? esc_html__('View & invite', 'unsupervised-schedular') : esc_html__('View details', 'unsupervised-schedular'); ?></a>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
</div>
+37 -3
View File
@@ -10,6 +10,9 @@ if (! defined('ABSPATH')) {
/**
* @var list<\Unsupervised\Schedular\Offering\Offering> $offerings
* @var \Unsupervised\Schedular\Offering\Offering|null $editing Offering loaded into the form, or null when adding.
* @var list<array{id: int, name: string}> $instructors Instructors offered in the assignment picker (studio admins only).
* @var bool $manageAll Whether the current user may assign classes to other instructors.
* @var string $notice Status message from the last save (slot-clearing / conflicts).
*/
$baseUrl = admin_url('admin.php?page=us-offerings');
@@ -26,6 +29,10 @@ if ($editing && null !== $editing->termStart && null !== $editing->termEnd && $e
<div class="wrap">
<h1><?php esc_html_e('Offerings', 'unsupervised-schedular'); ?></h1>
<?php if ('' !== $notice) : ?>
<div class="notice notice-info is-dismissible"><p><?php echo esc_html($notice); ?></p></div>
<?php endif; ?>
<h2><?php $editing ? esc_html_e('Edit Offering', 'unsupervised-schedular') : esc_html_e('Add Offering', 'unsupervised-schedular'); ?></h2>
<form method="post">
<?php wp_nonce_field('usc_offering_action'); ?>
@@ -49,6 +56,19 @@ if ($editing && null !== $editing->termStart && null !== $editing->termEnd && $e
</select>
</td>
</tr>
<?php if ($manageAll) : ?>
<tr>
<th><label for="class_instructor_id"><?php esc_html_e('Instructor', 'unsupervised-schedular'); ?></label></th>
<td>
<select name="class_instructor_id" id="class_instructor_id">
<?php foreach ($instructors as $instructor) : ?>
<option value="<?php echo esc_attr((string) $instructor['id']); ?>" <?php echo $editing && $editing->instructorId === $instructor['id'] ? 'selected' : ''; ?>><?php echo esc_html($instructor['name']); ?></option>
<?php endforeach; ?>
</select>
<p class="description"><?php esc_html_e('Who teaches this. Assigning a group class clears that instructors open booking slots at the class time.', 'unsupervised-schedular'); ?></p>
</td>
</tr>
<?php endif; ?>
<tr>
<th><label for="description"><?php esc_html_e('Description', 'unsupervised-schedular'); ?></label></th>
<td><textarea name="description" id="description" class="large-text" rows="4"><?php echo esc_textarea($editing->description ?? ''); ?></textarea></td>
@@ -85,6 +105,13 @@ if ($editing && null !== $editing->termStart && null !== $editing->termEnd && $e
<span class="description"><?php esc_html_e('Group classes only — date of the first class', 'unsupervised-schedular'); ?></span>
</td>
</tr>
<tr>
<th><label for="class_time"><?php esc_html_e('Class time', 'unsupervised-schedular'); ?></label></th>
<td>
<input type="time" name="class_time" id="class_time" value="<?php echo esc_attr(null === ($editing->classTime ?? null) ? '' : substr((string) $editing->classTime, 0, 5)); ?>">
<span class="description"><?php esc_html_e('Group classes only — the time each session starts. Combined with the duration to block the instructors availability.', 'unsupervised-schedular'); ?></span>
</td>
</tr>
<tr>
<th><?php esc_html_e('Sessions', 'unsupervised-schedular'); ?></th>
<td>
@@ -164,10 +191,17 @@ if ($editing && null !== $editing->termStart && null !== $editing->termEnd && $e
<td>
<?php if (null === $offering->termStart) : ?>
&mdash;
<?php elseif (null === $offering->termEnd || $offering->termEnd === $offering->termStart) : ?>
<?php echo esc_html((string) mysql2date('M j, Y', $offering->termStart)); ?>
<?php else : ?>
<?php echo esc_html((string) mysql2date('M j, Y', $offering->termStart) . ' ' . (string) mysql2date('M j, Y', $offering->termEnd)); ?>
<?php
if (null === $offering->termEnd || $offering->termEnd === $offering->termStart) {
echo esc_html((string) mysql2date('M j, Y', $offering->termStart));
} else {
echo esc_html((string) mysql2date('M j, Y', $offering->termStart) . ' ' . (string) mysql2date('M j, Y', $offering->termEnd));
}
if (null !== $offering->classTime) {
echo '<br><span class="description">' . esc_html((string) mysql2date('g:i a', $offering->termStart . ' ' . $offering->classTime)) . '</span>';
}
?>
<?php endif; ?>
</td>
<td><?php echo $offering->isActive ? esc_html__('Yes', 'unsupervised-schedular') : esc_html__('No', 'unsupervised-schedular'); ?></td>
@@ -343,4 +343,37 @@ class AvailabilityRepositoryTest extends TestCase
self::assertCount(1, $slots);
self::assertInstanceOf(AvailabilitySlot::class, $slots[0]);
}
public function testFindOverlappingQueriesTheInstructorAndWindow(): void
{
$row = (object) [
'id' => '5',
'instructor_id' => '3',
'offering_id' => null,
'start_dt' => '2026-09-08 16:00:00',
'end_dt' => '2026-09-08 17:00:00',
'duration_minutes' => '60',
'is_booked' => '0',
'recurrence_group' => null,
];
// Half-open overlap: start_dt < window end AND end_dt > window start, with
// the window bounds bound in that order.
$this->db->shouldReceive('prepare')
->once()
->with(
Mockery::pattern('/start_dt < %s AND end_dt > %s/'),
'wp_us_availability',
3,
'2026-09-08 17:00:00',
'2026-09-08 16:00:00'
)
->andReturn('SELECT ...');
$this->db->shouldReceive('get_results')->andReturn([$row]);
$slots = $this->repo->findOverlapping(3, '2026-09-08 16:00:00', '2026-09-08 17:00:00');
self::assertCount(1, $slots);
self::assertInstanceOf(AvailabilitySlot::class, $slots[0]);
}
}
@@ -55,6 +55,18 @@ class GroupClassControllerTest extends TestCase
Functions\when('get_users')->justReturn([]);
Functions\when('esc_attr')->returnArg();
Functions\when('esc_attr_e')->returnArg();
Functions\when('esc_url')->returnArg();
Functions\when('admin_url')->justReturn('admin.php?page=us-my-group-classes');
Functions\when('add_query_arg')->alias(
static fn ($key, $value, $url) => $url . '&' . $key . '=' . $value
);
Functions\when('absint')->alias(static fn ($v) => abs((int) $v));
Functions\when('mysql2date')->alias(
static fn (string $format, string $date) => date($format, (int) strtotime($date))
);
Functions\when('wp_nonce_field')->justReturn('');
$_GET = [];
}
private function offering(int $id, string $title, ?int $capacity): Offering
@@ -76,9 +88,29 @@ class GroupClassControllerTest extends TestCase
return (string) ob_get_clean();
}
public function testInstructorPageListsClassWithCapacityAndRoster(): void
public function testInstructorSummaryListsClassWithEnrolmentCountAndRosterLink(): void
{
$offering = $this->offering(8, 'Choir', 10);
$active = new Enrollment(offeringId: 8, studentId: 5, instructorId: 3, paymentId: 42, id: 1);
$this->offerings->shouldReceive('findAll')->once()
->with(3, Offering::KIND_GROUP_CLASS)->andReturn([$offering]);
$this->enrollments->shouldReceive('findByInstructor')->once()->with(3)->andReturn([$active]);
// The summary must not resolve individual students — it is a class list.
$this->payments->shouldReceive('findById')->never();
$html = $this->renderInstructor();
self::assertStringContainsString('Choir', $html);
self::assertStringContainsString('1 / 10', $html);
self::assertStringContainsString('View details', $html);
self::assertStringContainsString('class_id=8', $html);
}
public function testClassDetailListsRosterWithPaymentStatus(): void
{
Functions\when('get_userdata')->justReturn((object) ['display_name' => 'Ada Lovelace']);
$_GET = ['class_id' => '8'];
$offering = $this->offering(8, 'Choir', 10);
$enrollment = new Enrollment(offeringId: 8, studentId: 5, instructorId: 3, paymentId: 42, id: 1);
@@ -92,15 +124,51 @@ class GroupClassControllerTest extends TestCase
$html = $this->renderInstructor();
self::assertStringContainsString('Choir', $html);
self::assertStringContainsString('(1 / 10 enrolled)', $html);
self::assertStringContainsString('1 / 10 enrolled', $html);
self::assertStringContainsString('Ada Lovelace', $html);
self::assertStringContainsString('paid', $html);
}
public function testEnrolmentCountExcludesCancelledButRosterKeepsThem(): void
public function testClassDetailShowsClassSettingsAndInviteControlsForInviteOnly(): void
{
Functions\when('get_userdata')->justReturn((object) ['display_name' => 'Ada Lovelace']);
$_GET = ['class_id' => '8'];
$offering = new Offering(
instructorId: 3,
kind: Offering::KIND_GROUP_CLASS,
title: 'Private Choir',
price: 120.0,
description: 'A year of choir.',
durationMinutes: 60,
termStart: '2026-09-08',
termEnd: '2026-09-08',
classTime: '16:00:00',
accessMode: Offering::ACCESS_INVITE_ONLY,
id: 8,
);
$this->offerings->shouldReceive('findAll')->once()
->with(3, Offering::KIND_GROUP_CLASS)->andReturn([$offering]);
$this->enrollments->shouldReceive('findByInstructor')->once()->with(3)->andReturn([]);
$this->access->shouldReceive('findByOffering')->once()->with(8)->andReturn([]);
$html = $this->renderInstructor();
// The details section.
self::assertStringContainsString('Class details', $html);
self::assertStringContainsString('Ada Lovelace', $html);
self::assertStringContainsString('120.00', $html);
self::assertStringContainsString('A year of choir.', $html);
// The invite/add controls are reached from this page.
self::assertStringContainsString('Add students directly', $html);
self::assertStringContainsString('Invite by email', $html);
}
public function testClassDetailEnrolmentCountExcludesCancelledButRosterKeepsThem(): void
{
Functions\when('get_userdata')->justReturn((object) ['display_name' => 'Grace Hopper']);
$_GET = ['class_id' => '8'];
$offering = $this->offering(8, 'Band', null);
$active = new Enrollment(offeringId: 8, studentId: 5, instructorId: 3, id: 1);
@@ -112,14 +180,15 @@ class GroupClassControllerTest extends TestCase
$html = $this->renderInstructor();
// Unlimited capacity offering counts only the active enrolment.
self::assertStringContainsString('(1 enrolled)', $html);
self::assertStringContainsString('1 enrolled', $html);
// But the roster still shows the cancelled row.
self::assertStringContainsString('cancelled', $html);
}
public function testFreeEnrolmentShowsDashForPayment(): void
public function testClassDetailFreeEnrolmentShowsDashForPayment(): void
{
Functions\when('get_userdata')->justReturn((object) ['display_name' => 'Alan Turing']);
$_GET = ['class_id' => '8'];
$offering = $this->offering(8, 'Theory', 5);
$enrollment = new Enrollment(offeringId: 8, studentId: 5, instructorId: 3, paymentId: null, id: 1);
@@ -135,8 +204,6 @@ class GroupClassControllerTest extends TestCase
public function testEnrolmentsForOtherClassesAreNotMixedIn(): void
{
Functions\when('get_userdata')->justReturn((object) ['display_name' => 'Katherine Johnson']);
$offering = $this->offering(8, 'Choir', 5);
$mine = new Enrollment(offeringId: 8, studentId: 5, instructorId: 3, id: 1);
$other = new Enrollment(offeringId: 9, studentId: 6, instructorId: 3, id: 2);
@@ -146,11 +213,14 @@ class GroupClassControllerTest extends TestCase
$html = $this->renderInstructor();
self::assertStringContainsString('(1 / 5 enrolled)', $html);
self::assertStringContainsString('1 / 5', $html);
}
public function testClassWithNoEnrolmentsShowsEmptyMessage(): void
public function testClassDetailWithNoEnrolmentsShowsEmptyMessage(): void
{
Functions\when('get_userdata')->justReturn((object) ['display_name' => 'Ada Lovelace']);
$_GET = ['class_id' => '8'];
$offering = $this->offering(8, 'Jazz', 5);
$this->offerings->shouldReceive('findAll')->once()->andReturn([$offering]);
@@ -171,6 +241,64 @@ class GroupClassControllerTest extends TestCase
self::assertStringContainsString('You have no group classes.', $html);
}
public function testStudioAdminPageSummarisesClassesNotStudents(): void
{
Functions\when('get_userdata')->justReturn((object) ['display_name' => 'Ada Lovelace']);
$offering = new Offering(
instructorId: 3,
kind: Offering::KIND_GROUP_CLASS,
title: 'Choir',
capacity: 10,
termStart: '2026-09-08',
termEnd: '2026-09-08',
classTime: '16:00:00',
id: 8,
);
$this->offerings->shouldReceive('findAll')->once()
->with(0, Offering::KIND_GROUP_CLASS)->andReturn([$offering]);
$this->enrollments->shouldReceive('countActiveForOffering')->once()->with(8)->andReturn(4);
ob_start();
$this->controller->renderPage();
$html = (string) ob_get_clean();
self::assertStringContainsString('Choir', $html);
self::assertStringContainsString('Ada Lovelace', $html);
self::assertStringContainsString('4 / 10', $html);
}
public function testStudioAdminCanOpenClassDetailWithInviteControls(): void
{
// A studio admin (view_all_lessons) opens a class taught by instructor 7.
Functions\when('get_userdata')->justReturn((object) ['display_name' => 'Ada Lovelace']);
$_GET = ['class_id' => '8'];
$offering = new Offering(
instructorId: 7,
kind: Offering::KIND_GROUP_CLASS,
title: 'Private Choir',
price: 120.0,
accessMode: Offering::ACCESS_INVITE_ONLY,
id: 8,
);
$this->offerings->shouldReceive('findAll')->once()
->with(0, Offering::KIND_GROUP_CLASS)->andReturn([$offering]);
// Detail rosters are looked up by the class's own instructor (7).
$this->enrollments->shouldReceive('findByInstructor')->once()->with(7)->andReturn([]);
$this->access->shouldReceive('findByOffering')->once()->with(8)->andReturn([]);
ob_start();
$this->controller->renderPage();
$html = (string) ob_get_clean();
self::assertStringContainsString('Class details', $html);
self::assertStringContainsString('Add students directly', $html);
self::assertStringContainsString('Invite by email', $html);
}
public function testDeniesUsersWithoutViewLessonsCapability(): void
{
Functions\when('current_user_can')->justReturn(false);
@@ -184,6 +312,7 @@ class GroupClassControllerTest extends TestCase
protected function tearDown(): void
{
$_POST = [];
$_GET = [];
parent::tearDown();
}
@@ -332,8 +461,13 @@ class GroupClassControllerTest extends TestCase
self::assertStringContainsString('1 student(s) granted access.', $html);
}
public function testActionRejectedForClassNotOwnedByInstructor(): void
public function testActionRejectedForClassNotOwnedByPlainInstructor(): void
{
// A plain instructor (no view_all_lessons) may only manage their own classes.
Functions\when('current_user_can')->alias(
static fn (string $cap) => 'view_all_lessons' !== $cap
);
$_POST = ['usc_action' => 'add_direct', 'offering_id' => 8, 'student_ids' => [5]];
$this->stubActionContext();
@@ -346,4 +480,36 @@ class GroupClassControllerTest extends TestCase
self::assertStringContainsString('That group class was not found.', $html);
}
public function testStudioAdminCanManageInviteForAnotherInstructorsClass(): void
{
// current_user_can returns true for everything (incl. view_all_lessons),
// so the studio admin may add students to a class they do not own.
$_POST = ['usc_action' => 'add_direct', 'offering_id' => 8, 'student_ids' => [5]];
$this->stubActionContext();
$foreign = new Offering(instructorId: 7, kind: Offering::KIND_GROUP_CLASS, title: 'Other', price: 100.0, accessMode: Offering::ACCESS_INVITE_ONLY, id: 8);
$this->offerings->shouldReceive('findById')->with(8)->andReturn($foreign);
$this->enrollments->shouldReceive('hasActiveEnrollment')->with(8, 5)->andReturn(false);
$this->enrollments->shouldReceive('insert')->once()->andReturn(44);
// The enrolment and payment use the class's own instructor (7), not the admin.
$payment = new Payment(
studentId: 5,
instructorId: 7,
registrationType: Payment::REG_ENROLLMENT,
registrationId: 44,
amount: 100.0,
status: Payment::STATUS_PENDING,
id: 12,
);
$this->paymentService->shouldReceive('createForRegistration')
->once()->with(Payment::REG_ENROLLMENT, 44, 5, 7, 100.0, 'CAD', null)->andReturn($payment);
$this->enrollments->shouldReceive('setPaymentId')->once()->with(44, 12)->andReturn(true);
$this->access->shouldReceive('markEnrolled')->once()->with(8, 5);
$html = $this->renderInstructor();
self::assertStringContainsString('1 student(s) added to the class.', $html);
}
}
@@ -0,0 +1,122 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Tests\Unit\Offering;
use Mockery;
use Unsupervised\Schedular\Availability\AvailabilityRepository;
use Unsupervised\Schedular\Availability\AvailabilitySlot;
use Unsupervised\Schedular\Offering\ClassSlotReconciler;
use Unsupervised\Schedular\Offering\Offering;
use Unsupervised\Schedular\Tests\Unit\TestCase;
class ClassSlotReconcilerTest extends TestCase
{
private AvailabilityRepository&Mockery\MockInterface $availability;
private ClassSlotReconciler $reconciler;
protected function setUp(): void
{
parent::setUp();
$this->availability = Mockery::mock(AvailabilityRepository::class);
$this->reconciler = new ClassSlotReconciler($this->availability);
}
private function groupClass(?string $termEnd = null): Offering
{
return new Offering(
instructorId: 3,
kind: Offering::KIND_GROUP_CLASS,
title: 'Choir',
durationMinutes: 60,
termStart: '2026-09-08',
termEnd: $termEnd ?? '2026-09-08',
classTime: '16:00:00',
id: 8,
);
}
public function testRemovesOpenSlotsOverlappingASession(): void
{
$open = new AvailabilitySlot(3, '2026-09-08 16:00:00', '2026-09-08 17:00:00', 60, id: 12);
$this->availability->shouldReceive('findOverlapping')
->once()->with(3, '2026-09-08 16:00:00', '2026-09-08 17:00:00')
->andReturn([$open]);
$this->availability->shouldReceive('delete')->once()->with(12)->andReturn(true);
$result = $this->reconciler->reconcile($this->groupClass());
self::assertSame(1, $result['removed']);
self::assertSame([], $result['conflicts']);
}
public function testReportsBookedSlotAsConflictWithoutDeleting(): void
{
$booked = new AvailabilitySlot(3, '2026-09-08 16:00:00', '2026-09-08 17:00:00', 60, isBooked: true, id: 12);
$this->availability->shouldReceive('findOverlapping')->once()->andReturn([$booked]);
// A booked lesson is never deleted.
$this->availability->shouldReceive('delete')->never();
$result = $this->reconciler->reconcile($this->groupClass());
self::assertSame(0, $result['removed']);
self::assertSame(['2026-09-08 16:00:00'], $result['conflicts']);
}
public function testWeeklyClassReconcilesEverySession(): void
{
// Three weekly sessions from Sep 8 to Sep 22.
$this->availability->shouldReceive('findOverlapping')
->once()->with(3, '2026-09-08 16:00:00', '2026-09-08 17:00:00')->andReturn([]);
$this->availability->shouldReceive('findOverlapping')
->once()->with(3, '2026-09-15 16:00:00', '2026-09-15 17:00:00')->andReturn([]);
$this->availability->shouldReceive('findOverlapping')
->once()->with(3, '2026-09-22 16:00:00', '2026-09-22 17:00:00')->andReturn([]);
$result = $this->reconciler->reconcile($this->groupClass('2026-09-22'));
self::assertSame(0, $result['removed']);
}
public function testUnscheduledClassIsSkipped(): void
{
// No class time set — nothing to reconcile.
$offering = new Offering(
instructorId: 3,
kind: Offering::KIND_GROUP_CLASS,
title: 'Choir',
durationMinutes: 60,
termStart: '2026-09-08',
id: 8,
);
$this->availability->shouldReceive('findOverlapping')->never();
$result = $this->reconciler->reconcile($offering);
self::assertSame(['removed' => 0, 'conflicts' => []], $result);
}
public function testPrivateLessonOfferingIsSkipped(): void
{
$offering = new Offering(
instructorId: 3,
kind: Offering::KIND_PRIVATE_LESSON,
title: '30 min',
durationMinutes: 30,
termStart: '2026-09-08',
termEnd: '2026-09-08',
classTime: '16:00:00',
id: 8,
);
$this->availability->shouldReceive('findOverlapping')->never();
$result = $this->reconciler->reconcile($offering);
self::assertSame(['removed' => 0, 'conflicts' => []], $result);
}
}
+90 -1
View File
@@ -5,6 +5,7 @@ namespace Unsupervised\Schedular\Tests\Unit\Offering;
use Brain\Monkey\Functions;
use Mockery;
use Unsupervised\Schedular\Offering\ClassSlotReconciler;
use Unsupervised\Schedular\Offering\Offering;
use Unsupervised\Schedular\Offering\OfferingController;
use Unsupervised\Schedular\Offering\OfferingRepository;
@@ -13,6 +14,7 @@ use Unsupervised\Schedular\Tests\Unit\TestCase;
class OfferingControllerTest extends TestCase
{
private OfferingRepository&Mockery\MockInterface $repository;
private ClassSlotReconciler&Mockery\MockInterface $reconciler;
private OfferingController $controller;
protected function setUp(): void
@@ -20,13 +22,16 @@ class OfferingControllerTest extends TestCase
parent::setUp();
$this->repository = Mockery::mock(OfferingRepository::class);
$this->controller = new OfferingController($this->repository);
$this->reconciler = Mockery::mock(ClassSlotReconciler::class);
$this->reconciler->shouldReceive('reconcile')->andReturn(['removed' => 0, 'conflicts' => []])->byDefault();
$this->controller = new OfferingController($this->repository, $this->reconciler);
$_POST = [];
$_GET = [];
Functions\when('current_user_can')->justReturn(true);
Functions\when('get_current_user_id')->justReturn(3);
Functions\when('get_users')->justReturn([]);
Functions\when('check_admin_referer')->justReturn(true);
Functions\when('admin_url')->justReturn('admin.php?page=us-offerings');
Functions\when('add_query_arg')->alias(
@@ -70,6 +75,90 @@ class OfferingControllerTest extends TestCase
$this->render();
}
public function testAddGroupClassStoresClassTimeAndReconcilesSlots(): void
{
$_POST = [
'usc_action' => 'add',
'title' => 'Ballet Beginners',
'kind' => Offering::KIND_GROUP_CLASS,
'term_start' => '2026-09-08',
'class_time' => '16:30',
'duration_minutes' => '60',
];
$this->repository->shouldReceive('insert')->once()->with(Mockery::on(
static fn (Offering $o) => '16:30:00' === $o->classTime
))->andReturn(1);
$this->repository->shouldReceive('findAll')->andReturn([]);
// A scheduled class is reconciled against the instructor's availability,
// and the resulting notice is surfaced to the admin.
$this->reconciler->shouldReceive('reconcile')->once()->with(Mockery::on(
static fn (Offering $o) => '16:30:00' === $o->classTime
))->andReturn(['removed' => 2, 'conflicts' => []]);
$html = $this->render();
self::assertStringContainsString('2 open booking slots were removed', $html);
}
public function testGarbageClassTimeIsRejected(): void
{
$_POST = [
'usc_action' => 'add',
'title' => 'Choir',
'kind' => Offering::KIND_GROUP_CLASS,
'class_time' => 'not-a-time',
];
$this->repository->shouldReceive('insert')->once()->with(Mockery::on(
static fn (Offering $o) => null === $o->classTime
))->andReturn(1);
$this->repository->shouldReceive('findAll')->andReturn([]);
$this->render();
}
public function testStudioAdminAssignsClassToChosenInstructor(): void
{
$_POST = [
'usc_action' => 'add',
'title' => 'Choir',
'kind' => Offering::KIND_GROUP_CLASS,
'class_instructor_id' => '7',
];
$this->repository->shouldReceive('insert')->once()->with(Mockery::on(
static fn (Offering $o) => 7 === $o->instructorId
))->andReturn(1);
$this->repository->shouldReceive('findAll')->andReturn([]);
$this->render();
}
public function testInstructorCannotReassignClassToAnotherInstructor(): void
{
// A plain instructor (no manage_instructors) — the posted instructor id
// must be ignored so the class stays theirs.
Functions\when('current_user_can')->alias(
static fn (string $cap) => 'manage_instructors' !== $cap
);
$_POST = [
'usc_action' => 'add',
'title' => 'Choir',
'kind' => Offering::KIND_GROUP_CLASS,
'class_instructor_id' => '7',
];
$this->repository->shouldReceive('insert')->once()->with(Mockery::on(
static fn (Offering $o) => 3 === $o->instructorId
))->andReturn(1);
$this->repository->shouldReceive('findAll')->andReturn([]);
$this->render();
}
public function testAddInviteOnlyGroupClassStoresInviteOnlyAccess(): void
{
$_POST = [
@@ -22,6 +22,7 @@ class OfferingEndpointTest extends TestCase
parent::setUp();
Functions\when('get_current_user_id')->justReturn(5);
Functions\when('get_userdata')->justReturn((object) ['display_name' => 'Ada Lovelace']);
$this->repository = Mockery::mock(OfferingRepository::class);
$this->access = Mockery::mock(GroupAccessRepository::class);
@@ -88,6 +89,20 @@ class OfferingEndpointTest extends TestCase
self::assertSame([], $data);
}
public function testIndexIncludesInstructorNameForEachOffering(): void
{
$instructor = Mockery::mock(\WP_User::class);
$instructor->display_name = 'Ada Lovelace';
Functions\when('get_userdata')->justReturn($instructor);
$this->repository->shouldReceive('findAll')->andReturn([$this->group(1, Offering::ACCESS_PUBLIC)]);
$this->access->shouldReceive('findGrantedOfferingIds')->with(5)->andReturn([]);
$data = $this->endpoint->index(new \WP_REST_Request())->get_data();
self::assertSame('Ada Lovelace', $data[0]['instructor_name']);
}
public function testIndexOmitsEtransferEmailFromPublicListing(): void
{
$this->repository->shouldReceive('findAll')->andReturn([
+76
View File
@@ -52,6 +52,82 @@ class OfferingTest extends TestCase
self::assertSame('2026-09-08', Offering::weeklyTermEnd('2026-09-08', 0));
}
public function testNormalizeTimeAcceptsHtmlTimeInput(): void
{
self::assertSame('16:30:00', Offering::normalizeTime('16:30'));
self::assertSame('09:00:00', Offering::normalizeTime('09:00:00'));
}
public function testNormalizeTimeRejectsGarbage(): void
{
self::assertNull(Offering::normalizeTime(''));
self::assertNull(Offering::normalizeTime('25:00'));
self::assertNull(Offering::normalizeTime('not-a-time'));
}
public function testSessionWindowsForOneOffClass(): void
{
$offering = new Offering(
instructorId: 3,
kind: Offering::KIND_GROUP_CLASS,
title: 'Recital',
durationMinutes: 90,
termStart: '2026-09-08',
termEnd: '2026-09-08',
classTime: '16:00:00',
);
self::assertSame(
[['start' => '2026-09-08 16:00:00', 'end' => '2026-09-08 17:30:00']],
$offering->sessionWindows()
);
}
public function testSessionWindowsWalksWeeklyTerm(): void
{
$offering = new Offering(
instructorId: 3,
kind: Offering::KIND_GROUP_CLASS,
title: 'Choir',
durationMinutes: 60,
termStart: '2026-09-08',
termEnd: '2026-09-22',
classTime: '16:00:00',
);
$windows = $offering->sessionWindows();
self::assertCount(3, $windows);
self::assertSame('2026-09-08 16:00:00', $windows[0]['start']);
self::assertSame('2026-09-22 16:00:00', $windows[2]['start']);
self::assertSame('2026-09-22 17:00:00', $windows[2]['end']);
}
public function testSessionWindowsEmptyWhenScheduleIncomplete(): void
{
// No class time.
$noTime = new Offering(
instructorId: 3,
kind: Offering::KIND_GROUP_CLASS,
title: 'Choir',
durationMinutes: 60,
termStart: '2026-09-08',
termEnd: '2026-09-08',
);
self::assertSame([], $noTime->sessionWindows());
// No duration.
$noDuration = new Offering(
instructorId: 3,
kind: Offering::KIND_GROUP_CLASS,
title: 'Choir',
termStart: '2026-09-08',
termEnd: '2026-09-08',
classTime: '16:00:00',
);
self::assertSame([], $noDuration->sessionWindows());
}
public function testDefaults(): void
{
$offering = new Offering(1, Offering::KIND_GROUP_CLASS, 'Choir');