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]>
215 lines
8.1 KiB
PHP
215 lines
8.1 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
|
|
namespace Unsupervised\Schedular\GroupClass;
|
|
|
|
use Unsupervised\Schedular\Offering\Offering;
|
|
use Unsupervised\Schedular\Offering\OfferingRepository;
|
|
|
|
/**
|
|
* Turns group-class enrolments into dated sessions, so a class can appear
|
|
* alongside one-to-one lessons in every "upcoming" view.
|
|
*
|
|
* 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
|
|
* 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 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 {
|
|
|
|
/**
|
|
* Marks a row as a group-class session rather than a one-to-one lesson.
|
|
* Callers use it to withhold the per-lesson actions (cancel, detail links)
|
|
* that only mean something for a booked slot.
|
|
*/
|
|
public const KIND = 'group_class';
|
|
|
|
public function __construct(
|
|
private EnrollmentRepository $enrollments,
|
|
private OfferingRepository $offerings,
|
|
) {}
|
|
|
|
/**
|
|
* Upcoming sessions of every class a student is enrolled in, soonest first.
|
|
*
|
|
* A withdrawn (cancelled) enrolment contributes nothing; a completed one is
|
|
* 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, schedule: string|null}>
|
|
*/
|
|
public function upcomingForStudent( int $studentId, string $now ): array {
|
|
$rows = [];
|
|
|
|
foreach ( $this->enrollments->findByStudent( $studentId ) as $enrollment ) {
|
|
if ( Enrollment::STATUS_CANCELLED === $enrollment->status ) {
|
|
continue;
|
|
}
|
|
|
|
$offering = $this->offerings->findById( $enrollment->offeringId );
|
|
if ( null === $offering ) {
|
|
continue;
|
|
}
|
|
|
|
$rows = array_merge(
|
|
$rows,
|
|
$this->rowsFor( $offering, $now, (int) $enrollment->id, $enrollment->instructorId, $enrollment->status )
|
|
);
|
|
}
|
|
|
|
return self::sortedByStart( $rows );
|
|
}
|
|
|
|
/**
|
|
* Upcoming sessions of every active group class an instructor teaches,
|
|
* soonest first — one row per session, not per enrolled student. Enrolments
|
|
* 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, schedule: string|null}>
|
|
*/
|
|
public function upcomingForInstructor( int $instructorId, string $now ): array {
|
|
$rows = [];
|
|
|
|
$classes = $this->offerings->findAll( $instructorId, Offering::KIND_GROUP_CLASS, activeOnly: true );
|
|
|
|
foreach ( $classes as $offering ) {
|
|
$rows = array_merge( $rows, $this->rowsFor( $offering, $now, 0, $instructorId, Enrollment::STATUS_ACTIVE ) );
|
|
}
|
|
|
|
return self::sortedByStart( $rows );
|
|
}
|
|
|
|
/**
|
|
* 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{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 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, 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'] ) );
|
|
|
|
return $rows;
|
|
}
|
|
}
|