Editable policy name, one-page signup with adult birth year, group classes in upcoming lessons, and booking cleanup on user delete #161

Merged
thatguygriff merged 4 commits from feature/demo-followups-2026-07-30 into main 2026-07-30 15:28:12 +00:00
Owner

Six demo follow-ups, plus four fixes found while testing the branch.

The five from the demo list

Make policy name editable

PolicyRepository::updateTitle() plus a rename_policy action on the Policies screen. Only the title changes — the slug is what findBySlug() and the booking/signup gates resolve policies by, so a rename can never detach a policy from versions students have already accepted. Blank titles and titles past MAX_TITLE_LENGTH are ignored.

Signup is one page, and asks an adult student for their birth year

The studio's account-scope questions move off the second step and onto the main form, into an About you panel above the students being added. That panel also asks for the account holder's birth year — the same four-digit year every other student gives, stored under the same us_birth_year meta via GuardianService::setBirthYear().

register.js disables and hides the whole panel when the choice is "on behalf of one or more students": those questions describe a student, and a pure guardian is not one. A disabled fieldset is neither validated nor submitted, which is what stops a required field blocking the form on a control nobody can reach. The server enforces the same rules regardless.

The two-step machinery (data-steps, Next/Back, the early submit, enhanceSteps()) is gone, so the form has one submit rather than three.

Validate the password before the form is submitted

The meter scores the password as you type, but zxcvbn's dictionary loads asynchronously — a password typed in the first second was never scored at all, and the two-step gate advanced on a checkValidity() that had no opinion yet. It is now re-scored on submit; native validation has already run by then, so the verdict stops the submit by hand.

Group classes in upcoming lessons

New GroupClass\SessionSchedule, feeding GET /bookings (students and instructors) and the Upcoming lessons table on the admin student detail page. Rows carry kind = 'group_class' and no Cancel action — a session is one date in a term, not a booked slot. Withdrawal stays a whole-enrolment decision.

Cancel a deleted user's bookings

Auth\DeletedUserCleanup hooks delete_user / wpmu_delete_user: each upcoming lesson is marked cancelled, its slot released for rebooking, its still-pending payment voided; each active enrolment cancelled and its pending payment voided.

Past lessons are untouched: they happened and the payment report has to keep adding up. No account credit is issued for a paid lesson, unlike a student-initiated cancellation — the credit could only be spent on the account being deleted, so a refund owed to someone who has left stays the studio's decision to make and record.

Found while testing the branch

A class you are enrolled in must never silently vanish

The first pass listed only classes whose schedule resolved to exact datetimes — which needs a class time and a duration, both optional on the offering form, with the schedule note existing precisely so a studio can write "Tuesdays 4:00pm" instead. A class configured that way disappeared from the list entirely, which is the one thing this feature must not do.

Offering::sessionStarts() now splits "when does it meet" from "how long does it run"; sessionWindows() is that plus the duration, contract unchanged, since availability blocking and per-session billing need both ends of a window. The schedule degrades instead of disappearing:

Class has What the list gets
date + time + duration one dated row per remaining session, with an end time
date + time, no duration one dated row per remaining session, end_dt empty
no class time one row for the class, sorted by term start (or "now" once under way), with schedule text from Offering::scheduleLabel()
a term whose last day has passed nothing

schedule non-null means "a class, described in words, not a session at a known time"; every renderer shows that text in place of a date, and such a row's start_dt is a sort key only, never displayed.

A guardian takes their children with them

A child account is login-less and exists only so the guardian has somebody to book for; without the guardian nobody can reach it, book for it, or be billed for it, so it was left stranded on the roster still holding slots. Each child's bookings are released on the same terms, the us_guardians link row goes, and the account is deleted.

