Architectural Notes & Gotchas

Architectural Notes & Gotchas

Composer-managed dependencies

Three third-party libraries, all under vendor/ via composer.json (not manually bundled copies):

The old bundled copies (includes/gender-detector/, includes/country_code_converter-main/) are safe to delete – nothing references them anymore.

Birthdate storage format history

lh_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 <input type="date"> field's own Y-m-d value round-trip for no benefit. It was also written inconsistently – 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:

  1. Backup taken first (lh_profile-birthdate_backup_20260908 / _readable_backup_20260908 meta keys, covering every user).
  2. 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's primary field had been overwritten with a bad value while the _readable companion still held the correct original – restored from there.
  3. Four throwaway test accounts (shawfactor+...@gmail.com addresses) were found and deleted.
  4. 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 – not root-caused – that writes the write-time "now" into the field: some rows landed within days of the affected user'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 – they were cleared rather than left holding a wrong value dressed up as clean data.
  5. Every remaining row converted from its stored format (timestamp or already-correct string) to Y-m-d.

The root cause of the "now" bug was never traced – 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()'s max attribute is filterable via lh_profile_birthdate_minimum_age_years (default 0, i.e. today – unchanged behaviour). Restricting it to "today minus N years" is a client-side-only deterrent against the "now" bug's laziness angle: the native HTML5 date input's constraint validation refuses to submit a too-recent value, while the calendar picker UI itself stays fully browsable – 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 accounts

While 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@theaustraliatimes.com.au <-> real-email cross-reference pattern in both directions – 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 "secondary"). A saved query ("Users with a secondary email", lh_profile-emails, post 147118) and a written exploration brief exist for this – it's an lh-user-identity-adjacent problem, out of scope for this plugin, not investigated further here.

File size / MCP editing note

lh-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 – 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.