Files
unsupervised-scheduler/src/Offering/Offering.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

347 lines
13 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Offering;
use Unsupervised\Schedular\Val;
class Offering {
public const KIND_PRIVATE_LESSON = 'private_lesson';
public const KIND_GROUP_CLASS = 'group_class';
/**
* All valid offering kinds.
*
* @var list<string>
*/
public const VALID_KINDS = [ self::KIND_PRIVATE_LESSON, self::KIND_GROUP_CLASS ];
public const BILLING_ONE_TIME = 'one_time';
public const BILLING_FULL_TERM = 'full_term';
/** Billed 24 hours before each lesson, on a recurring schedule (see scheduled-billing.md). */
public const BILLING_WEEKLY = 'weekly';
/** Billed on the first of each month for every lesson that falls in the month. */
public const BILLING_MONTHLY = 'monthly';
/**
* All valid billing modes.
*
* @var list<string>
*/
public const VALID_BILLING_MODES = [ self::BILLING_ONE_TIME, self::BILLING_FULL_TERM, self::BILLING_WEEKLY, self::BILLING_MONTHLY ];
/**
* Billing modes whose payment is generated later by the daily billing scan
* rather than taken at registration.
*
* @var list<string>
*/
public const SCHEDULED_BILLING_MODES = [ self::BILLING_WEEKLY, self::BILLING_MONTHLY ];
/** Listed in the public catalogue; anyone with `book_lesson` may enrol. */
public const ACCESS_PUBLIC = 'public';
/** Hidden from the catalogue; only invited/added students may enrol (group classes). */
public const ACCESS_INVITE_ONLY = 'invite_only';
/**
* All valid access modes.
*
* @var list<string>
*/
public const VALID_ACCESS_MODES = [ self::ACCESS_PUBLIC, self::ACCESS_INVITE_ONLY ];
/** Maximum length of the title, matching the `title` VARCHAR(191) column. */
public const MAX_TITLE_LENGTH = 191;
/** Maximum length of the schedule note, matching the `schedule_note` VARCHAR(191) column. */
public const MAX_SCHEDULE_NOTE_LENGTH = 191;
/** Maximum length of the e-transfer email, matching the `etransfer_email` VARCHAR(191) column. */
public const MAX_ETRANSFER_EMAIL_LENGTH = 191;
public function __construct(
public readonly int $instructorId,
public readonly string $kind,
public readonly string $title,
public readonly float $price = 0.0,
public readonly string $currency = 'CAD',
public readonly string $billingMode = self::BILLING_ONE_TIME,
public readonly ?string $description = null,
public readonly ?int $durationMinutes = null,
public readonly bool $allowWeekly = false,
public readonly ?int $capacity = null,
public readonly ?string $termStart = null,
public readonly ?string $termEnd = null,
public readonly ?string $classTime = null,
public readonly ?string $enrollmentDeadline = null,
public readonly ?string $withdrawalDeadline = null,
public readonly ?string $scheduleNote = null,
public readonly ?string $etransferEmail = null,
public readonly ?int $cancellationCutoffHours = null,
public readonly string $accessMode = self::ACCESS_PUBLIC,
public readonly bool $isActive = true,
public readonly ?int $id = null,
) {}
/**
* Whether the offering is hidden from the public catalogue and reachable
* only by invited or directly-added students.
*/
public function isInviteOnly(): bool {
return self::ACCESS_INVITE_ONLY === $this->accessMode;
}
/**
* Whether this offering's payment is deferred to the daily billing scan
* (weekly / monthly) instead of being taken at registration.
*/
public function isScheduledBilling(): bool {
return in_array( $this->billingMode, self::SCHEDULED_BILLING_MODES, true );
}
/**
* The last day on which a student may enrol in this group class. Defaults to
* the first day of the class (`term_start`) when the instructor has not set an
* explicit deadline; null only when the class has no dates at all.
*/
public function effectiveEnrollmentDeadline(): ?string {
return $this->enrollmentDeadline ?? $this->termStart;
}
/**
* Whether enrolment is still open on `$today` (a `Y-m-d` date). Enrolment stays
* open through the end of the deadline day, so the first class is still
* enrollable under the default deadline. A class with no deadline at all (no
* dates configured) is always open.
*/
public function isEnrollmentOpen( string $today ): bool {
$deadline = $this->effectiveEnrollmentDeadline();
return null === $deadline || $today <= $deadline;
}
/**
* Whether a student may still withdraw themselves from this group class on
* `$today` (a `Y-m-d` date). Withdrawal stays open through the end of the
* deadline day. Unlike the enrolment deadline there is no implicit default: a
* class with no withdrawal deadline set stays open to withdrawal for its whole
* life, so the instructor must set a date to lock students in. A withdrawal
* made while open never issues an account credit — it only frees the seat and
* voids any still-pending payment.
*/
public function isWithdrawalOpen( string $today ): bool {
return null === $this->withdrawalDeadline || $today <= $this->withdrawalDeadline;
}
/**
* Normalise a submitted term date to canonical `Y-m-d`, or null when it is
* not a real calendar date. Round-trips through DateTimeImmutable so
* strings PHP would silently coerce (e.g. `2026-02-30`) are rejected.
*/
public static function normalizeDate( string $value ): ?string {
$date = \DateTimeImmutable::createFromFormat( '!Y-m-d', $value );
return false !== $date && $date->format( 'Y-m-d' ) === $value ? $date->format( 'Y-m-d' ) : null;
}
/**
* Last class date of a weekly term: the start date plus `$occurrences - 1`
* weeks. A one-off class (one occurrence) ends the day it starts.
*/
public static function weeklyTermEnd( string $termStart, int $occurrences ): string {
$weeks = max( 1, $occurrences ) - 1;
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 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.
*
* 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 sessionStarts(): array {
if ( null === $this->termStart || null === $this->classTime ) {
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;
$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++ ) {
$starts[] = $cursor->format( 'Y-m-d H:i:s' );
$cursor = $cursor->modify( '+7 days' );
$cursorDay = $cursor->format( 'Y-m-d' );
}
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 {
return new self(
instructorId: Val::int( $row->instructor_id ),
kind: Val::string( $row->kind ),
title: Val::string( $row->title ),
price: Val::float( $row->price ),
currency: Val::string( $row->currency ),
billingMode: Val::string( $row->billing_mode ),
description: Val::stringOrNull( $row->description ),
durationMinutes: Val::intOrNull( $row->duration_minutes ),
allowWeekly: Val::bool( $row->allow_weekly ),
capacity: Val::intOrNull( $row->capacity ),
termStart: Val::stringOrNull( $row->term_start ),
termEnd: Val::stringOrNull( $row->term_end ),
classTime: Val::stringOrNull( $row->class_time ?? null ),
enrollmentDeadline: Val::stringOrNull( $row->enrollment_deadline ?? null ),
withdrawalDeadline: Val::stringOrNull( $row->withdrawal_deadline ?? null ),
scheduleNote: Val::stringOrNull( $row->schedule_note ),
etransferEmail: Val::stringOrNull( $row->etransfer_email ),
cancellationCutoffHours: Val::intOrNull( $row->cancellation_cutoff_hours ),
accessMode: '' !== Val::string( $row->access_mode ?? '' ) ? Val::string( $row->access_mode ) : self::ACCESS_PUBLIC,
isActive: Val::bool( $row->is_active ),
id: Val::int( $row->id ),
);
}
/**
* Returns a plain array representation of the offering.
*
* The e-transfer destination email is a private payment-routing detail, so it
* is only included when $includeEtransferEmail is true (e.g. manager-only
* responses). The public offerings listing must omit it.
*
* @return array<string, mixed>
*/
public function toArray( bool $includeEtransferEmail = true ): array {
$out = [
'id' => $this->id,
'instructor_id' => $this->instructorId,
'kind' => $this->kind,
'title' => $this->title,
'description' => $this->description,
'duration_minutes' => $this->durationMinutes,
'price' => $this->price,
'currency' => $this->currency,
'billing_mode' => $this->billingMode,
'allow_weekly' => $this->allowWeekly,
'capacity' => $this->capacity,
'term_start' => $this->termStart,
'term_end' => $this->termEnd,
'class_time' => $this->classTime,
'enrollment_deadline' => $this->enrollmentDeadline,
'withdrawal_deadline' => $this->withdrawalDeadline,
'schedule_note' => $this->scheduleNote,
'cancellation_cutoff_hours' => $this->cancellationCutoffHours,
'access_mode' => $this->accessMode,
'is_active' => $this->isActive,
];
if ( $includeEtransferEmail ) {
$out['etransfer_email'] = $this->etransferEmail;
}
return $out;
}
}