Files
unsupervised-scheduler/src/GroupClass/SessionSchedule.php
T
thatguygriffandClaude Opus 5 c9a1205fc0
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
Never drop an enrolled class from upcoming lessons; delete a guardian's children with them; pin the panel's line spacing
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]>
2026-07-30 12:10:43 -03:00

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;
}
}