Never drop an enrolled class from upcoming lessons; delete a guardian's children with them; pin the panel's line spacing
CI / Tests (PHP 8.2) (pull_request) Successful in 58s
CI / PHPStan (pull_request) Successful in 2m53s
CI / Coding Standards (pull_request) Successful in 2m58s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m47s
CI / Build Plugin Zip (pull_request) Skipped
CI / Tests (PHP 8.1) (pull_request) Successful in 1m1s
CI / No Debug Code (pull_request) Successful in 3s

Three fixes from testing the branch.

A group class was only listed when its schedule resolved to exact
datetimes, which needs a class time *and* a duration — both optional on
the offering form, and the schedule note exists precisely so a studio can
write "Tuesdays 4:00pm" instead. A class configured that way vanished
from the list, which is the one thing this feature must never do. So
Offering::sessionStarts() splits "when does it meet" from "how long does
it run" (sessionWindows() is that plus the duration, unchanged), and
SessionSchedule degrades instead of disappearing: dated rows with an open
end when there is no duration, and a single row carrying
Offering::scheduleLabel() when there is no time to derive dates from.
Only a class whose last day has passed drops out.

Deleting a guardian now deletes the children linked to them, releasing
each one's lessons and enrolments first. A child account is login-less
and exists only so the guardian has somebody to book for; without the
guardian nobody can reach it, book for it, or be billed for it, so it was
left stranded on the roster still holding slots. A `handled` set makes
the re-entrant delete_user each child deletion fires a no-op, and stops a
circular link recursing.

The upcoming panel never stated its own line-height, so a theme setting
line-height: 0 above it — the usual icon-font reset — was inherited
straight through. Below 1 that produces both reported symptoms at once:
stacked lines overlap, and the status pill's background is shorter than
the text in it. Pinned at the same id-level specificity as the rest.

