Collect a birth year instead of a full date of birth
CI / Tests (PHP 8.1) (pull_request) Successful in 43s
CI / No Debug Code (pull_request) Successful in 2s
CI / Tests (PHP 8.2) (pull_request) Successful in 50s
CI / PHPStan (pull_request) Successful in 2m55s
CI / Coding Standards (pull_request) Successful in 3m0s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m40s
CI / Build Plugin Zip (pull_request) Skipped

Signup and the profile page now ask for a four-digit year between 1900 and
the current year. Anything else — a short year, a full date, a year in the
future — is discarded rather than stored, so a typo cannot leave a nonsense
age on the record.

The year lives in a new us_birth_year user meta rather than reusing
us_date_of_birth, which would have left one key holding two formats. The old
key is not migrated in bulk. Instead GuardianService handles it in two
halves: birthYear() falls back to the year of the old date when the new key
is absent, so a student added before this change still shows one, and
setBirthYear() deletes the old date on every save.

That deletion is what makes the fallback safe rather than merely tidy.
Without it, clearing the birth year on a student who predates the change
would leave the old date behind for the fallback to read straight back, and
the year could never be cleared at all.

Stored in user meta, so no Schema.php change and no USC_VERSION bump.

Closes #147

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
2026-07-29 20:47:56 -03:00
co-authored by Claude Opus 5
parent 3a83decc82
commit 7e2bba79fe
15 changed files with 219 additions and 77 deletions
+1
View File
@@ -14,6 +14,7 @@ each change under the current top section as you work.
## [1.3.1] ## [1.3.1]
### Changed ### Changed
- Signup and the profile page now ask for a **birth year** rather than a full date of birth — a four-digit year between 1900 and the current year, with anything else discarded rather than stored. Students added before this change keep showing a birth year, derived from the date already on file; that old full date is then dropped the first time the record is saved, so the studio ends up holding only what it now asks for. No bulk purge runs, so a site wanting the remaining old dates gone should clear the `us_date_of_birth` user meta directly.
- The interface now says **student** where it said "child" and **profile** where it said "family". The `[us_family]` page is headed **Your profile**, its form is **Add a student**, signup asks for a **Student's name**, and the wp-admin students list and student screen both label the relationship **Profile**. Two strings were reworded rather than swapped: the students list reads **Managed by _name_** (a bare "Student of _name_" would read as a teacher's pupil), and a managed account is described as a **managed student account** so it is not confused with the account holder. Internal names — database columns, request parameters, form field names, the `us_family` shortcode and the `us-scheduler/family` block — are unchanged, since they are contracts with existing installs and saved post content. - The interface now says **student** where it said "child" and **profile** where it said "family". The `[us_family]` page is headed **Your profile**, its form is **Add a student**, signup asks for a **Student's name**, and the wp-admin students list and student screen both label the relationship **Profile**. Two strings were reworded rather than swapped: the students list reads **Managed by _name_** (a bare "Student of _name_" would read as a teacher's pupil), and a managed account is described as a **managed student account** so it is not confused with the account holder. Internal names — database columns, request parameters, form field names, the `us_family` shortcode and the `us-scheduler/family` block — are unchanged, since they are contracts with existing installs and saved post content.
### Fixed ### Fixed
+1 -1
View File
@@ -492,7 +492,7 @@
font-weight: 600; font-weight: 600;
} }
.us-family-child-dob { .us-family-child-birth-year {
font-size: 0.9em; font-size: 0.9em;
opacity: 0.75; opacity: 0.75;
} }
+1 -1
View File
@@ -168,7 +168,7 @@ No-op when no registration page is set.
## Parent/Guardian Signup ## Parent/Guardian Signup
The registration form also offers **"I'm registering as a parent or guardian"**, The registration form also offers **"I'm registering as a parent or guardian"**,
which reveals a repeatable child block (name, date of birth, and the which reveals a repeatable child block (name, birth year, and the
account-scope questions asked **per child**). Each child becomes a login-less account-scope questions asked **per child**). Each child becomes a login-less
`us_student` user linked to the guardian, and the signup policies are recorded `us_student` user linked to the guardian, and the signup policies are recorded
once per child with the guardian as the acceptor. Available on every signup path once per child with the guardian as the acceptor. Available on every signup path
+23 -4
View File
@@ -67,11 +67,28 @@ requires migrating every existing row.
never be linked twice. never be linked twice.
The table is a link table, not a child record: the child's **name** is their The table is a link table, not a child record: the child's **name** is their
`display_name` on `wp_users`, and their date of birth is the `us_date_of_birth` `display_name` on `wp_users`, and their birth year is the `us_birth_year`
user meta. Keeping them on the user row means the admin student screens, user meta. Keeping them on the user row means the admin student screens,
`get_users()` ordering, and every existing `student_id` lookup keep working with `get_users()` ordering, and every existing `student_id` lookup keep working with
no special-casing. no special-casing.
### The legacy `us_date_of_birth` meta
This feature originally collected a full date of birth in `us_date_of_birth`.
Nothing writes that key any more. It is handled entirely inside
`GuardianService`:
- **Read** — `birthYear()` falls back to the year of the old date when
`us_birth_year` is absent, so a child added before the change still shows one
without a migration step.
- **Write** — `setBirthYear()` deletes `us_date_of_birth` on *every* save,
including a save that clears the year. Without that the fallback would
resurrect the old date on the next read and the year could never be cleared.
The upshot is a lazy migration: a child's full date survives until their record
is next edited, then goes for good. There is no bulk purge — a site that wants
the remaining old dates gone should delete the `us_date_of_birth` meta directly.
v1 is deliberately **one guardian per child**: `GuardianRepository::insert()` v1 is deliberately **one guardian per child**: `GuardianRepository::insert()`
refuses to link a child that already has a guardian. The unique key and the refuses to link a child that already has a guardian. The unique key and the
guardian-side lookups already support many-to-many, so adding a second guardian guardian-side lookups already support many-to-many, so adding a second guardian
@@ -130,7 +147,9 @@ least one child name.
Per child the form collects: Per child the form collects:
- **Name** (required) - **Name** (required)
- **Date of birth** (optional, `us_date_of_birth` meta) - **Birth year** (optional, `us_birth_year` meta) — a four-digit year between
1900 and the current year. Anything else is discarded rather than stored, so
a typo cannot leave a nonsense age on the record.
- **Every account-scope registration question** (`Registration\Question`, - **Every account-scope registration question** (`Registration\Question`,
`SCOPE_ACCOUNT`) — asked once per child, not once per guardian, because in `SCOPE_ACCOUNT`) — asked once per child, not once per guardian, because in
practice they describe the student (instrument, level, school). The guardian practice they describe the student (instrument, level, school). The guardian
@@ -173,12 +192,12 @@ child.
## Managing children ## Managing children
`[us_family]` (block: **Profile**) renders the guardian's manage-children screen: `[us_family]` (block: **Profile**) renders the guardian's manage-children screen:
list the children, add one, edit a name/date of birth, 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
questions are asked here too, so a child added later carries the same questions are asked here too, so a child added later carries the same
information as one added at signup. information as one added at signup.
- **Edit** updates `display_name` and `us_date_of_birth`. - **Edit** updates `display_name` and `us_birth_year`.
- **Remove** unlinks the child and **deletes the child user**, but only when the - **Remove** unlinks the child and **deletes the child user**, but only when the
child has no lessons and no enrolments — a child with history is refused, so child has no lessons and no enrolments — a child with history is refused, so
removing one can never orphan a lesson, payment or credit removing one can never orphan a lesson, payment or credit
+5 -5
View File
@@ -477,11 +477,11 @@ class RegistrationPage {
/** /**
* The child blocks submitted with a guardian signup, as * The child blocks submitted with a guardian signup, as
* `children[<n>][name|dob|answers]`. Blocks with no name are dropped rather * `children[<n>][name|birth_year|answers]`. Blocks with no name are dropped rather
* than rejected — the form always renders one spare block for "add another", * than rejected — the form always renders one spare block for "add another",
* and an untouched spare is not a mistake the guardian needs telling about. * and an untouched spare is not a mistake the guardian needs telling about.
* *
* @return list<array{name: string, dob: string, answers: array<int, string>}> * @return list<array{name: string, birth_year: string, answers: array<int, string>}>
*/ */
private function submittedChildren(): array { private function submittedChildren(): array {
// The submit nonce is verified by the caller before this runs. // The submit nonce is verified by the caller before this runs.
@@ -509,7 +509,7 @@ class RegistrationPage {
$out[] = [ $out[] = [
'name' => $name, 'name' => $name,
'dob' => sanitize_text_field( Val::string( wp_unslash( $child['dob'] ?? '' ) ) ), 'birth_year' => sanitize_text_field( Val::string( wp_unslash( $child['birth_year'] ?? '' ) ) ),
'answers' => $answers, 'answers' => $answers,
]; ];
} }
@@ -528,7 +528,7 @@ class RegistrationPage {
* re-register and children they never confirmed, so it is undone entirely and * re-register and children they never confirmed, so it is undone entirely and
* they simply try again. * they simply try again.
* *
* @param list<array{name: string, dob: string, answers: array<int, string>}> $children * @param list<array{name: string, birth_year: string, answers: array<int, string>}> $children
* @param list<Question> $questions * @param list<Question> $questions
* @param list<array{policy: Policy, version: \Unsupervised\Schedular\Policy\PolicyVersion}> $policyForms * @param list<array{policy: Policy, version: \Unsupervised\Schedular\Policy\PolicyVersion}> $policyForms
*/ */
@@ -536,7 +536,7 @@ class RegistrationPage {
$created = []; $created = [];
foreach ( $children as $child ) { foreach ( $children as $child ) {
$childId = $this->guardians->createChild( $guardianId, $child['name'], $child['dob'] ); $childId = $this->guardians->createChild( $guardianId, $child['name'], $child['birth_year'] );
if ( $childId instanceof \WP_Error ) { if ( $childId instanceof \WP_Error ) {
foreach ( $created as $id ) { foreach ( $created as $id ) {
+2 -2
View File
@@ -187,11 +187,11 @@ class BlockPreview {
$add = sprintf( $add = sprintf(
'<h4>%s</h4><p><label for="us-child-name">%s</label><input type="text" id="us-child-name"></p>' '<h4>%s</h4><p><label for="us-child-name">%s</label><input type="text" id="us-child-name"></p>'
. '<p><label for="us-child-dob">%s</label><input type="date" id="us-child-dob"></p>' . '<p><label for="us-child-birth-year">%s</label><input type="number" id="us-child-birth-year" placeholder="YYYY"></p>'
. '<p><button type="button" disabled>%s</button></p>', . '<p><button type="button" disabled>%s</button></p>',
esc_html__( 'Add a student', 'unsupervised-schedular' ), esc_html__( 'Add a student', 'unsupervised-schedular' ),
esc_html__( 'Name', 'unsupervised-schedular' ), esc_html__( 'Name', 'unsupervised-schedular' ),
esc_html__( 'Date of birth', 'unsupervised-schedular' ), esc_html__( 'Birth year', 'unsupervised-schedular' ),
esc_html__( 'Add student', 'unsupervised-schedular' ) esc_html__( 'Add student', 'unsupervised-schedular' )
); );
+3 -3
View File
@@ -119,7 +119,7 @@ class FamilyPage {
*/ */
private function handleAdd( int $guardianId ): string|\WP_Error { private function handleAdd( int $guardianId ): string|\WP_Error {
$name = $this->postString( 'child_name' ); $name = $this->postString( 'child_name' );
$dateOfBirth = $this->postString( 'child_dob' ); $birthYear = $this->postString( 'child_birth_year' );
$relationship = $this->postString( 'child_relationship' ); $relationship = $this->postString( 'child_relationship' );
$questions = $this->questions->findByScope( Question::SCOPE_ACCOUNT, activeOnly: true ); $questions = $this->questions->findByScope( Question::SCOPE_ACCOUNT, activeOnly: true );
@@ -130,7 +130,7 @@ class FamilyPage {
return $missing; return $missing;
} }
$childId = $this->guardians->createChild( $guardianId, $name, $dateOfBirth, $relationship ); $childId = $this->guardians->createChild( $guardianId, $name, $birthYear, $relationship );
if ( $childId instanceof \WP_Error ) { if ( $childId instanceof \WP_Error ) {
return $childId; return $childId;
} }
@@ -144,7 +144,7 @@ class FamilyPage {
// 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 ) );
$error = $this->guardians->updateChild( $guardianId, $childId, $this->postString( 'child_name' ), $this->postString( 'child_dob' ) ); $error = $this->guardians->updateChild( $guardianId, $childId, $this->postString( 'child_name' ), $this->postString( 'child_birth_year' ) );
return $error ?? self::RESULT_UPDATED; return $error ?? self::RESULT_UPDATED;
} }
+74 -19
View File
@@ -23,9 +23,24 @@ class GuardianService {
*/ */
public const META_CHILD = 'us_child'; public const META_CHILD = 'us_child';
/** A child's date of birth (`Y-m-d`), collected at signup and editable after. */ /** A child's birth year (`YYYY`), collected at signup and editable after. */
public const META_BIRTH_YEAR = 'us_birth_year';
/**
* The full date of birth this feature used to collect. Nothing writes it any
* more: it is read once, to derive a birth year for a child who predates the
* change, and cleared the moment that child's record is next saved. Kept
* public so a site that wants to purge the old dates outright can find them.
*/
public const META_DOB = 'us_date_of_birth'; public const META_DOB = 'us_date_of_birth';
/**
* The earliest birth year the form will accept. Old enough for any student a
* studio will ever enrol, and late enough to reject a typo like `19` or `190`
* that would otherwise be stored as a plausible-looking year.
*/
private const MIN_BIRTH_YEAR = 1900;
/** /**
* Domain used for a child's placeholder login address. `.invalid` is reserved * Domain used for a child's placeholder login address. `.invalid` is reserved
* by RFC 2606 and can never resolve, so a child's address is guaranteed * by RFC 2606 and can never resolve, so a child's address is guaranteed
@@ -48,7 +63,7 @@ class GuardianService {
* Returns the new user ID, or a `WP_Error` when the name is blank or WordPress * Returns the new user ID, or a `WP_Error` when the name is blank or WordPress
* refuses the insert. * refuses the insert.
*/ */
public function createChild( int $guardianId, string $name, string $dateOfBirth = '', string $relationship = '' ): int|\WP_Error { public function createChild( int $guardianId, string $name, string $birthYear = '', string $relationship = '' ): int|\WP_Error {
$name = trim( $name ); $name = trim( $name );
if ( '' === $name ) { if ( '' === $name ) {
return new \WP_Error( 'missing_name', __( 'Please give each student a name.', 'unsupervised-schedular' ) ); return new \WP_Error( 'missing_name', __( 'Please give each student a name.', 'unsupervised-schedular' ) );
@@ -73,7 +88,7 @@ class GuardianService {
$userId = (int) $userId; $userId = (int) $userId;
update_user_meta( $userId, self::META_CHILD, '1' ); update_user_meta( $userId, self::META_CHILD, '1' );
$this->setDateOfBirth( $userId, $dateOfBirth ); $this->setBirthYear( $userId, $birthYear );
$linkId = $this->guardians->insert( $linkId = $this->guardians->insert(
new GuardianLink( new GuardianLink(
@@ -96,13 +111,13 @@ class GuardianService {
} }
/** /**
* Rename a child and update their date of birth. Refuses a student the caller * Rename a child and update their birth year. Refuses a student the caller
* is not the guardian of, so the family screen cannot be turned into an * is not the guardian of, so the family screen cannot be turned into an
* arbitrary user editor by posting someone else's id. * arbitrary user editor by posting someone else's id.
* *
* Returns null on success, mirroring {@see \Unsupervised\Schedular\Registration\RegistrationGate::validate()}. * Returns null on success, mirroring {@see \Unsupervised\Schedular\Registration\RegistrationGate::validate()}.
*/ */
public function updateChild( int $guardianId, int $studentId, string $name, string $dateOfBirth = '' ): ?\WP_Error { public function updateChild( int $guardianId, int $studentId, string $name, string $birthYear = '' ): ?\WP_Error {
if ( ! $this->guardians->isGuardianOf( $guardianId, $studentId ) ) { if ( ! $this->guardians->isGuardianOf( $guardianId, $studentId ) ) {
return new \WP_Error( 'forbidden', __( 'That is not one of your students.', 'unsupervised-schedular' ) ); return new \WP_Error( 'forbidden', __( 'That is not one of your students.', 'unsupervised-schedular' ) );
} }
@@ -124,7 +139,7 @@ class GuardianService {
return $result; return $result;
} }
$this->setDateOfBirth( $studentId, $dateOfBirth ); $this->setBirthYear( $studentId, $birthYear );
return null; return null;
} }
@@ -235,7 +250,7 @@ class GuardianService {
* A guardian's children, in link order, with the details the family and admin * A guardian's children, in link order, with the details the family and admin
* screens display. * screens display.
* *
* @return list<array{id: int, name: string, date_of_birth: string, relationship: string}> * @return list<array{id: int, name: string, birth_year: string, relationship: string}>
*/ */
public function children( int $guardianId ): array { public function children( int $guardianId ): array {
$out = []; $out = [];
@@ -246,7 +261,7 @@ class GuardianService {
$out[] = [ $out[] = [
'id' => $link->studentId, 'id' => $link->studentId,
'name' => UserName::format( $user instanceof \WP_User ? $user : null, $link->studentId ), 'name' => UserName::format( $user instanceof \WP_User ? $user : null, $link->studentId ),
'date_of_birth' => Val::string( get_user_meta( $link->studentId, self::META_DOB, true ) ), 'birth_year' => $this->birthYear( $link->studentId ),
'relationship' => $link->relationship, 'relationship' => $link->relationship,
]; ];
} }
@@ -327,24 +342,64 @@ class GuardianService {
} }
/** /**
* Store a child's date of birth, or clear it when blank or unparseable. Kept * Store a child's birth year, or clear it when blank or out of range.
* as `Y-m-d` so it sorts and displays consistently wherever it is read. *
* Either way the legacy full date of birth goes with it. That is what makes
* the read fallback in {@see birthYear()} safe: without it, clearing the year
* on a child who predates this change would leave the old date behind for the
* fallback to resurrect on the very next read.
*/ */
private function setDateOfBirth( int $userId, string $dateOfBirth ): void { private function setBirthYear( int $userId, string $birthYear ): void {
$dateOfBirth = trim( $dateOfBirth );
if ( '' === $dateOfBirth ) {
delete_user_meta( $userId, self::META_DOB ); delete_user_meta( $userId, self::META_DOB );
$year = $this->normaliseBirthYear( $birthYear );
if ( 0 === $year ) {
delete_user_meta( $userId, self::META_BIRTH_YEAR );
return; return;
} }
$parsed = \DateTimeImmutable::createFromFormat( 'Y-m-d', $dateOfBirth ); update_user_meta( $userId, self::META_BIRTH_YEAR, (string) $year );
if ( false === $parsed ) {
delete_user_meta( $userId, self::META_DOB );
return;
} }
update_user_meta( $userId, self::META_DOB, $parsed->format( 'Y-m-d' ) ); /**
* A submitted birth year as an integer, or 0 when it is blank, not a number,
* or outside {@see MIN_BIRTH_YEAR}..this year. A year in the future is a typo
* every time, so it is refused rather than stored.
*/
private function normaliseBirthYear( string $birthYear ): int {
$birthYear = trim( $birthYear );
if ( '' === $birthYear || 1 !== preg_match( '/^\d{4}$/', $birthYear ) ) {
return 0;
}
$year = (int) $birthYear;
if ( $year < self::MIN_BIRTH_YEAR || $year > (int) current_time( 'Y' ) ) {
return 0;
}
return $year;
}
/**
* A child's birth year, or an empty string when none is recorded.
*
* Falls back to the year of the full date of birth this feature used to
* collect, so a child added before the change still shows one. The fallback
* is read-only and one-way: {@see setBirthYear()} drops the old date as soon
* as the record is saved again.
*/
private function birthYear( int $userId ): string {
$year = Val::string( get_user_meta( $userId, self::META_BIRTH_YEAR, true ) );
if ( '' !== $year ) {
return $year;
}
$legacy = Val::string( get_user_meta( $userId, self::META_DOB, true ) );
return 1 === preg_match( '/^(\d{4})-/', $legacy, $m ) ? $m[1] : '';
} }
/** /**
+3 -3
View File
@@ -18,7 +18,7 @@ if (! defined('ABSPATH')) {
* @var float $creditBalance Balance of the account that settles this student's charges — the guardian's for a child. * @var float $creditBalance Balance of the account that settles this student's charges — the guardian's for a child.
* @var string $creditCurrency * @var string $creditCurrency
* @var array{id: int, name: string, email: string}|null $guardian The parent/guardian who books for this student, or null when they book for themselves. * @var array{id: int, name: string, email: string}|null $guardian The parent/guardian who books for this student, or null when they book for themselves.
* @var list<array{id: int, name: string, date_of_birth: string, relationship: string}> $children Children this student books for. * @var list<array{id: int, name: string, birth_year: string, relationship: string}> $children Children this student books for.
* @var array{id: int, name: string, email: string} $payer Who is billed for this student — themselves, or their guardian. * @var array{id: int, name: string, email: string} $payer Who is billed for this student — themselves, or their guardian.
* @var string $pageSlug * @var string $pageSlug
* @var string $backUrl * @var string $backUrl
@@ -131,8 +131,8 @@ $renderLessons = static function (array $rows, bool $withActions = false): void
<?php foreach ($children as $child) : ?> <?php foreach ($children as $child) : ?>
<li> <li>
<a href="<?php echo esc_url($detailUrl($child['id'])); ?>"><?php echo esc_html($child['name']); ?></a> <a href="<?php echo esc_url($detailUrl($child['id'])); ?>"><?php echo esc_html($child['name']); ?></a>
<?php if ($child['date_of_birth'] !== '') : ?> <?php if ($child['birth_year'] !== '') : ?>
<span class="description"><?php echo esc_html($child['date_of_birth']); ?></span> <span class="description"><?php echo esc_html($child['birth_year']); ?></span>
<?php endif; ?> <?php endif; ?>
</li> </li>
<?php endforeach; ?> <?php endforeach; ?>
+1 -1
View File
@@ -6,7 +6,7 @@ if (! defined('ABSPATH')) {
} }
/** /**
* @var list<array{id: int, name: string, email: string, registered: string, upcoming: int, enrolments: int, guardian: array{id: int, name: string, email: string}|null, children: list<array{id: int, name: string, date_of_birth: string, relationship: string}>}> $students * @var list<array{id: int, name: string, email: string, registered: string, upcoming: int, enrolments: int, guardian: array{id: int, name: string, email: string}|null, children: list<array{id: int, name: string, birth_year: string, relationship: string}>}> $students
* @var string $pageSlug * @var string $pageSlug
*/ */
+7 -7
View File
@@ -8,7 +8,7 @@ if (! defined('ABSPATH')) {
} }
/** /**
* @var list<array{id: int, name: string, date_of_birth: 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.
* @var string $notice Confirmation of a completed add/edit/remove, if any. * @var string $notice Confirmation of a completed add/edit/remove, if any.
@@ -42,8 +42,8 @@ if (! defined('ABSPATH')) {
<input type="text" name="child_name" id="us-edit-name-<?php echo esc_attr((string) $child['id']); ?>" value="<?php echo esc_attr($child['name']); ?>" required> <input type="text" name="child_name" id="us-edit-name-<?php echo esc_attr((string) $child['id']); ?>" value="<?php echo esc_attr($child['name']); ?>" required>
</p> </p>
<p> <p>
<label for="us-edit-dob-<?php echo esc_attr((string) $child['id']); ?>"><?php esc_html_e('Date of birth', 'unsupervised-schedular'); ?></label> <label for="us-edit-birth-year-<?php echo esc_attr((string) $child['id']); ?>"><?php esc_html_e('Birth year', 'unsupervised-schedular'); ?></label>
<input type="date" name="child_dob" id="us-edit-dob-<?php echo esc_attr((string) $child['id']); ?>" value="<?php echo esc_attr($child['date_of_birth']); ?>"> <input type="number" name="child_birth_year" id="us-edit-birth-year-<?php echo esc_attr((string) $child['id']); ?>" value="<?php echo esc_attr($child['birth_year']); ?>" min="1900" max="<?php echo esc_attr(current_time('Y')); ?>" step="1" inputmode="numeric" placeholder="<?php esc_attr_e('YYYY', 'unsupervised-schedular'); ?>">
</p> </p>
<p> <p>
<button type="submit"><?php esc_html_e('Save', 'unsupervised-schedular'); ?></button> <button type="submit"><?php esc_html_e('Save', 'unsupervised-schedular'); ?></button>
@@ -52,8 +52,8 @@ if (! defined('ABSPATH')) {
</form> </form>
<?php else : ?> <?php else : ?>
<span class="us-family-child-name"><?php echo esc_html($child['name']); ?></span> <span class="us-family-child-name"><?php echo esc_html($child['name']); ?></span>
<?php if ($child['date_of_birth'] !== '') : ?> <?php if ($child['birth_year'] !== '') : ?>
<span class="us-family-child-dob"><?php echo esc_html($child['date_of_birth']); ?></span> <span class="us-family-child-birth-year"><?php echo esc_html($child['birth_year']); ?></span>
<?php endif; ?> <?php endif; ?>
<span class="us-family-child-actions"> <span class="us-family-child-actions">
<a href="<?php echo esc_url(add_query_arg('us_edit_child', $child['id'], (string) get_permalink())); ?>"><?php esc_html_e('Edit', 'unsupervised-schedular'); ?></a> <a href="<?php echo esc_url(add_query_arg('us_edit_child', $child['id'], (string) get_permalink())); ?>"><?php esc_html_e('Edit', 'unsupervised-schedular'); ?></a>
@@ -80,8 +80,8 @@ if (! defined('ABSPATH')) {
<input type="text" name="child_name" id="us-child-name" required> <input type="text" name="child_name" id="us-child-name" required>
</p> </p>
<p> <p>
<label for="us-child-dob"><?php esc_html_e('Date of birth', 'unsupervised-schedular'); ?></label> <label for="us-child-birth-year"><?php esc_html_e('Birth year', 'unsupervised-schedular'); ?></label>
<input type="date" name="child_dob" id="us-child-dob"> <input type="number" name="child_birth_year" id="us-child-birth-year" min="1900" max="<?php echo esc_attr(current_time('Y')); ?>" step="1" inputmode="numeric" placeholder="<?php esc_attr_e('YYYY', 'unsupervised-schedular'); ?>">
</p> </p>
<p> <p>
<label for="us-child-relationship"><?php esc_html_e('Your relationship to them', 'unsupervised-schedular'); ?></label> <label for="us-child-relationship"><?php esc_html_e('Your relationship to them', 'unsupervised-schedular'); ?></label>
+2 -2
View File
@@ -90,8 +90,8 @@ if (! defined('ABSPATH')) {
<input type="text" name="children[0][name]" id="us-child-0-name"> <input type="text" name="children[0][name]" id="us-child-0-name">
</p> </p>
<p> <p>
<label for="us-child-0-dob"><?php esc_html_e('Date of birth', 'unsupervised-schedular'); ?></label> <label for="us-child-0-birth-year"><?php esc_html_e('Birth year', 'unsupervised-schedular'); ?></label>
<input type="date" name="children[0][dob]" id="us-child-0-dob"> <input type="number" name="children[0][birth_year]" id="us-child-0-birth-year" min="1900" max="<?php echo esc_attr(current_time('Y')); ?>" step="1" inputmode="numeric" placeholder="<?php esc_attr_e('YYYY', 'unsupervised-schedular'); ?>">
</p> </p>
<?php foreach ($accountQuestions as $question) : ?> <?php foreach ($accountQuestions as $question) : ?>
<?php <?php
+10 -10
View File
@@ -618,10 +618,10 @@ class RegistrationPageTest extends TestCase
'display_name' => 'Grace', 'display_name' => 'Grace',
'us_is_guardian' => '1', 'us_is_guardian' => '1',
'children' => [ 'children' => [
['name' => 'Ada', 'dob' => '2015-04-02', 'answers' => [7 => 'Piano']], ['name' => 'Ada', 'birth_year' => '2015', 'answers' => [7 => 'Piano']],
['name' => 'Alan', 'dob' => '', 'answers' => [7 => 'Violin']], ['name' => 'Alan', 'birth_year' => '', 'answers' => [7 => 'Violin']],
// An untouched spare block is dropped, not rejected. // An untouched spare block is dropped, not rejected.
['name' => ' ', 'dob' => '', 'answers' => []], ['name' => ' ', 'birth_year' => '', 'answers' => []],
], ],
]; ];
@@ -633,7 +633,7 @@ class RegistrationPageTest extends TestCase
Functions\when('wp_insert_user')->justReturn(42); Functions\when('wp_insert_user')->justReturn(42);
Functions\when('is_wp_error')->alias(static fn ($thing): bool => $thing instanceof \WP_Error); Functions\when('is_wp_error')->alias(static fn ($thing): bool => $thing instanceof \WP_Error);
$this->ctx['guardians']->shouldReceive('createChild')->once()->with(42, 'Ada', '2015-04-02')->andReturn(101); $this->ctx['guardians']->shouldReceive('createChild')->once()->with(42, 'Ada', '2015')->andReturn(101);
$this->ctx['guardians']->shouldReceive('createChild')->once()->with(42, 'Alan', '')->andReturn(102); $this->ctx['guardians']->shouldReceive('createChild')->once()->with(42, 'Alan', '')->andReturn(102);
$recorded = []; $recorded = [];
@@ -658,7 +658,7 @@ class RegistrationPageTest extends TestCase
'password' => 'password123', 'password' => 'password123',
'display_name' => 'Grace', 'display_name' => 'Grace',
'us_is_guardian' => '1', 'us_is_guardian' => '1',
'children' => [['name' => '', 'dob' => '', 'answers' => []]], 'children' => [['name' => '', 'birth_year' => '', 'answers' => []]],
]; ];
Functions\when('email_exists')->justReturn(false); Functions\when('email_exists')->justReturn(false);
@@ -681,8 +681,8 @@ class RegistrationPageTest extends TestCase
'display_name' => 'Grace', 'display_name' => 'Grace',
'us_is_guardian' => '1', 'us_is_guardian' => '1',
'children' => [ 'children' => [
['name' => 'Ada', 'dob' => '', 'answers' => [7 => 'Piano']], ['name' => 'Ada', 'birth_year' => '', 'answers' => [7 => 'Piano']],
['name' => 'Alan', 'dob' => '', 'answers' => [7 => ' ']], ['name' => 'Alan', 'birth_year' => '', 'answers' => [7 => ' ']],
], ],
]; ];
@@ -708,8 +708,8 @@ class RegistrationPageTest extends TestCase
'display_name' => 'Grace', 'display_name' => 'Grace',
'us_is_guardian' => '1', 'us_is_guardian' => '1',
'children' => [ 'children' => [
['name' => 'Ada', 'dob' => '', 'answers' => []], ['name' => 'Ada', 'birth_year' => '', 'answers' => []],
['name' => 'Alan', 'dob' => '', 'answers' => []], ['name' => 'Alan', 'birth_year' => '', 'answers' => []],
], ],
]; ];
@@ -745,7 +745,7 @@ class RegistrationPageTest extends TestCase
'display_name' => 'Grace', 'display_name' => 'Grace',
'us_is_guardian' => '1', 'us_is_guardian' => '1',
'accept' => [3], 'accept' => [3],
'children' => [['name' => 'Ada', 'dob' => '', 'answers' => []]], 'children' => [['name' => 'Ada', 'birth_year' => '', 'answers' => []]],
]; ];
$version = new PolicyVersion(policyId: 1, versionNumber: 1, body: 'Terms', status: PolicyVersion::STATUS_PUBLISHED, id: 3); $version = new PolicyVersion(policyId: 1, versionNumber: 1, body: 'Terms', status: PolicyVersion::STATUS_PUBLISHED, id: 3);
+8 -6
View File
@@ -38,6 +38,8 @@ class FamilyPageTest extends TestCase
Functions\when('wp_enqueue_style')->justReturn(null); Functions\when('wp_enqueue_style')->justReturn(null);
Functions\when('wp_nonce_field')->justReturn(''); Functions\when('wp_nonce_field')->justReturn('');
Functions\when('get_permalink')->justReturn('https://studio.test/family/'); Functions\when('get_permalink')->justReturn('https://studio.test/family/');
// The birth-year input caps itself at the current year.
Functions\when('current_time')->justReturn('2026');
Functions\when('absint')->alias(static fn ($value) => abs((int) $value)); Functions\when('absint')->alias(static fn ($value) => abs((int) $value));
Functions\when('sanitize_key')->alias(static fn (string $v): string => strtolower(preg_replace('/[^a-z0-9_\-]/i', '', $v) ?? '')); Functions\when('sanitize_key')->alias(static fn (string $v): string => strtolower(preg_replace('/[^a-z0-9_\-]/i', '', $v) ?? ''));
Functions\when('sanitize_text_field')->alias(static fn (string $v): string => trim($v)); Functions\when('sanitize_text_field')->alias(static fn (string $v): string => trim($v));
@@ -92,14 +94,14 @@ class FamilyPageTest extends TestCase
public function testRenderListsTheGuardiansChildren(): void public function testRenderListsTheGuardiansChildren(): void
{ {
$this->guardians->shouldReceive('children')->once()->with(5)->andReturn([ $this->guardians->shouldReceive('children')->once()->with(5)->andReturn([
['id' => 42, 'name' => 'Ada', 'date_of_birth' => '2015-04-02', 'relationship' => 'Parent'], ['id' => 42, 'name' => 'Ada', 'birth_year' => '2015', 'relationship' => 'Parent'],
]); ]);
$this->questions->shouldReceive('findByScope')->andReturn([]); $this->questions->shouldReceive('findByScope')->andReturn([]);
$html = $this->page->render([]); $html = $this->page->render([]);
self::assertStringContainsString('Ada', $html); self::assertStringContainsString('Ada', $html);
self::assertStringContainsString('2015-04-02', $html); self::assertStringContainsString('2015', $html);
self::assertStringContainsString('Add a student', $html); self::assertStringContainsString('Add a student', $html);
} }
@@ -108,13 +110,13 @@ class FamilyPageTest extends TestCase
$_POST = [ $_POST = [
'us_family_action' => 'add', 'us_family_action' => 'add',
'child_name' => 'Ada', 'child_name' => 'Ada',
'child_dob' => '2015-04-02', 'child_birth_year' => '2015',
'child_relationship' => 'Parent', 'child_relationship' => 'Parent',
'us_answers' => [7 => 'Piano'], 'us_answers' => [7 => 'Piano'],
]; ];
$this->questions->shouldReceive('findByScope')->once()->andReturn([$this->question(7, true)]); $this->questions->shouldReceive('findByScope')->once()->andReturn([$this->question(7, true)]);
$this->guardians->shouldReceive('createChild')->once()->with(5, 'Ada', '2015-04-02', 'Parent')->andReturn(42); $this->guardians->shouldReceive('createChild')->once()->with(5, 'Ada', '2015', 'Parent')->andReturn(42);
$this->answers->shouldReceive('insert') $this->answers->shouldReceive('insert')
->once() ->once()
@@ -179,10 +181,10 @@ class FamilyPageTest extends TestCase
'us_family_action' => 'edit', 'us_family_action' => 'edit',
'child_id' => '42', 'child_id' => '42',
'child_name' => 'Ada L', 'child_name' => 'Ada L',
'child_dob' => '2015-04-02', 'child_birth_year' => '2015',
]; ];
$this->guardians->shouldReceive('updateChild')->once()->with(5, 42, 'Ada L', '2015-04-02')->andReturn(null); $this->guardians->shouldReceive('updateChild')->once()->with(5, 42, 'Ada L', '2015')->andReturn(null);
$captured = null; $captured = null;
$this->capturingPage($captured)->maybeHandleSubmit(); $this->capturingPage($captured)->maybeHandleSubmit();
+73 -8
View File
@@ -52,6 +52,8 @@ class GuardianServiceTest extends TestCase
return true; return true;
} }
); );
// The birth-year range is validated against "this year", so pin it.
Functions\when('current_time')->justReturn('2026');
Functions\when('wp_generate_password')->justReturn('abc123def456'); Functions\when('wp_generate_password')->justReturn('abc123def456');
Functions\when('email_exists')->justReturn(false); Functions\when('email_exists')->justReturn(false);
Functions\when('is_wp_error')->alias(static fn ($thing): bool => $thing instanceof \WP_Error); Functions\when('is_wp_error')->alias(static fn ($thing): bool => $thing instanceof \WP_Error);
@@ -84,14 +86,14 @@ class GuardianServiceTest extends TestCase
->with(Mockery::on(static fn (GuardianLink $l): bool => $l->guardianId === 5 && $l->studentId === 42 && $l->relationship === 'Parent')) ->with(Mockery::on(static fn (GuardianLink $l): bool => $l->guardianId === 5 && $l->studentId === 42 && $l->relationship === 'Parent'))
->andReturn(7); ->andReturn(7);
$result = $this->service->createChild(5, ' Ada ', '2015-04-02', 'Parent'); $result = $this->service->createChild(5, ' Ada ', '2015', 'Parent');
self::assertSame(42, $result); self::assertSame(42, $result);
self::assertSame('Ada', $captured['display_name']); self::assertSame('Ada', $captured['display_name']);
// The address is on the reserved .invalid TLD, so it can never receive mail. // The address is on the reserved .invalid TLD, so it can never receive mail.
self::assertStringEndsWith('@child.invalid', $captured['user_email']); self::assertStringEndsWith('@child.invalid', $captured['user_email']);
self::assertSame('1', $this->meta[42][GuardianService::META_CHILD]); self::assertSame('1', $this->meta[42][GuardianService::META_CHILD]);
self::assertSame('2015-04-02', $this->meta[42][GuardianService::META_DOB]); self::assertSame('2015', $this->meta[42][GuardianService::META_BIRTH_YEAR]);
} }
public function testCreateChildRejectsABlankName(): void public function testCreateChildRejectsABlankName(): void
@@ -117,14 +119,30 @@ class GuardianServiceTest extends TestCase
self::assertInstanceOf(\WP_Error::class, $this->service->createChild(5, 'Ada')); self::assertInstanceOf(\WP_Error::class, $this->service->createChild(5, 'Ada'));
} }
public function testCreateChildClearsAnUnparseableDateOfBirth(): void /**
* @dataProvider unusableBirthYears
*/
public function testCreateChildClearsAnUnusableBirthYear(string $submitted): void
{ {
Functions\when('wp_insert_user')->justReturn(42); Functions\when('wp_insert_user')->justReturn(42);
$this->guardians->shouldReceive('insert')->once()->andReturn(7); $this->guardians->shouldReceive('insert')->once()->andReturn(7);
$this->service->createChild(5, 'Ada', 'not-a-date'); $this->service->createChild(5, 'Ada', $submitted);
self::assertArrayNotHasKey(GuardianService::META_DOB, $this->meta[42] ?? []); self::assertArrayNotHasKey(GuardianService::META_BIRTH_YEAR, $this->meta[42] ?? []);
}
/** @return array<string, array{string}> */
public static function unusableBirthYears(): array
{
return [
'not a number' => ['not-a-year'],
'a full date' => ['2015-04-02'],
'too few digits' => ['15'],
'too many digits' => ['20155'],
'before 1900' => ['1899'],
'later than today' => ['2027'],
];
} }
public function testCanActForSelfAndOwnChildOnly(): void public function testCanActForSelfAndOwnChildOnly(): void
@@ -187,6 +205,53 @@ class GuardianServiceTest extends TestCase
self::assertSame([false, false, true], array_column($students, 'is_self')); self::assertSame([false, false, true], array_column($students, 'is_self'));
} }
public function testChildrenReportsTheStoredBirthYear(): void
{
$this->meta[42][GuardianService::META_BIRTH_YEAR] = '2015';
$this->guardians->shouldReceive('findByGuardian')->with(5)->andReturn([new GuardianLink(5, 42)]);
Functions\when('get_userdata')->justReturn($this->user(42, 'Ada', 'Lovelace'));
self::assertSame('2015', $this->service->children(5)[0]['birth_year']);
}
/**
* A child added before this feature switched to a year has only the old full
* date on record, and must still show a birth year.
*/
public function testChildrenDerivesABirthYearFromALegacyDateOfBirth(): void
{
$this->meta[42][GuardianService::META_DOB] = '2015-04-02';
$this->guardians->shouldReceive('findByGuardian')->with(5)->andReturn([new GuardianLink(5, 42)]);
Functions\when('get_userdata')->justReturn($this->user(42, 'Ada', 'Lovelace'));
self::assertSame('2015', $this->service->children(5)[0]['birth_year']);
}
/**
* Saving a child drops the legacy full date. Without that, clearing the birth
* year on a child who predates the change would leave the old date behind for
* the fallback above to resurrect on the next read.
*/
public function testSavingAChildClearsTheLegacyDateOfBirth(): void
{
$this->meta[42][GuardianService::META_DOB] = '2015-04-02';
$this->guardians->shouldReceive('isGuardianOf')->with(5, 42)->andReturn(true);
Functions\when('wp_update_user')->justReturn(42);
self::assertNull($this->service->updateChild(5, 42, 'Ada L', ''));
self::assertArrayNotHasKey(GuardianService::META_DOB, $this->meta[42] ?? []);
self::assertArrayNotHasKey(GuardianService::META_BIRTH_YEAR, $this->meta[42] ?? []);
$this->guardians->shouldReceive('findByGuardian')->with(5)->andReturn([new GuardianLink(5, 42)]);
Functions\when('get_userdata')->justReturn($this->user(42, 'Ada', 'Lovelace'));
self::assertSame('', $this->service->children(5)[0]['birth_year']);
}
public function testBookableStudentsIsJustTheUserWithoutChildren(): void public function testBookableStudentsIsJustTheUserWithoutChildren(): void
{ {
$this->guardians->shouldReceive('findByGuardian')->with(9)->andReturn([]); $this->guardians->shouldReceive('findByGuardian')->with(9)->andReturn([]);
@@ -228,7 +293,7 @@ class GuardianServiceTest extends TestCase
self::assertInstanceOf(\WP_Error::class, $this->service->updateChild(5, 99, 'Mallory')); self::assertInstanceOf(\WP_Error::class, $this->service->updateChild(5, 99, 'Mallory'));
} }
public function testUpdateChildRenamesAndStoresTheDateOfBirth(): void public function testUpdateChildRenamesAndStoresTheBirthYear(): void
{ {
$this->guardians->shouldReceive('isGuardianOf')->with(5, 42)->andReturn(true); $this->guardians->shouldReceive('isGuardianOf')->with(5, 42)->andReturn(true);
Functions\expect('wp_update_user') Functions\expect('wp_update_user')
@@ -236,8 +301,8 @@ class GuardianServiceTest extends TestCase
->with(['ID' => 42, 'display_name' => 'Ada L', 'nickname' => 'Ada L']) ->with(['ID' => 42, 'display_name' => 'Ada L', 'nickname' => 'Ada L'])
->andReturn(42); ->andReturn(42);
self::assertNull($this->service->updateChild(5, 42, 'Ada L', '2015-04-02')); self::assertNull($this->service->updateChild(5, 42, 'Ada L', '2015'));
self::assertSame('2015-04-02', $this->meta[42][GuardianService::META_DOB]); self::assertSame('2015', $this->meta[42][GuardianService::META_BIRTH_YEAR]);
} }
public function testRemoveChildUnlinksAndDeletesAChildWithNoHistory(): void public function testRemoveChildUnlinksAndDeletesAChildWithNoHistory(): void