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
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)
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]>
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]>
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]>
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]>
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
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 arename_policyaction on the Policies screen. Only the title changes — the slug is whatfindBySlug()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 pastMAX_TITLE_LENGTHare 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_yearmeta viaGuardianService::setBirthYear().register.jsdisables 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 arequiredfield 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 onsubmit; native validation has already run by then, so the verdict stops the submit by hand.Group classes in upcoming lessons
New
GroupClass\SessionSchedule, feedingGET /bookings(students and instructors) and the Upcoming lessons table on the admin student detail page. Rows carrykind = '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\DeletedUserCleanuphooksdelete_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:end_dtemptyscheduletext fromOffering::scheduleLabel()schedulenon-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'sstart_dtis 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_guardianslink row goes, and the account is deleted.Deleting a child fires
delete_useragain and re-enters the same handler — ahandledset 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
nicknameto itsuser_login, and signup uses the email address as the login — so every self-registered account carried its own address as its nickname andUserName::format()fell straight through to it. The name they typed was indisplay_nameall 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 setsnicknameat insert, so new accounts are right at the source. Accounts created by a guardian were never affected —createChild()setsnicknameoutright, 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 settingline-height: 0anywhere 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
USC_VERSIONbump.BookingEndpointandStudentControllertakeSessionSchedule;DeletedUserCleanuptakesGuardianRepository+GuardianService. Wiring updated inRestRegistrar,AdminMenuandPlugin..us-guardianbox style is now.us-reg-group, shared by all three grouped sections.Tests
composer test— 866 tests, 2458 assertions, all passcomposer lint— no errorscomposer cs— cleanNew:
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