4 Commits
Author SHA1 Message Date
thatguygriff 1e4e21e8d3 Merge pull request 'Show a recurring lesson's policy acceptances and intake answers on every week of it' (#168) from fix/167-series-policy-acceptances into main
CI / Tests (PHP 8.2) (push) Successful in 44s
CI / Tests (PHP 8.1) (push) Successful in 54s
CI / No Debug Code (push) Successful in 1s
CI / PHPStan (push) Successful in 2m54s
CI / Coding Standards (push) Successful in 2m58s
CI / Tests (PHP 8.3) (push) Successful in 2m43s
Release / Build and Publish Release (push) Successful in 2m58s
Release / Open next-version bump PR (push) Successful in 4s
CI / Build Plugin Zip (push) Successful in 2m46s
Reviewed-on: #168
2026-07-30 19:41:03 +00:00
thatguygriffandClaude Opus 5 df3462a8b3 Show a series' policy acceptances on every occurrence
CI / Tests (PHP 8.2) (pull_request) Successful in 44s
CI / Tests (PHP 8.1) (pull_request) Successful in 54s
CI / No Debug Code (pull_request) Successful in 2s
CI / PHPStan (pull_request) Successful in 2m54s
CI / Coding Standards (pull_request) Successful in 2m59s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m43s
CI / Build Plugin Zip (pull_request) Skipped
A weekly booking reserves a series of lessons, but the student answers
the intake and ticks the policy boxes once — so BookingEndpoint records
both against the anchor lesson alone. The admin detail view looked them
up by whichever lesson id was being viewed, so every occurrence after
the first showed no answers and no acceptances at all.

LessonDetail now takes the Lesson rather than a bare id and resolves the
registration to `series_id ?? id`, so each occurrence reads the anchor's
records. This is the same seam PaymentService already uses to find a
series lesson's payment on the anchor.

Nothing was ever missing from the database, so existing bookings read
correctly with no migration and no schema change.

