11 Commits
Author SHA1 Message Date
thatguygriff 36e7178158 Merge pull request 'Show booked lesson info on upcoming lists and add admin booking detail' (#104) from feature/lesson-booking-detail into main
CI / Tests (PHP 8.2) (push) Successful in 39s
CI / Coding Standards (push) Successful in 2m52s
CI / PHPStan (push) Successful in 2m55s
CI / Tests (PHP 8.3) (push) Successful in 2m36s
CI / Tests (PHP 8.1) (push) Successful in 55s
CI / No Debug Code (push) Successful in 2s
CI / Build Plugin Zip (push) Successful in 2m46s
Reviewed-on: #104
2026-07-24 14:12:33 +00:00
thatguygriffandClaude Opus 4.8 32619a1b75 Show booked lesson info on upcoming lists and add admin booking detail
CI / Tests (PHP 8.1) (pull_request) Successful in 46s
CI / Coding Standards (pull_request) Successful in 2m53s
CI / Tests (PHP 8.2) (pull_request) Successful in 44s
CI / No Debug Code (pull_request) Successful in 3s
CI / PHPStan (pull_request) Successful in 3m12s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m36s
CI / Build Plugin Zip (pull_request) Skipped
Front end: the student "upcoming lessons" panel now shows each booked
offering's name and length next to the time, and renders only the soonest
five lessons with a "Show all" reveal. GET /bookings returns offering_title
and duration_minutes so the list needs no extra request.

Admin: the Scheduler and My Lessons week/list views now show the booked
offering, and each lesson links to a detail view showing the policy versions
the student accepted (with acceptance time and IP) and their intake answers.
On My Lessons an instructor may only open their own lessons; the studio
Scheduler may open any.

composer test / composer lint / composer cs all pass.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-24 11:07:06 -03:00
thatguygriff 1a447743b3 Merge pull request 'Group-class enrolment deadline with instructor late-enrolment override' (#103) from feature/group-class-enrollment-deadline into main
CI / Tests (PHP 8.1) (push) Successful in 47s
CI / PHPStan (push) Successful in 2m52s
CI / Tests (PHP 8.3) (push) Successful in 2m39s
CI / Tests (PHP 8.2) (push) Successful in 47s
CI / No Debug Code (push) Successful in 2s
CI / Coding Standards (push) Successful in 3m4s
CI / Build Plugin Zip (push) Successful in 2m47s
Reviewed-on: #103
2026-07-24 13:45:54 +00:00
thatguygriffandClaude Opus 4.8 fc7c0fa966 Show "Enrol by <date>" on the group-class card while enrolment is open
CI / Build Plugin Zip (pull_request) Skipped
CI / Tests (PHP 8.1) (pull_request) Successful in 46s
CI / Tests (PHP 8.2) (pull_request) Successful in 48s
CI / No Debug Code (pull_request) Successful in 2s
CI / PHPStan (pull_request) Successful in 2m47s
CI / Coding Standards (pull_request) Successful in 3m1s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m40s
The front end used the deadline only to gate the Enrol button; students had
no way to see when enrolment closes. Add an "Enrol by <date>" line to each
class card, shown while enrolment is still open, for the effective deadline
(the instructor's date, or the first class day by default).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-24 10:36:51 -03:00
thatguygriffandClaude Opus 4.8 bf29162587 Add group-class enrolment deadline with instructor late-enrolment override
CI / Tests (PHP 8.1) (pull_request) Successful in 48s
CI / Tests (PHP 8.2) (pull_request) Successful in 47s
CI / No Debug Code (pull_request) Successful in 3s
CI / PHPStan (pull_request) Successful in 2m51s
CI / Coding Standards (pull_request) Successful in 3m2s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m37s
CI / Build Plugin Zip (pull_request) Skipped
Group classes gain an instructor-set enrolment deadline (new
us_offerings.enrollment_deadline column) that defaults to the first day of
the class (term_start). Past the deadline students can no longer self-enrol:
the enrolment endpoint rejects it (403 enrollment_closed) and the front-end
class list shows "Enrolment has closed." in place of the Enrol button.

Instructors keep a manual path: the "Add students directly" control on each
class's details page now renders for public classes too (not just
invite-only) and deliberately bypasses the deadline and capacity, so a
student can be added as a late enrolment after the class has closed. Past
the deadline the details page labels these as late enrolments.

Bumps USC_VERSION to 1.1.3 for the schema change.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-24 10:19:38 -03:00
thatguygriff 991ed2f5ad Merge pull request 'Bump version to 1.1.2' (#102) from release/bump-1.1.2 into main
CI / Tests (PHP 8.1) (push) Successful in 44s
CI / Tests (PHP 8.2) (push) Successful in 43s
CI / No Debug Code (push) Successful in 2s
CI / Tests (PHP 8.3) (push) Successful in 2m36s
CI / Coding Standards (push) Successful in 2m45s
CI / PHPStan (push) Successful in 3m15s
CI / Build Plugin Zip (push) Successful in 2m53s
Reviewed-on: #102
2026-07-24 11:56:08 +00:00
Release Bot ad2ddefebf Bump version to 1.1.2 and open changelog section 2026-07-24 11:54:08 +00:00
thatguygriff 1fe28d5575 Merge pull request 'Show the Enable auto-updates toggle for the self-updater' (#101) from feature/auto-update-toggle into main
CI / Coding Standards (push) Successful in 2m52s
CI / No Debug Code (push) Successful in 2s
CI / Tests (PHP 8.2) (push) Successful in 40s
CI / Tests (PHP 8.1) (push) Successful in 54s
CI / PHPStan (push) Successful in 2m49s
CI / Tests (PHP 8.3) (push) Successful in 2m40s
Release / Build and Publish Release (push) Successful in 3m1s
Release / Open next-version bump PR (push) Successful in 5s
CI / Build Plugin Zip (push) Successful in 2m47s
Reviewed-on: #101
2026-07-24 11:47:24 +00:00
thatguygriffandClaude Opus 4.8 51dd032668 Show the Enable auto-updates toggle for the self-updater
CI / Tests (PHP 8.1) (pull_request) Successful in 47s
CI / Tests (PHP 8.2) (pull_request) Successful in 47s
CI / No Debug Code (pull_request) Successful in 3s
CI / Coding Standards (pull_request) Successful in 3m7s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m38s
CI / PHPStan (pull_request) Successful in 2m50s
CI / Build Plugin Zip (pull_request) Skipped
WordPress only renders the "Enable auto-updates" toggle for a plugin that
appears in the update_plugins transient's response or no_update list, which
is what sets core's update-supported flag. UpdateChecker only populated the
response side (when a newer release existed), so between releases the plugin
was absent from the transient and the toggle never showed.

provideUpdate() now returns a no_update payload (installed version, empty
package) whenever no newer release is offered — including when the release
lookup fails — so the plugin stays in the transient and the toggle appears.
The response path (one-click and unattended updates) is unchanged.

Bumps to 1.1.1 so the fix ships to installed sites via the self-updater.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-24 08:46:31 -03:00
thatguygriff 37ec8a3315 Merge pull request 'Bump version to 1.1.1' (#100) from release/bump-1.1.1 into main
CI / Tests (PHP 8.2) (push) Successful in 46s
CI / Tests (PHP 8.1) (push) Successful in 46s
CI / PHPStan (push) Successful in 2m47s
CI / Coding Standards (push) Successful in 2m50s
CI / Build Plugin Zip (push) Successful in 2m49s
CI / No Debug Code (push) Successful in 2s
CI / Tests (PHP 8.3) (push) Successful in 2m40s
Reviewed-on: #100
2026-07-23 21:29:37 +00:00
Release Bot f93c5aba05 Bump version to 1.1.1 and open changelog section 2026-07-23 21:09:01 +00:00
33 changed files with 988 additions and 97 deletions
+13
View File
@@ -11,6 +11,19 @@ When a `v*` tag is pushed, `.gitea/workflows/release.yml` publishes the matching
the plugin to the next patch version and adds a fresh section here for it. Record
each change under the current top section as you work.
## [1.1.3]
### Added
- Group classes now carry an **enrolment deadline** the instructor sets on the offering. It defaults to the first day of the class, and once it passes students can no longer enrol — the enrolment page shows the class as closed and the API rejects late enrolments. While enrolment is open, each class card shows an "Enrol by" date.
- Instructors can add students to any group class by hand from its details page (**Add students directly**), which now appears for public classes too, not just invite-only ones. This bypasses the enrolment deadline and capacity, so a student can be enrolled as a **late enrolment** after the class has closed to self-enrolment.
## [1.1.2]
## [1.1.1]
### Fixed
- The **Enable auto-updates** toggle now appears for the plugin on the Plugins screen. The self-updater now reports the plugin to WordPress even when it is already current, so core marks it update-supported and shows the toggle; previously the toggle was hidden between releases.
## [1.1.0]
### Added
+32
View File
@@ -46,6 +46,26 @@
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
}
.us-my-lesson-info {
display: flex;
flex-direction: column;
gap: 2px;
}
.us-my-lesson-title {
font-size: 1.05em;
}
.us-my-lesson-duration {
font-weight: normal;
color: #666;
}
.us-my-lesson-when {
color: #555;
}
.us-my-lesson-actions {
@@ -54,6 +74,18 @@
align-items: center;
}
.us-show-all-lessons {
background: transparent;
border: 1px solid #ccc;
border-radius: 4px;
padding: 6px 14px;
cursor: pointer;
}
.us-show-all-lessons:hover {
border-color: #888;
}
.us-cancel-lesson {
background: transparent;
border: 1px solid #ccc;
+38 -9
View File
@@ -388,6 +388,25 @@
return status.charAt(0).toUpperCase() + status.slice(1);
}
// How many upcoming lessons to show before the "Show all" reveal.
const INITIAL_LESSON_COUNT = 5;
function lessonRowHtml(l) {
const title = l.offering_title ? escHtml(String(l.offering_title)) : 'Lesson';
const duration = l.duration_minutes ? ` <span class="us-my-lesson-duration">(${escHtml(String(l.duration_minutes))} min)</span>` : '';
return `
<div class="us-my-lesson">
<span class="us-my-lesson-info">
<strong class="us-my-lesson-title">${title}${duration}</strong>
<span class="us-my-lesson-when">${escHtml(dayLabel(dayKey(l.start_dt)))} · ${escHtml(timeOf(l.start_dt))}${escHtml(timeOf(l.end_dt))}</span>
</span>
<span class="us-my-lesson-actions">
<span class="us-lesson-status us-lesson-status-${escHtml(String(l.status))}">${escHtml(lessonStatusLabel(String(l.status)))}</span>
<button type="button" class="us-cancel-lesson" data-lesson-id="${l.id}">Cancel</button>
</span>
</div>`;
}
function renderMyLessons(lessons) {
const upcoming = lessons.filter((l) => l.start_dt);
if (!upcoming.length) {
@@ -395,20 +414,30 @@
return;
}
// Show only the soonest few by default; the rest sit hidden behind a
// reveal so a busy student's list stays short.
const visible = upcoming.slice(0, INITIAL_LESSON_COUNT);
const hidden = upcoming.slice(INITIAL_LESSON_COUNT);
myLessons.innerHTML = `
<div class="us-my-lessons">
<h3>Your upcoming lessons</h3>
${upcoming.map((l) => `
<div class="us-my-lesson">
<span>${escHtml(dayLabel(dayKey(l.start_dt)))} · ${escHtml(timeOf(l.start_dt))}${escHtml(timeOf(l.end_dt))}</span>
<span class="us-my-lesson-actions">
<span class="us-lesson-status us-lesson-status-${escHtml(String(l.status))}">${escHtml(lessonStatusLabel(String(l.status)))}</span>
<button type="button" class="us-cancel-lesson" data-lesson-id="${l.id}">Cancel</button>
</span>
</div>
`).join('')}
${visible.map(lessonRowHtml).join('')}
${hidden.length ? `
<div class="us-my-lessons-more" hidden>${hidden.map(lessonRowHtml).join('')}</div>
<button type="button" class="us-show-all-lessons">Show all ${upcoming.length} lessons</button>
` : ''}
</div>`;
const moreBox = myLessons.querySelector('.us-my-lessons-more');
const showAll = myLessons.querySelector('.us-show-all-lessons');
if (showAll && moreBox) {
showAll.addEventListener('click', () => {
moreBox.hidden = false;
showAll.remove();
});
}
myLessons.querySelectorAll('.us-cancel-lesson').forEach((btn) => {
btn.addEventListener('click', () => cancelLesson(Number(btn.dataset.lessonId)));
});
+26 -1
View File
@@ -103,6 +103,26 @@
return [termLabel(o), timeLabel(o)].filter(Boolean).join(' · ');
}
// Today as a Y-m-d string in the visitor's local timezone, for lexicographic
// comparison against the class's Y-m-d enrolment deadline.
function todayYmd() {
const now = new Date();
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
}
// The effective enrolment deadline: the instructor's set deadline, or the
// first class day by default. Empty when the class has no dates at all.
function enrolmentDeadline(o) {
return o.enrollment_deadline || o.term_start || '';
}
// Enrolment closes at the end of the deadline day. Mirrors the server-side
// Offering::isEnrollmentOpen() gate.
function isEnrollmentOpen(o) {
const deadline = enrolmentDeadline(o);
return !deadline || todayYmd() <= deadline;
}
function renderClasses(offerings, enrolledOfferingIds) {
let groups = offerings.filter((o) => o.kind === 'group_class');
if (singleOfferingId) {
@@ -123,9 +143,14 @@
${o.schedule_note ? `<p>${escHtml(o.schedule_note)}</p>` : ''}
${o.description ? `<p>${escHtml(o.description)}</p>` : ''}
<p>${escHtml(Number(o.price).toFixed(2))} ${escHtml(o.currency)}</p>
${!enrolledOfferingIds.has(Number(o.id)) && isEnrollmentOpen(o) && enrolmentDeadline(o)
? `<p class="us-enrol-deadline">Enrol by ${escHtml(formatDate(enrolmentDeadline(o)))}</p>`
: ''}
${enrolledOfferingIds.has(Number(o.id))
? '<p class="us-enrolled"><strong>You are enrolled in this class.</strong></p>'
: `<button data-offering-id="${o.id}" class="us-enrol-btn">Enrol</button>`}
: (isEnrollmentOpen(o)
? `<button data-offering-id="${o.id}" class="us-enrol-btn">Enrol</button>`
: '<p class="us-enrol-closed"><strong>Enrolment has closed.</strong></p>')}
</div>
`).join('');
+27 -9
View File
@@ -48,6 +48,20 @@ cancelled enrolment does not block re-enrolling).
Capacity is enforced at enrolment time by counting `active` rows for the offering;
a class at capacity rejects further enrolments.
Enrolment also closes after the class's **enrolment deadline** (the instructor's
`enrollment_deadline`, defaulting to `term_start` — the first class day; see
`offerings.md`). Past the deadline `POST /enrollments` rejects the enrolment with
`403 enrollment_closed`, and the class list shows "Enrolment has closed." in place
of the Enrol button. While enrolment is still open the class card shows an
"Enrol by" line with the effective deadline date.
The deadline only bounds student **self**-enrolment. An instructor (or studio admin)
can still enrol someone by hand from the class **details page** — the **Add students
directly** control, available for every group class, deliberately bypasses the
deadline (and capacity) so a **late enrolment** can be added after the class has
closed. Past the deadline the details page labels these as late enrolments. See
**Admin Interface** below.
## REST API
| Method | Endpoint | Permission |
|----------|----------------------------------------------|----------------------------------|
@@ -74,13 +88,15 @@ flips their grant from `invited` to `enrolled`.
Access to an invite-only class is recorded in `{prefix}us_group_access` — a grant per
person, separate from the enrolment itself. The instructor manages access from
**My Lessons → My Group Classes**, which renders three controls under each invite-only
class:
**My Lessons → My Group Classes**. **Add students directly** is available on every
class's details page (see **Admin Interface**); invite-only classes add two more
controls beneath it:
1. **Add students directly** — the selected registered students are enrolled immediately
(`status = active`) with a **pending payment** at the class price (comp students are
settled at once by `PaymentService`). No access grant is needed — this writes straight
to `us_group_enrollments` + `us_payments`.
to `us_group_enrollments` + `us_payments`. It bypasses the enrolment deadline and
capacity, so it doubles as the **late-enrolment** path after a class has closed.
2. **Make available** — the selected registered students get an `invited` grant so the
class appears in their own group-class list; they then self-enrol through the normal
paid flow. Each is emailed a "you've been added" notice.
@@ -121,12 +137,14 @@ class becomes enrollable for them — they choose whether to enrol.
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
price, schedule note, enrolment deadline, status), the roster of enrolled students with
enrolment and payment status, and an **Add students** section. Every class — public or
invite-only — carries the **Add students directly** control there, which enrols the
selected students immediately (a late enrolment past the deadline; the section says so
when the deadline has passed). Invite-only classes additionally get the
**make-available** and **invite-by-email** controls plus the list of who has been invited
but not yet enrolled. These are nonce-checked `usc_action` POSTs, scoped to the owning
instructor. The summary (`templates/admin/my-group-classes.php`) and the details page
(`templates/admin/my-group-class-detail.php`) are separate templates.
## Implementation
+11 -2
View File
@@ -29,7 +29,7 @@ Students register for a private lesson by choosing an offering, picking a time (
7. `POST /bookings` creates the lesson row(s) (`status = pending`), records answers and policy acceptances, marks `us_availability.is_booked = 1`, and links the payment. A booking with nothing owed (a free offering) creates no payment and is `confirmed` immediately.
8. On successful payment (or comp) the lesson is `confirmed` and a receipt is emailed.
9. Instructor sees the booking under **My Lessons** and may update status via `PATCH /bookings/{id}/status`.
10. The booking page also shows the student their upcoming lessons (`GET /bookings`) with a per-lesson status badge (pending payment / confirmed) and a **Cancel** button.
10. The booking page also shows the student their upcoming lessons (`GET /bookings`) — each with the booked offering's name and length, when it happens, a per-lesson status badge (pending payment / confirmed), and a **Cancel** button. Only the soonest five are shown; a **Show all** control reveals the rest. `GET /bookings` includes `offering_title` and `duration_minutes` for each lesson so the list needs no extra request.
## Cancellation
Students cancel their own lessons via `POST /bookings/{id}/cancel` (idempotent).
@@ -85,7 +85,12 @@ kind `group_class`; see `group-classes.md`.
Both pages open in a **Week** calendar view by default (`usc_view`/`usc_week`
query params, same pattern as the availability page, bucketed via
`Availability\WeekCalendar`), with the original table available as the **List**
view — the list is where the per-lesson HST and e-transfer edit forms live.
view — the list is where the per-lesson HST and e-transfer edit forms live. Both
views show the booked offering's name, and each lesson links through (`?lesson_id=`)
to a **detail view** (`LessonController::maybeRenderDetail()`) that shows the
offering, time, status, notes, the policy versions the student accepted (with
acceptance time and IP), and their intake-question answers. On **My Lessons** an
instructor may only open their own lessons; the studio **Scheduler** may open any.
## Frontend Shortcodes
- `[us_booking]` — student calendar + registration flow; requires `book_lesson` capability
@@ -96,6 +101,7 @@ view — the list is where the per-lesson HST and e-transfer edit forms live.
- Model: `Unsupervised\Schedular\Booking\Lesson`
- Registration gate: `Unsupervised\Schedular\Registration\RegistrationGate` — validates and records intake answers + booking-scoped policy acceptances; shared with group enrolment
- Admin controller: `Unsupervised\Schedular\Booking\LessonController`
- Admin lesson detail presenter: `Unsupervised\Schedular\Booking\LessonDetail` (per-lesson intake answers + policy acceptances), template `templates/admin/lesson-detail.php`
- REST endpoint: `Unsupervised\Schedular\Booking\BookingEndpoint`
- Frontend: `Unsupervised\Schedular\Booking\BookingPage`, `Unsupervised\Schedular\Auth\LoginPage`
@@ -109,3 +115,6 @@ view — the list is where the per-lesson HST and e-transfer edit forms live.
## Tests
- `tests/Unit/Booking/BookingRepositoryTest.php`
- `tests/Unit/Booking/LessonTest.php`
- `tests/Unit/Booking/LessonControllerTest.php`
- `tests/Unit/Booking/LessonDetailTest.php`
- `tests/Unit/Booking/BookingEndpointTest.php`
+12 -1
View File
@@ -21,6 +21,7 @@ An offering is anything a student can register for: a private-lesson type (30 or
| `term_start` | DATE | Group / term offerings — first 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 |
| `enrollment_deadline` | DATE | Group only — last day students may enrol; NULL defaults to `term_start` (the first class day) |
| `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`) |
| `access_mode` | VARCHAR(20) | `public` (listed in the catalog) or `invite_only` (group classes hidden from the catalog — see `group-classes.md`) |
@@ -51,6 +52,16 @@ one-off), and returns an empty list unless date, time, and a positive duration a
all set. These windows drive availability reconciliation (see **Instructor
assignment** below and `group-classes.md`).
## Enrolment deadline
A group class carries an optional `enrollment_deadline` the instructor sets on the
offering form (blank leaves it NULL). `Offering::effectiveEnrollmentDeadline()`
resolves it to the stored date, or to `term_start` (the first class day) when unset,
so a class with no explicit deadline still closes to new enrolments once the first
class arrives. `Offering::isEnrollmentOpen($today)` compares a `Y-m-d` "today"
against that effective deadline (inclusive — the deadline day is still open). The
enrolment endpoint enforces it (`403 enrollment_closed`) and the front-end
group-class list mirrors the same rule; see `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
@@ -84,7 +95,7 @@ Studio admin and instructors manage offerings under **Offerings** in wp-admin.
## Implementation
- Repository: `Unsupervised\Schedular\Offering\OfferingRepository`
- Model: `Unsupervised\Schedular\Offering\Offering` (`normalizeTime`, `sessionWindows`)
- Model: `Unsupervised\Schedular\Offering\Offering` (`normalizeTime`, `sessionWindows`, `effectiveEnrollmentDeadline`, `isEnrollmentOpen`)
- Admin controller: `Unsupervised\Schedular\Offering\OfferingController`
- REST endpoint: `Unsupervised\Schedular\Offering\OfferingEndpoint` (public listing includes `instructor_name`)
- Availability reconciliation: `Unsupervised\Schedular\Offering\ClassSlotReconciler` (uses `Availability\AvailabilityRepository::findOverlapping`)
+8 -1
View File
@@ -50,9 +50,16 @@ update for a same-slug plugin and makes core fire the
4. When newer, returns the release's first `.zip` asset as the update
package. Core takes over from there: Plugins-screen notice, one-click
update, and WP-Cron auto-updates if enabled.
5. When not newer — the site is current, or the lookup failed — returns a
`no_update` payload (installed version, empty package). This keeps the
plugin in core's `update_plugins` transient so core's `update-supported`
flag stays set and the **Enable auto-updates** toggle shows on the
Plugins screen. Without it, an off-directory plugin is absent from the
transient between releases and the toggle never appears.
Any API failure, malformed response, or asset-less release degrades to
"no update available" — never an error surfaced to the site.
"no update available" (the `no_update` payload) — never an error surfaced
to the site, and never a lost auto-update toggle during a Gitea blip.
## Cutting a Release
1. Bump the version in `unsupervised-schedular.php` (both the `Version:`
+2 -1
View File
@@ -17,6 +17,7 @@ use Unsupervised\Schedular\Auth\StudentController;
use Unsupervised\Schedular\Auth\StudentHistory;
use Unsupervised\Schedular\Booking\BookingRepository;
use Unsupervised\Schedular\Booking\LessonController;
use Unsupervised\Schedular\Booking\LessonDetail;
use Unsupervised\Schedular\GroupClass\EnrollmentRepository;
use Unsupervised\Schedular\GroupClass\GroupAccessRepository;
use Unsupervised\Schedular\GroupClass\GroupClassController;
@@ -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 ) {
$this->availabilityController = new AvailabilityController( $availability, $offerings );
$this->lessonController = new LessonController( $bookings, $payments, $availability );
$this->lessonController = new LessonController( $bookings, $payments, $availability, $offerings, new LessonDetail( $answers, $questions, $acceptances, $policies, $policyVersions ) );
$this->offeringController = new OfferingController( $offerings, new ClassSlotReconciler( $availability ) );
$this->questionController = new QuestionController( $questions, $offerings );
$this->policyController = new PolicyController( $policies, $policyVersions, $policyService );
+15 -5
View File
@@ -123,17 +123,27 @@ class BookingEndpoint {
}
/**
* A lesson's array form plus its slot's start/end times, so front-end lists
* can show when the session happens without a second request.
* A lesson's array form plus its slot's start/end times and the booked
* offering's name, so front-end lists can show what the session is and when
* it happens without a second request.
*
* @return array<string, mixed>
*/
private function lessonWithTimes( Lesson $lesson ): array {
$slot = $this->availability->findById( $lesson->slotId );
$slot = $this->availability->findById( $lesson->slotId );
$offering = null !== $lesson->offeringId ? $this->offerings->findById( $lesson->offeringId ) : null;
// Prefer the offering's own length; fall back to the slot's when the
// offering has none (a generic, duration-less type).
$duration = null !== $offering && null !== $offering->durationMinutes
? $offering->durationMinutes
: $slot?->durationMinutes;
return $lesson->toArray() + [
'start_dt' => $slot?->startDt,
'end_dt' => $slot?->endDt,
'start_dt' => $slot?->startDt,
'end_dt' => $slot?->endDt,
'offering_title' => $offering?->title,
'duration_minutes' => $duration,
];
}
+46
View File
@@ -7,6 +7,7 @@ use Unsupervised\Schedular\Auth\RoleManager;
use Unsupervised\Schedular\Availability\AvailabilityRepository;
use Unsupervised\Schedular\Availability\AvailabilitySlot;
use Unsupervised\Schedular\Availability\WeekCalendar;
use Unsupervised\Schedular\Offering\OfferingRepository;
use Unsupervised\Schedular\Payment\Payment;
use Unsupervised\Schedular\Payment\PaymentRepository;
use Unsupervised\Schedular\Val;
@@ -17,6 +18,8 @@ class LessonController {
private BookingRepository $repository,
private PaymentRepository $payments,
private AvailabilityRepository $availability,
private OfferingRepository $offerings,
private LessonDetail $detail,
) {}
public function renderAdminDashboard(): void {
@@ -24,6 +27,10 @@ class LessonController {
wp_die( esc_html__( 'You do not have permission to view this page.', 'unsupervised-schedular' ) );
}
if ( $this->maybeRenderDetail( 'us-scheduler', false ) ) {
return;
}
$this->handleEtransferUpdate( false );
$rows = array_map( fn( Lesson $lesson ): array => $this->row( $lesson ), $this->repository->findAllUpcoming() );
@@ -36,6 +43,10 @@ class LessonController {
wp_die( esc_html__( 'You do not have permission to view lessons.', 'unsupervised-schedular' ) );
}
if ( $this->maybeRenderDetail( 'us-my-lessons', true ) ) {
return;
}
$this->handleEtransferUpdate( true );
$rows = array_map( fn( Lesson $lesson ): array => $this->row( $lesson ), $this->repository->findUpcomingForInstructor( get_current_user_id() ) );
@@ -43,6 +54,36 @@ class LessonController {
$this->renderLessonsPage( $rows, 'us-my-lessons' );
}
/**
* When the request targets a single lesson (`?lesson_id=`), render its detail
* view and report that the page has been handled. Instructors may only open
* their own lessons; the studio dashboard ($onlyOwn = false) may open any.
*/
private function maybeRenderDetail( string $pageSlug, bool $onlyOwn ): bool {
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only lesson selector.
$lessonId = absint( Val::int( $_GET['lesson_id'] ?? 0 ) );
if ( $lessonId <= 0 ) {
return false;
}
$lesson = $this->repository->findById( $lessonId );
$backUrl = admin_url( 'admin.php?page=' . $pageSlug );
if ( null === $lesson || ( $onlyOwn && get_current_user_id() !== $lesson->instructorId ) ) {
$row = null;
$answers = [];
$accepts = [];
} else {
$row = $this->row( $lesson );
$answers = $this->detail->answers( $lessonId );
$accepts = $this->detail->acceptances( $lessonId );
}
include USC_PLUGIN_DIR . 'templates/admin/lesson-detail.php';
return true;
}
/**
* Render the lessons template with its calendar view state: week (default)
* or list, plus which week the week view shows.
@@ -111,10 +152,15 @@ class LessonController {
$instructor = get_userdata( $lesson->instructorId );
$payment = null !== $lesson->paymentId ? $this->payments->findById( $lesson->paymentId ) : null;
$slot = $this->availability->findById( $lesson->slotId );
$offering = null !== $lesson->offeringId ? $this->offerings->findById( $lesson->offeringId ) : null;
return [
'lesson_id' => (int) $lesson->id,
'student' => $student ? $student->display_name : (string) $lesson->studentId,
'instructor' => $instructor ? $instructor->display_name : (string) $lesson->instructorId,
'offering' => $offering ? $offering->title : '—',
'duration' => null !== $offering && null !== $offering->durationMinutes ? $offering->durationMinutes : 0,
'recurrence' => $lesson->recurrence,
'time' => $slot ? $this->formatSlotTime( $slot ) : '—',
'day' => $slot ? substr( $slot->startDt, 0, 10 ) : '',
'time_short' => $slot ? Val::string( mysql2date( 'g:i A', $slot->startDt ) ) : '—',
+73
View File
@@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Booking;
use Unsupervised\Schedular\Policy\AcceptanceRepository;
use Unsupervised\Schedular\Policy\PolicyAcceptance;
use Unsupervised\Schedular\Policy\PolicyRepository;
use Unsupervised\Schedular\Policy\PolicyVersionRepository;
use Unsupervised\Schedular\Registration\Answer;
use Unsupervised\Schedular\Registration\AnswerRepository;
use Unsupervised\Schedular\Registration\QuestionRepository;
/**
* Builds the display rows for the admin lesson detail view: the intake answers
* the student submitted and the policy versions they accepted when booking.
*
* Scoped to a single lesson (the `lesson` registration type), mirroring the
* per-student history in {@see \Unsupervised\Schedular\Auth\StudentHistory}.
*/
class LessonDetail {
public function __construct(
private AnswerRepository $answers,
private QuestionRepository $questions,
private AcceptanceRepository $acceptances,
private PolicyRepository $policies,
private PolicyVersionRepository $versions,
) {}
/**
* The intake-question answers recorded for this lesson, in submission order.
*
* @return list<array{question: string, answer: string}>
*/
public function answers( int $lessonId ): array {
return array_map(
function ( Answer $answer ): array {
$question = $this->questions->findById( $answer->questionId );
$value = $answer->answerValue ?? '';
return [
'question' => $question ? $question->label : sprintf( '#%d', $answer->questionId ),
'answer' => '' === $value ? '—' : $value,
];
},
$this->answers->findByRegistration( Answer::REG_LESSON, $lessonId )
);
}
/**
* The policy versions the student accepted when booking this lesson, with the
* captured acceptance time and IP for the audit trail.
*
* @return list<array{policy: string, version: string, accepted_at: string, ip: string}>
*/
public function acceptances( int $lessonId ): array {
return array_map(
function ( PolicyAcceptance $acceptance ): array {
$version = $this->versions->findById( $acceptance->policyVersionId );
$policy = $version ? $this->policies->findById( $version->policyId ) : null;
return [
'policy' => $policy ? $policy->title : sprintf( '#%d', $acceptance->policyVersionId ),
'version' => $version ? sprintf( 'v%d', $version->versionNumber ) : '—',
'accepted_at' => $acceptance->acceptedAt ?? '',
'ip' => $acceptance->ipAddress ?? '',
];
},
$this->acceptances->findByRegistration( PolicyAcceptance::REG_LESSON, $lessonId )
);
}
}
+6
View File
@@ -95,6 +95,12 @@ class EnrollmentEndpoint {
return new \WP_Error( 'invite_required', __( 'This class is by invitation only.', 'unsupervised-schedular' ), [ 'status' => 403 ] );
}
// Enrolment closes at the end of the deadline day — the instructor's set
// deadline, or the first class day by default.
if ( ! $offering->isEnrollmentOpen( Val::string( current_time( 'Y-m-d' ) ) ) ) {
return new \WP_Error( 'enrollment_closed', __( 'Enrolment for this class has closed.', 'unsupervised-schedular' ), [ 'status' => 403 ] );
}
if ( null !== $offering->capacity && $this->enrollments->countActiveForOffering( $offeringId ) >= $offering->capacity ) {
return new \WP_Error( 'class_full', __( 'This class is full.', 'unsupervised-schedular' ), [ 'status' => 409 ] );
}
+18 -10
View File
@@ -176,7 +176,7 @@ class GroupClassController {
* 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}>}
* @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, deadline: string, enrollment_open: bool, 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 = [];
@@ -195,16 +195,20 @@ class GroupClassController {
];
}
$deadline = $offering->effectiveEnrollmentDeadline();
return $this->classSummary( $offering, $enrollments ) + [
'instructor' => $this->instructorName( $offering ),
'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 ) : [],
'instructor' => $this->instructorName( $offering ),
'price' => $offering->price,
'currency' => $offering->currency,
'duration' => $offering->durationMinutes,
'description' => $offering->description,
'schedule_note' => $offering->scheduleNote,
'deadline' => null !== $deadline ? (string) mysql2date( 'M j, Y', $deadline ) : '',
'enrollment_open' => $offering->isEnrollmentOpen( Val::string( current_time( 'Y-m-d' ) ) ),
'active' => $offering->isActive,
'roster' => $roster,
'invited' => $offering->isInviteOnly() ? $this->pendingInvites( (int) $offering->id ) : [],
];
}
@@ -312,6 +316,10 @@ class GroupClassController {
* Directly enrol registered students, each with a pending payment at the
* class price (comp students are settled immediately by the payment service).
*
* This is the instructor's manual enrolment path and deliberately bypasses the
* enrolment deadline and capacity, so a student can be added as a late
* enrolment after the class has closed to self-enrolment.
*
* @param list<int> $studentIds
*/
private function addDirect( Offering $offering, array $studentIds ): string {
+24
View File
@@ -54,6 +54,7 @@ class Offering {
public readonly ?string $termStart = null,
public readonly ?string $termEnd = null,
public readonly ?string $classTime = null,
public readonly ?string $enrollmentDeadline = null,
public readonly ?string $scheduleNote = null,
public readonly ?string $etransferEmail = null,
public readonly ?int $cancellationCutoffHours = null,
@@ -70,6 +71,27 @@ class Offering {
return self::ACCESS_INVITE_ONLY === $this->accessMode;
}
/**
* 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;
}
/**
* 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
@@ -169,6 +191,7 @@ class Offering {
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 ),
scheduleNote: Val::stringOrNull( $row->schedule_note ),
etransferEmail: Val::stringOrNull( $row->etransfer_email ),
cancellationCutoffHours: Val::intOrNull( $row->cancellation_cutoff_hours ),
@@ -203,6 +226,7 @@ class Offering {
'term_start' => $this->termStart,
'term_end' => $this->termEnd,
'class_time' => $this->classTime,
'enrollment_deadline' => $this->enrollmentDeadline,
'schedule_note' => $this->scheduleNote,
'cancellation_cutoff_hours' => $this->cancellationCutoffHours,
'access_mode' => $this->accessMode,
+5
View File
@@ -209,6 +209,10 @@ class OfferingController {
$classTime = Offering::normalizeTime( sanitize_text_field( Val::string( wp_unslash( $_POST['class_time'] ?? '' ) ) ) );
// A blank (or invalid) deadline means "use the default" — the first class
// day (term_start), applied by Offering::effectiveEnrollmentDeadline().
$enrollmentDeadline = Offering::normalizeDate( sanitize_text_field( Val::string( wp_unslash( $_POST['enrollment_deadline'] ?? '' ) ) ) );
return new Offering(
instructorId: $this->resolveInstructorId( $instructorId, $manageAll, $existing ),
kind: $kind,
@@ -223,6 +227,7 @@ class OfferingController {
termStart: $termStart,
termEnd: $termEnd,
classTime: $classTime,
enrollmentDeadline: $enrollmentDeadline,
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'] ?? '' ) ) ) ),
cancellationCutoffHours: $cutoffHours,
+2
View File
@@ -161,6 +161,7 @@ class OfferingEndpoint {
capacity: $this->nullableInt( $request->get_param( 'capacity' ) ),
termStart: $this->nullableText( $request->get_param( 'term_start' ) ),
termEnd: $this->nullableText( $request->get_param( 'term_end' ) ),
enrollmentDeadline: $this->nullableText( $request->get_param( 'enrollment_deadline' ) ),
scheduleNote: $this->nullableText( $request->get_param( 'schedule_note' ) ),
etransferEmail: $this->nullableEmail( $request->get_param( 'etransfer_email' ) ),
cancellationCutoffHours: $this->nullableInt( $request->get_param( 'cancellation_cutoff_hours' ) ),
@@ -208,6 +209,7 @@ class OfferingEndpoint {
capacity: $request->has_param( 'capacity' ) ? $this->nullableInt( $request->get_param( 'capacity' ) ) : $existing->capacity,
termStart: $request->has_param( 'term_start' ) ? $this->nullableText( $request->get_param( 'term_start' ) ) : $existing->termStart,
termEnd: $request->has_param( 'term_end' ) ? $this->nullableText( $request->get_param( 'term_end' ) ) : $existing->termEnd,
enrollmentDeadline: $request->has_param( 'enrollment_deadline' ) ? $this->nullableText( $request->get_param( 'enrollment_deadline' ) ) : $existing->enrollmentDeadline,
scheduleNote: $request->has_param( 'schedule_note' ) ? $this->nullableText( $request->get_param( 'schedule_note' ) ) : $existing->scheduleNote,
etransferEmail: $request->has_param( 'etransfer_email' ) ? $this->nullableEmail( $request->get_param( 'etransfer_email' ) ) : $existing->etransferEmail,
cancellationCutoffHours: $request->has_param( 'cancellation_cutoff_hours' ) ? $this->nullableInt( $request->get_param( 'cancellation_cutoff_hours' ) ) : $existing->cancellationCutoffHours,
+5 -3
View File
@@ -14,12 +14,13 @@ class OfferingRepository {
/**
* Column formats aligned to {@see columns()} (instructor_id, kind, title,
* description, duration_minutes, price, currency, billing_mode, allow_weekly,
* capacity, term_start, term_end, class_time, schedule_note, etransfer_email,
* cancellation_cutoff_hours, access_mode, is_active).
* capacity, term_start, term_end, class_time, enrollment_deadline,
* schedule_note, etransfer_email, cancellation_cutoff_hours, access_mode,
* is_active).
*
* @var list<string>
*/
private const COLUMN_FORMATS = [ '%d', '%s', '%s', '%s', '%d', '%f', '%s', '%s', '%d', '%d', '%s', '%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', '%s', '%d', '%s', '%d' ];
public function insert( Offering $offering ): int {
$this->db->insert(
@@ -61,6 +62,7 @@ class OfferingRepository {
'term_start' => $offering->termStart,
'term_end' => $offering->termEnd,
'class_time' => $offering->classTime,
'enrollment_deadline' => $offering->enrollmentDeadline,
'schedule_note' => $offering->scheduleNote,
'etransfer_email' => $offering->etransferEmail,
'cancellation_cutoff_hours' => $offering->cancellationCutoffHours,
+1
View File
@@ -64,6 +64,7 @@ class Schema {
term_start DATE DEFAULT NULL,
term_end DATE DEFAULT NULL,
class_time TIME DEFAULT NULL,
enrollment_deadline DATE DEFAULT NULL,
schedule_note VARCHAR(191) DEFAULT NULL,
etransfer_email VARCHAR(191) DEFAULT NULL,
cancellation_cutoff_hours SMALLINT UNSIGNED DEFAULT NULL,
+25 -11
View File
@@ -75,9 +75,18 @@ class UpdateChecker {
}
/**
* `update_plugins_{hostname}` filter callback. Returns the incoming
* value untouched unless a newer release with a zip asset exists, in
* which case it returns the update array core expects.
* `update_plugins_{hostname}` filter callback.
*
* For a newer release with a zip asset, returns the update array core
* files under the transient's `response` list (the update offer).
* Otherwise — the plugin is current, or the release lookup failed — it
* returns a payload with the installed version and no package, which core
* files under `no_update`. That `no_update` entry is what sets core's
* `update-supported` flag and makes the "Enable auto-updates" toggle
* appear on the Plugins screen; without it, an off-directory plugin is
* absent from the transient between releases and the toggle never shows.
*
* The incoming value is only passed through untouched for other plugins.
*/
public function provideUpdate( mixed $update, mixed $plugin_data, mixed $plugin_file ): mixed {
if ( plugin_basename( USC_PLUGIN_FILE ) !== $plugin_file ) {
@@ -86,19 +95,24 @@ class UpdateChecker {
$release = $this->latestRelease();
if ( '' === $release['version'] || '' === $release['package'] ) {
return $update;
}
if ( version_compare( $release['version'], USC_VERSION, '<=' ) ) {
return $update;
if ( '' !== $release['version'] && '' !== $release['package']
&& version_compare( $release['version'], USC_VERSION, '>' ) ) {
return [
'slug' => 'unsupervised-schedular',
'version' => $release['version'],
'url' => self::REPO_URL,
'package' => $release['package'],
];
}
// No newer release: answer with a `no_update` payload so core keeps
// the plugin in the update transient and shows the auto-update toggle.
// The empty package leaves core nothing to auto-install, as intended.
return [
'slug' => 'unsupervised-schedular',
'version' => $release['version'],
'version' => USC_VERSION,
'url' => self::REPO_URL,
'package' => $release['package'],
'package' => '',
];
}
+121
View File
@@ -0,0 +1,121 @@
<?php
declare(strict_types=1);
if (! defined('ABSPATH')) {
exit;
}
/**
* @var array{lesson_id: int, student: string, instructor: string, offering: string, duration: int, recurrence: string, time: string, status: string, notes: string, payment_id: int, currency: string, total: float}|null $row
* @var list<array{question: string, answer: string}> $answers
* @var list<array{policy: string, version: string, accepted_at: string, ip: string}> $accepts
* @var string $backUrl
*/
?>
<div class="wrap">
<h1><?php esc_html_e('Lesson details', 'unsupervised-schedular'); ?></h1>
<p><a href="<?php echo esc_url($backUrl); ?>">&laquo; <?php esc_html_e('Back to lessons', 'unsupervised-schedular'); ?></a></p>
<?php if (null === $row) : ?>
<p><?php esc_html_e('This lesson could not be found.', 'unsupervised-schedular'); ?></p>
<?php else : ?>
<table class="form-table">
<tbody>
<tr>
<th scope="row"><?php esc_html_e('Lesson', 'unsupervised-schedular'); ?></th>
<td>
<?php echo esc_html($row['offering']); ?>
<?php if ($row['duration'] > 0) : ?>
<?php
/* translators: %d: lesson length in minutes */
echo esc_html(sprintf(__('(%d min)', 'unsupervised-schedular'), $row['duration']));
?>
<?php endif; ?>
</td>
</tr>
<tr>
<th scope="row"><?php esc_html_e('Student', 'unsupervised-schedular'); ?></th>
<td><?php echo esc_html($row['student']); ?></td>
</tr>
<tr>
<th scope="row"><?php esc_html_e('Instructor', 'unsupervised-schedular'); ?></th>
<td><?php echo esc_html($row['instructor']); ?></td>
</tr>
<tr>
<th scope="row"><?php esc_html_e('Date/Time', 'unsupervised-schedular'); ?></th>
<td>
<?php echo esc_html($row['time']); ?>
<?php if ('weekly' === $row['recurrence']) : ?>
<em>(<?php esc_html_e('weekly', 'unsupervised-schedular'); ?>)</em>
<?php endif; ?>
</td>
</tr>
<tr>
<th scope="row"><?php esc_html_e('Status', 'unsupervised-schedular'); ?></th>
<td><?php echo esc_html($row['status']); ?></td>
</tr>
<?php if ($row['payment_id'] > 0) : ?>
<tr>
<th scope="row"><?php esc_html_e('Total', 'unsupervised-schedular'); ?></th>
<td><?php echo esc_html($row['currency'] . ' ' . number_format($row['total'], 2)); ?></td>
</tr>
<?php endif; ?>
<?php if ('' !== $row['notes']) : ?>
<tr>
<th scope="row"><?php esc_html_e('Notes', 'unsupervised-schedular'); ?></th>
<td><?php echo esc_html($row['notes']); ?></td>
</tr>
<?php endif; ?>
</tbody>
</table>
<h2><?php esc_html_e('Policies accepted', 'unsupervised-schedular'); ?></h2>
<?php if (empty($accepts)) : ?>
<p><?php esc_html_e('None recorded for this booking.', 'unsupervised-schedular'); ?></p>
<?php else : ?>
<table class="wp-list-table widefat fixed striped">
<thead>
<tr>
<th><?php esc_html_e('Policy', 'unsupervised-schedular'); ?></th>
<th><?php esc_html_e('Version', 'unsupervised-schedular'); ?></th>
<th><?php esc_html_e('Accepted', 'unsupervised-schedular'); ?></th>
<th><?php esc_html_e('IP address', 'unsupervised-schedular'); ?></th>
</tr>
</thead>
<tbody>
<?php foreach ($accepts as $acceptance) : ?>
<tr>
<td><?php echo esc_html($acceptance['policy']); ?></td>
<td><?php echo esc_html($acceptance['version']); ?></td>
<td><?php echo esc_html('' !== $acceptance['accepted_at'] ? (string) mysql2date('M j, Y g:i A', $acceptance['accepted_at']) : '—'); ?></td>
<td><?php echo esc_html('' !== $acceptance['ip'] ? $acceptance['ip'] : '—'); ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
<h2><?php esc_html_e('Intake answers', 'unsupervised-schedular'); ?></h2>
<?php if (empty($answers)) : ?>
<p><?php esc_html_e('None recorded for this booking.', 'unsupervised-schedular'); ?></p>
<?php else : ?>
<table class="wp-list-table widefat fixed striped">
<thead>
<tr>
<th><?php esc_html_e('Question', 'unsupervised-schedular'); ?></th>
<th><?php esc_html_e('Answer', 'unsupervised-schedular'); ?></th>
</tr>
</thead>
<tbody>
<?php foreach ($answers as $answer) : ?>
<tr>
<td><?php echo esc_html($answer['question']); ?></td>
<td><?php echo esc_html($answer['answer']); ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
<?php endif; ?>
</div>
+21 -3
View File
@@ -6,10 +6,10 @@ if (! defined('ABSPATH')) {
}
/**
* @var list<array{student: string, instructor: string, time: string, day: string, time_short: string, status: string, notes: string, payment_id: int, currency: string, amount: float, tax_rate: float, tax_amount: float, total: float, etransfer_email: string, etransfer_editable: bool, tax_editable: bool}> $rows
* @var list<array{lesson_id: int, student: string, instructor: string, offering: string, duration: int, recurrence: string, time: string, day: string, time_short: string, status: string, notes: string, payment_id: int, currency: string, amount: float, tax_rate: float, tax_amount: float, total: float, etransfer_email: string, etransfer_editable: bool, tax_editable: bool}> $rows
* @var 'list'|'week' $view
* @var string $weekStart
* @var list<array{date: string, items: list<array{student: string, time_short: string, status: string}>}> $weekDays
* @var list<array{date: string, items: list<array{lesson_id: int, student: string, offering: string, time_short: string, status: string}>}> $weekDays
* @var string $prevWeek
* @var string $nextWeek
* @var string $baseUrl
@@ -58,7 +58,9 @@ if (! defined('ABSPATH')) {
<p style="margin:0 0 8px;">
<strong><?php echo esc_html($item['time_short']); ?></strong><br>
<?php echo esc_html($item['student']); ?><br>
<em><?php echo esc_html($item['status']); ?></em>
<span><?php echo esc_html($item['offering']); ?></span><br>
<em><?php echo esc_html($item['status']); ?></em><br>
<a href="<?php echo esc_url(add_query_arg('lesson_id', (string) $item['lesson_id'], $baseUrl)); ?>"><?php esc_html_e('Details', 'unsupervised-schedular'); ?></a>
</p>
<?php endforeach; ?>
</td>
@@ -74,12 +76,14 @@ if (! defined('ABSPATH')) {
<tr>
<th><?php esc_html_e('Student', 'unsupervised-schedular'); ?></th>
<th><?php esc_html_e('Instructor', 'unsupervised-schedular'); ?></th>
<th><?php esc_html_e('Lesson', 'unsupervised-schedular'); ?></th>
<th><?php esc_html_e('Date/Time', 'unsupervised-schedular'); ?></th>
<th><?php esc_html_e('Status', 'unsupervised-schedular'); ?></th>
<th><?php esc_html_e('HST', 'unsupervised-schedular'); ?></th>
<th><?php esc_html_e('Total', 'unsupervised-schedular'); ?></th>
<th><?php esc_html_e('E-transfer email', 'unsupervised-schedular'); ?></th>
<th><?php esc_html_e('Notes', 'unsupervised-schedular'); ?></th>
<th><?php esc_html_e('Details', 'unsupervised-schedular'); ?></th>
</tr>
</thead>
<tbody>
@@ -87,6 +91,17 @@ if (! defined('ABSPATH')) {
<tr>
<td><?php echo esc_html($row['student']); ?></td>
<td><?php echo esc_html($row['instructor']); ?></td>
<td>
<?php echo esc_html($row['offering']); ?>
<?php if ($row['duration'] > 0) : ?>
<span style="color:#666;">
<?php
/* translators: %d: lesson length in minutes */
echo esc_html(sprintf(__('(%d min)', 'unsupervised-schedular'), $row['duration']));
?>
</span>
<?php endif; ?>
</td>
<td><?php echo esc_html($row['time']); ?></td>
<td><?php echo esc_html($row['status']); ?></td>
<td>
@@ -118,6 +133,9 @@ if (! defined('ABSPATH')) {
<?php endif; ?>
</td>
<td><?php echo esc_html($row['notes']); ?></td>
<td>
<a href="<?php echo esc_url(add_query_arg('lesson_id', (string) $row['lesson_id'], $baseUrl)); ?>"><?php esc_html_e('View', 'unsupervised-schedular'); ?></a>
</td>
</tr>
<?php endforeach; ?>
</tbody>
+50 -28
View File
@@ -6,7 +6,7 @@ if (! defined('ABSPATH')) {
}
/**
* @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 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, deadline: string, enrollment_open: bool, 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
@@ -78,6 +78,12 @@ if (! defined('ABSPATH')) {
<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 ('' !== $class['deadline']) : ?>
<tr>
<th scope="row"><?php esc_html_e('Enrolment deadline', 'unsupervised-schedular'); ?></th>
<td><?php echo esc_html($class['deadline']); ?></td>
</tr>
<?php endif; ?>
<?php if (null !== $class['schedule_note'] && '' !== $class['schedule_note']) : ?>
<tr>
<th scope="row"><?php esc_html_e('Schedule note', 'unsupervised-schedular'); ?></th>
@@ -130,35 +136,51 @@ if (! defined('ABSPATH')) {
</table>
<?php endif; ?>
<?php if ($class['invite_only']) : ?>
<h2><?php esc_html_e('Invite &amp; enrol students', 'unsupervised-schedular'); ?></h2>
<h2>
<?php
echo $class['invite_only']
? esc_html__('Invite &amp; enrol students', 'unsupervised-schedular')
: esc_html__('Add students', 'unsupervised-schedular');
?>
</h2>
<?php if (! $class['enrollment_open']) : ?>
<p class="description"><?php esc_html_e('Enrolment has closed for this class. Students you add here are enrolled as late enrolments.', 'unsupervised-schedular'); ?></p>
<?php elseif ($class['invite_only']) : ?>
<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 endif; ?>
<?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 if ($class['invite_only'] && ! 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
echo $class['enrollment_open']
? esc_html__('Enrols them now with a pending payment.', 'unsupervised-schedular')
: esc_html__('Enrols them now with a pending payment, past the enrolment deadline.', '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; ?>
</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>
</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>
<?php if ($class['invite_only']) : ?>
<form method="post">
<?php wp_nonce_field('usc_group_action'); ?>
<input type="hidden" name="offering_id" value="<?php echo esc_attr((string) $class['id']); ?>">
@@ -184,6 +206,6 @@ if (! defined('ABSPATH')) {
<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 endif; ?>
</div>
</div>
+7
View File
@@ -112,6 +112,13 @@ if ($editing && null !== $editing->termStart && null !== $editing->termEnd && $e
<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>
<th><label for="enrollment_deadline"><?php esc_html_e('Enrolment deadline', 'unsupervised-schedular'); ?></label></th>
<td>
<input type="date" name="enrollment_deadline" id="enrollment_deadline" value="<?php echo esc_attr($editing->enrollmentDeadline ?? ''); ?>">
<p class="description"><?php esc_html_e('Group classes only — the last day students may enrol. Leave blank to default to the first day of the class.', 'unsupervised-schedular'); ?></p>
</td>
</tr>
<tr>
<th><?php esc_html_e('Sessions', 'unsupervised-schedular'); ?></th>
<td>
@@ -544,4 +544,22 @@ class BookingEndpointTest extends TestCase
self::assertSame('2026-07-01 10:00:00', $data[0]['start_dt']);
self::assertSame('2026-07-01 11:00:00', $data[0]['end_dt']);
}
public function testMyLessonsIncludesBookedOfferingName(): void
{
Functions\when('current_user_can')->justReturn(false);
$lesson = new Lesson(slotId: 10, studentId: 5, instructorId: 3, offeringId: 8, status: Lesson::STATUS_PENDING, id: 77);
$this->bookings->shouldReceive('findUpcomingForStudent')->with(5)->once()->andReturn([$lesson]);
$this->availability->shouldReceive('findById')->with(10)->andReturn($this->slot(10, 3, 8));
$this->offerings->shouldReceive('findById')->with(8)->andReturn(
new Offering(instructorId: 3, kind: Offering::KIND_PRIVATE_LESSON, title: 'Piano Lesson', durationMinutes: 60, id: 8)
);
$result = $this->endpoint->myLessons(new \WP_REST_Request([]));
$data = $result->get_data();
self::assertSame('Piano Lesson', $data[0]['offering_title']);
self::assertSame(60, $data[0]['duration_minutes']);
}
}
+97 -1
View File
@@ -10,6 +10,8 @@ use Unsupervised\Schedular\Availability\AvailabilitySlot;
use Unsupervised\Schedular\Booking\BookingRepository;
use Unsupervised\Schedular\Booking\Lesson;
use Unsupervised\Schedular\Booking\LessonController;
use Unsupervised\Schedular\Booking\LessonDetail;
use Unsupervised\Schedular\Offering\OfferingRepository;
use Unsupervised\Schedular\Payment\PaymentRepository;
use Unsupervised\Schedular\Tests\Unit\TestCase;
@@ -18,6 +20,8 @@ class LessonControllerTest extends TestCase
private BookingRepository&Mockery\MockInterface $bookings;
private PaymentRepository&Mockery\MockInterface $payments;
private AvailabilityRepository&Mockery\MockInterface $availability;
private OfferingRepository&Mockery\MockInterface $offerings;
private LessonDetail&Mockery\MockInterface $detail;
private LessonController $controller;
protected function setUp(): void
@@ -27,7 +31,9 @@ class LessonControllerTest extends TestCase
$this->bookings = Mockery::mock(BookingRepository::class);
$this->payments = Mockery::mock(PaymentRepository::class);
$this->availability = Mockery::mock(AvailabilityRepository::class);
$this->controller = new LessonController($this->bookings, $this->payments, $this->availability);
$this->offerings = Mockery::mock(OfferingRepository::class);
$this->detail = Mockery::mock(LessonDetail::class);
$this->controller = new LessonController($this->bookings, $this->payments, $this->availability, $this->offerings, $this->detail);
$_POST = [];
$_GET = [];
@@ -176,6 +182,96 @@ class LessonControllerTest extends TestCase
self::assertStringNotContainsString('9:00 AM', $html);
}
public function testListViewShowsBookedOfferingName(): void
{
$_GET['usc_view'] = 'list';
$lesson = new Lesson(slotId: 10, studentId: 5, instructorId: 3, offeringId: 8, id: 1);
$slot = new AvailabilitySlot(
instructorId: 3,
startDt: '2026-07-06 09:00:00',
endDt: '2026-07-06 10:00:00',
id: 10
);
$offering = new \Unsupervised\Schedular\Offering\Offering(
instructorId: 3,
kind: 'private_lesson',
title: 'Piano Lesson',
durationMinutes: 60,
id: 8
);
$this->bookings->shouldReceive('findAllUpcoming')->once()->andReturn([$lesson]);
$this->availability->shouldReceive('findById')->once()->with(10)->andReturn($slot);
$this->offerings->shouldReceive('findById')->once()->with(8)->andReturn($offering);
$html = $this->render();
self::assertStringContainsString('Piano Lesson', $html);
self::assertStringContainsString('lesson_id=1', $html);
}
public function testLessonIdRoutesToDetailWithAnswersAndPolicies(): void
{
$_GET['lesson_id'] = '1';
$lesson = new Lesson(slotId: 10, studentId: 5, instructorId: 3, offeringId: 8, id: 1);
$slot = new AvailabilitySlot(
instructorId: 3,
startDt: '2026-07-06 09:00:00',
endDt: '2026-07-06 10:00:00',
id: 10
);
$offering = new \Unsupervised\Schedular\Offering\Offering(
instructorId: 3,
kind: 'private_lesson',
title: 'Piano Lesson',
durationMinutes: 60,
id: 8
);
$this->bookings->shouldReceive('findById')->once()->with(1)->andReturn($lesson);
$this->availability->shouldReceive('findById')->once()->with(10)->andReturn($slot);
$this->offerings->shouldReceive('findById')->once()->with(8)->andReturn($offering);
$this->detail->shouldReceive('answers')->once()->with(1)->andReturn([
['question' => 'Skill level', 'answer' => 'Beginner'],
]);
$this->detail->shouldReceive('acceptances')->once()->with(1)->andReturn([
['policy' => 'Cancellation', 'version' => 'v2', 'accepted_at' => '2026-07-01 10:00:00', 'ip' => '1.2.3.4'],
]);
// The list of lessons must never be queried when routing to a detail view.
$this->bookings->shouldNotReceive('findAllUpcoming');
$html = $this->render();
self::assertStringContainsString('Lesson details', $html);
self::assertStringContainsString('Piano Lesson', $html);
self::assertStringContainsString('Skill level', $html);
self::assertStringContainsString('Beginner', $html);
self::assertStringContainsString('Cancellation', $html);
}
public function testInstructorCannotOpenAnotherInstructorsLessonDetail(): void
{
$_GET['lesson_id'] = '1';
Functions\when('get_current_user_id')->justReturn(99);
// The lesson belongs to instructor 3, not the current user (99).
$lesson = new Lesson(slotId: 10, studentId: 5, instructorId: 3, offeringId: 8, id: 1);
$this->bookings->shouldReceive('findById')->once()->with(1)->andReturn($lesson);
$this->detail->shouldNotReceive('answers');
$this->detail->shouldNotReceive('acceptances');
ob_start();
$this->controller->renderInstructorLessons();
$html = (string) ob_get_clean();
self::assertStringContainsString('could not be found', $html);
self::assertStringNotContainsString('Skill level', $html);
}
private function render(): string
{
ob_start();
+95
View File
@@ -0,0 +1,95 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Tests\Unit\Booking;
use Mockery;
use Unsupervised\Schedular\Booking\LessonDetail;
use Unsupervised\Schedular\Policy\AcceptanceRepository;
use Unsupervised\Schedular\Policy\Policy;
use Unsupervised\Schedular\Policy\PolicyAcceptance;
use Unsupervised\Schedular\Policy\PolicyRepository;
use Unsupervised\Schedular\Policy\PolicyVersion;
use Unsupervised\Schedular\Policy\PolicyVersionRepository;
use Unsupervised\Schedular\Registration\Answer;
use Unsupervised\Schedular\Registration\AnswerRepository;
use Unsupervised\Schedular\Registration\Question;
use Unsupervised\Schedular\Registration\QuestionRepository;
use Unsupervised\Schedular\Tests\Unit\TestCase;
class LessonDetailTest extends TestCase
{
private AnswerRepository&Mockery\MockInterface $answers;
private QuestionRepository&Mockery\MockInterface $questions;
private AcceptanceRepository&Mockery\MockInterface $acceptances;
private PolicyRepository&Mockery\MockInterface $policies;
private PolicyVersionRepository&Mockery\MockInterface $versions;
private LessonDetail $detail;
protected function setUp(): void
{
parent::setUp();
$this->answers = Mockery::mock(AnswerRepository::class);
$this->questions = Mockery::mock(QuestionRepository::class);
$this->acceptances = Mockery::mock(AcceptanceRepository::class);
$this->policies = Mockery::mock(PolicyRepository::class);
$this->versions = Mockery::mock(PolicyVersionRepository::class);
$this->detail = new LessonDetail(
$this->answers,
$this->questions,
$this->acceptances,
$this->policies,
$this->versions
);
}
public function testAnswersPairEachAnswerWithItsQuestionLabel(): void
{
$this->answers->shouldReceive('findByRegistration')->once()->with(Answer::REG_LESSON, 7)->andReturn([
new Answer(questionId: 2, registrationType: Answer::REG_LESSON, registrationId: 7, studentId: 5, answerValue: 'Beginner'),
new Answer(questionId: 9, registrationType: Answer::REG_LESSON, registrationId: 7, studentId: 5, answerValue: null),
]);
$this->questions->shouldReceive('findById')->with(2)->andReturn(new Question(offeringId: 1, label: 'Skill level', id: 2));
$this->questions->shouldReceive('findById')->with(9)->andReturn(null);
self::assertSame(
[
['question' => 'Skill level', 'answer' => 'Beginner'],
['question' => '#9', 'answer' => '—'],
],
$this->detail->answers(7)
);
}
public function testAcceptancesResolvePolicyTitleVersionAndAuditTrail(): void
{
$this->acceptances->shouldReceive('findByRegistration')->once()->with(PolicyAcceptance::REG_LESSON, 7)->andReturn([
new PolicyAcceptance(
policyVersionId: 4,
studentId: 5,
registrationType: PolicyAcceptance::REG_LESSON,
registrationId: 7,
ipAddress: '1.2.3.4',
acceptedAt: '2026-07-01 10:00:00'
),
]);
$this->versions->shouldReceive('findById')->with(4)->andReturn(new PolicyVersion(policyId: 3, versionNumber: 2, id: 4));
$this->policies->shouldReceive('findById')->with(3)->andReturn(new Policy(title: 'Cancellation', slug: 'cancellation', id: 3));
self::assertSame(
[
[
'policy' => 'Cancellation',
'version' => 'v2',
'accepted_at' => '2026-07-01 10:00:00',
'ip' => '1.2.3.4',
],
],
$this->detail->acceptances(7)
);
}
}
@@ -32,6 +32,7 @@ class EnrollmentEndpointTest extends TestCase
Functions\when('wp_unslash')->returnArg();
Functions\when('sanitize_text_field')->returnArg();
Functions\when('get_current_user_id')->justReturn(5);
Functions\when('current_time')->justReturn('2026-07-24');
$this->enrollments = Mockery::mock(EnrollmentRepository::class);
$this->offerings = Mockery::mock(OfferingRepository::class);
@@ -108,6 +109,36 @@ class EnrollmentEndpointTest extends TestCase
);
}
public function testRejectsEnrollmentAfterExplicitDeadline(): void
{
// current_time is stubbed to 2026-07-24, past the 2026-07-10 deadline.
$offering = new Offering(instructorId: 3, kind: Offering::KIND_GROUP_CLASS, title: 'Choir', termStart: '2026-07-01', enrollmentDeadline: '2026-07-10', id: 8);
$this->offerings->shouldReceive('findById')->with(8)->andReturn($offering);
$this->enrollments->shouldReceive('hasActiveEnrollment')->with(8, 5)->andReturn(false);
$this->enrollments->shouldReceive('insert')->never();
$result = $this->endpoint->enroll(new \WP_REST_Request(['offering_id' => 8]));
self::assertInstanceOf(\WP_Error::class, $result);
self::assertSame('enrollment_closed', $result->get_error_code());
self::assertSame(403, $result->error_data['enrollment_closed']['status']);
}
public function testRejectsEnrollmentAfterDefaultDeadlineOfFirstClassDay(): void
{
// No explicit deadline, so it defaults to term_start (the first class day),
// which is in the past relative to the stubbed 2026-07-24 "today".
$offering = new Offering(instructorId: 3, kind: Offering::KIND_GROUP_CLASS, title: 'Choir', termStart: '2026-07-20', id: 8);
$this->offerings->shouldReceive('findById')->with(8)->andReturn($offering);
$this->enrollments->shouldReceive('hasActiveEnrollment')->with(8, 5)->andReturn(false);
$this->enrollments->shouldReceive('insert')->never();
$result = $this->endpoint->enroll(new \WP_REST_Request(['offering_id' => 8]));
self::assertInstanceOf(\WP_Error::class, $result);
self::assertSame('enrollment_closed', $result->get_error_code());
}
public function testInviteOnlyClassRejectsStudentWithoutGrant(): void
{
$this->offerings->shouldReceive('findById')->with(8)->andReturn($this->inviteOnlyOffering());
@@ -65,6 +65,7 @@ class GroupClassControllerTest extends TestCase
static fn (string $format, string $date) => date($format, (int) strtotime($date))
);
Functions\when('wp_nonce_field')->justReturn('');
Functions\when('current_time')->justReturn('2026-01-01');
$_GET = [];
}
@@ -183,6 +184,49 @@ class GroupClassControllerTest extends TestCase
self::assertStringContainsString('Invite by email', $html);
}
public function testClassDetailOffersDirectAddForPublicClassWithoutInviteControls(): void
{
Functions\when('get_userdata')->justReturn($this->userNamed('Ada Lovelace'));
$_GET = ['class_id' => '8'];
// A plain public group class — the instructor can still add students
// directly (a late enrolment), but the invite-only controls are absent.
$offering = $this->offering(8, 'Choir', 10);
$this->offerings->shouldReceive('findAll')->once()->andReturn([$offering]);
$this->enrollments->shouldReceive('findByInstructor')->once()->with(3)->andReturn([]);
$html = $this->renderInstructor();
self::assertStringContainsString('Add students directly', $html);
self::assertStringContainsString('add_direct', $html);
self::assertStringNotContainsString('Invite by email', $html);
self::assertStringNotContainsString('Make available to students', $html);
}
public function testClassDetailFlagsLateEnrolmentPastTheDeadline(): void
{
Functions\when('get_userdata')->justReturn($this->userNamed('Ada Lovelace'));
// current_time is stubbed to 2026-01-01, which is past this class's deadline.
$_GET = ['class_id' => '8'];
$offering = new Offering(
instructorId: 3,
kind: Offering::KIND_GROUP_CLASS,
title: 'Choir',
termStart: '2025-09-08',
id: 8,
);
$this->offerings->shouldReceive('findAll')->once()->andReturn([$offering]);
$this->enrollments->shouldReceive('findByInstructor')->once()->with(3)->andReturn([]);
$html = $this->renderInstructor();
self::assertStringContainsString('late enrolments', $html);
self::assertStringContainsString('Add students directly', $html);
}
public function testClassDetailEnrolmentCountExcludesCancelledButRosterKeepsThem(): void
{
Functions\when('get_userdata')->justReturn($this->userNamed('Grace Hopper'));
@@ -102,6 +102,43 @@ class OfferingControllerTest extends TestCase
self::assertStringContainsString('2 open booking slots were removed', $html);
}
public function testAddGroupClassStoresEnrollmentDeadline(): void
{
$_POST = [
'usc_action' => 'add',
'title' => 'Ballet Beginners',
'kind' => Offering::KIND_GROUP_CLASS,
'term_start' => '2026-09-08',
'enrollment_deadline' => '2026-08-31',
];
$this->repository->shouldReceive('insert')->once()->with(Mockery::on(
static fn (Offering $o) => '2026-08-31' === $o->enrollmentDeadline
))->andReturn(1);
$this->repository->shouldReceive('findAll')->andReturn([]);
$this->reconciler->shouldReceive('reconcile')->once()->andReturn(['removed' => 0, 'conflicts' => []]);
$this->render();
}
public function testBlankEnrollmentDeadlineLeavesItNullToDefaultToFirstClass(): void
{
$_POST = [
'usc_action' => 'add',
'title' => 'Choir',
'kind' => Offering::KIND_GROUP_CLASS,
'term_start' => '2026-09-08',
];
$this->repository->shouldReceive('insert')->once()->with(Mockery::on(
static fn (Offering $o) => null === $o->enrollmentDeadline
))->andReturn(1);
$this->repository->shouldReceive('findAll')->andReturn([]);
$this->reconciler->shouldReceive('reconcile')->once()->andReturn(['removed' => 0, 'conflicts' => []]);
$this->render();
}
public function testGarbageClassTimeIsRejected(): void
{
$_POST = [
+44
View File
@@ -277,4 +277,48 @@ class OfferingTest extends TestCase
self::assertContains(Offering::BILLING_ONE_TIME, Offering::VALID_BILLING_MODES);
self::assertContains(Offering::BILLING_FULL_TERM, Offering::VALID_BILLING_MODES);
}
public function testEffectiveEnrollmentDeadlineDefaultsToTermStart(): void
{
$offering = new Offering(1, Offering::KIND_GROUP_CLASS, 'Choir', termStart: '2026-09-08');
self::assertSame('2026-09-08', $offering->effectiveEnrollmentDeadline());
}
public function testEffectiveEnrollmentDeadlineUsesExplicitValueWhenSet(): void
{
$offering = new Offering(1, Offering::KIND_GROUP_CLASS, 'Choir', termStart: '2026-09-08', enrollmentDeadline: '2026-08-31');
self::assertSame('2026-08-31', $offering->effectiveEnrollmentDeadline());
}
public function testEffectiveEnrollmentDeadlineIsNullWithoutDates(): void
{
$offering = new Offering(1, Offering::KIND_GROUP_CLASS, 'Choir');
self::assertNull($offering->effectiveEnrollmentDeadline());
}
public function testIsEnrollmentOpenOnAndBeforeTheDeadlineDay(): void
{
$offering = new Offering(1, Offering::KIND_GROUP_CLASS, 'Choir', termStart: '2026-09-08', enrollmentDeadline: '2026-08-31');
self::assertTrue($offering->isEnrollmentOpen('2026-08-30'));
self::assertTrue($offering->isEnrollmentOpen('2026-08-31'));
self::assertFalse($offering->isEnrollmentOpen('2026-09-01'));
}
public function testIsEnrollmentOpenAlwaysTrueWithoutADeadline(): void
{
$offering = new Offering(1, Offering::KIND_GROUP_CLASS, 'Choir');
self::assertTrue($offering->isEnrollmentOpen('2099-01-01'));
}
public function testToArrayIncludesEnrollmentDeadline(): void
{
$offering = new Offering(1, Offering::KIND_GROUP_CLASS, 'Choir', enrollmentDeadline: '2026-08-31', id: 10);
self::assertSame('2026-08-31', $offering->toArray()['enrollment_deadline']);
}
}
+32 -10
View File
@@ -32,6 +32,22 @@ class UpdateCheckerTest extends TestCase
return ['name' => $name, 'browser_download_url' => self::PACKAGE_URL];
}
/**
* The payload provideUpdate() returns when no newer release is offered.
* Core files this under the transient's `no_update` list, which is what
* makes the "Enable auto-updates" toggle appear. USC_VERSION is 1.0.0 in
* the test bootstrap.
*/
private function noUpdatePayload(): array
{
return [
'slug' => 'unsupervised-schedular',
'version' => '1.0.0',
'url' => UpdateChecker::REPO_URL,
'package' => '',
];
}
public function testRegisterHooksHostnameFilter(): void
{
Filters\expectAdded('update_plugins_git.unsupervised.ca')->once();
@@ -109,7 +125,7 @@ class UpdateCheckerTest extends TestCase
self::assertFalse($result);
}
public function testNoUpdateWhenReleaseIsNotNewer(): void
public function testNoUpdatePayloadWhenReleaseIsNotNewer(): void
{
Functions\when('plugin_basename')->justReturn(self::PLUGIN_FILE);
Functions\when('get_transient')->justReturn(false);
@@ -118,7 +134,9 @@ class UpdateCheckerTest extends TestCase
$result = (new UpdateChecker())->provideUpdate(false, [], self::PLUGIN_FILE);
self::assertFalse($result);
// Current version → core files this under `no_update` so the
// auto-update toggle stays visible; no package to install.
self::assertSame($this->noUpdatePayload(), $result);
}
public function testUsesCachedReleaseWithoutHittingApi(): void
@@ -134,7 +152,7 @@ class UpdateCheckerTest extends TestCase
self::assertSame('2.0.0', $result['version']);
}
public function testApiFailureIsCachedAndReturnsUpdateUnchanged(): void
public function testApiFailureIsCachedAndStillReportsUpdateSupport(): void
{
Functions\when('plugin_basename')->justReturn(self::PLUGIN_FILE);
Functions\when('get_transient')->justReturn(false);
@@ -150,10 +168,12 @@ class UpdateCheckerTest extends TestCase
$result = (new UpdateChecker())->provideUpdate(false, [], self::PLUGIN_FILE);
self::assertFalse($result);
// Even with the lookup failed we still return the `no_update` payload,
// so the auto-update toggle does not flicker away during a Gitea blip.
self::assertSame($this->noUpdatePayload(), $result);
}
public function testNon200ResponseReturnsUpdateUnchanged(): void
public function testNon200ResponseReturnsNoUpdatePayload(): void
{
Functions\when('plugin_basename')->justReturn(self::PLUGIN_FILE);
Functions\when('get_transient')->justReturn(false);
@@ -162,7 +182,7 @@ class UpdateCheckerTest extends TestCase
$result = (new UpdateChecker())->provideUpdate(false, [], self::PLUGIN_FILE);
self::assertFalse($result);
self::assertSame($this->noUpdatePayload(), $result);
}
public function testPicksFirstZipAssetAndSkipsOthers(): void
@@ -181,7 +201,7 @@ class UpdateCheckerTest extends TestCase
self::assertSame(self::PACKAGE_URL, $result['package']);
}
public function testReleaseWithoutZipAssetOffersNoUpdate(): void
public function testReleaseWithoutZipAssetOffersNoUpdatePayload(): void
{
Functions\when('plugin_basename')->justReturn(self::PLUGIN_FILE);
Functions\when('get_transient')->justReturn(false);
@@ -192,10 +212,12 @@ class UpdateCheckerTest extends TestCase
$result = (new UpdateChecker())->provideUpdate(false, [], self::PLUGIN_FILE);
self::assertFalse($result);
// No installable package means no update to offer, but we still keep
// the plugin in `no_update` so the toggle shows.
self::assertSame($this->noUpdatePayload(), $result);
}
public function testMalformedApiBodyOffersNoUpdate(): void
public function testMalformedApiBodyOffersNoUpdatePayload(): void
{
Functions\when('plugin_basename')->justReturn(self::PLUGIN_FILE);
Functions\when('get_transient')->justReturn(false);
@@ -207,6 +229,6 @@ class UpdateCheckerTest extends TestCase
$result = (new UpdateChecker())->provideUpdate(false, [], self::PLUGIN_FILE);
self::assertFalse($result);
self::assertSame($this->noUpdatePayload(), $result);
}
}
+2 -2
View File
@@ -3,7 +3,7 @@
* Plugin Name: Unsupervised Scheduler
* Plugin URI: https://git.unsupervised.ca/Unsupervised/unsupervised-scheduler
* Description: Instructor/student lesson scheduling for WordPress.
* Version: 1.1.0
* Version: 1.1.3
* Requires at least: 6.2
* Requires PHP: 8.1
* Author: Unsupervised
@@ -21,7 +21,7 @@ if (! defined('ABSPATH')) {
exit;
}
define('USC_VERSION', '1.1.0');
define('USC_VERSION', '1.1.3');
define('USC_PLUGIN_FILE', __FILE__);
define('USC_PLUGIN_DIR', plugin_dir_path(__FILE__));
define('USC_PLUGIN_URL', plugin_dir_url(__FILE__));