Deleting a child fires delete_user again and re-enters the same handler — a handled set of user ids makes that a no-op and also stops a self-referential or circular link recursing. GuardianService::removeChild() (the family screen's Remove) is unchanged and still refuses a child with history: that is a guardian tidying up, not an admin deleting an account.

People are named by their name, not their email address

"Managed by [email protected]" in the students table, the same under Booked by, instructor names on the class pages. WordPress defaults a new account's nickname to its user_login, and signup uses the email address as the login — so every self-registered account carried its own address as its nickname and UserName::format() fell straight through to it. The name they typed was in display_name all along.

format() now walks nickname then display name, skipping either when it is really the login or the email, so existing accounts read correctly with nothing to migrate. An identifier still never reaches the screen: an account with nothing but its address on file falls back to the id, as before. Signup also sets nickname at insert, so new accounts are right at the source. Accounts created by a guardian were never affected — createChild() sets nickname outright, which is exactly why children read correctly and their parents did not.

"Booked by" on the student detail page

The parent/guardian was named only further down under Profile, and only when there was one — so a page without that line was ambiguous between "books for themselves" and "the lookup found nothing". It now sits in the Account table beside display name and email, as the guardian's name linked to their own page, and always renders. Name only; their address is one click away on their own page.

The upcoming-lessons panel collapsing onto itself

Reported live: rows rendering on top of one another, and the status pill's background stopping short of the text inside it. One cause for both — the panel never stated its own line-height, so a theme setting line-height: 0 anywhere above it (the usual icon-font reset) was inherited straight through. Below 1 the line box is shorter than its glyphs, which stacks lines on top of each other and shrinks an inline-block pill's background. Now pinned at the same id-level specificity as the rest of the panel.

Notes

  • No schema change, so no USC_VERSION bump.
  • Constructor signatures changed: BookingEndpoint and StudentController take SessionSchedule; DeletedUserCleanup takes GuardianRepository + GuardianService. Wiring updated in RestRegistrar, AdminMenu and Plugin.
  • The signup form's .us-guardian box style is now .us-reg-group, shared by all three grouped sections.
  • Out of scope, flagged: deleting an instructor still leaves their students' lessons pointing at a user that no longer resolves. The ask covered the deleted account and anyone booked through it, so I have not widened it further.

Tests

  • composer test — 866 tests, 2458 assertions, all pass
  • composer lint — no errors
  • composer cs — clean

New: tests/Unit/GroupClass/SessionScheduleTest.php, tests/Unit/Auth/DeletedUserCleanupTest.php. Extended: OfferingTest (sessionStarts, scheduleLabel, lastClassDay), UserNameTest (nickname-is-really-the-login), RegistrationPageTest (birth year, one-page form), BookingEndpointTest (session merging), PolicyControllerTest, PolicyRepositoryTest.

🤖 Generated with Claude Code

Six demo follow-ups, plus four fixes found while testing the branch. ## The five from the demo list ### Make policy name editable `PolicyRepository::updateTitle()` plus a `rename_policy` action on the Policies screen. Only the title changes — the slug is what `findBySlug()` and the booking/signup gates resolve policies by, so a rename can never detach a policy from versions students have already accepted. Blank titles and titles past `MAX_TITLE_LENGTH` are ignored. ### Signup is one page, and asks an adult student for their birth year The studio's account-scope questions move off the second step and onto the main form, into an **About you** panel above the students being added. That panel also asks for the account holder's **birth year** — the same four-digit year every other student gives, stored under the same `us_birth_year` meta via `GuardianService::setBirthYear()`. `register.js` disables *and* hides the whole panel when the choice is "on behalf of one or more students": those questions describe a student, and a pure guardian is not one. A disabled fieldset is neither validated nor submitted, which is what stops a `required` field blocking the form on a control nobody can reach. The server enforces the same rules regardless. The two-step machinery (`data-steps`, Next/Back, the early submit, `enhanceSteps()`) is gone, so the form has one submit rather than three. ### Validate the password before the form is submitted The meter scores the password as you type, but zxcvbn's dictionary loads asynchronously — a password typed in the first second was never scored at all, and the two-step gate advanced on a `checkValidity()` that had no opinion yet. It is now re-scored on `submit`; native validation has already run by then, so the verdict stops the submit by hand. ### Group classes in upcoming lessons New `GroupClass\SessionSchedule`, feeding `GET /bookings` (students *and* instructors) and the **Upcoming lessons** table on the admin student detail page. Rows carry `kind = 'group_class'` and no Cancel action — a session is one date in a term, not a booked slot. Withdrawal stays a whole-enrolment decision. ### Cancel a deleted user's bookings `Auth\DeletedUserCleanup` hooks `delete_user` / `wpmu_delete_user`: each **upcoming** lesson is marked cancelled, its slot released for rebooking, its still-pending payment voided; each **active** enrolment cancelled and its pending payment voided. Past lessons are untouched: they happened and the payment report has to keep adding up. No account credit is issued for a paid lesson, unlike a student-initiated cancellation — the credit could only be spent on the account being deleted, so a refund owed to someone who has left stays the studio's decision to make and record. ## Found while testing the branch ### A class you are enrolled in must never silently vanish The first pass listed only classes whose schedule resolved to exact datetimes — which needs a class time **and** a duration, both optional on the offering form, with the schedule note existing precisely so a studio can write "Tuesdays 4:00pm" instead. A class configured that way disappeared from the list entirely, which is the one thing this feature must not do. `Offering::sessionStarts()` now splits "when does it meet" from "how long does it run"; `sessionWindows()` is that plus the duration, contract unchanged, since availability blocking and per-session billing need both ends of a window. The schedule degrades instead of disappearing: | Class has | What the list gets | |---|---| | date + time + duration | one dated row per remaining session, with an end time | | date + time, no duration | one dated row per remaining session, `end_dt` empty | | no class time | one row for the class, sorted by term start (or "now" once under way), with `schedule` text from `Offering::scheduleLabel()` | | a term whose last day has passed | nothing | `schedule` non-null means "a class, described in words, not a session at a known time"; every renderer shows that text in place of a date, and such a row's `start_dt` is a **sort key only**, never displayed. ### A guardian takes their children with them A child account is login-less and exists only so the guardian has somebody to book for; without the guardian nobody can reach it, book for it, or be billed for it, so it was left stranded on the roster still holding slots. Each child's bookings are released on the same terms, the `us_guardians` link row goes, and the account is deleted. Deleting a child fires `delete_user` again and re-enters the same handler — a `handled` set of user ids makes that a no-op and also stops a self-referential or circular link recursing. `GuardianService::removeChild()` (the family screen's Remove) is unchanged and still refuses a child with history: that is a guardian tidying up, not an admin deleting an account. ### People are named by their name, not their email address "Managed by [email protected]" in the students table, the same under Booked by, instructor names on the class pages. WordPress defaults a new account's `nickname` to its `user_login`, and signup uses the email address as the login — so every self-registered account carried its own address as its nickname and `UserName::format()` fell straight through to it. The name they typed was in `display_name` all along. `format()` now walks nickname then display name, skipping either when it is really the login or the email, so **existing accounts read correctly with nothing to migrate**. An identifier still never reaches the screen: an account with nothing but its address on file falls back to the id, as before. Signup also sets `nickname` at insert, so new accounts are right at the source. Accounts created by a guardian were never affected — `createChild()` sets `nickname` outright, which is exactly why children read correctly and their parents did not. ### "Booked by" on the student detail page The parent/guardian was named only further down under Profile, and only when there was one — so a page without that line was ambiguous between "books for themselves" and "the lookup found nothing". It now sits in the **Account** table beside display name and email, as the guardian's name linked to their own page, and always renders. Name only; their address is one click away on their own page. ### The upcoming-lessons panel collapsing onto itself Reported live: rows rendering on top of one another, and the status pill's background stopping short of the text inside it. One cause for both — the panel never stated its own `line-height`, so a theme setting `line-height: 0` anywhere above it (the usual icon-font reset) was inherited straight through. Below 1 the line box is shorter than its glyphs, which stacks lines on top of each other *and* shrinks an inline-block pill's background. Now pinned at the same id-level specificity as the rest of the panel. ## Notes - **No schema change**, so no `USC_VERSION` bump. - Constructor signatures changed: `BookingEndpoint` and `StudentController` take `SessionSchedule`; `DeletedUserCleanup` takes `GuardianRepository` + `GuardianService`. Wiring updated in `RestRegistrar`, `AdminMenu` and `Plugin`. - The signup form's `.us-guardian` box style is now `.us-reg-group`, shared by all three grouped sections. - **Out of scope, flagged:** deleting an *instructor* still leaves their students' lessons pointing at a user that no longer resolves. The ask covered the deleted account and anyone booked through it, so I have not widened it further. ## Tests - `composer test` — 866 tests, 2458 assertions, all pass - `composer lint` — no errors - `composer cs` — clean New: `tests/Unit/GroupClass/SessionScheduleTest.php`, `tests/Unit/Auth/DeletedUserCleanupTest.php`. Extended: `OfferingTest` (`sessionStarts`, `scheduleLabel`, `lastClassDay`), `UserNameTest` (nickname-is-really-the-login), `RegistrationPageTest` (birth year, one-page form), `BookingEndpointTest` (session merging), `PolicyControllerTest`, `PolicyRepositoryTest`. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
thatguygriff added 1 commit 2026-07-30 14:47:48 +00:00
Demo follow-ups: editable policy name, one-page signup, group classes in upcoming lessons, deletion cleanup
CI / Tests (PHP 8.1) (pull_request) Successful in 1m0s
CI / Tests (PHP 8.2) (pull_request) Successful in 1m0s
CI / No Debug Code (pull_request) Successful in 3s
CI / Coding Standards (pull_request) Successful in 3m8s
CI / Build Plugin Zip (pull_request) Skipped
CI / PHPStan (pull_request) Successful in 2m49s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m44s
cb347ffca0
Five items from the latest demo pass:

- A policy's title can be edited from the Policies screen. Only the title
  moves; the slug is what the gates resolve policies by, so a rename can
  never detach a policy from acceptances already recorded against it.
- Signup is one page again. The studio's registration questions move from
  a second step behind "Next" onto the main form, in an "About you" panel
  above the students being added, and that panel also asks an adult
  student for their birth year (the same us_birth_year meta a child's
  uses). register.js disables and hides the whole panel for a pure
  guardian, since the questions describe a student.
- The password is re-scored on submit, not only as it is typed. zxcvbn's
  dictionary arrives after page load, so a password typed straight away
  was never scored at all and the first the student heard of it was the
  server rejecting the whole form.
- Group-class sessions appear alongside lessons wherever upcoming lessons
  are listed: the [us_scheduler] panel (students and instructors) and the
  admin student detail page. GroupClass\SessionSchedule derives them from
  Offering::sessionWindows(), the same derivation the billing scan uses.
  They carry kind = 'group_class' and no Cancel action - a session is one
  date in a term, not a booked slot.
- Deleting a user releases what the account was holding: each upcoming
  lesson is cancelled, its slot freed for rebooking, its pending payment
  voided, and active class enrolments cancelled. Past lessons and paid
  history are left alone.

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

Co-Authored-By: Claude Opus 5 <[email protected]>
thatguygriff added 1 commit 2026-07-30 15:10:46 +00:00
Never drop an enrolled class from upcoming lessons; delete a guardian's children with them; pin the panel's line spacing
CI / Tests (PHP 8.2) (pull_request) Successful in 58s
CI / PHPStan (pull_request) Successful in 2m53s
CI / Coding Standards (pull_request) Successful in 2m58s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m47s
CI / Build Plugin Zip (pull_request) Skipped
CI / Tests (PHP 8.1) (pull_request) Successful in 1m1s
CI / No Debug Code (pull_request) Successful in 3s
c9a1205fc0
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]>
thatguygriff added 1 commit 2026-07-30 15:18:46 +00:00
Show "Booked by" in the Account section of a student's detail page
CI / No Debug Code (pull_request) Successful in 2s
CI / PHPStan (pull_request) Successful in 2m48s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m42s
CI / Tests (PHP 8.2) (pull_request) Successful in 57s
CI / Tests (PHP 8.1) (pull_request) Successful in 59s
CI / Coding Standards (pull_request) Successful in 3m1s
CI / Build Plugin Zip (pull_request) Skipped
122f7a0f53
The parent/guardian was only named further down under Profile, where it
reads as background rather than as an account fact, and only when there
was one — so a page with no such line was ambiguous between "books for
themselves" and "the lookup found nothing".

