Group-class scheduling, instructor assignment, and details/invite management #98

Merged
thatguygriff merged 1 commits from feature/group-class-scheduling into main 2026-07-23 20:34:16 +00:00
23 changed files with 1355 additions and 207 deletions
Showing only changes of commit b066bef353 - Show all commits
+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**. - Instructor group-class roster view under **My Lessons**.
- Cancellation cutoff that limits how close to a lesson a student can cancel. - Cancellation cutoff that limits how close to a lesson a student can cancel.
- Studio-defined account-registration questions collected during student sign-up. - 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 ### Changed
- Plugin metadata links now point at Unsupervised and the Gitea repository. - 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] ## [1.0.0]
+16 -1
View File
@@ -89,6 +89,20 @@
return `${formatDate(o.term_start)} ${formatDate(o.term_end)} (${sessions} weekly sessions)`; 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) { function renderClasses(offerings, enrolledOfferingIds) {
let groups = offerings.filter((o) => o.kind === 'group_class'); let groups = offerings.filter((o) => o.kind === 'group_class');
if (singleOfferingId) { if (singleOfferingId) {
@@ -104,7 +118,8 @@
list.innerHTML = groups.map((o) => ` list.innerHTML = groups.map((o) => `
<div class="us-class"> <div class="us-class">
<h3>${escHtml(o.title)}</h3> <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.schedule_note ? `<p>${escHtml(o.schedule_note)}</p>` : ''}
${o.description ? `<p>${escHtml(o.description)}</p>` : ''} ${o.description ? `<p>${escHtml(o.description)}</p>` : ''}
<p>${escHtml(Number(o.price).toFixed(2))} ${escHtml(o.currency)}</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` | | `payment_id` | BIGINT UNSIGNED | Nullable FK → `us_payments.id` |
| `enrolled_at` | DATETIME | Insertion time | | `enrolled_at` | DATETIME | Insertion time |
## Class Dates ## Class Dates, Time, and Instructor
A group class offering carries `term_start`/`term_end` (see `offerings.md`): A group class offering carries `term_start`/`term_end` plus a `class_time` and an
one-off classes end the day they start; weekly classes run a set number of owning `instructor_id` (see `offerings.md`): one-off classes end the day they
sessions. The class card on the enrolment page shows the date or date range start; weekly classes run a set number of sessions, all at `class_time`. The class
with the session count. 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 ## Enrolment Flow
The class list is loaded together with the student's own enrolments 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 | | `created_at` | DATETIME | Insertion time |
## Admin Interface ## Admin Interface
- **Group Classes** (`view_all_lessons` / studio admin): all active enrolments across instructors - **Group Classes** (`view_all_lessons` / studio admin): a per-class summary across
- **My Lessons → My Group Classes** (`view_own_lessons` / instructor): the instructor's instructors — each class with its instructor, when it meets, and its active-enrolment
own group classes, each showing its active-enrolment count against capacity and a count against capacity (not a flat list of individual student enrolments). Selecting a
per-class roster of enrolled students with enrolment and payment status. Invite-only class (`?class_id=<id>`) opens the same per-class **details page** described below, so a
classes additionally list who has been invited but not yet enrolled and carry the studio admin — including an owner-operator who also teaches, for whom the instructor
add/make-available/invite-by-email controls (nonce-checked `usc_action` POSTs, scoped to **My Group Classes** menu is hidden — can view any class's roster and manage invite-only
the owning instructor) 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 ## Implementation
- Repository: `Unsupervised\Schedular\GroupClass\EnrollmentRepository` (`countActiveForOffering`/`hasActiveEnrollment` enforce capacity and prevent duplicates) - Repository: `Unsupervised\Schedular\GroupClass\EnrollmentRepository` (`countActiveForOffering`/`hasActiveEnrollment` enforce capacity and prevent duplicates)
- Access grants: `Unsupervised\Schedular\GroupClass\GroupAccess` + `GroupAccessRepository` (`hasGrant`, `findGrantedOfferingIds`, `markEnrolled`, `linkStudentByEmail`) - Access grants: `Unsupervised\Schedular\GroupClass\GroupAccess` + `GroupAccessRepository` (`hasGrant`, `findGrantedOfferingIds`, `markEnrolled`, `linkStudentByEmail`)
- Model: `Unsupervised\Schedular\GroupClass\Enrollment` - 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` - 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) - 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`) - 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 | | `capacity` | SMALLINT | Group only — max enrolments; NULL for private |
| `term_start` | DATE | Group / term offerings — first day; NULL otherwise | | `term_start` | DATE | Group / term offerings — first day; NULL otherwise |
| `term_end` | DATE | Group / term offerings — last 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"| | `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`) | | `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`) | | `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 student-facing class card shows the date (one-off) or the date range with the
weekly session count. 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 ## Admin Interface
Studio admin and instructors manage offerings under **Offerings** in wp-admin. Studio admin and instructors manage offerings under **Offerings** in wp-admin.
- Studio admin (`manage_offerings`) manages offerings for any instructor. - 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 ## Implementation
- Repository: `Unsupervised\Schedular\Offering\OfferingRepository` - Repository: `Unsupervised\Schedular\Offering\OfferingRepository`
- Model: `Unsupervised\Schedular\Offering\Offering` - Model: `Unsupervised\Schedular\Offering\Offering` (`normalizeTime`, `sessionWindows`)
- Admin controller: `Unsupervised\Schedular\Offering\OfferingController` - 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
- `tests/Unit/Offering/OfferingControllerTest.php` - `tests/Unit/Offering/OfferingControllerTest.php`
- `tests/Unit/Offering/OfferingRepositoryTest.php` - `tests/Unit/Offering/OfferingRepositoryTest.php`
- `tests/Unit/Offering/OfferingTest.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\EnrollmentRepository;
use Unsupervised\Schedular\GroupClass\GroupAccessRepository; use Unsupervised\Schedular\GroupClass\GroupAccessRepository;
use Unsupervised\Schedular\GroupClass\GroupClassController; use Unsupervised\Schedular\GroupClass\GroupClassController;
use Unsupervised\Schedular\Offering\ClassSlotReconciler;
use Unsupervised\Schedular\Offering\OfferingController; use Unsupervised\Schedular\Offering\OfferingController;
use Unsupervised\Schedular\Offering\OfferingRepository; use Unsupervised\Schedular\Offering\OfferingRepository;
use Unsupervised\Schedular\Payment\BillingMethodResolver; 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 ) { 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->availabilityController = new AvailabilityController( $availability, $offerings );
$this->lessonController = new LessonController( $bookings, $payments, $availability ); $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->questionController = new QuestionController( $questions, $offerings );
$this->policyController = new PolicyController( $policies, $policyVersions, $policyService ); $this->policyController = new PolicyController( $policies, $policyVersions, $policyService );
$this->registrationController = new RegistrationController( $invites ); $this->registrationController = new RegistrationController( $invites );
@@ -181,6 +181,28 @@ class AvailabilityRepository {
return array_map( AvailabilitySlot::fromRow( ... ), $rows ?? [] ); 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 { public function findById( int $id ): ?AvailabilitySlot {
$row = $this->db->get_row( $row = $this->db->get_row(
$this->db->prepare( 'SELECT * FROM %i WHERE id = %d', $this->table, $id ) $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, 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 { public function renderPage(): void {
if ( ! current_user_can( RoleManager::CAP_VIEW_ALL_LESSONS ) ) { if ( ! current_user_can( RoleManager::CAP_VIEW_ALL_LESSONS ) ) {
wp_die( esc_html__( 'You do not have permission to view group classes.', 'unsupervised-schedular' ) ); 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( $rows = array_map(
function ( Enrollment $enrollment ): array { function ( Offering $offering ): array {
$offering = $this->offerings->findById( $enrollment->offeringId ); $instructor = get_userdata( $offering->instructorId );
$student = get_userdata( $enrollment->studentId );
return [ return [
'student' => $student ? $student->display_name : (string) $enrollment->studentId, 'id' => $offering->id,
'offering' => $offering ? $offering->title : (string) $enrollment->offeringId, 'title' => $offering->title,
'status' => $enrollment->status, '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'; include USC_PLUGIN_DIR . 'templates/admin/group-classes.php';
} }
/** /**
* Instructor view: their own group classes with per-class rosters. Each class * Instructor view. By default a summary of the instructor's own group classes
* shows its enrolment count against capacity plus a roster of enrolled * — each with when it meets and how many are enrolled — rather than a dump of
* students with enrolment and payment status. Invite-only classes also carry * every roster. A `class_id` query param drills into one class to show its
* controls to add, grant access to, or email-invite students. * roster of enrolled students and, for invite-only classes, the controls to
* add, grant access to, or email-invite students.
*/ */
public function renderInstructorPage(): void { public function renderInstructorPage(): void {
if ( ! current_user_can( RoleManager::CAP_VIEW_LESSONS ) ) { if ( ! current_user_can( RoleManager::CAP_VIEW_LESSONS ) ) {
@@ -67,50 +110,127 @@ class GroupClassController {
$notice = $this->handleFormAction( $instructorId ); $notice = $this->handleFormAction( $instructorId );
} }
$offerings = $this->offerings->findAll( $instructorId, Offering::KIND_GROUP_CLASS );
$enrollments = $this->enrollments->findByInstructor( $instructorId ); $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( $classes = array_map(
function ( Offering $offering ) use ( $enrollments ): array { fn( Offering $offering ): array => $this->classSummary( $offering, $enrollments ),
$roster = []; $offerings
$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 )
); );
$students = $this->studentOptions(); $baseUrl = admin_url( 'admin.php?page=us-my-group-classes' );
include USC_PLUGIN_DIR . 'templates/admin/my-group-classes.php'; 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 * 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. * 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. * 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 { private function handleFormAction( int $instructorId ): string {
// Nonce is verified by the caller before this method runs. // Nonce is verified by the caller before this method runs.
@@ -152,7 +275,10 @@ class GroupClassController {
$offeringId = absint( Val::int( $_POST['offering_id'] ?? 0 ) ); $offeringId = absint( Val::int( $_POST['offering_id'] ?? 0 ) );
$offering = $offeringId > 0 ? $this->offerings->findById( $offeringId ) : null; $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' ); 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 ?int $capacity = null,
public readonly ?string $termStart = null, public readonly ?string $termStart = null,
public readonly ?string $termEnd = null, public readonly ?string $termEnd = null,
public readonly ?string $classTime = null,
public readonly ?string $scheduleNote = null, public readonly ?string $scheduleNote = null,
public readonly ?string $etransferEmail = null, public readonly ?string $etransferEmail = null,
public readonly ?int $cancellationCutoffHours = null, public readonly ?int $cancellationCutoffHours = null,
@@ -90,6 +91,69 @@ class Offering {
return ( new \DateTimeImmutable( $termStart ) )->modify( '+' . ( 7 * $weeks ) . ' days' )->format( 'Y-m-d' ); 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 { public static function fromRow( \stdClass $row ): self {
return new self( return new self(
instructorId: Val::int( $row->instructor_id ), instructorId: Val::int( $row->instructor_id ),
@@ -104,6 +168,7 @@ class Offering {
capacity: Val::intOrNull( $row->capacity ), capacity: Val::intOrNull( $row->capacity ),
termStart: Val::stringOrNull( $row->term_start ), termStart: Val::stringOrNull( $row->term_start ),
termEnd: Val::stringOrNull( $row->term_end ), termEnd: Val::stringOrNull( $row->term_end ),
classTime: Val::stringOrNull( $row->class_time ?? null ),
scheduleNote: Val::stringOrNull( $row->schedule_note ), scheduleNote: Val::stringOrNull( $row->schedule_note ),
etransferEmail: Val::stringOrNull( $row->etransfer_email ), etransferEmail: Val::stringOrNull( $row->etransfer_email ),
cancellationCutoffHours: Val::intOrNull( $row->cancellation_cutoff_hours ), cancellationCutoffHours: Val::intOrNull( $row->cancellation_cutoff_hours ),
@@ -137,6 +202,7 @@ class Offering {
'capacity' => $this->capacity, 'capacity' => $this->capacity,
'term_start' => $this->termStart, 'term_start' => $this->termStart,
'term_end' => $this->termEnd, 'term_end' => $this->termEnd,
'class_time' => $this->classTime,
'schedule_note' => $this->scheduleNote, 'schedule_note' => $this->scheduleNote,
'cancellation_cutoff_hours' => $this->cancellationCutoffHours, 'cancellation_cutoff_hours' => $this->cancellationCutoffHours,
'access_mode' => $this->accessMode, 'access_mode' => $this->accessMode,
+120 -9
View File
@@ -8,7 +8,10 @@ use Unsupervised\Schedular\Val;
class OfferingController { class OfferingController {
public function __construct( private OfferingRepository $repository ) {} public function __construct(
private OfferingRepository $repository,
private ClassSlotReconciler $reconciler,
) {}
public function renderPage(): void { public function renderPage(): void {
if ( ! current_user_can( RoleManager::CAP_MANAGE_OFFERINGS ) ) { if ( ! current_user_can( RoleManager::CAP_MANAGE_OFFERINGS ) ) {
@@ -18,10 +21,15 @@ class OfferingController {
$instructorId = get_current_user_id(); $instructorId = get_current_user_id();
$manageAll = current_user_can( RoleManager::CAP_MANAGE_INSTRUCTORS ); $manageAll = current_user_can( RoleManager::CAP_MANAGE_INSTRUCTORS );
$notice = '';
if ( isset( $_POST['usc_action'] ) && check_admin_referer( 'usc_offering_action' ) ) { 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) — // View-state query param only (which offering the form is editing) —
// nothing is mutated from it, so no nonce applies. // nothing is mutated from it, so no nonce applies.
// phpcs:disable WordPress.Security.NonceVerification.Recommended // phpcs:disable WordPress.Security.NonceVerification.Recommended
@@ -43,15 +51,22 @@ class OfferingController {
include USC_PLUGIN_DIR . 'templates/admin/offerings.php'; 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. // Nonce is verified by the caller (renderPage) before this method runs.
// phpcs:disable WordPress.Security.NonceVerification.Missing // phpcs:disable WordPress.Security.NonceVerification.Missing
$action = sanitize_key( Val::string( wp_unslash( $_POST['usc_action'] ?? '' ) ) ); $action = sanitize_key( Val::string( wp_unslash( $_POST['usc_action'] ?? '' ) ) );
if ( 'add' === $action ) { if ( 'add' === $action ) {
$offering = $this->offeringFromPost( $instructorId ); $offering = $this->offeringFromPost( $instructorId, $manageAll );
if ( null !== $offering ) { if ( null !== $offering ) {
$this->repository->insert( $offering ); $this->repository->insert( $offering );
return $this->reconcileNotice( $offering );
} }
} }
@@ -60,9 +75,11 @@ class OfferingController {
if ( $offeringId > 0 ) { if ( $offeringId > 0 ) {
$existing = $this->repository->findById( $offeringId ); $existing = $this->repository->findById( $offeringId );
if ( $existing && ( $manageAll || $existing->instructorId === $instructorId ) ) { if ( $existing && ( $manageAll || $existing->instructorId === $instructorId ) ) {
$offering = $this->offeringFromPost( $instructorId, $existing ); $offering = $this->offeringFromPost( $instructorId, $manageAll, $existing );
if ( null !== $offering ) { if ( null !== $offering ) {
$this->repository->update( $offeringId, $offering ); $this->repository->update( $offeringId, $offering );
return $this->reconcileNotice( $offering );
} }
} }
} }
@@ -78,15 +95,86 @@ class OfferingController {
} }
} }
// phpcs:enable WordPress.Security.NonceVerification.Missing // 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 * 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: * 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 * it keeps the existing id and currency so an update can never rewrite those
* reassign an offering to whoever happens to submit the form. * 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. // Nonce is verified by the caller (renderPage) before this method runs.
// phpcs:disable WordPress.Security.NonceVerification.Missing // phpcs:disable WordPress.Security.NonceVerification.Missing
$title = sanitize_text_field( Val::string( wp_unslash( $_POST['title'] ?? '' ) ) ); $title = sanitize_text_field( Val::string( wp_unslash( $_POST['title'] ?? '' ) ) );
@@ -119,8 +207,10 @@ class OfferingController {
$termEnd = 'weekly' === $recurrence ? Offering::weeklyTermEnd( $termStart, $sessions ) : $termStart; $termEnd = 'weekly' === $recurrence ? Offering::weeklyTermEnd( $termStart, $sessions ) : $termStart;
} }
$classTime = Offering::normalizeTime( sanitize_text_field( Val::string( wp_unslash( $_POST['class_time'] ?? '' ) ) ) );
return new Offering( return new Offering(
instructorId: null !== $existing ? $existing->instructorId : $instructorId, instructorId: $this->resolveInstructorId( $instructorId, $manageAll, $existing ),
kind: $kind, kind: $kind,
title: $title, title: $title,
price: max( 0.0, (float) sanitize_text_field( Val::string( wp_unslash( $_POST['price'] ?? '0' ) ) ) ), 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, capacity: $capacity > 0 ? $capacity : null,
termStart: $termStart, termStart: $termStart,
termEnd: $termEnd, termEnd: $termEnd,
classTime: $classTime,
scheduleNote: $this->nullableText( sanitize_text_field( Val::string( wp_unslash( $_POST['schedule_note'] ?? '' ) ) ) ), 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'] ?? '' ) ) ) ), etransferEmail: $this->nullableText( sanitize_email( Val::string( wp_unslash( $_POST['etransfer_email'] ?? '' ) ) ) ),
cancellationCutoffHours: $cutoffHours, cancellationCutoffHours: $cutoffHours,
@@ -142,6 +233,26 @@ class OfferingController {
// phpcs:enable WordPress.Security.NonceVerification.Missing // 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 { private function nullableText( string $value ): ?string {
return '' === $value ? null : $value; return '' === $value ? null : $value;
} }
+19 -2
View File
@@ -77,8 +77,25 @@ class OfferingEndpoint {
$offerings[] = $granted; $offerings[] = $granted;
} }
// Public listing: omit the private e-transfer destination email. // Public listing: omit the private e-transfer destination email, and
return new \WP_REST_Response( array_map( fn( Offering $o ) => $o->toArray( includeEtransferEmail: false ), $offerings ), 200 ); // 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, * Column formats aligned to {@see columns()} (instructor_id, kind, title,
* description, duration_minutes, price, currency, billing_mode, allow_weekly, * 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). * cancellation_cutoff_hours, access_mode, is_active).
* *
* @var list<string> * @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 { public function insert( Offering $offering ): int {
$this->db->insert( $this->db->insert(
@@ -60,6 +60,7 @@ class OfferingRepository {
'capacity' => $offering->capacity, 'capacity' => $offering->capacity,
'term_start' => $offering->termStart, 'term_start' => $offering->termStart,
'term_end' => $offering->termEnd, 'term_end' => $offering->termEnd,
'class_time' => $offering->classTime,
'schedule_note' => $offering->scheduleNote, 'schedule_note' => $offering->scheduleNote,
'etransfer_email' => $offering->etransferEmail, 'etransfer_email' => $offering->etransferEmail,
'cancellation_cutoff_hours' => $offering->cancellationCutoffHours, 'cancellation_cutoff_hours' => $offering->cancellationCutoffHours,
+1
View File
@@ -63,6 +63,7 @@ class Schema {
capacity SMALLINT UNSIGNED DEFAULT NULL, capacity SMALLINT UNSIGNED DEFAULT NULL,
term_start DATE DEFAULT NULL, term_start DATE DEFAULT NULL,
term_end DATE DEFAULT NULL, term_end DATE DEFAULT NULL,
class_time TIME DEFAULT NULL,
schedule_note VARCHAR(191) DEFAULT NULL, schedule_note VARCHAR(191) DEFAULT NULL,
etransfer_email VARCHAR(191) DEFAULT NULL, etransfer_email VARCHAR(191) DEFAULT NULL,
cancellation_cutoff_hours SMALLINT UNSIGNED DEFAULT NULL, cancellation_cutoff_hours SMALLINT UNSIGNED DEFAULT NULL,
+35 -8
View File
@@ -5,29 +5,56 @@ if (! defined('ABSPATH')) {
exit; 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"> <div class="wrap">
<h1><?php esc_html_e('Group Classes', 'unsupervised-schedular'); ?></h1> <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)) : ?> <?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 : ?> <?php else : ?>
<table class="wp-list-table widefat fixed striped"> <table class="wp-list-table widefat fixed striped">
<thead> <thead>
<tr> <tr>
<th><?php esc_html_e('Student', 'unsupervised-schedular'); ?></th>
<th><?php esc_html_e('Class', '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> </tr>
</thead> </thead>
<tbody> <tbody>
<?php foreach ($rows as $row) : ?> <?php foreach ($rows as $row) : ?>
<tr> <tr>
<td><?php echo esc_html($row['student']); ?></td> <td>
<td><?php echo esc_html($row['offering']); ?></td> <?php echo esc_html($row['title']); ?>
<td><?php echo esc_html($row['status']); ?></td> <?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> </tr>
<?php endforeach; ?> <?php endforeach; ?>
</tbody> </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|null, title: string, when: string, capacity: int|null, enrolled: int, invite_only: bool}> $classes
* @var list<array{id: int, name: string}> $students
* @var string $notice * @var string $notice
* @var string $baseUrl
*/ */
?> ?>
<div class="wrap"> <div class="wrap">
<h1><?php esc_html_e('My Group Classes', 'unsupervised-schedular'); ?></h1> <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) : ?> <?php if ('' !== $notice) : ?>
<div class="notice notice-info is-dismissible"><p><?php echo esc_html($notice); ?></p></div> <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)) : ?> <?php if (empty($classes)) : ?>
<p><?php esc_html_e('You have no group classes.', 'unsupervised-schedular'); ?></p> <p><?php esc_html_e('You have no group classes.', 'unsupervised-schedular'); ?></p>
<?php else : ?> <?php else : ?>
<?php foreach ($classes as $class) : ?> <table class="wp-list-table widefat fixed striped">
<h2> <thead>
<?php echo esc_html($class['title']); ?> <tr>
<?php if ($class['invite_only']) : ?> <th><?php esc_html_e('Class', 'unsupervised-schedular'); ?></th>
<span class="dashicons dashicons-lock" title="<?php esc_attr_e('Invite only', 'unsupervised-schedular'); ?>"></span> <th><?php esc_html_e('When', 'unsupervised-schedular'); ?></th>
<?php endif; ?> <th><?php esc_html_e('Enrolled', 'unsupervised-schedular'); ?></th>
<span class="count"> <th><?php esc_html_e('Actions', 'unsupervised-schedular'); ?></th>
<?php </tr>
if (null === $class['capacity']) { </thead>
printf( <tbody>
/* translators: %d: number of enrolled students. */ <?php foreach ($classes as $class) : ?>
esc_html__('(%d enrolled)', 'unsupervised-schedular'), <tr>
(int) $class['enrolled'] <td>
); <?php echo esc_html($class['title']); ?>
} else { <?php if ($class['invite_only']) : ?>
printf( <span class="dashicons dashicons-lock" title="<?php esc_attr_e('Invite only', 'unsupervised-schedular'); ?>"></span>
/* translators: 1: number of enrolled students, 2: class capacity. */ <?php endif; ?>
esc_html__('(%1$d / %2$d enrolled)', 'unsupervised-schedular'), </td>
(int) $class['enrolled'], <td><?php echo '' !== $class['when'] ? esc_html($class['when']) : '&mdash;'; ?></td>
(int) $class['capacity'] <td>
); <?php
} if (null === $class['capacity']) {
?> echo esc_html((string) $class['enrolled']);
</span> } else {
</h2> echo esc_html($class['enrolled'] . ' / ' . $class['capacity']);
}
<?php if (empty($class['roster'])) : ?> ?>
<p><?php esc_html_e('No enrolments yet.', 'unsupervised-schedular'); ?></p> </td>
<?php else : ?> <td>
<table class="wp-list-table widefat fixed striped"> <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>
<thead> </td>
<tr> </tr>
<th><?php esc_html_e('Student', 'unsupervised-schedular'); ?></th> <?php endforeach; ?>
<th><?php esc_html_e('Enrolment', 'unsupervised-schedular'); ?></th> </tbody>
<th><?php esc_html_e('Payment', 'unsupervised-schedular'); ?></th> </table>
</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; ?>
<?php endif; ?> <?php endif; ?>
</div> </div>
+37 -3
View File
@@ -10,6 +10,9 @@ if (! defined('ABSPATH')) {
/** /**
* @var list<\Unsupervised\Schedular\Offering\Offering> $offerings * @var list<\Unsupervised\Schedular\Offering\Offering> $offerings
* @var \Unsupervised\Schedular\Offering\Offering|null $editing Offering loaded into the form, or null when adding. * @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'); $baseUrl = admin_url('admin.php?page=us-offerings');
@@ -26,6 +29,10 @@ if ($editing && null !== $editing->termStart && null !== $editing->termEnd && $e
<div class="wrap"> <div class="wrap">
<h1><?php esc_html_e('Offerings', 'unsupervised-schedular'); ?></h1> <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> <h2><?php $editing ? esc_html_e('Edit Offering', 'unsupervised-schedular') : esc_html_e('Add Offering', 'unsupervised-schedular'); ?></h2>
<form method="post"> <form method="post">
<?php wp_nonce_field('usc_offering_action'); ?> <?php wp_nonce_field('usc_offering_action'); ?>
@@ -49,6 +56,19 @@ if ($editing && null !== $editing->termStart && null !== $editing->termEnd && $e
</select> </select>
</td> </td>
</tr> </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> <tr>
<th><label for="description"><?php esc_html_e('Description', 'unsupervised-schedular'); ?></label></th> <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> <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> <span class="description"><?php esc_html_e('Group classes only — date of the first class', 'unsupervised-schedular'); ?></span>
</td> </td>
</tr> </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> <tr>
<th><?php esc_html_e('Sessions', 'unsupervised-schedular'); ?></th> <th><?php esc_html_e('Sessions', 'unsupervised-schedular'); ?></th>
<td> <td>
@@ -164,10 +191,17 @@ if ($editing && null !== $editing->termStart && null !== $editing->termEnd && $e
<td> <td>
<?php if (null === $offering->termStart) : ?> <?php if (null === $offering->termStart) : ?>
&mdash; &mdash;
<?php elseif (null === $offering->termEnd || $offering->termEnd === $offering->termStart) : ?>
<?php echo esc_html((string) mysql2date('M j, Y', $offering->termStart)); ?>
<?php else : ?> <?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; ?> <?php endif; ?>
</td> </td>
<td><?php echo $offering->isActive ? esc_html__('Yes', 'unsupervised-schedular') : esc_html__('No', 'unsupervised-schedular'); ?></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::assertCount(1, $slots);
self::assertInstanceOf(AvailabilitySlot::class, $slots[0]); 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('get_users')->justReturn([]);
Functions\when('esc_attr')->returnArg(); Functions\when('esc_attr')->returnArg();
Functions\when('esc_attr_e')->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 private function offering(int $id, string $title, ?int $capacity): Offering
@@ -76,9 +88,29 @@ class GroupClassControllerTest extends TestCase
return (string) ob_get_clean(); 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']); Functions\when('get_userdata')->justReturn((object) ['display_name' => 'Ada Lovelace']);
$_GET = ['class_id' => '8'];
$offering = $this->offering(8, 'Choir', 10); $offering = $this->offering(8, 'Choir', 10);
$enrollment = new Enrollment(offeringId: 8, studentId: 5, instructorId: 3, paymentId: 42, id: 1); $enrollment = new Enrollment(offeringId: 8, studentId: 5, instructorId: 3, paymentId: 42, id: 1);
@@ -92,15 +124,51 @@ class GroupClassControllerTest extends TestCase
$html = $this->renderInstructor(); $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('Ada Lovelace', $html);
self::assertStringContainsString('paid', $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']); Functions\when('get_userdata')->justReturn((object) ['display_name' => 'Grace Hopper']);
$_GET = ['class_id' => '8'];
$offering = $this->offering(8, 'Band', null); $offering = $this->offering(8, 'Band', null);
$active = new Enrollment(offeringId: 8, studentId: 5, instructorId: 3, id: 1); $active = new Enrollment(offeringId: 8, studentId: 5, instructorId: 3, id: 1);
@@ -112,14 +180,15 @@ class GroupClassControllerTest extends TestCase
$html = $this->renderInstructor(); $html = $this->renderInstructor();
// Unlimited capacity offering counts only the active enrolment. // 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. // But the roster still shows the cancelled row.
self::assertStringContainsString('cancelled', $html); self::assertStringContainsString('cancelled', $html);
} }
public function testFreeEnrolmentShowsDashForPayment(): void public function testClassDetailFreeEnrolmentShowsDashForPayment(): void
{ {
Functions\when('get_userdata')->justReturn((object) ['display_name' => 'Alan Turing']); Functions\when('get_userdata')->justReturn((object) ['display_name' => 'Alan Turing']);
$_GET = ['class_id' => '8'];
$offering = $this->offering(8, 'Theory', 5); $offering = $this->offering(8, 'Theory', 5);
$enrollment = new Enrollment(offeringId: 8, studentId: 5, instructorId: 3, paymentId: null, id: 1); $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 public function testEnrolmentsForOtherClassesAreNotMixedIn(): void
{ {
Functions\when('get_userdata')->justReturn((object) ['display_name' => 'Katherine Johnson']);
$offering = $this->offering(8, 'Choir', 5); $offering = $this->offering(8, 'Choir', 5);
$mine = new Enrollment(offeringId: 8, studentId: 5, instructorId: 3, id: 1); $mine = new Enrollment(offeringId: 8, studentId: 5, instructorId: 3, id: 1);
$other = new Enrollment(offeringId: 9, studentId: 6, instructorId: 3, id: 2); $other = new Enrollment(offeringId: 9, studentId: 6, instructorId: 3, id: 2);
@@ -146,11 +213,14 @@ class GroupClassControllerTest extends TestCase
$html = $this->renderInstructor(); $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); $offering = $this->offering(8, 'Jazz', 5);
$this->offerings->shouldReceive('findAll')->once()->andReturn([$offering]); $this->offerings->shouldReceive('findAll')->once()->andReturn([$offering]);
@@ -171,6 +241,64 @@ class GroupClassControllerTest extends TestCase
self::assertStringContainsString('You have no group classes.', $html); 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 public function testDeniesUsersWithoutViewLessonsCapability(): void
{ {
Functions\when('current_user_can')->justReturn(false); Functions\when('current_user_can')->justReturn(false);
@@ -184,6 +312,7 @@ class GroupClassControllerTest extends TestCase
protected function tearDown(): void protected function tearDown(): void
{ {
$_POST = []; $_POST = [];
$_GET = [];
parent::tearDown(); parent::tearDown();
} }
@@ -332,8 +461,13 @@ class GroupClassControllerTest extends TestCase
self::assertStringContainsString('1 student(s) granted access.', $html); 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]]; $_POST = ['usc_action' => 'add_direct', 'offering_id' => 8, 'student_ids' => [5]];
$this->stubActionContext(); $this->stubActionContext();
@@ -346,4 +480,36 @@ class GroupClassControllerTest extends TestCase
self::assertStringContainsString('That group class was not found.', $html); 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 Brain\Monkey\Functions;
use Mockery; use Mockery;
use Unsupervised\Schedular\Offering\ClassSlotReconciler;
use Unsupervised\Schedular\Offering\Offering; use Unsupervised\Schedular\Offering\Offering;
use Unsupervised\Schedular\Offering\OfferingController; use Unsupervised\Schedular\Offering\OfferingController;
use Unsupervised\Schedular\Offering\OfferingRepository; use Unsupervised\Schedular\Offering\OfferingRepository;
@@ -13,6 +14,7 @@ use Unsupervised\Schedular\Tests\Unit\TestCase;
class OfferingControllerTest extends TestCase class OfferingControllerTest extends TestCase
{ {
private OfferingRepository&Mockery\MockInterface $repository; private OfferingRepository&Mockery\MockInterface $repository;
private ClassSlotReconciler&Mockery\MockInterface $reconciler;
private OfferingController $controller; private OfferingController $controller;
protected function setUp(): void protected function setUp(): void
@@ -20,13 +22,16 @@ class OfferingControllerTest extends TestCase
parent::setUp(); parent::setUp();
$this->repository = Mockery::mock(OfferingRepository::class); $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 = []; $_POST = [];
$_GET = []; $_GET = [];
Functions\when('current_user_can')->justReturn(true); Functions\when('current_user_can')->justReturn(true);
Functions\when('get_current_user_id')->justReturn(3); Functions\when('get_current_user_id')->justReturn(3);
Functions\when('get_users')->justReturn([]);
Functions\when('check_admin_referer')->justReturn(true); Functions\when('check_admin_referer')->justReturn(true);
Functions\when('admin_url')->justReturn('admin.php?page=us-offerings'); Functions\when('admin_url')->justReturn('admin.php?page=us-offerings');
Functions\when('add_query_arg')->alias( Functions\when('add_query_arg')->alias(
@@ -70,6 +75,90 @@ class OfferingControllerTest extends TestCase
$this->render(); $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 public function testAddInviteOnlyGroupClassStoresInviteOnlyAccess(): void
{ {
$_POST = [ $_POST = [
@@ -22,6 +22,7 @@ class OfferingEndpointTest extends TestCase
parent::setUp(); parent::setUp();
Functions\when('get_current_user_id')->justReturn(5); 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->repository = Mockery::mock(OfferingRepository::class);
$this->access = Mockery::mock(GroupAccessRepository::class); $this->access = Mockery::mock(GroupAccessRepository::class);
@@ -88,6 +89,20 @@ class OfferingEndpointTest extends TestCase
self::assertSame([], $data); 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 public function testIndexOmitsEtransferEmailFromPublicListing(): void
{ {
$this->repository->shouldReceive('findAll')->andReturn([ $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)); 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 public function testDefaults(): void
{ {
$offering = new Offering(1, Offering::KIND_GROUP_CLASS, 'Choir'); $offering = new Offering(1, Offering::KIND_GROUP_CLASS, 'Choir');