diff --git a/CHANGELOG.md b/CHANGELOG.md
index a596d9c..ef6e2f1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -14,6 +14,7 @@ each change under the current top section as you work.
## [1.3.1]
### 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.
### Fixed
diff --git a/assets/css/frontend.css b/assets/css/frontend.css
index 78f1a1e..24b8cbe 100644
--- a/assets/css/frontend.css
+++ b/assets/css/frontend.css
@@ -492,7 +492,7 @@
font-weight: 600;
}
-.us-family-child-dob {
+.us-family-child-birth-year {
font-size: 0.9em;
opacity: 0.75;
}
diff --git a/docs/features/account-registration.md b/docs/features/account-registration.md
index 4cea3b2..564f840 100644
--- a/docs/features/account-registration.md
+++ b/docs/features/account-registration.md
@@ -168,7 +168,7 @@ No-op when no registration page is set.
## Parent/Guardian Signup
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
`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
diff --git a/docs/features/parent-guardian-accounts.md b/docs/features/parent-guardian-accounts.md
index ed0cbe2..fc9cc05 100644
--- a/docs/features/parent-guardian-accounts.md
+++ b/docs/features/parent-guardian-accounts.md
@@ -67,11 +67,28 @@ requires migrating every existing row.
never be linked twice.
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,
`get_users()` ordering, and every existing `student_id` lookup keep working with
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()`
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
@@ -130,7 +147,9 @@ least one child name.
Per child the form collects:
- **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`,
`SCOPE_ACCOUNT`) — asked once per child, not once per guardian, because in
practice they describe the student (instrument, level, school). The guardian
@@ -173,12 +192,12 @@ child.
## Managing children
`[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
questions are asked here too, so a child added later carries the same
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
child has no lessons and no enrolments — a child with history is refused, so
removing one can never orphan a lesson, payment or credit
diff --git a/src/Auth/RegistrationPage.php b/src/Auth/RegistrationPage.php
index 5f1e85a..dca08a2 100644
--- a/src/Auth/RegistrationPage.php
+++ b/src/Auth/RegistrationPage.php
@@ -477,11 +477,11 @@ class RegistrationPage {
/**
* The child blocks submitted with a guardian signup, as
- * `children[%s
'
- . ''
+ . ''
. '',
esc_html__( 'Add a student', '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' )
);
diff --git a/src/Guardian/FamilyPage.php b/src/Guardian/FamilyPage.php
index 9805662..a5da4f0 100644
--- a/src/Guardian/FamilyPage.php
+++ b/src/Guardian/FamilyPage.php
@@ -119,7 +119,7 @@ class FamilyPage {
*/
private function handleAdd( int $guardianId ): string|\WP_Error {
$name = $this->postString( 'child_name' );
- $dateOfBirth = $this->postString( 'child_dob' );
+ $birthYear = $this->postString( 'child_birth_year' );
$relationship = $this->postString( 'child_relationship' );
$questions = $this->questions->findByScope( Question::SCOPE_ACCOUNT, activeOnly: true );
@@ -130,7 +130,7 @@ class FamilyPage {
return $missing;
}
- $childId = $this->guardians->createChild( $guardianId, $name, $dateOfBirth, $relationship );
+ $childId = $this->guardians->createChild( $guardianId, $name, $birthYear, $relationship );
if ( $childId instanceof \WP_Error ) {
return $childId;
}
@@ -144,7 +144,7 @@ class FamilyPage {
// phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce checked by the caller.
$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;
}
diff --git a/src/Guardian/GuardianService.php b/src/Guardian/GuardianService.php
index 3ff8282..53fd04c 100644
--- a/src/Guardian/GuardianService.php
+++ b/src/Guardian/GuardianService.php
@@ -23,9 +23,24 @@ class GuardianService {
*/
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';
+ /**
+ * 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
* 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
* 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 );
if ( '' === $name ) {
return new \WP_Error( 'missing_name', __( 'Please give each student a name.', 'unsupervised-schedular' ) );
@@ -73,7 +88,7 @@ class GuardianService {
$userId = (int) $userId;
update_user_meta( $userId, self::META_CHILD, '1' );
- $this->setDateOfBirth( $userId, $dateOfBirth );
+ $this->setBirthYear( $userId, $birthYear );
$linkId = $this->guardians->insert(
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
* arbitrary user editor by posting someone else's id.
*
* 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 ) ) {
return new \WP_Error( 'forbidden', __( 'That is not one of your students.', 'unsupervised-schedular' ) );
}
@@ -124,7 +139,7 @@ class GuardianService {
return $result;
}
- $this->setDateOfBirth( $studentId, $dateOfBirth );
+ $this->setBirthYear( $studentId, $birthYear );
return null;
}
@@ -235,7 +250,7 @@ class GuardianService {
* A guardian's children, in link order, with the details the family and admin
* screens display.
*
- * @return list
- - + +
@@ -52,8 +52,8 @@ if (! defined('ABSPATH')) { - - + + @@ -80,8 +80,8 @@ if (! defined('ABSPATH')) {
- - + +
diff --git a/templates/frontend/register-page.php b/templates/frontend/register-page.php index 6a756e2..0483fb2 100644 --- a/templates/frontend/register-page.php +++ b/templates/frontend/register-page.php @@ -90,8 +90,8 @@ if (! defined('ABSPATH')) {
- - + +
'Grace', 'us_is_guardian' => '1', 'children' => [ - ['name' => 'Ada', 'dob' => '2015-04-02', 'answers' => [7 => 'Piano']], - ['name' => 'Alan', 'dob' => '', 'answers' => [7 => 'Violin']], + ['name' => 'Ada', 'birth_year' => '2015', 'answers' => [7 => 'Piano']], + ['name' => 'Alan', 'birth_year' => '', 'answers' => [7 => 'Violin']], // 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('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); $recorded = []; @@ -658,7 +658,7 @@ class RegistrationPageTest extends TestCase 'password' => 'password123', 'display_name' => 'Grace', 'us_is_guardian' => '1', - 'children' => [['name' => '', 'dob' => '', 'answers' => []]], + 'children' => [['name' => '', 'birth_year' => '', 'answers' => []]], ]; Functions\when('email_exists')->justReturn(false); @@ -681,8 +681,8 @@ class RegistrationPageTest extends TestCase 'display_name' => 'Grace', 'us_is_guardian' => '1', 'children' => [ - ['name' => 'Ada', 'dob' => '', 'answers' => [7 => 'Piano']], - ['name' => 'Alan', 'dob' => '', 'answers' => [7 => ' ']], + ['name' => 'Ada', 'birth_year' => '', 'answers' => [7 => 'Piano']], + ['name' => 'Alan', 'birth_year' => '', 'answers' => [7 => ' ']], ], ]; @@ -708,8 +708,8 @@ class RegistrationPageTest extends TestCase 'display_name' => 'Grace', 'us_is_guardian' => '1', 'children' => [ - ['name' => 'Ada', 'dob' => '', 'answers' => []], - ['name' => 'Alan', 'dob' => '', 'answers' => []], + ['name' => 'Ada', 'birth_year' => '', 'answers' => []], + ['name' => 'Alan', 'birth_year' => '', 'answers' => []], ], ]; @@ -745,7 +745,7 @@ class RegistrationPageTest extends TestCase 'display_name' => 'Grace', 'us_is_guardian' => '1', '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); diff --git a/tests/Unit/Guardian/FamilyPageTest.php b/tests/Unit/Guardian/FamilyPageTest.php index 3730484..210018a 100644 --- a/tests/Unit/Guardian/FamilyPageTest.php +++ b/tests/Unit/Guardian/FamilyPageTest.php @@ -38,6 +38,8 @@ class FamilyPageTest extends TestCase Functions\when('wp_enqueue_style')->justReturn(null); Functions\when('wp_nonce_field')->justReturn(''); 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('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)); @@ -92,14 +94,14 @@ class FamilyPageTest extends TestCase public function testRenderListsTheGuardiansChildren(): void { $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([]); $html = $this->page->render([]); self::assertStringContainsString('Ada', $html); - self::assertStringContainsString('2015-04-02', $html); + self::assertStringContainsString('2015', $html); self::assertStringContainsString('Add a student', $html); } @@ -108,13 +110,13 @@ class FamilyPageTest extends TestCase $_POST = [ 'us_family_action' => 'add', 'child_name' => 'Ada', - 'child_dob' => '2015-04-02', + 'child_birth_year' => '2015', 'child_relationship' => 'Parent', 'us_answers' => [7 => 'Piano'], ]; $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') ->once() @@ -179,10 +181,10 @@ class FamilyPageTest extends TestCase 'us_family_action' => 'edit', 'child_id' => '42', '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; $this->capturingPage($captured)->maybeHandleSubmit(); diff --git a/tests/Unit/Guardian/GuardianServiceTest.php b/tests/Unit/Guardian/GuardianServiceTest.php index 805aaae..8b6c880 100644 --- a/tests/Unit/Guardian/GuardianServiceTest.php +++ b/tests/Unit/Guardian/GuardianServiceTest.php @@ -52,6 +52,8 @@ class GuardianServiceTest extends TestCase 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('email_exists')->justReturn(false); 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')) ->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('Ada', $captured['display_name']); // The address is on the reserved .invalid TLD, so it can never receive mail. self::assertStringEndsWith('@child.invalid', $captured['user_email']); 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 @@ -117,14 +119,30 @@ class GuardianServiceTest extends TestCase 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); $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