Tests: composer test (863), composer lint, composer cs all pass.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
2026-07-30 12:10:43 -03:00
co-authored by Claude Opus 5
parent cb347ffca0
commit c9a1205fc0
14 changed files with 663 additions and 89 deletions
+62 -7
View File
@@ -8,10 +8,13 @@ use Unsupervised\Schedular\Booking\BookingRepository;
use Unsupervised\Schedular\Booking\Lesson;
use Unsupervised\Schedular\GroupClass\Enrollment;
use Unsupervised\Schedular\GroupClass\EnrollmentRepository;
use Unsupervised\Schedular\Guardian\GuardianRepository;
use Unsupervised\Schedular\Guardian\GuardianService;
use Unsupervised\Schedular\Payment\PaymentService;
/**
* Gives back what a deleted account was holding.
* Gives back what a deleted account was holding, and takes the accounts that
* only existed underneath it with it.
*
* WordPress deletes a user without knowing anything about lessons, so a student
* removed from **Users → Delete** used to leave their bookings behind: the
@@ -24,6 +27,13 @@ use Unsupervised\Schedular\Payment\PaymentService;
* lessons are deliberately left alone: they happened, they may have been paid
* for, and the payment report has to keep adding up.
*
* A **guardian** takes their children with them. A child account is login-less
* and exists only so the guardian has somebody to book for; without the
* guardian nobody can reach it, book for it, or be billed for it, so leaving it
* behind leaves an unreachable student on the roster holding slots that will
* never be used. Each child's bookings are released on the same terms, the link
* row goes, and the account is deleted.
*
* No account credit is issued for a paid lesson, unlike a cancellation the
* student asks for. A credit only has value against future billing on the
* account it belongs to, and that account is being deleted; a refund owed to
@@ -32,38 +42,62 @@ use Unsupervised\Schedular\Payment\PaymentService;
*/
class DeletedUserCleanup {
/**
* Accounts already dealt with this request, so deleting a guardian's child
* — which fires `delete_user` again and re-enters this very handler — cannot
* loop or redo work. It also makes a self-referential or circular guardian
* link, however it got into the table, terminate rather than recurse.
*
* @var array<int, true>
*/
private array $handled = [];
public function __construct(
private BookingRepository $bookings,
private AvailabilityRepository $availability,
private EnrollmentRepository $enrollments,
private PaymentService $payments,
private GuardianRepository $links,
private GuardianService $guardians,
) {}
public function register(): void {
// `delete_user` fires before the row goes, which is what lets the lookups
// below still find the account's bookings. `wpmu_delete_user` is the
// multisite equivalent for a user removed from the network entirely.
// below still find the account's bookings and children. `wpmu_delete_user`
// is the multisite equivalent for a user removed from the network entirely.
add_action( 'delete_user', [ $this, 'releaseBookings' ] );
add_action( 'wpmu_delete_user', [ $this, 'releaseBookings' ] );
}
/**
* Cancel and release everything the account had booked ahead of it.
* Release everything the account had booked ahead of it, then remove any
* children that only existed to be booked for.
*/
public function releaseBookings( int $userId ): void {
if ( $userId <= 0 ) {
if ( $userId <= 0 || isset( $this->handled[ $userId ] ) ) {
return;
}
$this->handled[ $userId ] = true;
$this->release( $userId );
$this->removeChildren( $userId );
}
/**
* Cancel one account's upcoming lessons and active enrolments, freeing the
* slot and voiding the pending payment behind each.
*/
private function release( int $studentId ): void {
// Upcoming and not already cancelled — the only bookings that are still
// holding anything.
foreach ( $this->bookings->findUpcomingForStudent( $userId ) as $lesson ) {
foreach ( $this->bookings->findUpcomingForStudent( $studentId ) as $lesson ) {
$this->bookings->updateStatus( (int) $lesson->id, Lesson::STATUS_CANCELLED );
$this->availability->release( $lesson->slotId );
$this->payments->voidPending( $lesson->paymentId );
}
foreach ( $this->enrollments->findByStudent( $userId ) as $enrollment ) {
foreach ( $this->enrollments->findByStudent( $studentId ) as $enrollment ) {
if ( Enrollment::STATUS_ACTIVE !== $enrollment->status ) {
continue;
}
@@ -72,4 +106,25 @@ class DeletedUserCleanup {
$this->payments->voidPending( $enrollment->paymentId );
}
}
/**
* Delete every child linked to a departing guardian, releasing what each was
* holding first. Each child is marked handled *before* it is deleted, so the
* `delete_user` this fires re-enters and returns without redoing the release.
*/
private function removeChildren( int $guardianId ): void {
foreach ( $this->links->findByGuardian( $guardianId ) as $link ) {
$childId = $link->studentId;
if ( $childId <= 0 || $childId === $guardianId || isset( $this->handled[ $childId ] ) ) {
continue;
}
$this->handled[ $childId ] = true;
$this->release( $childId );
$this->links->delete( $guardianId, $childId );
$this->guardians->deleteUser( $childId );
}
}
}
+4
View File
@@ -205,6 +205,7 @@ class StudentController {
return [
'id' => (int) $lesson->id,
'kind' => 'lesson',
'schedule' => null,
'start_dt' => $slot ? $slot->startDt : '',
'end_dt' => $slot ? $slot->endDt : '',
'offering' => $offering ? $offering->title : '—',
@@ -231,6 +232,9 @@ class StudentController {
'kind' => SessionSchedule::KIND,
'start_dt' => $session['start_dt'],
'end_dt' => $session['end_dt'],
// Set when the class has no time to put on a clock; shown in the
// When column in place of a date. See GroupClass\SessionSchedule.
'schedule' => $session['schedule'],
'offering' => $session['offering_title'],
'instructor' => $instructor ? $instructor->display_name : (string) $session['instructor_id'],
'status' => $session['status'],
+123 -43
View File
@@ -13,13 +13,25 @@ use Unsupervised\Schedular\Offering\OfferingRepository;
* A group class is stored as a term (`term_start`, `term_end`, `class_time`)
* rather than as rows in `us_availability`, which is why an enrolment on its own
* has no date on it and why nothing that listed lessons ever showed one. The
* concrete windows come from {@see Offering::sessionWindows()} — the same
* derivation the billing scan and the class-slot reconciler use, so a student's
* list, an instructor's list and the invoice all agree on when the class meets.
* dates come from {@see Offering::sessionStarts()} — the same derivation the
* billing scan and the class-slot reconciler build on, so a student's list, an
* instructor's list and the invoice all agree on when the class meets.
*
* A class whose schedule is not fully specified yields no windows and so
* contributes no rows: better to leave it out of a dated list than to invent a
* time for it.
* **A class you are enrolled in must never silently vanish from the list.** Both
* the class time and the duration are optional on the offering form, and the
* schedule note exists precisely so a studio can write "Tuesdays 4:00pm" instead
* of pinning the class to a clock. So the schedule degrades rather than
* disappearing:
*
* - date **and** time set — one dated row per remaining session, closed off with
* the duration when there is one and left open-ended when there is not;
* - no time to derive dates from — a single row for the class as a whole, sorted
* by when the term starts and labelled with `schedule` text
* ({@see Offering::scheduleLabel()}) in place of a time.
*
* A row's `schedule` is the tell: non-null means "this is a class, described in
* words, not a session at a known time", and every renderer shows that text
* instead of a date and time.
*/
class SessionSchedule {
@@ -42,7 +54,7 @@ class SessionSchedule {
* kept, since "completed" describes the enrolment's billing state and says
* nothing about whether the class has met yet.
*
* @return list<array{enrollment_id: int, offering_id: int, offering_title: string, instructor_id: int, status: string, start_dt: string, end_dt: string, duration_minutes: int|null}>
* @return list<array{enrollment_id: int, offering_id: int, offering_title: string, instructor_id: int, status: string, start_dt: string, end_dt: string, duration_minutes: int|null, schedule: string|null}>
*/
public function upcomingForStudent( int $studentId, string $now ): array {
$rows = [];
@@ -57,18 +69,10 @@ class SessionSchedule {
continue;
}
foreach ( $this->windowsFrom( $offering, $now ) as $window ) {
$rows[] = [
'enrollment_id' => (int) $enrollment->id,
'offering_id' => (int) $offering->id,
'offering_title' => $offering->title,
'instructor_id' => $enrollment->instructorId,
'status' => $enrollment->status,
'start_dt' => $window['start'],
'end_dt' => $window['end'],
'duration_minutes' => $offering->durationMinutes,
];
}
$rows = array_merge(
$rows,
$this->rowsFor( $offering, $now, (int) $enrollment->id, $enrollment->instructorId, $enrollment->status )
);
}
return self::sortedByStart( $rows );
@@ -80,7 +84,7 @@ class SessionSchedule {
* are not consulted at all: a class the instructor has to turn up and teach
* belongs on their schedule whether or not anyone has signed up yet.
*
* @return list<array{enrollment_id: int, offering_id: int, offering_title: string, instructor_id: int, status: string, start_dt: string, end_dt: string, duration_minutes: int|null}>
* @return list<array{enrollment_id: int, offering_id: int, offering_title: string, instructor_id: int, status: string, start_dt: string, end_dt: string, duration_minutes: int|null, schedule: string|null}>
*/
public function upcomingForInstructor( int $instructorId, string $now ): array {
$rows = [];
@@ -88,43 +92,119 @@ class SessionSchedule {
$classes = $this->offerings->findAll( $instructorId, Offering::KIND_GROUP_CLASS, activeOnly: true );
foreach ( $classes as $offering ) {
foreach ( $this->windowsFrom( $offering, $now ) as $window ) {
$rows[] = [
'enrollment_id' => 0,
'offering_id' => (int) $offering->id,
'offering_title' => $offering->title,
'instructor_id' => $instructorId,
'status' => Enrollment::STATUS_ACTIVE,
'start_dt' => $window['start'],
'end_dt' => $window['end'],
'duration_minutes' => $offering->durationMinutes,
];
}
$rows = array_merge( $rows, $this->rowsFor( $offering, $now, 0, $instructorId, Enrollment::STATUS_ACTIVE ) );
}
return self::sortedByStart( $rows );
}
/**
* The class's session windows that have not started yet.
* One class's contribution to an upcoming list: its remaining dated sessions,
* or — when it has no time to derive dates from — a single row describing the
* class in words. Empty only when the class has demonstrably finished.
*
* @return list<array{start: string, end: string}>
* @return list<array{enrollment_id: int, offering_id: int, offering_title: string, instructor_id: int, status: string, start_dt: string, end_dt: string, duration_minutes: int|null, schedule: string|null}>
*/
private function windowsFrom( Offering $offering, string $now ): array {
return array_values(
array_filter(
$offering->sessionWindows(),
static fn( array $window ): bool => $window['start'] >= $now
)
);
private function rowsFor( Offering $offering, string $now, int $enrollmentId, int $instructorId, string $status ): array {
$base = [
'enrollment_id' => $enrollmentId,
'offering_id' => (int) $offering->id,
'offering_title' => $offering->title,
'instructor_id' => $instructorId,
'status' => $status,
'duration_minutes' => $offering->durationMinutes,
];
$starts = $offering->sessionStarts();
// Dated: the class says exactly when it meets, so list what is left of it
// — and nothing at all once the term is over.
if ( [] !== $starts ) {
$rows = [];
foreach ( $starts as $start ) {
if ( $start < $now ) {
continue;
}
$rows[] = $base + [
'start_dt' => $start,
// Left open when no duration is set. Knowing a class starts at
// four o'clock is worth showing even without knowing when it
// ends; guessing an end time is not.
'end_dt' => $this->endOf( $offering, $start ),
'schedule' => null,
];
}
return $rows;
}
// Undated: no class time, so there is nothing to put on a clock. The class
// still gets a row — it is enrolled in and running — described by the
// studio's own schedule note or its term dates.
if ( ! $this->isStillRunning( $offering, $now ) ) {
return [];
}
return [
$base + [
// A sort key, not a claim about when the class meets: a class yet to
// start sorts to its first day, one already under way to right now.
// `schedule` is what any renderer actually shows.
'start_dt' => $this->sortKeyFor( $offering, $now ),
'end_dt' => '',
'schedule' => $offering->scheduleLabel(),
],
];
}
/**
* When a session that starts at `$start` finishes, or an empty string when the
* class has no duration to close it off with.
*/
private function endOf( Offering $offering, string $start ): string {
if ( null === $offering->durationMinutes || $offering->durationMinutes <= 0 ) {
return '';
}
return ( new \DateTimeImmutable( $start ) )
->add( new \DateInterval( 'PT' . $offering->durationMinutes . 'M' ) )
->format( 'Y-m-d H:i:s' );
}
/**
* Whether an undated class still has life in it: its last day has not passed,
* or it has no dates at all (in which case nothing says it has ended, and
* dropping it would be the very disappearance this class exists to prevent).
*/
private function isStillRunning( Offering $offering, string $now ): bool {
$lastDay = $offering->lastClassDay();
return null === $lastDay || $lastDay >= substr( $now, 0, 10 );
}
/**
* Where an undated class sits in a list ordered by time: at its first day when
* that is still ahead, otherwise at `$now`, so a term already under way reads
* as current rather than as ancient history.
*/
private function sortKeyFor( Offering $offering, string $now ): string {
if ( null === $offering->termStart ) {
return $now;
}
$firstDay = $offering->termStart . ' 00:00:00';
return $firstDay > $now ? $firstDay : $now;
}
/**
* Soonest session first, so classes from separate enrolments interleave by
* date rather than arriving grouped by class.
*
* @param list<array{enrollment_id: int, offering_id: int, offering_title: string, instructor_id: int, status: string, start_dt: string, end_dt: string, duration_minutes: int|null}> $rows
* @return list<array{enrollment_id: int, offering_id: int, offering_title: string, instructor_id: int, status: string, start_dt: string, end_dt: string, duration_minutes: int|null}>
* @param list<array{enrollment_id: int, offering_id: int, offering_title: string, instructor_id: int, status: string, start_dt: string, end_dt: string, duration_minutes: int|null, schedule: string|null}> $rows
* @return list<array{enrollment_id: int, offering_id: int, offering_title: string, instructor_id: int, status: string, start_dt: string, end_dt: string, duration_minutes: int|null, schedule: string|null}>
*/
private static function sortedByStart( array $rows ): array {
usort( $rows, static fn( array $a, array $b ): int => strcmp( $a['start_dt'], $b['start_dt'] ) );
+78 -21
View File
@@ -175,22 +175,19 @@ class Offering {
}
/**
* 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.
* The datetime each session of this group class starts, derived from the class
* date(s) and the class time. A weekly class yields one per week from
* `term_start` through `term_end`; a one-off class yields a single one.
*
* @return list<array{start: string, end: string}>
* Deliberately does **not** need a duration: knowing *when* a class meets is a
* separate question from knowing how long it runs, and a studio can quite
* reasonably set the first without the second. Returns an empty list when
* there is no date or no time, since neither can be invented.
*
* @return list<string> `Y-m-d H:i:s` starts, earliest first.
*/
public function sessionWindows(): array {
if (
null === $this->termStart
|| null === $this->classTime
|| null === $this->durationMinutes
|| $this->durationMinutes <= 0
) {
public function sessionStarts(): array {
if ( null === $this->termStart || null === $this->classTime ) {
return [];
}
@@ -200,25 +197,85 @@ class Offering {
}
$lastDay = null !== $this->termEnd ? $this->termEnd : $this->termStart;
$step = new \DateInterval( 'PT' . $this->durationMinutes . 'M' );
$windows = [];
$starts = [];
$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' ),
];
$starts[] = $cursor->format( 'Y-m-d H:i:s' );
$cursor = $cursor->modify( '+7 days' );
$cursorDay = $cursor->format( 'Y-m-d' );
}
return $windows;
return $starts;
}
/**
* The concrete start/end datetimes of every session of this group class:
* {@see sessionStarts()} closed off with the class duration. 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 —
* callers that block availability or bill per session need both ends.
*
* @return list<array{start: string, end: string}>
*/
public function sessionWindows(): array {
if ( null === $this->durationMinutes || $this->durationMinutes <= 0 ) {
return [];
}
$step = new \DateInterval( 'PT' . $this->durationMinutes . 'M' );
return array_map(
static fn( string $start ): array => [
'start' => $start,
'end' => ( new \DateTimeImmutable( $start ) )->add( $step )->format( 'Y-m-d H:i:s' ),
],
$this->sessionStarts()
);
}
/**
* The last day this class meets, or null when it has no dates at all.
*/
public function lastClassDay(): ?string {
return $this->termEnd ?? $this->termStart;
}
/**
* Plain-language wording for when this class meets, for the places that have
* to say something about a class whose schedule cannot be resolved to dates.
* Prefers the studio's own note ("Tuesdays 4:00pm") — that field exists
* precisely so a class can describe its schedule without pinning it to a
* time — then the term dates, and finally an honest admission that nothing
* has been set.
*/
public function scheduleLabel(): string {
$note = null !== $this->scheduleNote ? trim( $this->scheduleNote ) : '';
if ( '' !== $note ) {
return $note;
}
if ( null === $this->termStart ) {
return __( 'Schedule to be confirmed', 'unsupervised-schedular' );
}
$start = (string) mysql2date( 'M j, Y', $this->termStart );
if ( null === $this->termEnd || $this->termEnd === $this->termStart ) {
return $start;
}
return sprintf(
/* translators: 1: first class date, 2: last class date. */
__( '%1$s %2$s', 'unsupervised-schedular' ),
$start,
(string) mysql2date( 'M j, Y', $this->termEnd )
);
}
public static function fromRow( \stdClass $row ): self {
+1 -1
View File
@@ -110,7 +110,7 @@ class Plugin {
( new RegistrationLoginGate() )->register();
( new ChildLoginGate() )->register();
( new StudentAdminGuard() )->register();
( new DeletedUserCleanup( $bookings, $availability, $enrollments, $paymentService ) )->register();
( new DeletedUserCleanup( $bookings, $availability, $enrollments, $paymentService, $guardianRepo, $guardians ) )->register();
( new EmailConfirmationHandler( $settings, $registrationMailer ) )->register();
( new AdminMenu( $availability, $bookings, $offerings, $questions, $answers, $policies, $policyVersions, $policyService, $acceptances, $invites, $enrollments, $groupAccess, $settings, $paymentRepo, $paymentService, $resolver, $registrationMailer, $creditRepo, $guardians ) )->register();
( new RestRegistrar( $availability, $bookings, $offerings, $questions, $policies, $policyVersions, $policyService, $registrationGate, $enrollments, $groupAccess, $paymentService, $guardians ) )->register();