@prefix sioc: <http://rdfs.org/sioc/ns#> .
@prefix dc: <http://purl.org/dc/elements/1.1/> .
@prefix dcterms: <http://purl.org/dc/terms/> .
@prefix xsd: <http://www.w3.org/2001/XMLSchema#> .
@prefix content: <http://purl.org/rss/1.0/modules/content/> .
@prefix rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> .
@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .

<https://lhero.org/?post_type=lh-portfolio&#038;p=147171>
  a sioc:Post ;
  dc:title "Architectural Notes & Gotchas" ;
  dcterms:identifier 147171 ;
  dc:modified "2026-09-11T16:41:47Z"^^xsd:dateTime ;
  dc:created "2026-09-09T01:46:22Z"^^xsd:dateTime ;
  sioc:link <https://lhero.org/portfolio/lh-profile-page/architectural-notes-gotchas/> ;
  sioc:has_creator <https://lhero.org/author/1/#account> ;
  sioc:has_container <https://lhero.org/#posts> ;
  content:encoded "<ul class=\"lh_portfolio-meta\"><li><strong>Type:</strong> Doc-section</li><li><strong>Part of:</strong> <a href=\"https://lhero.org/portfolio/lh-profile-page/\">LH Profile Page</a></li></ul><h1 class=\"wp-block-heading\">Architectural Notes &amp; Gotchas</h1><h2 class=\"wp-block-heading\">Composer-managed dependencies</h2><p class=\"wp-block-paragraph\">Three third-party libraries, all under <code>vendor/</code> via <code>composer.json</code> (not manually bundled copies):</p><ul class=\"wp-block-list\"><li><strong><code>giggsey/libphonenumber-for-php-lite</code></strong> &#8211; phone number parsing/validation (E.164).</li><li><strong><code>tuqqu/gender-detector</code></strong> (v0.5.2) &#8211; migrated from a manually-bundled <code>includes/gender-detector/</code> copy this session. Note the API changed between versions: the old bundled copy used <code>-&gt;detect($name)</code> returning a string; the current Composer version uses <code>-&gt;getGender($name)</code> returning a <code>Gender</code> enum. <code>guess_first_name_gender()</code> uses the new API &#8211; but <strong>this method has zero callers anywhere in the codebase currently</strong> (confirmed via grep), so it&#039;s dead code, not wired to any hook.</li><li><strong><code>writecrow/country_code_converter</code></strong> (v1.3.0) &#8211; migrated from a manually-bundled <code>includes/country_code_converter-main/</code> copy this session. Same <code>convert()</code> API in both versions, no call-site rewrite needed. Used in <code>register_core_scripts()</code> to derive the phone widget&#039;s default country from the site&#039;s timezone.</li></ul><p class=\"wp-block-paragraph\">The old bundled copies (<code>includes/gender-detector/</code>, <code>includes/country_code_converter-main/</code>) are safe to delete &#8211; nothing references them anymore.</p><h2 class=\"wp-block-heading\">Birthdate storage format history</h2><p class=\"wp-block-paragraph\"><code>lh_profile-birthdate</code> was originally a UNIX timestamp. This was a poor fit: no meaningful time-of-day, an implicit timezone-dependent midnight, negative-timestamp handling needed for anyone born before 1970, and it fought the native <code>&lt;input type=&quot;date&quot;&gt;</code> field&#039;s own <code>Y-m-d</code> value round-trip for no benefit. It was also written inconsistently &#8211; some code paths wrote a timestamp, others wrote a raw date string directly.</p><p class=\"wp-block-paragraph\"><strong>v2.13</strong> switched storage to a plain <code>Y-m-d</code> ISO date string, alongside a full one-off data migration:</p><ol class=\"wp-block-list\"><li>Backup taken first (<code>lh_profile-birthdate_backup_20260908</code> / <code>_readable_backup_20260908</code> meta keys, covering every user).</li><li>A cross-field comparison (both <code>lh_profile-birthdate</code> and <code>lh_profile-birthdate_readable</code> normalised to a common format before comparing, to avoid false positives from pure formatting differences) found one genuine discrepancy: one user&#039;s primary field had been overwritten with a bad value while the <code>_readable</code> companion still held the correct original &#8211; restored from there.</li><li>Four throwaway test accounts (<code>shawfactor+...&#64;gmail.com</code> addresses) were found and deleted.</li><li><strong>39 real members</strong> had a birthdate that converted to an implausible year (post-2016, given the club has no members under 12). Investigation showed this was caused by a live bug &#8211; not root-caused &#8211; that writes the write-time &quot;now&quot; into the field: some rows landed within days of the affected user&#039;s own registration date (consistent with a signup-time default), while others showed the <em>exact same</em> date despite completely unrelated registration years (consistent with a separate batch/cron process touching existing records on one specific day). Both <code>lh_profile-birthdate</code> and <code>lh_profile-birthdate_readable</code> held the identical wrong value for these 39, meaning the true original birthdate was unrecoverable &#8211; they were cleared rather than left holding a wrong value dressed up as clean data.</li><li>Every remaining row converted from its stored format (timestamp or already-correct string) to <code>Y-m-d</code>.</li></ol><p class=\"wp-block-paragraph\"><strong>The root cause of the &quot;now&quot; bug was never traced</strong> &#8211; deliberately deferred, since this plugin had many structural changes around this time and some bad data was considered likely regardless.</p><h2 class=\"wp-block-heading\">Minimum-age filter (v2.14)</h2><p class=\"wp-block-paragraph\"><code>render_birthdate_input()</code>&#039;s <code>max</code> attribute is filterable via <code>lh_profile_birthdate_minimum_age_years</code> (default <code>0</code>, i.e. today &#8211; unchanged behaviour). Restricting it to &quot;today minus N years&quot; is a <strong>client-side-only</strong> deterrent against the &quot;now&quot; bug&#039;s laziness angle: the native HTML5 date input&#039;s constraint validation refuses to submit a too-recent value, while the calendar picker UI itself stays fully browsable &#8211; nothing is hidden or disabled beyond blocking selection past the cutoff. Not server-side validation. See task <strong>147165</strong> (queue <code>lh-membership</code>) for wiring this up to a real configured value.</p><h2 class=\"wp-block-heading\">Discovery: <code>lh_profile-emails</code> reveals likely duplicate accounts</h2><p class=\"wp-block-paragraph\">While investigating birthdate data, a related discovery: 1,888 users have a non-empty <code>lh_profile-emails</code> value, and the large majority follow a <code>firstname.lastname&#64;theaustraliatimes.com.au</code> &lt;-&gt; real-email cross-reference pattern in both directions &#8211; strongly suggesting a large number of duplicate WordPress accounts (one with a real email as primary, one with a fabricated placeholder email as primary, each referencing the other as &quot;secondary&quot;). A saved query (&quot;Users with a secondary email&quot;, <code>lh_profile-emails</code>, post 147118) and a written exploration brief exist for this &#8211; it&#039;s an <code>lh-user-identity</code>-adjacent problem, out of scope for this plugin, not investigated further here.</p><h2 class=\"wp-block-heading\">File size / MCP editing note</h2><p class=\"wp-block-paragraph\"><code>lh-profile-page.php</code> (the god class) is ~115KB. Proposing changes to it via <code>lh-mcp-code-changes</code> MCP tooling risks payload-size issues; the working pattern this session was to build/verify the change in a local sandbox copy, then hand the full file back for manual upload via SFTP/hosting panel, rather than <code>propose-code-change</code>/<code>apply-code-change</code>. The extracted field class files (each a few KB) apply cleanly via the normal MCP flow.</p><p class=\"wp-block-paragraph\"><strong>Manual-upload ordering matters</strong>: <code>bp_init()</code> unconditionally <code>require_once</code>s all nine xProfile extender files, and <code>plugin_init()</code> unconditionally <code>require_once</code>s every field class file &#8211; if a file containing a <code>require_once</code> for another file is uploaded before that other file exists, any page load in that window fatals. Always upload newly-created dependency files <em>before</em> the file that requires them.</p>"^^rdf:XMLLiteral ;
  sioc:content "Type: Doc-sectionPart of: LH Profile PageArchitectural Notes &amp; GotchasComposer-managed dependenciesThree third-party libraries, all under vendor/ via composer.json (not manually bundled copies):giggsey/libphonenumber-for-php-lite &#8211; phone number parsing/validation (E.164).tuqqu/gender-detector (v0.5.2) &#8211; migrated from a manually-bundled includes/gender-detector/ copy this session. Note the API changed between versions: the old bundled copy used -&gt;detect($name) returning a string; the current Composer version uses -&gt;getGender($name) returning a Gender enum. guess_first_name_gender() uses the new API &#8211; but this method has zero callers anywhere in the codebase currently (confirmed via grep), so it&#039;s dead code, not wired to any hook.writecrow/country_code_converter (v1.3.0) &#8211; migrated from a manually-bundled includes/country_code_converter-main/ copy this session. Same convert() API in both versions, no call-site rewrite needed. Used in register_core_scripts() to derive the phone widget&#039;s default country from the site&#039;s timezone.The old bundled copies (includes/gender-detector/, includes/country_code_converter-main/) are safe to delete &#8211; nothing references them anymore.Birthdate storage format historylh_profile-birthdate was originally a UNIX timestamp. This was a poor fit: no meaningful time-of-day, an implicit timezone-dependent midnight, negative-timestamp handling needed for anyone born before 1970, and it fought the native &lt;input type=&quot;date&quot;&gt; field&#039;s own Y-m-d value round-trip for no benefit. It was also written inconsistently &#8211; some code paths wrote a timestamp, others wrote a raw date string directly.v2.13 switched storage to a plain Y-m-d ISO date string, alongside a full one-off data migration:Backup taken first (lh_profile-birthdate_backup_20260908 / _readable_backup_20260908 meta keys, covering every user).A cross-field comparison (both lh_profile-birthdate and lh_profile-birthdate_readable normalised to a common format before comparing, to avoid false positives from pure formatting differences) found one genuine discrepancy: one user&#039;s primary field had been overwritten with a bad value while the _readable companion still held the correct original &#8211; restored from there.Four throwaway test accounts (shawfactor+...&#64;gmail.com addresses) were found and deleted.39 real members had a birthdate that converted to an implausible year (post-2016, given the club has no members under 12). Investigation showed this was caused by a live bug &#8211; not root-caused &#8211; that writes the write-time &quot;now&quot; into the field: some rows landed within days of the affected user&#039;s own registration date (consistent with a signup-time default), while others showed the exact same date despite completely unrelated registration years (consistent with a separate batch/cron process touching existing records on one specific day). Both lh_profile-birthdate and lh_profile-birthdate_readable held the identical wrong value for these 39, meaning the true original birthdate was unrecoverable &#8211; they were cleared rather than left holding a wrong value dressed up as clean data.Every remaining row converted from its stored format (timestamp or already-correct string) to Y-m-d.The root cause of the &quot;now&quot; bug was never traced &#8211; deliberately deferred, since this plugin had many structural changes around this time and some bad data was considered likely regardless.Minimum-age filter (v2.14)render_birthdate_input()&#039;s max attribute is filterable via lh_profile_birthdate_minimum_age_years (default 0, i.e. today &#8211; unchanged behaviour). Restricting it to &quot;today minus N years&quot; is a client-side-only deterrent against the &quot;now&quot; bug&#039;s laziness angle: the native HTML5 date input&#039;s constraint validation refuses to submit a too-recent value, while the calendar picker UI itself stays fully browsable &#8211; nothing is hidden or disabled beyond blocking selection past the cutoff. Not server-side validation. See task 147165 (queue lh-membership) for wiring this up to a real configured value.Discovery: lh_profile-emails reveals likely duplicate accountsWhile investigating birthdate data, a related discovery: 1,888 users have a non-empty lh_profile-emails value, and the large majority follow a firstname.lastname&#64;theaustraliatimes.com.au &lt;-&gt; real-email cross-reference pattern in both directions &#8211; strongly suggesting a large number of duplicate WordPress accounts (one with a real email as primary, one with a fabricated placeholder email as primary, each referencing the other as &quot;secondary&quot;). A saved query (&quot;Users with a secondary email&quot;, lh_profile-emails, post 147118) and a written exploration brief exist for this &#8211; it&#039;s an lh-user-identity-adjacent problem, out of scope for this plugin, not investigated further here.File size / MCP editing notelh-profile-page.php (the god class) is ~115KB. Proposing changes to it via lh-mcp-code-changes MCP tooling risks payload-size issues; the working pattern this session was to build/verify the change in a local sandbox copy, then hand the full file back for manual upload via SFTP/hosting panel, rather than propose-code-change/apply-code-change. The extracted field class files (each a few KB) apply cleanly via the normal MCP flow.Manual-upload ordering matters: bp_init() unconditionally require_onces all nine xProfile extender files, and plugin_init() unconditionally require_onces every field class file &#8211; if a file containing a require_once for another file is uploaded before that other file exists, any page load in that window fatals. Always upload newly-created dependency files before the file that requires them." ;
  sioc:topic <https://lhero.org/lh_portfolio-type/doc-section/>, <https://lhero.org/?taxonomy=author&term=cap-1> .

<https://lhero.org/author/1/#account> rdfs:seeAlso <https://lhero.org/author/1/?feed=lhrdf&format=turtle> .
<https://lhero.org/lh_portfolio-type/doc-section/> rdfs:seeAlso <https://lhero.org/lh_portfolio-type/doc-section/?feed=lhrdf&format=turtle> .
<https://lhero.org/?taxonomy=author&term=cap-1> rdfs:seeAlso <https://lhero.org/?taxonomy=author&term=cap-1&feed=lhrdf&format=turtle> .