It now sits in the Account table beside display name and email, as the
guardian's name linked to their own detail page, and always renders: a
student who books for themselves says so outright. No email address —
theirs is one click away on their own page, and repeating it here only
makes the row harder to scan. The Profile section keeps only the note
explaining the placeholder email, which is a different point.

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

Co-Authored-By: Claude Opus 5 <[email protected]>
thatguygriff added 1 commit 2026-07-30 15:24:07 +00:00
Name people by their name, not their email address
CI / Tests (PHP 8.2) (pull_request) Successful in 58s
CI / Tests (PHP 8.1) (pull_request) Successful in 59s
CI / Coding Standards (pull_request) Successful in 2m51s
CI / PHPStan (pull_request) Successful in 3m1s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m42s
CI / No Debug Code (pull_request) Successful in 3s
CI / Build Plugin Zip (pull_request) Skipped
8fd7bf983d
Anywhere the plugin named a person it could show their email instead —
"Managed by [email protected]" in the students table, the same under
Booked by, instructor names on the class pages.

WordPress defaults a new account's `nickname` to its `user_login`, and
signup uses the email address as the login. So every self-registered
account carried its own address as its nickname, and UserName::format()
fell straight through to it. The name they typed was in `display_name`
all along. Accounts created by a guardian were never affected —
GuardianService::createChild() sets `nickname` outright, which is exactly
why children read correctly and their parents did not.

UserName::format() now walks nickname then display name, skipping either
when it is really the login or the email, so existing accounts read
correctly with nothing to migrate. An identifier still never reaches the
screen: an account with nothing but its address on file falls back to the
id, as before. Signup also sets `nickname` at insert, so new accounts are
right at the source rather than relying on the fallback.

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

Co-Authored-By: Claude Opus 5 <[email protected]>
thatguygriff merged commit 5d98aedfa5 into main 2026-07-30 15:28:12 +00:00
thatguygriff deleted branch feature/demo-followups-2026-07-30 2026-07-30 15:28:13 +00:00
Sign in to join this conversation.
No Reviewers
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: Unsupervised/unsupervised-scheduler#161