Closes #167

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-07-30 16:35:26 -03:00
thatguygriff 748478f2f1 Merge pull request 'Let the account holder edit their own details on the profile page' (#166) from feature/165-editable-own-profile into main
CI / Tests (PHP 8.1) (push) Successful in 46s
CI / Tests (PHP 8.2) (push) Successful in 55s
CI / No Debug Code (push) Successful in 3s
CI / PHPStan (push) Successful in 2m57s
CI / Coding Standards (push) Successful in 3m3s
CI / Tests (PHP 8.3) (push) Successful in 2m48s
CI / Build Plugin Zip (push) Successful in 2m50s
Reviewed-on: #166
2026-07-30 19:29:21 +00:00
thatguygriffandClaude Opus 5 f97b8a4576 Let the account holder edit their own profile details
CI / Tests (PHP 8.1) (pull_request) Successful in 45s
CI / No Debug Code (pull_request) Successful in 2s
CI / Tests (PHP 8.2) (pull_request) Successful in 55s
CI / PHPStan (pull_request) Successful in 2m57s
CI / Coding Standards (pull_request) Successful in 3m3s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m42s
CI / Build Plugin Zip (pull_request) Skipped
The Profile block is headed "Your profile", but the one person on it you
could not change was yourself: your name, your birth year, and whether you
take lessons yourself were fixed at whatever signup recorded, and correcting
any of them meant asking a studio admin.

A "Your details" section now opens the page, saved through the same
nonce-checked template_redirect post/redirect/get path the child rows use:

- Your name, written to display_name and nickname together, for the reason
  updateChild() does — UserName reads the nickname first, and leaving it
  behind would put the account's email address back on every screen that
  names a person.
- "I take lessons myself", the positive of us_guardian_only. This makes good
  on the claim already in bookableStudents() and the feature doc that a
  guardian-only account can put itself right from the profile page.
- Your birth year, held to the same normaliseBirthYear() rule as every other
  student.

The email is shown but not editable: it is the account's user_login as well
as its address, so changing it stays a studio-side job.

The birth-year field deliberately carries no `required` attribute. It is
asked of a student only, and this page loads no JavaScript, so a
browser-enforced `required` would leave a guardian who books solely for
other people unable to submit the form at all; handleSelf() enforces it
against the checkbox instead. Unticking the box does not clear a stored
birth year — it says who books, not "forget what is on file".

Closes #165

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-07-30 15:08:05 -03:00
15 changed files with 526 additions and 21 deletions
+5
View File
@@ -15,6 +15,11 @@ each change under the current top section as you work.
### Added ### Added
- **A registration question can now be asked of students only, and can be required of a student without being required of the account holder.** Every account-signup question was asked of everybody who registered, on the same terms — so "School and grade" had to be put to the adult signing themselves up, and a question a studio needed answered for a child could only be made required by demanding it of everyone. Each question now says who it is asked of — everyone, or only the students you register on behalf of — and carries its own **Required** setting for each: optional for you, required for every student you enrol, is now a thing a studio can ask for. Existing questions are untouched: they stay asked of everyone, and one that was required stays required of everyone. - **A registration question can now be asked of students only, and can be required of a student without being required of the account holder.** Every account-signup question was asked of everybody who registered, on the same terms — so "School and grade" had to be put to the adult signing themselves up, and a question a studio needed answered for a child could only be made required by demanding it of everyone. Each question now says who it is asked of — everyone, or only the students you register on behalf of — and carries its own **Required** setting for each: optional for you, required for every student you enrol, is now a thing a studio can ask for. Existing questions are untouched: they stay asked of everyone, and one that was required stays required of everyone.
- **You can now edit your own details on the profile page**, not just your students'. The page is called **Your profile**, and until now the one person on it you could not change was yourself: a mistyped name at signup, or a name that had since changed, meant asking the studio to fix it. **Your details** now sits at the top of the page with your name, your birth year, and whether you take lessons yourself. Your email address is shown but not editable — it is also how you sign in, so changing it stays a studio-side job.
- **"I take lessons myself" can be corrected after signup.** Signup asks whether you are registering just yourself, only on behalf of students, or both, and the answer decides whether you are offered as a student when booking. Choosing wrongly — or taking up lessons later alongside the children you book for — used to leave you asking the studio to change it. Ticking the box makes you bookable again and asks for your birth year like any other student; unticking it takes you back off the list without discarding the birth year you already gave, so ticking it back on costs you nothing.
### Fixed
- **A recurring lesson now shows the policies the student accepted on every week of it, not just the first.** Booking a weekly lesson reserves a series of them, and the student answers the intake questions and agrees to the studio's policies once, for the whole reservation. Opening any week after the first showed no answers and no policies accepted — as though nothing had been agreed to. Nothing was ever missing: the agreement was recorded against the first lesson of the series and every other week was looking for one of its own. Each week of a series now shows the intake answers and the full acceptance record — policy, version, when it was accepted, and from where — captured when the reservation was booked. Existing bookings read correctly straight away; there is nothing to re-collect from anyone.
## [1.4.1] ## [1.4.1]
+26
View File
@@ -521,6 +521,32 @@
} }
/* The guardian's manage-children screen ([us_family]). */ /* The guardian's manage-children screen ([us_family]). */
/*
* The account holder's own details, set off from the students below so the two
* halves of the page do not read as one long form.
*/
.us-family-self {
margin-bottom: 24px;
padding-bottom: 16px;
border-bottom: 1px solid #eee;
}
.us-family-self-email span {
font-weight: 600;
}
/*
* Guidance under a control, not a label: it explains when a field matters
* rather than naming it, so it is sized down and reads after the input.
*/
.us-field-hint {
display: block;
margin-top: 4px;
font-size: 0.9em;
opacity: 0.75;
}
.us-family-list { .us-family-list {
margin: 0 0 20px; margin: 0 0 20px;
padding: 0; padding: 0;
+1 -1
View File
@@ -160,7 +160,7 @@ instructor may only open their own lessons; the studio **Scheduler** may open an
- Model: `Unsupervised\Schedular\Booking\Lesson` - Model: `Unsupervised\Schedular\Booking\Lesson`
- Registration gate: `Unsupervised\Schedular\Registration\RegistrationGate` — validates and records intake answers + booking-scoped policy acceptances; shared with group enrolment - 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 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` - Admin lesson detail presenter: `Unsupervised\Schedular\Booking\LessonDetail` (per-lesson intake answers + policy acceptances), template `templates/admin/lesson-detail.php`. A weekly series is answered for and agreed to once, against the anchor lesson, so the presenter reads `series_id ?? id` — every occurrence shows the same intake and audit trail, not just the first.
- REST endpoint: `Unsupervised\Schedular\Booking\BookingEndpoint` - REST endpoint: `Unsupervised\Schedular\Booking\BookingEndpoint`
- Frontend: `Unsupervised\Schedular\Booking\BookingPage`, `Unsupervised\Schedular\Auth\LoginPage` - Frontend: `Unsupervised\Schedular\Booking\BookingPage`, `Unsupervised\Schedular\Auth\LoginPage`
- Upcoming-lessons panel: rendered client-side into `#us-my-lessons` by `assets/js/booking.js` (`lessonRowHtml`/`renderMyLessons`), mirrored for the editor by `BlockPreview::upcomingLessons()` — keep the two markup shapes in step. - Upcoming-lessons panel: rendered client-side into `#us-my-lessons` by `assets/js/booking.js` (`lessonRowHtml`/`renderMyLessons`), mirrored for the editor by `BlockPreview::upcomingLessons()` — keep the two markup shapes in step.
+48 -4
View File
@@ -187,7 +187,8 @@ than writing `0`, so "not set" stays the one spelling of "yes, a student".
One guard: a guardian-only account with **nobody linked to it** is still offered One guard: a guardian-only account with **nobody linked to it** is still offered
itself, because an empty picker is no way to book at all. They can put the itself, because an empty picker is no way to book at all. They can put the
account right from the profile page. account right from the profile page — see **Your details** below, where the flag
is editable as "I take lessons myself".
Per child the form collects: Per child the form collects:
- **Name** (required) - **Name** (required)
@@ -267,9 +268,51 @@ Booking-scope policies are accepted at booking time by whoever is signed in;
`BookingEndpoint` passes the same `accepted_by` when a guardian books for a `BookingEndpoint` passes the same `accepted_by` when a guardian books for a
child. child.
## Managing children ## Managing the account
`[us_family]` (block: **Profile**) renders the guardian's manage-children screen: ### Your details
The profile screen opens with the account holder's own record, because the
alternative was a page called **Your profile** on which the one person who could
not be edited was you. The form saves through
`GuardianService::updateSelf()` and holds:
- **Your name** — `display_name` and `nickname`, written together for the reason
`updateChild()` does: `UserName` reads the nickname first, and leaving it
behind would put the account's email address back on every screen that names a
person.
- **I take lessons myself** — the positive of `us_guardian_only`, so the form
asks the question the way a person answers it and `updateSelf()` is the one
place the sense is flipped. This is what makes good on "they can put the
account right from the profile page": an account that registered as a pure
guardian and later took up lessons — or ticked the wrong radio at signup — can
now correct itself instead of asking the studio to.
- **Your birth year** — `us_birth_year`, the same meta and the same
`normaliseBirthYear()` rule every student is held to.
Two decisions worth keeping:
- The birth-year field carries **no `required` attribute**. It is asked of a
student only, and the family screen loads no JavaScript, so a browser-enforced
`required` would leave a guardian who books solely for other people unable to
submit the form at all. `FamilyPage::handleSelf()` enforces it against the
checkbox instead, which is where the condition actually lives.
- Unticking **I take lessons myself** does **not** clear a stored birth year.
The box says who books, not "forget what you know about me", and someone who
ticks it back on the next visit should find their details as they left them.
The **email** is shown but not editable: it is the account's `user_login` as
well as its address, so changing it is a studio-side job rather than a
profile-screen one.
The account-scope questions are not re-asked here, in either direction — the
child rows do not offer them on edit either, and a studio that needs a newly
self-declared student's answers asks for them the same way it would for any
other change of circumstance.
### Managing children
The rest of `[us_family]` (block: **Profile**) is the manage-children screen:
list the children, add one, edit a name/birth year, remove one. list the children, add one, edit a name/birth year, remove one.
- **Add** creates another accountless child user and links it. Account-scope - **Add** creates another accountless child user and links it. Account-scope
@@ -366,7 +409,8 @@ need — via `GuardianService::contactFor()`.
- Models: `Unsupervised\Schedular\Guardian\GuardianLink` - Models: `Unsupervised\Schedular\Guardian\GuardianLink`
- Repository: `Unsupervised\Schedular\Guardian\GuardianRepository` - Repository: `Unsupervised\Schedular\Guardian\GuardianRepository`
- Service: `Unsupervised\Schedular\Guardian\GuardianService` (child creation, - Service: `Unsupervised\Schedular\Guardian\GuardianService` (child creation,
`canActFor()`, `payerFor()`, `contactFor()`, removal rules) `canActFor()`, `payerFor()`, `contactFor()`, removal rules, and the account
holder's own record via `accountHolder()`/`updateSelf()`)
- Login block: `Unsupervised\Schedular\Guardian\ChildLoginGate` - Login block: `Unsupervised\Schedular\Guardian\ChildLoginGate`
- Frontend: `Unsupervised\Schedular\Guardian\FamilyPage` (`[us_family]`) - Frontend: `Unsupervised\Schedular\Guardian\FamilyPage` (`[us_family]`)
- Shared question field: `Unsupervised\Schedular\Registration\QuestionField` - Shared question field: `Unsupervised\Schedular\Registration\QuestionField`
+23 -4
View File
@@ -176,10 +176,26 @@ class BlockPreview {
} }
/** /**
* Sample family (manage-children) page: two representative children and the * Sample family page: the account holder's own details, two representative
* add form, with the controls inert so the editor preview cannot post. * children and the add form, with the controls inert so the editor preview
* cannot post.
*/ */
public static function family(): string { public static function family(): string {
$self = sprintf(
'<h4>%s</h4><p class="us-family-self-email">%s <span>[email protected]</span></p>'
. '<p><label for="us-own-name">%s' . self::REQUIRED_MARK . '</label><input type="text" id="us-own-name" value="%s"></p>'
. '<p><label><input type="checkbox" checked disabled> %s</label></p>'
. '<p><label for="us-own-birth-year">%s</label><input type="number" id="us-own-birth-year" placeholder="YYYY"></p>'
. '<p><button type="button" disabled>%s</button></p>',
esc_html__( 'Your details', 'unsupervised-schedular' ),
esc_html__( 'Email', 'unsupervised-schedular' ),
esc_html__( 'Your name', 'unsupervised-schedular' ),
esc_attr__( 'Grace Hopper', 'unsupervised-schedular' ),
esc_html__( 'I take lessons myself', 'unsupervised-schedular' ),
esc_html__( 'Your birth year', 'unsupervised-schedular' ),
esc_html__( 'Save my details', 'unsupervised-schedular' )
);
$children = ''; $children = '';
foreach ( [ 'Ada Lovelace', 'Alan Turing' ] as $name ) { foreach ( [ 'Ada Lovelace', 'Alan Turing' ] as $name ) {
$children .= sprintf( $children .= sprintf(
@@ -202,9 +218,12 @@ class BlockPreview {
); );
return sprintf( return sprintf(
'<div class="us-family">%s<h3>%s</h3><ul class="us-family-list">%s</ul><form class="us-family-add">%s</form></div>', '<div class="us-family">%s<h3>%s</h3><form class="us-family-self">%s</form>'
self::note( __( 'Editor preview — signed-in guardians see and manage their own students here.', 'unsupervised-schedular' ) ), . '<h4>%s</h4><ul class="us-family-list">%s</ul><form class="us-family-add">%s</form></div>',
self::note( __( 'Editor preview — signed-in visitors see and manage their own details and students here.', 'unsupervised-schedular' ) ),
esc_html__( 'Your profile', 'unsupervised-schedular' ), esc_html__( 'Your profile', 'unsupervised-schedular' ),
$self,
esc_html__( 'Your students', 'unsupervised-schedular' ),
$children, $children,
$add $add
); );
+2 -2
View File
@@ -75,8 +75,8 @@ class LessonController {
$accepts = []; $accepts = [];
} else { } else {
$row = $this->row( $lesson ); $row = $this->row( $lesson );
$answers = $this->detail->answers( $lessonId ); $answers = $this->detail->answers( $lesson );
$accepts = $this->detail->acceptances( $lessonId ); $accepts = $this->detail->acceptances( $lesson );
} }
include USC_PLUGIN_DIR . 'templates/admin/lesson-detail.php'; include USC_PLUGIN_DIR . 'templates/admin/lesson-detail.php';
+18 -4
View File
@@ -17,6 +17,12 @@ use Unsupervised\Schedular\Registration\QuestionRepository;
* *
* Scoped to a single lesson (the `lesson` registration type), mirroring the * Scoped to a single lesson (the `lesson` registration type), mirroring the
* per-student history in {@see \Unsupervised\Schedular\Auth\StudentHistory}. * per-student history in {@see \Unsupervised\Schedular\Auth\StudentHistory}.
*
* A weekly reservation is answered for and agreed to once, so its answers and
* acceptances hang off the series anchor. Every occurrence therefore reads its
* series' registration, not its own id — otherwise only the first lesson of a
* series showed the intake and the audit trail, and the rest looked as though
* nothing had been accepted.
*/ */
class LessonDetail { class LessonDetail {
@@ -33,7 +39,7 @@ class LessonDetail {
* *
* @return list<array{question: string, answer: string}> * @return list<array{question: string, answer: string}>
*/ */
public function answers( int $lessonId ): array { public function answers( Lesson $lesson ): array {
return array_map( return array_map(
function ( Answer $answer ): array { function ( Answer $answer ): array {
$question = $this->questions->findById( $answer->questionId ); $question = $this->questions->findById( $answer->questionId );
@@ -44,7 +50,7 @@ class LessonDetail {
'answer' => '' === $value ? '—' : $value, 'answer' => '' === $value ? '—' : $value,
]; ];
}, },
$this->answers->findByRegistration( Answer::REG_LESSON, $lessonId ) $this->answers->findByRegistration( Answer::REG_LESSON, $this->registrationId( $lesson ) )
); );
} }
@@ -54,7 +60,7 @@ class LessonDetail {
* *
* @return list<array{policy: string, version: string, accepted_at: string, ip: string}> * @return list<array{policy: string, version: string, accepted_at: string, ip: string}>
*/ */
public function acceptances( int $lessonId ): array { public function acceptances( Lesson $lesson ): array {
return array_map( return array_map(
function ( PolicyAcceptance $acceptance ): array { function ( PolicyAcceptance $acceptance ): array {
$version = $this->versions->findById( $acceptance->policyVersionId ); $version = $this->versions->findById( $acceptance->policyVersionId );
@@ -67,7 +73,15 @@ class LessonDetail {
'ip' => $acceptance->ipAddress ?? '', 'ip' => $acceptance->ipAddress ?? '',
]; ];
}, },
$this->acceptances->findByRegistration( PolicyAcceptance::REG_LESSON, $lessonId ) $this->acceptances->findByRegistration( PolicyAcceptance::REG_LESSON, $this->registrationId( $lesson ) )
); );
} }
/**
* The lesson id the booking's answers and acceptances were recorded against:
* the series anchor for a weekly reservation, the lesson itself otherwise.
*/
private function registrationId( Lesson $lesson ): int {
return $lesson->seriesId ?? (int) $lesson->id;
}
} }
+21 -2
View File
@@ -10,8 +10,8 @@ use Unsupervised\Schedular\Registration\QuestionRepository;
use Unsupervised\Schedular\Val; use Unsupervised\Schedular\Val;
/** /**
* The guardian's "my family" screen (`[us_family]`): list, add, edit and remove * The guardian's "my family" screen (`[us_family]`): their own details, plus
* the children they book for. * list, add, edit and remove the children they book for.
* *
* Submissions are processed on `template_redirect` — before any output — and * Submissions are processed on `template_redirect` — before any output — and
* post/redirect/get back to the page, so a refresh cannot resubmit and add the * post/redirect/get back to the page, so a refresh cannot resubmit and add the
@@ -23,6 +23,7 @@ class FamilyPage {
private const RESULT_ADDED = 'added'; private const RESULT_ADDED = 'added';
private const RESULT_UPDATED = 'updated'; private const RESULT_UPDATED = 'updated';
private const RESULT_REMOVED = 'removed'; private const RESULT_REMOVED = 'removed';
private const RESULT_SELF = 'self';
/** /**
* Error from the most recent submission processed on `template_redirect`, * Error from the most recent submission processed on `template_redirect`,
@@ -58,6 +59,7 @@ class FamilyPage {
$userId = get_current_user_id(); $userId = get_current_user_id();
$self = $this->guardians->accountHolder( $userId );
$children = $this->guardians->children( $userId ); $children = $this->guardians->children( $userId );
$questions = $this->questions->findByScope( Question::SCOPE_ACCOUNT, activeOnly: true ); $questions = $this->questions->findByScope( Question::SCOPE_ACCOUNT, activeOnly: true );
$error = $this->submitError; $error = $this->submitError;
@@ -99,6 +101,7 @@ class FamilyPage {
'add' => $this->handleAdd( $userId ), 'add' => $this->handleAdd( $userId ),
'edit' => $this->handleEdit( $userId ), 'edit' => $this->handleEdit( $userId ),
'remove' => $this->handleRemove( $userId ), 'remove' => $this->handleRemove( $userId ),
'self' => $this->handleSelf( $userId ),
default => new \WP_Error( 'unknown_action', __( 'Unrecognised request.', 'unsupervised-schedular' ) ), default => new \WP_Error( 'unknown_action', __( 'Unrecognised request.', 'unsupervised-schedular' ) ),
}; };
@@ -149,6 +152,21 @@ class FamilyPage {
return $error ?? self::RESULT_UPDATED; return $error ?? self::RESULT_UPDATED;
} }
/**
* Save the account holder's own details. The birth year is only asked of a
* student, so it is the checkbox — not the browser — that decides whether one
* is required; the field carries no `required` attribute, or a guardian who
* books only for other people could never submit the form at all.
*/
private function handleSelf( int $userId ): string|\WP_Error {
// phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce checked by the caller.
$isStudent = isset( $_POST['is_student'] );
$error = $this->guardians->updateSelf( $userId, $this->postString( 'own_name' ), $this->postString( 'own_birth_year' ), $isStudent );
return $error ?? self::RESULT_SELF;
}
private function handleRemove( int $guardianId ): string|\WP_Error { private function handleRemove( int $guardianId ): string|\WP_Error {
// phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce checked by the caller. // phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce checked by the caller.
$childId = absint( Val::int( $_POST['child_id'] ?? 0 ) ); $childId = absint( Val::int( $_POST['child_id'] ?? 0 ) );
@@ -244,6 +262,7 @@ class FamilyPage {
self::RESULT_ADDED => __( 'Student added.', 'unsupervised-schedular' ), self::RESULT_ADDED => __( 'Student added.', 'unsupervised-schedular' ),
self::RESULT_UPDATED => __( 'Details updated.', 'unsupervised-schedular' ), self::RESULT_UPDATED => __( 'Details updated.', 'unsupervised-schedular' ),
self::RESULT_REMOVED => __( 'Student removed.', 'unsupervised-schedular' ), self::RESULT_REMOVED => __( 'Student removed.', 'unsupervised-schedular' ),
self::RESULT_SELF => __( 'Your details have been updated.', 'unsupervised-schedular' ),
default => '', default => '',
}; };
} }
+66
View File
@@ -163,6 +163,52 @@ class GuardianService {
return null; return null;
} }
/**
* Update the account holder's own details from the profile screen: their
* name, whether they are a student in their own right, and — when they are —
* their birth year.
*
* `$isStudent` is the positive of what {@see META_GUARDIAN_ONLY} stores, so
* the form can ask the question the way a person would answer it and this is
* the single place the sense is flipped.
*
* Returns null on success, mirroring {@see updateChild()}.
*/
public function updateSelf( int $userId, string $name, string $birthYear, bool $isStudent ): ?\WP_Error {
$name = trim( $name );
if ( '' === $name ) {
return new \WP_Error( 'missing_name', __( 'Please give your name.', 'unsupervised-schedular' ) );
}
if ( $isStudent && 0 === self::normaliseBirthYear( $birthYear ) ) {
return new \WP_Error( 'missing_birth_year', self::ownBirthYearError() );
}
$result = wp_update_user(
[
'ID' => $userId,
'display_name' => $name,
'nickname' => $name,
]
);
if ( is_wp_error( $result ) ) {
return $result;
}
$this->setGuardianOnly( $userId, ! $isStudent );
// Only written when they are a student. Saying "I only book for other
// people" is a statement about who books, not an instruction to forget a
// year already on file — and someone who ticks the box back on the next
// visit should find their own details as they left them.
if ( $isStudent ) {
$this->setBirthYear( $userId, $birthYear );
}
return null;
}
/** /**
* Unlink a child and delete their account. Refused once the child has any * Unlink a child and delete their account. Refused once the child has any
* lesson or enrolment history: their id is referenced by lessons, payments and * lesson or enrolment history: their id is referenced by lessons, payments and
@@ -319,6 +365,26 @@ class GuardianService {
return $out; return $out;
} }
/**
* The account holder's own details, as the profile screen's form needs them.
* The counterpart to {@see children()} for the person reading the page.
*
* `is_student` is the positive of {@see META_GUARDIAN_ONLY} — see
* {@see updateSelf()}, which reads it back the same way round.
*
* @return array{name: string, email: string, birth_year: string, is_student: bool}
*/
public function accountHolder( int $userId ): array {
$user = get_userdata( $userId );
return [
'name' => UserName::format( $user instanceof \WP_User ? $user : null, $userId ),
'email' => $user instanceof \WP_User ? $user->user_email : '',
'birth_year' => $this->birthYear( $userId ),
'is_student' => ! self::isGuardianOnly( $userId ),
];
}
/** /**
* The guardian behind a child, or null when the student books for themselves. * The guardian behind a child, or null when the student books for themselves.
* *
+47
View File
@@ -8,6 +8,7 @@ if (! defined('ABSPATH')) {
} }
/** /**
* @var array{name: string, email: string, birth_year: string, is_student: bool} $self The account holder's own details.
* @var list<array{id: int, name: string, birth_year: string, relationship: string}> $children * @var list<array{id: int, name: string, birth_year: string, relationship: string}> $children
* @var list<\Unsupervised\Schedular\Registration\Question> $questions Account-scope questions, asked once per child. * @var list<\Unsupervised\Schedular\Registration\Question> $questions Account-scope questions, asked once per child.
* @var string $error Validation error from the last submission, if any. * @var string $error Validation error from the last submission, if any.
@@ -26,6 +27,52 @@ if (! defined('ABSPATH')) {
<p class="us-error" role="alert"><?php echo esc_html($error); ?></p> <p class="us-error" role="alert"><?php echo esc_html($error); ?></p>
<?php endif; ?> <?php endif; ?>
<form method="post" action="" class="us-family-self">
<?php wp_nonce_field('us_family'); ?>
<input type="hidden" name="us_family_action" value="self">
<h4><?php esc_html_e('Your details', 'unsupervised-schedular'); ?></h4>
<?php if ($self['email'] !== '') : ?>
<?php /* Shown, not editable: the address is the account's login, and changing it is a studio-side job. */ ?>
<p class="us-family-self-email">
<?php esc_html_e('Email', 'unsupervised-schedular'); ?>
<span><?php echo esc_html($self['email']); ?></span>
</p>
<?php endif; ?>
<p>
<label for="us-own-name"><?php esc_html_e('Your name', 'unsupervised-schedular'); ?> <span class="us-required" aria-hidden="true">*</span></label>
<input type="text" name="own_name" id="us-own-name" autocomplete="name" required value="<?php echo esc_attr($self['name']); ?>">
</p>
<p>
<label>
<input type="checkbox" name="is_student" value="1"<?php checked($self['is_student']); ?>>
<?php esc_html_e('I take lessons myself', 'unsupervised-schedular'); ?>
</label>
<span class="us-field-hint"><?php esc_html_e('Leave this unticked if you only book for the students below — you will not be offered as a student yourself.', 'unsupervised-schedular'); ?></span>
</p>
<?php
/*
* Asked of a student only, so `required` is deliberately absent: the box
* above is what decides, and the browser cannot be told to enforce a
* field conditionally without JavaScript this page does not load.
* `handleSelf()` enforces it on the server either way.
*/
?>
<p>
<label for="us-own-birth-year"><?php esc_html_e('Your birth year', 'unsupervised-schedular'); ?></label>
<input type="number" name="own_birth_year" id="us-own-birth-year" value="<?php echo esc_attr($self['birth_year']); ?>" min="1900" max="<?php echo esc_attr(current_time('Y')); ?>" step="1" inputmode="numeric" autocomplete="bday-year" placeholder="<?php esc_attr_e('YYYY', 'unsupervised-schedular'); ?>" aria-describedby="us-own-birth-year-hint">
<span class="us-field-hint" id="us-own-birth-year-hint"><?php esc_html_e('Needed only if you take lessons yourself.', 'unsupervised-schedular'); ?></span>
</p>
<p>
<button type="submit"><?php esc_html_e('Save my details', 'unsupervised-schedular'); ?></button>
</p>
</form>
<h4><?php esc_html_e('Your students', 'unsupervised-schedular'); ?></h4>
<?php if (empty($children)) : ?> <?php if (empty($children)) : ?>
<p><?php esc_html_e('You have not added any students yet. Add one below to start booking lessons for them.', 'unsupervised-schedular'); ?></p> <p><?php esc_html_e('You have not added any students yet. Add one below to start booking lessons for them.', 'unsupervised-schedular'); ?></p>
<?php else : ?> <?php else : ?>
+17
View File
@@ -74,6 +74,23 @@ class BlockPreviewTest extends TestCase
self::assertStringContainsString('us-editor-note', $html); self::assertStringContainsString('us-editor-note', $html);
} }
/**
* The preview is what someone placing the block styles against, so it has to
* show both halves of the page — your own details as well as your students'.
*/
public function testFamilyPreviewShowsTheAccountHoldersDetailsAndTheirStudents(): void
{
$html = BlockPreview::family();
self::assertStringContainsString('class="us-family-self"', $html);
self::assertStringContainsString('id="us-own-name"', $html);
self::assertStringContainsString('id="us-own-birth-year"', $html);
self::assertStringContainsString('I take lessons myself', $html);
self::assertStringContainsString('class="us-family-list"', $html);
self::assertStringContainsString('class="us-family-add"', $html);
self::assertStringContainsString('us-editor-note', $html);
}
public function testRegistrationPreviewShowsADisabledSampleForm(): void public function testRegistrationPreviewShowsADisabledSampleForm(): void
{ {
$html = BlockPreview::registration(); $html = BlockPreview::registration();
+4 -2
View File
@@ -233,10 +233,12 @@ class LessonControllerTest extends TestCase
$this->bookings->shouldReceive('findById')->once()->with(1)->andReturn($lesson); $this->bookings->shouldReceive('findById')->once()->with(1)->andReturn($lesson);
$this->availability->shouldReceive('findById')->once()->with(10)->andReturn($slot); $this->availability->shouldReceive('findById')->once()->with(10)->andReturn($slot);
$this->offerings->shouldReceive('findById')->once()->with(8)->andReturn($offering); $this->offerings->shouldReceive('findById')->once()->with(8)->andReturn($offering);
$this->detail->shouldReceive('answers')->once()->with(1)->andReturn([ // The lesson itself is handed over, so the presenter can follow a series
// occurrence back to the anchor its answers and acceptances hang off.
$this->detail->shouldReceive('answers')->once()->with($lesson)->andReturn([
['question' => 'Skill level', 'answer' => 'Beginner'], ['question' => 'Skill level', 'answer' => 'Beginner'],
]); ]);
$this->detail->shouldReceive('acceptances')->once()->with(1)->andReturn([ $this->detail->shouldReceive('acceptances')->once()->with($lesson)->andReturn([
['policy' => 'Cancellation', 'version' => 'v2', 'accepted_at' => '2026-07-01 10:00:00', 'ip' => '1.2.3.4'], ['policy' => 'Cancellation', 'version' => 'v2', 'accepted_at' => '2026-07-01 10:00:00', 'ip' => '1.2.3.4'],
]); ]);
+55 -2
View File
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Unsupervised\Schedular\Tests\Unit\Booking; namespace Unsupervised\Schedular\Tests\Unit\Booking;
use Mockery; use Mockery;
use Unsupervised\Schedular\Booking\Lesson;
use Unsupervised\Schedular\Booking\LessonDetail; use Unsupervised\Schedular\Booking\LessonDetail;
use Unsupervised\Schedular\Policy\AcceptanceRepository; use Unsupervised\Schedular\Policy\AcceptanceRepository;
use Unsupervised\Schedular\Policy\Policy; use Unsupervised\Schedular\Policy\Policy;
@@ -60,7 +61,7 @@ class LessonDetailTest extends TestCase
['question' => 'Skill level', 'answer' => 'Beginner'], ['question' => 'Skill level', 'answer' => 'Beginner'],
['question' => '#9', 'answer' => '—'], ['question' => '#9', 'answer' => '—'],
], ],
$this->detail->answers(7) $this->detail->answers($this->lesson(7))
); );
} }
@@ -89,7 +90,59 @@ class LessonDetailTest extends TestCase
'ip' => '1.2.3.4', 'ip' => '1.2.3.4',
], ],
], ],
$this->detail->acceptances(7) $this->detail->acceptances($this->lesson(7))
);
}
public function testSeriesOccurrenceReadsTheAnchorsAnswersAndAcceptances(): void
{
// Occurrence #12 of a weekly reservation anchored on lesson 7: the intake
// and the agreement were recorded once, against the anchor.
$occurrence = $this->lesson(12, seriesId: 7);
$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'),
]);
$this->questions->shouldReceive('findById')->with(2)->andReturn(new Question(offeringId: 1, label: 'Skill level', id: 2));
$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(
[['question' => 'Skill level', 'answer' => 'Beginner']],
$this->detail->answers($occurrence)
);
self::assertSame(
[[
'policy' => 'Cancellation',
'version' => 'v2',
'accepted_at' => '2026-07-01 10:00:00',
'ip' => '1.2.3.4',
]],
$this->detail->acceptances($occurrence)
);
}
private function lesson(int $id, ?int $seriesId = null): Lesson
{
return new Lesson(
slotId: 1,
studentId: 5,
instructorId: 9,
offeringId: 1,
recurrence: null === $seriesId ? Lesson::RECURRENCE_SINGLE : Lesson::RECURRENCE_WEEKLY,
seriesId: $seriesId,
id: $id
); );
} }
} }
+112
View File
@@ -76,6 +76,21 @@ class FamilyPageTest extends TestCase
return $page; return $page;
} }
/**
* The account holder's own details, which every render reads.
*
* @param array{name?: string, email?: string, birth_year?: string, is_student?: bool} $overrides
*/
private function expectAccountHolder(array $overrides = []): void
{
$this->guardians->shouldReceive('accountHolder')->with(5)->andReturn($overrides + [
'name' => 'Grace',
'email' => '[email protected]',
'birth_year' => '1984',
'is_student' => true,
]);
}
/** /**
* A question required of everyone, or of nobody the shape every question * A question required of everyone, or of nobody the shape every question
* had before the account holder and the students could differ, and the shape * had before the account holder and the students could differ, and the shape
@@ -105,6 +120,7 @@ class FamilyPageTest extends TestCase
public function testRenderListsTheGuardiansChildren(): void public function testRenderListsTheGuardiansChildren(): void
{ {
$this->expectAccountHolder();
$this->guardians->shouldReceive('children')->once()->with(5)->andReturn([ $this->guardians->shouldReceive('children')->once()->with(5)->andReturn([
['id' => 42, 'name' => 'Ada', 'birth_year' => '2015', 'relationship' => 'Parent'], ['id' => 42, 'name' => 'Ada', 'birth_year' => '2015', 'relationship' => 'Parent'],
]); ]);
@@ -117,6 +133,101 @@ class FamilyPageTest extends TestCase
self::assertStringContainsString('Add a student', $html); self::assertStringContainsString('Add a student', $html);
} }
public function testRenderShowsTheAccountHoldersOwnDetails(): void
{
$this->expectAccountHolder();
$this->guardians->shouldReceive('children')->andReturn([]);
$this->questions->shouldReceive('findByScope')->andReturn([]);
$html = $this->page->render([]);
self::assertStringContainsString('Your details', $html);
self::assertStringContainsString('value="Grace"', $html);
self::assertStringContainsString('[email protected]', $html);
self::assertStringContainsString('value="1984"', $html);
// A student in their own right has the box ticked.
self::assertStringContainsString("checked='checked'", $html);
}
public function testAGuardianOnlyAccountRendersTheStudentBoxUnticked(): void
{
$this->expectAccountHolder(['is_student' => false]);
$this->guardians->shouldReceive('children')->andReturn([]);
$this->questions->shouldReceive('findByScope')->andReturn([]);
$html = $this->page->render([]);
self::assertStringContainsString('name="is_student"', $html);
self::assertStringNotContainsString("checked='checked'", $html);
}
/**
* The birth-year field must not carry `required`: it is asked of a student
* only, and the browser would otherwise block a guardian who books solely
* for other people from ever saving the form.
*/
public function testTheOwnBirthYearFieldIsNotBrowserRequired(): void
{
$this->expectAccountHolder(['is_student' => false, 'birth_year' => '']);
$this->guardians->shouldReceive('children')->andReturn([]);
$this->questions->shouldReceive('findByScope')->andReturn([]);
$html = $this->page->render([]);
self::assertMatchesRegularExpression('/<input[^>]*name="own_birth_year"(?![^>]*\brequired\b)[^>]*>/', $html);
}
public function testSavingOwnDetailsDelegatesToTheServiceAndRedirects(): void
{
$_POST = [
'us_family_action' => 'self',
'own_name' => 'Grace H',
'own_birth_year' => '1984',
'is_student' => '1',
];
$this->guardians->shouldReceive('updateSelf')->once()->with(5, 'Grace H', '1984', true)->andReturn(null);
$captured = null;
$this->capturingPage($captured)->maybeHandleSubmit();
self::assertSame('https://studio.test/family/?us_family=self', $captured);
}
/** An unticked checkbox is simply absent from the post — that is the "no". */
public function testAnAbsentStudentBoxSavesTheAccountAsGuardianOnly(): void
{
$_POST = [
'us_family_action' => 'self',
'own_name' => 'Grace H',
'own_birth_year' => '',
];
$this->guardians->shouldReceive('updateSelf')->once()->with(5, 'Grace H', '', false)->andReturn(null);
$captured = null;
$this->capturingPage($captured)->maybeHandleSubmit();
self::assertSame('https://studio.test/family/?us_family=self', $captured);
}
public function testOwnDetailsRefusalIsShownRatherThanRedirected(): void
{
$_POST = ['us_family_action' => 'self', 'own_name' => 'Grace', 'is_student' => '1'];
$this->guardians->shouldReceive('updateSelf')->once()->andReturn(
new \WP_Error('missing_birth_year', 'Please give your birth year.')
);
$captured = null;
$page = $this->capturingPage($captured);
$page->shouldNotReceive('redirect');
$page->maybeHandleSubmit();
self::assertNull($captured);
}
public function testAddCreatesTheChildRecordsItsAnswersAndRedirects(): void public function testAddCreatesTheChildRecordsItsAnswersAndRedirects(): void
{ {
$_POST = [ $_POST = [
@@ -347,6 +458,7 @@ class FamilyPageTest extends TestCase
{ {
$_GET = ['us_family' => 'added']; $_GET = ['us_family' => 'added'];
$this->expectAccountHolder();
$this->guardians->shouldReceive('children')->andReturn([]); $this->guardians->shouldReceive('children')->andReturn([]);
$this->questions->shouldReceive('findByScope')->andReturn([]); $this->questions->shouldReceive('findByScope')->andReturn([]);
@@ -321,6 +321,87 @@ class GuardianServiceTest extends TestCase
self::assertSame('2015', $this->meta[42][GuardianService::META_BIRTH_YEAR]); self::assertSame('2015', $this->meta[42][GuardianService::META_BIRTH_YEAR]);
} }
public function testUpdateSelfRenamesAndStoresTheBirthYear(): void
{
$this->meta[5][GuardianService::META_GUARDIAN_ONLY] = '1';
Functions\expect('wp_update_user')
->once()
->with(['ID' => 5, 'display_name' => 'Grace H', 'nickname' => 'Grace H'])
->andReturn(5);
self::assertNull($this->service->updateSelf(5, 'Grace H', '1984', true));
self::assertSame('1984', $this->meta[5][GuardianService::META_BIRTH_YEAR]);
// Saying they take lessons makes them a bookable student again.
self::assertFalse(GuardianService::isGuardianOnly(5));
}
public function testUpdateSelfMarksTheAccountGuardianOnly(): void
{
Functions\when('wp_update_user')->justReturn(5);
self::assertNull($this->service->updateSelf(5, 'Grace H', '', false));
self::assertSame('1', $this->meta[5][GuardianService::META_GUARDIAN_ONLY]);
}
/**
* "I only book for other people" says who books, not "forget my birth year"
* ticking the box back on should not have cost them what was on file.
*/
public function testUpdateSelfKeepsAStoredBirthYearWhenTheyAreNoLongerAStudent(): void
{
$this->meta[5][GuardianService::META_BIRTH_YEAR] = '1984';
Functions\when('wp_update_user')->justReturn(5);
self::assertNull($this->service->updateSelf(5, 'Grace H', '', false));
self::assertSame('1984', $this->meta[5][GuardianService::META_BIRTH_YEAR]);
}
public function testUpdateSelfRejectsABlankName(): void
{
Functions\expect('wp_update_user')->never();
self::assertInstanceOf(\WP_Error::class, $this->service->updateSelf(5, ' ', '1984', true));
}
/**
* The browser cannot enforce the year conditionally, so the server is the
* only thing standing between a student and a nonsense age on their record.
*
* @dataProvider unusableBirthYears
*/
public function testUpdateSelfRefusesAnUnusableBirthYearFromAStudent(string $submitted): void
{
Functions\expect('wp_update_user')->never();
self::assertInstanceOf(\WP_Error::class, $this->service->updateSelf(5, 'Grace H', $submitted, true));
}
public function testAccountHolderReportsTheirOwnDetails(): void
{
$this->meta[5][GuardianService::META_BIRTH_YEAR] = '1984';
Functions\when('get_userdata')->justReturn($this->user(5, 'Grace', 'Hopper', email: '[email protected]'));
self::assertSame(
['name' => 'Grace Hopper', 'email' => '[email protected]', 'birth_year' => '1984', 'is_student' => true],
$this->service->accountHolder(5)
);
}
public function testAccountHolderReportsAGuardianOnlyAccountAsNotAStudent(): void
{
$this->meta[5][GuardianService::META_GUARDIAN_ONLY] = '1';
Functions\when('get_userdata')->justReturn($this->user(5, 'Grace', 'Hopper', email: '[email protected]'));
self::assertFalse($this->service->accountHolder(5)['is_student']);
}
public function testRemoveChildUnlinksAndDeletesAChildWithNoHistory(): void public function testRemoveChildUnlinksAndDeletesAChildWithNoHistory(): void
{ {
$this->guardians->shouldReceive('isGuardianOf')->with(5, 42)->andReturn(true); $this->guardians->shouldReceive('isGuardianOf')->with(5, 42)->andReturn(true);