Version 8.10.1.4 - May 14, 2026 - Upgrade Notice Refresh for Legacy 6.x Updaters
Version-bump release whose only purpose is to push a new readme.txt to WordPress.org so legacy users still on the 6.x line see a clear "MAJOR UPGRADE" notice the first time they click Update. WordPress only renders the Upgrade Notice block whose header matches the new version exactly, so the previous notice (which described only the 8.10.1.3 SVN-compatibility fix) was never going to reach a 6.x user. No PHP, schema, asset, or template changes - readme.txt copy and version strings only.
+ Item 1: README.txt - new = 8.10.1.4 = upgrade notice block
* "MAJOR UPGRADE from 6.x" message: full modernization summary, "back up your site first" advisory, pointer to the Description tab for the long-form rundown.
* Previous = 8.10.1.3 = block restored to its original WP.org SVN compatibility wording (kept for historical accuracy since that release already shipped).
* Stable tag header bumped 8.10.1.3 -> 8.10.1.4.
+ Item 2: pet-match-pro.php - version strings bumped
* Plugin header "Version:" 8.10.1.3 -> 8.10.1.4 (line 19).
* Constants::VERSION '8.10.1.3' -> '8.10.1.4' (line 74).
+ Item 3: No code paths touched
* No PHP, JS, CSS, template, or schema modifications.
* No new constants, no new options, no new hooks.
* No operator action required on update.
----
Version 8.10.1.3 - May 13, 2026 - WP.org SVN Compatibility (Trait Constant Removal)
Single-file packaging fix discovered while pushing 8.10.1.2 to the WordPress.org SVN repository. The WP.org pre-commit hook lints every PHP file with an older PHP build that does not yet recognize trait constants (PHP 8.2+ feature). The 8.10.x line introduced one `private const` inside `SearchTemplateTrait`, which blocked the commit with `Fatal error: Traits cannot have constants in Standard input code on line 1604`. Resolved by converting the constant to a private static method - same data, same lookup pattern, accepted by the WP.org lint.
+ Item 1: SearchTemplateTrait - replaced `private const SORT_FILTER_FIELD_ALIASES` with `private static function getSortFilterFieldAliases(): array`
* public/templates/includes/class-pet-match-pro-search-template-trait.php line 1604: the partner-specific sort/filter alias map (Constants::PETPOINT => ['breed' => 'primarybreed', 'name' => 'animalname', 'id' => 'animalid']) is now returned from a private static method instead of being declared as a trait constant.
* Two internal call sites updated to use the method form:
- line 1691 buildSortFilterAliasMirrorAttrs(): self::SORT_FILTER_FIELD_ALIASES[$partner] -> self::getSortFilterFieldAliases()[$partner]
- line 1886 (active-sort detection logic): same rewrite.
* Comment block at line 1604 retained verbatim - the alias map's rationale, shape contract, and "add new aliases here" guidance still apply unchanged.
+ Item 2: Codebase audit - no other PHP 8.2+ syntax found
* Verified no other traits contain `const` declarations (poster-template-trait, field-filter-trait, field-exclusion-filter-trait all clean).
* No `readonly class` declarations.
* No DNF (disjunctive normal form) types - e.g., `(A&B)|C` in type positions.
* No standalone `true` / `false` types in return-type or parameter-type positions.
* No `#[\AllowDynamicProperties]` attribute usage.
* Plugin remains PHP 8.1+ compatible.
+ Item 3: Version constants
* pet-match-pro.php Version header and Constants::VERSION bumped 8.10.1.2 -> 8.10.1.3.
* Constants::ANALYTICS_SCHEMA_VERSION stays at '2.1' (no schema change).
* README.txt Stable tag bumped to 8.10.1.3; new Upgrade Notice entry above the 8.10.1.2 entry.
* Operator action items: none.
* Verification:
* `svn commit` to https://plugins.svn.wordpress.org/petmatchpro/ succeeds without the trait-constant lint failure.
* Sort buttons (Breed, Name, ID) on PetPoint search results still render with the correct active state after page load and after a sort change.
* Filter dropdown using `breed` against a PetPoint result set still matches `data-primarybreed` cards.
----------------------------------------
Version 8.10.1.2 - May 10, 2026 - PetPoint XML Diagnostics, Foster-Location Warning, AF lost_found Combo Field Display
Three operator-facing fixes uncovered during partner-by-partner testing on the demo sites. No schema change. No write-path change. No new shortcode params. Operator action items: none.
+ Item 1: PetPoint - Failed-XML logs now include libxml errors and a body snippet
* includes/pp/class-pet-match-pro-pp-api.php animalDetail(): replaced `@simplexml_load_string()` with the standard libxml-internal-errors pattern. On parse failure the log entry now carries the actual libxml messages and the first 500 chars of the response body alongside animal_id and method.
* Previously the warning "[PetMatchPro] PetPoint: Failed to parse animal detail XML" was unactionable - operators saw the animal_id but had no insight into what PP actually returned (HTML error page? truncated XML? BOM? Wrong content-type?). With the new fields a single log line tells us whether it's an authentication failure, a gone-animal celebration redirect from PP's side, or a malformed XML payload.
* The `libxml_use_internal_errors()` toggle and `libxml_clear_errors()` calls are wrapped around the parse so the diagnostic does not leak libxml state into the rest of the request.
+ Item 2: AllApi - Foster-location check no longer emits "Array to string conversion"
* includes/class-pet-match-pro-all-api.php isAnimalInFoster() line 2703: getAnimalProperty() can return an array when the partner field is an empty XML element (PetPoint's SimpleXML parses ` ` to `[]`) or a nested object without a 'value' key. Casting that array to string with `(string)` was emitting a PHP warning to the error log on every detail-page render and search-result card.
* Fix: extract the raw value first, then `is_scalar() ? (string) : ''`. Arrays and objects degrade to empty string rather than throwing. Foster detection is unchanged for the scalar case; previously-warning paths now silently return false (animal not in foster) when the location is structurally invalid.
+ Item 3: AnimalsFirst lost_found combo type - admin-configured fields now display
* Reported symptom: with `[pmp-search type="lost_found"]`, combo method set to "found", and "type" / "intake_type" admin checkboxes enabled under Found search results, neither field rendered on the cards even after hard refresh and cache purge.
* Root cause: AllApi::callMethod_Parameters() assigns Settings::METHOD_TYPE_PREFERRED to any non-standard shortcode type. `lost_found` is non-standard, so `apiInstance->methodValue` became 'preferred'. In the three universal AF search templates, getMethodValue() short-circuited to `apiInstance->methodValue` before checking the resolved methodCall, so downstream admin-field lookup keyed off `preferred_search_details` (empty) instead of `found_search_details` (where the operator's checkboxes lived).
* Fix: re-ordered getMethodValue() in three AF templates so combo-type detection runs BEFORE the apiInstance->methodValue short-circuit. When the shortcode `type` contains `lost_found`, the template now resolves via `methodCall` (foundSearch -> found, lostSearch -> lost) instead of trusting the synthetic 'preferred' assignment.
* Files changed: public/templates/af/universal-search-default.php, public/templates/af/universal-search-no-filter.php, public/templates/af/universal-search-filter-widget.php.
* Templates intentionally not touched: featured-search-{default,carousel,compact} (hardcoded to METHOD_TYPE_ADOPT - cannot hit combo path), universal-search-structured (already resolves via resolveMethodTypeFromCall() correctly), adopt-celebration-similar (adopt-only by design).
+ Item 4: Version constants
* pet-match-pro.php Version header and Constants::VERSION bumped 8.10.1.1 -> 8.10.1.2. Constants::ANALYTICS_SCHEMA_VERSION stays at '2.1' (no schema change).
* README.txt Stable tag bumped to 8.10.1.2; new Upgrade Notice entry above the 8.10.1.1 entry.
* Operator action items: none.
* Verification on a deployed site:
* PetPoint detail page on a gone or invalid animal ID: error log entry includes `libxml` array and `body_snippet` showing what PP returned.
* AnimalsFirst lost_found combo search with `type` and `intake_type` checked under Found in admin: both fields now display on every card.
* PetPoint or AF detail render: no recurring "Array to string conversion in class-pet-match-pro-all-api.php on line 2703" warnings in the PHP error log.
----------------------------------------
Version 8.10.1.1 - May 8, 2026 - Detail Template Fixes (PP filter labels, currency, side-by-side video column)
Bug-fix patch on top of 8.10.1. Three fixes covering the PetPoint adopt-details-navigation-similar template and the side-by-side detail layouts shared by AnimalsFirst and RescueGroups. No schema change. No write-path change. No new shortcode params. Operator action items: none.
+ Item 1: PetPoint adopt-details-navigation-similar - location filter label resolution
* public/templates/pp/adopt-details-navigation-similar.php: getFieldValue() was returning the raw API location value (e.g. "Kennel - Greenhill") instead of running it through the admin-configured filter group mapping. With Filter Values configured under General > Filter Values (Filter 1 = "Kennel - Greenhill" / Filter 1 Label = "Main Kennel"), the search results showed the label correctly but the detail page showed the raw value. Brought to parity with af/adopt-details-navigation-similar by adding a getFilterGroupConfigs() lookup at the top of getFieldValue() that calls resolveDetailFieldDisplayValue() for any field a filter config covers (location, kennel, site, etc.).
* The new lookup runs before the common-field switch and the keyMappings table, so admin-configured labels always win over raw API values without breaking the existing field resolution chain.
+ Item 2: PetPoint adopt-details-navigation-similar - currency formatting on all render paths
* Same file: getFieldValue() was returning the price field unformatted ("250" instead of "$250.00") because formatCurrencyValue() was only invoked from renderQuickFields() and renderTitleSection(), not from renderStatsRow() or renderStatsFull(). Moved the formatCurrencyValue() call into getFieldValue() itself so every consumer benefits from a single chokepoint.
* Added an explicit 'price' key mapping to the keyMappings table - previously price was reachable only via the 'fee' alias chain (price -> Price -> fee -> Fee -> AdoptionFee). Direct lookup is faster and more readable.
* universal-details-navigation.php was already correct - this fix only applies to the navigation-similar variant.
+ Item 3: Side-by-side detail layout - videos no longer hijack a third column
* Templates affected: rg/adopt-similar.php, rg/adopt-default.php, af/adopt-default.php, af/adopt-conversion.php, af/adopt-conversion-no-app.php, af/adopt-conversion-similar.php (6 templates).
* Symptom: when an animal had video URLs, the .pmp-details-videos block rendered as a direct sibling of .pmp-details-image-column inside .pmp-details-image-row. With width:100% on the videos block competing against a 90px thumbnails block in the same flex row, the main image got squeezed out and three vertical columns appeared (image / thumbs / videos).
* Fix: wrap renderThumbnails() and renderVideos() inside a new
so they share one right-side column. Videos now stack vertically below thumbnails inside that column instead of forming their own.
* public/css/pet-match-pro-styles.css: added .pmp-details-thumbs-column rule (flex: 0 0 90px; flex-direction: column) plus child overrides forcing the nested .pmp-details-thumbnails and .pmp-details-videos to flex:0 0 auto with width:100% and column direction. The min file (pet-match-pro-styles.min.css) regenerated from source.
* PetPoint detail templates were not modified - PP detail templates do not currently call renderVideos(), so the bug never manifested on PP.
* Templates already using the correct pattern (universal-details-navigation, adopt-details-navigation-similar, adopt-wide, adopt-profile-3-column, rg/adopt-cpa) were not touched.
+ Item 4: Version constants
* pet-match-pro.php Version header and Constants::VERSION bumped 8.10.1 -> 8.10.1.1. Constants::ANALYTICS_SCHEMA_VERSION stays at '2.1' (no schema change).
* README.txt Stable tag bumped to 8.10.1.1; new Upgrade Notice entry above the 8.10.1 entry.
* Operator action items: none.
* Verification on a deployed site:
* PetPoint adopt-details-navigation-similar template with Filter Values configured: location displays the admin label, not the raw API value.
* Same template with price in stats_row or stats_full: value renders with the configured currency symbol (e.g. "$250.00"), matching the rendering in quick_fields and title_fields.
* RescueGroups adopt-similar (and the other 5 fixed templates) on an animal with video URLs: main image renders at full size, thumbnails column on the right contains photo thumbs followed by video play tiles stacked vertically. No third column.
* Templates not in the fix list render unchanged.
----------------------------------------
Version 8.10.1 - May 8, 2026 - Quality Pass Follow-Through
Closes the items deferred from 8.10.0: the error_log REMOVE sweep, identifier quoting in the analytics ALTER and rollup queries, and a refreshed i18n new-strings inventory ready for the next Claude Cowork translation pass. No schema change. No write-path change. No public-facing UI change. Operator action items: none.
+ Item 1: Developer-leftover error_log sweep
* Project-wide error_log count reduced from 93 to 34. Every remaining call now follows the wpdb-failure mandate or the structured [PMP Analytics] / [PMP Rollup] / [PetMatchPro] operational format.
* pet-match-pro.php: 18 commented //error_log lines removed across loadDependencies(), defineAdminHooks(), and initializeAnalytics(). Empty-after-removal else branches collapsed. The unreachable "Try alternate path" license-file lookup that only existed to host commented logs was deleted.
* admin/class-pet-match-pro-admin-settings.php: 4 commented checkpoint logs (PMP Settings constructor, Logo Path) removed.
* admin/class-pet-match-pro-functions.php: 10 commented field-debug + input-callback logs removed.
* includes/class-pet-match-pro-all-api.php: 2 commented fieldExclusions debug logs removed.
* includes/cache/class-pet-match-pro-api-cache.php: the entire logDebug() helper removed (1 wrapped error_log + 3 callsites in get/set). Per-cache-check chatter with no caller value.
* includes/rg/class-pet-match-pro-rg-api.php: 5 WP_DEBUG-gated chatter logs removed (init checkpoint, cURL retry, request dump, missing-apikey/orgID checkpoints). Doubly-redundant `if (defined('WP_DEBUG') && WP_DEBUG) { if (defined("WP_DEBUG") && WP_DEBUG) { error_log(...); } }` pattern eliminated.
* public/templates/includes/class-pet-match-pro-search-template-trait.php: 1 commented labels-debug var_export removed.
* 9 search templates (pp/found, pp/lost, pp/universal, af/featured-{compact,carousel,default}, af/universal-{default,filter-widget,no-filter}): 18 template-exception logs without [PMP] prefix removed; catch blocks preserved with "Silently degrade - template falls back to non-API rendering." comment for the apiFunction reflection path, and `$this->fieldLevels = [];` retained for the field-levels path.
* Audit B row 55 (deactivator.php:206) intentionally kept: the surrounding commented code block is a documented example demonstrating the wpdb-failure logging pattern future devs should follow.
+ Item 2: Analytics SQL identifier quoting
* includes/analytics/class-pet-match-pro-analytics-db.php (schema 2.0 -> 2.1 daily UNIQUE-key migration ALTER, lines 346-349): the table identifier, the index identifier, and all 8 column identifiers in the UNIQUE KEY definition are now wrapped in backticks. Disambiguates from MySQL reserved words and matches the standard identifier-quoting convention.
* includes/analytics/class-pet-match-pro-analytics-daily-rollup.php (rollupDay() DELETE at :215 and INSERT at :271): every {$dailyTable}, {$eventsTable}, and column-name identifier interpolated from class-property constants now uses backticks. User-driven values (date, dayStart, dayEnd) were already routed through $wpdb->prepare() in earlier 8.10.x work; this release only adds the identifier-quoting layer. No behavior change today, but future column-name additions that collide with reserved words will work without further changes.
* Audit D escape-3 spot-fix in admin-settings.php skipped after review: every echo $html / echo $var site sampled (settings builders, SEO diag block, color CSS output, do_settings_sections body capture) already wraps user data with esc_attr / esc_html / esc_html__ at the sprintf or concat layer. Per the 8.10.0 plan: don't refactor existing-correct code.
+ Item 3: i18n new-strings inventory refreshed
* docs/superpowers/audits/2026-05-06-a-i18n-new-strings.md replaced with a 144-msgid catalog of every translatable string present in 8.10.1 source but not yet in the frozen 8.6.4 pet-match-pro.pot. Each entry lists first-observed file:line so Cowork can spot-check context before translating.
* Local environment lacks WP-CLI / gettext, so the inventory was extracted via grep + awk diff; Cowork's `wp i18n make-pot` run remains the authoritative final list. All _n() plural pairs in current code (Month/Months, Year/Years, minute/minutes, animal/animals, two analytics-insights long-form plurals) are already in the 8.6.4 .pot - no new pairs in 8.7.0 - 8.10.1.
* Translation notes added: placeholder preservation (%s, %d, %1$s reordering rules), HTML anchor tags inside msgids, the " -- " em-dash sentinel from feedback_no_em_dash.md, the do-not-translate PetMatchPro / PMP brand rule, and a 6-step Cowork workflow ending in the .pot/.po/.mo commit.
* The .pot regeneration, .po msgmerge, and .mo recompile happen in the Cowork session so the translation pass lands as one focused commit.
+ Item 4: Version constants
* pet-match-pro.php Version header and Constants::VERSION bumped 8.10.0 -> 8.10.1. Constants::ANALYTICS_SCHEMA_VERSION stays at '2.1' (no schema change).
* README.txt Stable tag bumped to 8.10.1; new Upgrade Notice entry above the 8.10.0 entry.
* Operator action items: none. The release is internal cleanup and audit follow-through.
* Verification on a deployed site:
* Reload search results, detail pages, and admin Settings: no PHP errors in debug.log. Intentional logs (analytics rollup, wpdb-failure mandate) still fire when their conditions are met.
* Trigger a search with field exclusions configured: debug.log still shows `[PetMatchPro] [DEBUG] Field-Exclusion: ...` entries (PetPoint via the inline filter, AF/RG via the trait); the chatter that previously fired on every successful request is gone.
* Visit the Analytics tab: dashboard widgets still render (rollup query identifier quoting is transparent under MySQL).
* grep -rc error_log --include='*.php' from project root returns ~34, all wpdb-mandate or structured operational logs.
----------------------------------------
Version 8.10.0 - May 8, 2026 - Quality Pass + Audit Remediation
External 5-track audit (i18n / error logging / license gating / WP coding standards / WP.org compliance) drove a code-quality and security-hardening pass across admin, public, and partner-API surfaces. No data-model or schema changes. No operator action required for upgrade. The single visible behavior change is on broken API keys: a previously-misleading "Parse Error: Unable to parse response" public message is now an actionable "Authentication Error: Verify your API key in general Settings."
+ Item 1: Security - unserialize() called on decrypted license payloads now passes ['allowed_classes' => false]
* admin/license/class-pet-match-pro-license.php (2 sites, lines 498 and 881): forbids arbitrary object instantiation. If the licensing server is ever compromised or a MITM intercepts, gadget-chain code execution is no longer reachable from the deserialization paths. Test path: Free / Premium / Preferred all survive deserialization unchanged.
+ Item 2: Security - color values validated on save AND on render
* admin/class-pet-match-pro-admin-settings.php sanitizeColorOptions(): each non-empty submitted value is validated against an allowlist of CSS color formats (hex 3/4/6/8-digit, the 147 named CSS colors plus transparent/currentColor, rgb/rgba/hsl/hsla, and the inherit/initial/unset/revert keywords). Invalid values are silently reverted to the previously-saved value (empty if never set, preserving cascade-default behavior) and a settings_error notice is queued so the admin sees the rejection. The new isValidCssColor() helper is private and reusable.
* public/partials/pet-match-pro-public-color-css.php getColor(): blocks CSS-injection breakout chars (semicolon, brace, angle bracket, quote) at the read chokepoint. All ~30 echoes of color values get the protection automatically. Hex/named/functional CSS color formats all pass.
+ Item 3: Security - escaping fix in license-activation form
* admin/index.php: replaced esc_attr_e(purchaseEmail, $this->slug) with echo esc_attr($purchaseEmail). The bareword 'purchaseEmail' was being treated as an undefined PHP constant (deprecation warning + literal string output); the form's email value now populates the configured PMP_lic_email correctly.
+ Item 4: License gating - admin and wizard hide-when-gated instead of showing locked UI
* Tabs / accordions / partner method registers / wizard preset cards / wizard method types / wizard vanity URLs all reflect the active license tier. The surface shrinks to what the operator can actually use; matches WP.org repo team conventions for fewer locked-state hints.
* Coordinated changes across admin/class-pet-match-pro-admin-settings.php, the admin/partials/* level files, admin/css/pet-match-pro-admin.css (lock-glyph removal from disabled-field labels), correct license-key for General Exclusions sub-accordion, per-method gating for Display > Empty Fields checkboxes, plugin menu position aligned across tiers.
+ Item 5: License gating - Free-tier shortcode params + admin features unblocked
* admin/partials/pmp-option-levels-general.php: shortcode_thumbs param downgraded from PREMIUM to FREE to match the existing Settings::THUMBS admin checkbox tier. The admin always offered the feature; the param strip path was silently removing it on Free.
* public/templates/includes/class-pet-match-pro-base-detail-template.php getMaxThumbs(): removed the redundant inner license check at line 695 that was ignoring the (now-Free) shortcode param. License gating is enforced upstream by stripPaidDetailParams().
* Settings::PAGE_DETAILS . '_' . Settings::POSTER bumped from PREMIUM to FREE so the "Poster Details Page" admin selector is configurable on Free (the print-poster feature itself was already free).
+ Item 6: Public-facing UX - silent template fallback to admin-configured (P1.10)
* AllApi::getSafeFallbackTemplate(template, methodType, isSearch): when a Free-tier visitor requests a paid template, the renderer substitutes the admin-configured template name instead of showing "Template Upgrade Required" to the visitor. AllApi::getTemplateLicenseError() is deprecated to always return ''. Six callers updated (resolveSearchTemplate plus 4 direct callers in PP/AF/RG).
+ Item 7: Public-facing UX - audience-gating for admin notices
* AllApi::buildUpgradeButton() / buildConfigurationNotice() and the inline poster notices in BaseDetailTemplate now wrap output in current_user_can('manage_options'). Visitors get '' (silent); admins still see the in-place "Not Configured" / "Upgrade to Use" hints. Pattern should extend to any other admin-domain UX leaking to public visitors.
+ Item 8: Public-facing UX - clearer errors and structured diagnostic logging across all 3 API clients
* PetPoint, AnimalsFirst, RescueGroups now share the same error-message taxonomy: Connection Error (network/wp_error), Authentication Error (HTTP 401/403 with actionable "Verify your API key in
" hint), API Error (other 4xx/5xx with HTTP code shown), Parse Error (200 OK + malformed body, with the partner-specific parser error included).
* Each path emits a structured ErrorLogger entry with http_code + method + 200-char response_excerpt. Pre-8.10.0 the broken-key public message was the misleading "Parse Error: Unable to parse response" and the debug log was an empty {"context":""}.
* RescueGroups postJson() now exposes http_code via curl_getinfo so the wrapper-based RG flow gets the same HTTP-status check as PP/AF.
* AllApi::buildErrorMessage() drops the empty `context` field from log entries when the caller didn't supply one.
+ Item 9: Public-facing UX - PetPoint XML-level field-exclusion logging
* includes/pp/class-pet-match-pro-pp-api.php applyFieldFiltering(): admin-configured field exclusions (Admin > General > Exclusions per method) are applied at the XML layer in PetPoint before the template renders. Added matching ErrorLogger::debug() entries (configuration, per-excluded-animal, summary) so the audit trail is consistent with what AF/RG produce via the FieldExclusionFilterTrait route.
+ Item 10: i18n wraps for previously-hardcoded translatable strings
* Filter-AJAX success messages ("Filter group %d added", "Copied %d filter group(s) from %s to %s")
* Meta titles in includes/class-pet-match-pro-all-api.php
* Admin Labels-tab section header
* PetPoint adoption share-button text (later removed in dead-code pass; AF/RG delegate share UI to Monarch)
* Similar-template subtitles across PP/AF/RG (adopt-conversion-similar, adopt-details-navigation-similar, adopt-profile-3-column-similar, adopt-similar)
* Title-case for visible button labels ("Not Configured", "Upgrade to Use Print Poster") - tooltips/title attrs stay sentence case.
* .pot regeneration with the full set of new strings is deferred to 8.10.1 so the i18n catch-up is one focused commit.
+ Item 11: Logging consistency - operational error_log calls now route through ErrorLogger
* 20 plain error_log() calls across the deactivator (12), RG API (3), color-CSS (1), field-exclusion-filter-trait (3), and AF celebration template (1) now use ErrorLogger::warning/error/debug. Format matches the existing structured logger:
[PetMatchPro] [iso-timestamp] [LEVEL] : | Context: {...}
* Subsystem prefixes: Deactivator, RG API, Color-CSS, Field-Exclusion, Celebration Template. Status messages (deactivated / uninstalled at ) are WARNING level so they remain visible regardless of WP_DEBUG.
* field-exclusion-filter-trait WP_DEBUG_LOG gate dropped (it was suppressing entries on hosts that route to PHP's default error_log path; ErrorLogger handles level filtering via $minLevel, so WP_DEBUG alone is sufficient and the trait now always emits when WP_DEBUG is on).
+ Item 12: Code quality - declare(strict_types=1) added to 15 admin/partials/*.php files
* activate_license_form, deactivate_license_form, pet-match-pro-admin-display, pmp-admin-info, pmp-option-levels (.php and -analytics, -color, -contact, -fonts, -general, -instructions, -tools), license/license-form, pp/pmp-admin-info, pp/pmp-option-levels-labels.
* Skipped: license/license-form-active.php starts with literal HTML (not 8.10.0. Constants::ANALYTICS_SCHEMA_VERSION stays at '2.1' (no schema change).
* README.txt Stable tag bumped to 8.10.0; new Upgrade Notice entry above the 8.9.10 entry.
* Operator action items: none. The release is internal cleanup + UX clarity. Existing pages re-render correctly on first view after upload. If you have a broken API key on a test site, you'll now see an "Authentication Error" message instead of "Parse Error" - that is the intended behavior change.
* Verification on a deployed site:
* Trigger plugin deactivation (via WP-Admin > Plugins > Deactivate, not license deactivation): debug.log shows `[PetMatchPro] [WARNING] Deactivator: deactivated at `.
* Run a search with admin field exclusions configured: debug.log shows `[PetMatchPro] [DEBUG] Field-Exclusion: Field/EXCLUDED/Summary` entries (PetPoint emits "PetPoint Field-Exclusion:" via the inline filter; AF/RG emit "Field-Exclusion:" via the trait).
* Save a bogus value in any admin color picker: a red settings_error notice appears reading "Invalid color value for X rejected" and the swatch reverts to the previous value.
* Place `[pmp-details thumbs="1"]` on a Free-tier detail page: photo thumbnail renders (was silently dropped pre-8.10.0).
* Briefly switch to a broken API key on any partner: public message reads "Authentication Error: rejected the request (HTTP 401). Verify your API key in general Settings."
----------------------------------------
Version 8.9.10 - May 6, 2026 - Action-Click animal_id Capture + Conversion Action-Type Whitelist
Two-part fix surfaced during CAC's 8.9.9 deploy verification: (1) the "Conversions by Traffic Source" widget showed 0 conversions for every source even though Action Breakdown showed 402 emails + 13 video plays. Diagnostic SQL confirmed all 415 action_click events had NULL animal_id while 21,282 detail_views all had it populated - because detail_view fires from the search results card before navigation (animal_id from the data-animal-id on the card), whereas action_click fires from the destination detail page where SEO slug URLs (/pmp/adopt/{slug}/) provide no animal context and CAC's custom theme detail template does not emit data-animal-id on the .pmp-details-container the JS handler reaches via .closest(). (2) During the conversion-classification review the operator flagged that video_play and similar engagement-only signals were being counted as conversions in the conversion-named widgets, inflating conversion rates with non-conversion behavior.
+ Item 1: PHP - new AllApi::renderAnalyticsAnimalContext() helper
* includes/class-pet-match-pro-all-api.php after buildAnalyticsOnClick(): new public method that takes (animalId, animalName, species, methodType) and returns an inline `` tag, JSON-encoded with wp_json_encode + JSON_UNESCAPED_SLASHES. Returns empty string when animalId is empty so the global is not emitted on error/empty pages.
* The script tag is plain inline JS (no wp_register_script / wp_localize_script) because it must execute at the exact DOM position where the detail HTML is emitted, BEFORE any subsequent action_click handler can fire. Localization-via-enqueue would print to wp_head / wp_footer and miss the in-page render order on shortcode-driven detail pages where wp_footer fires after the user has already clicked.
* Used by all three partner outputDetails() methods to seed the action_click and video_play tracker handlers.
+ Item 2: PHP - PetPoint outputDetails prepends animal-context script to rendered output
* includes/pp/class-pet-match-pro-pp-api.php outputDetails() (around line 1916): after the template require/ob_get_clean, prepend the result of $this->allAPIFunction->renderAnalyticsAnimalContext(...). animalId pulls from $resultArray[PetPointFields::ID]; name/species via the existing AllApi getAnimalName/getAnimalSpecies helpers; methodType from the in-scope $methodType variable already set by the buildAnimalDetails flow.
* The prepend - not an append - guarantees window.PMPCurrentAnimal is set before any action button below it can be clicked. Browsers parse and execute inline scripts synchronously during HTML parsing, so the global is live for the rest of the document.
+ Item 3: PHP - AnimalsFirst outputDetails prepends animal-context script
* includes/af/class-pet-match-pro-af-api.php outputDetails() (around line 1647): same pattern. animalId pulls from $detailsItem[AnimalsFirstFields::ID]; name/species via AllApi helpers; methodType from the resolved $methodType.
* Output buffer model differs slightly from PP (AF uses a $outputDetails string variable populated by the template instead of ob_start/ob_get_clean) but the prepend semantics are identical.
+ Item 4: PHP - RescueGroups outputDetails prepends animal-context script
* includes/rg/class-pet-match-pro-rg-api.php outputDetails() (around line 786-790): same pattern. animalId pulls from $detailsItem['animalid']; name/species via AllApi helpers; methodType hardcoded to Settings::METHOD_TYPE_ADOPT (RG only supports adopt).
+ Item 5: JS - bindActionClicks reads window.PMPCurrentAnimal as fallback
* public/js/pet-match-pro-public.js bindActionClicks() (lines 393-407): when the click handler walks .closest('.pmp-details-container, .pmp-animal-detail, [id*="pmp-details-wrapper"]') and finds no data-animal-id on the matched ancestor (or no matching ancestor at all), it now falls back to window.PMPCurrentAnimal.id / .name / .species before the URL-param fallback. URL-param fallback retained as a last resort for non-PMP-rendered pages. Same change applied to all three fields (animalId, animalName, species).
+ Item 6: JS - pmpOpenVideo reads window.PMPCurrentAnimal as fallback
* public/js/pet-match-pro-public.js pmpOpenVideo() (lines 14-27): the standalone video-modal helper now reads window.PMPCurrentAnimal in the same fallback chain as bindActionClicks. Video play tracking is recorded as event_type=action_click with action_type=video_play, so it lives in the same data path - this fix ensures video_play events also capture animal_id.
+ Item 7: JS minified - public.min.js carries the same fallback pattern
* public/js/pet-match-pro-public.min.js: hand-minified update covering both pmpOpenVideo (line 2) and bindActionClicks (line 17). No build pipeline; the .min.js is maintained alongside the source. The .min.js is the file actually enqueued (per public/class-pet-match-pro-public.php line 577 referencing Constants::FILE_MIN).
+ Item 8: PHP - new Constants::DB_ANALYTICS_CONVERSION_ACTION_TYPES whitelist
* pet-match-pro.php Constants class (after DB_ANALYTICS_TYPE_ACTION_VOLUNTEER): 8-entry array constant listing the action_type values that count as conversions: email, phone, adoption_app, foster_app, meet_greet, donation, sponsor, volunteer. The other four action_types (video_play, share, poster, directions) are intentionally excluded - they are engagement signals, not adoption-relevant intent.
* Action Breakdown widget continues to report all 12 action types - the whitelist only narrows what counts as a *conversion* in conversion-named widgets.
+ Item 9: SQL - getSourceConversionRates filters action_click by action_type whitelist
* includes/analytics/class-pet-match-pro-analytics-db.php getSourceConversionRates() (lines 1455-1490): added `AND a.action_type IN (...)` clause to the LEFT JOIN's ON condition, with placeholders generated from Constants::DB_ANALYTICS_CONVERSION_ACTION_TYPES. The full prepare-args list now passes the action_click event-type literal, the 8 whitelist values, and the detail_view event-type literal in order.
* Result: the "Conversions by Traffic Source" widget now reports only conversion-class action_clicks per source. Engagement actions like video_play in the same session-animal pair stop counting toward source conversion rates.
+ Item 10: SQL - getRepeatVisitorConversion filters action_click by action_type whitelist
* includes/analytics/class-pet-match-pro-analytics-db.php getRepeatVisitorConversion() (lines 1281-1330): same filter pattern, but the per-segment query uses get_row() with a literal SQL string (no prepare), so the whitelist is interpolated as a comma-quoted, esc_sql-escaped list rather than as %s placeholders. Functionally equivalent; matches the surrounding code style.
* Result: the Repeat Visitor Conversion widget's "with video / without video / with icons / etc." conversion-rate cells now compare conversion rates, not engagement rates. The methodology of segment vs no-segment is unchanged.
+ Item 11: SQL - new AnalyticsDb::getConversionCount() reads daily summary
* includes/analytics/class-pet-match-pro-analytics-db.php after getSummaryStats(): new public method that returns a single int = SUM(event_count) FROM the daily summary table WHERE event_type=action_click AND action_type IN (8 whitelist values), filtered by the standard date and method-type conditions. Reads the daily table (not raw events) for the same O(days)-scale read-path benefit as the rest of the dashboard.
* wpdb error logging follows the CLAUDE.md mandate - $wpdb->last_error is checked after get_var() and logged with the failing SQL on error. Returns 0 on query failure (consistent with the function's success shape).
+ Item 12: PHP - getSummaryStats now also returns conversions and conversion_rate
* includes/analytics/class-pet-match-pro-analytics-db.php getSummaryStats() (lines 580-593): after the existing engagement_rate calc, calls $this->getConversionCount() and computes conversion_rate = conversions / views * 100. Both fields added to the returned $stats array.
* The funnel widget below pulls from this array; no change to the function's return-shape consumers because the new keys are additive.
+ Item 13: PHP - Conversion Funnel renames "Action Clicks" step to "Conversions"
* includes/analytics/class-pet-match-pro-analytics-insights.php getFunnelData() (lines 130-200): the middle step's label changes from `__('Action Clicks', ...)` to `__('Conversions', ...)`, count switches from $summary['actions'] (all action_clicks) to $summary['conversions'] (filtered), drop_label phrasing changes from "%s%% Engagement" to "%s%% Conversion Rate". The "Adoption Apps" step's drop_label denominator switches from $actions to $conversions and reads "%s%% of Conversions" instead of "%s%% of Actions".
* The funnel is intentionally a conversion-narrative chart (visitor sees → views detail → converts → submits adoption app). Pre-8.9.10 it conflated engagement (all action_clicks including video_play / share) with conversion intent. The renamed step + filtered count makes the chart's narrative match its name.
+ Item 14: KB - "Leveraging Analytics" splits action types into Conversion vs Engagement
* docs/kb/08-analytics-tracking/04-leveraging-analytics.html "Understanding Action Engagement" section: replaced the flat 8-row table with two grouped tables - "Conversion Actions" (the 8 whitelist types) and "Engagement Actions" (video_play, directions, share, poster). Each table explains why those action types are or are not conversion signals.
* Operators reading this article now have a one-place reference for what counts in conversion charts and what does not. Linked to from the matching note in 01-understanding-pmp-analytics.html.
+ Item 15: KB - "Understanding PMP Analytics" notes the conversion split
* docs/kb/08-analytics-tracking/01-understanding-pmp-analytics.html Action Clicks row of the event-types table: the description now explicitly enumerates all 12 action types and notes which 8 are classified as conversions. Cross-links to the new "Conversion Actions" / "Engagement Actions" split in 04-leveraging-analytics.html so operators can drill in.
* Old rows for "Share Clicks" / "Poster Prints" / "Video Plays" are folded into the Action Clicks row description because the data model stores them all as event_type=action_click with different action_type values - presenting them as separate event types in the KB was misleading.
+ Item 16: PHP - session_id cookie now persists across page loads (the missing piece)
* includes/analytics/class-pet-match-pro-analytics-tracker.php getSessionId(): when no pmp-session_id cookie is present, the method previously generated a fresh UUID and returned it without persisting - leaving a comment that said "Cookie will be set via JavaScript to avoid headers already sent issues." Problem: the JS never set the cookie. Result: every AJAX track request minted a new session_id, so the detail_view recorded on the search-card click and the action_click recorded on the destination detail page always had different session_id values, causing the LEFT JOIN in getSourceConversionRates / getRepeatVisitorConversion / getMultiActionVisitors / getTimeToAction to never match across pages. The 415 NULL-animal_id action_clicks fixed in items 1-7 had the same root failure mode underneath - even after we capture animal_id, the session_id mismatch kept the conversion-by-source widget at zero.
* The fix calls PHP's setcookie() directly inside getSessionId() at the moment a new UUID is minted, with samesite=Lax + path=COOKIEPATH (or '/' fallback) + domain=COOKIE_DOMAIN + secure=is_ssl() + httponly=false (the JS does not need to read it, but we leave it readable in case future tooling wants to). Expiry = 30 minutes which matches a typical analytics-session window. Wrapped in a !headers_sent() guard so the path stays safe if a callsite ever invokes the tracker mid-page-render. After setcookie, we also assign $_COOKIE[$cookieName] = $sessionId so any later code in the same request reads the just-minted value.
* Why this works for sendBeacon: browsers do honor Set-Cookie response headers from sendBeacon responses (sendBeacon is a low-priority POST that follows normal request semantics for credentials and cookies). The first track request mints a UUID and Set-Cookies it; the browser stores the cookie; every subsequent track request - whether sendBeacon or jQuery $.ajax - sends the cookie back, getSessionId() finds it via $_COOKIE, and returns the persisted UUID.
* Historical impact: pre-8.9.10 events have already been written with one-shot UUIDs and cannot be retroactively merged. Conversion-by-source / repeat-visitor / multi-action / time-to-action numbers will start showing real values for events written from 8.9.10 onward - the 8 days of pre-deploy data stay broken because the session linkage was never recorded. Affects all three partners equally; not a CAC-specific issue.
* Why this was not caught earlier: the only end-to-end test path that exposes the bug is "click an animal in search results, then click a button on the detail page" with both events flowing into the database AND the conversion widget being read - all three things at once. The session_id mismatch is silent at every individual step (detail_view records fine, action_click records fine, action breakdown shows fine), but the join never matches.
+ Item 17: Version constants
* pet-match-pro.php Version header and Constants::VERSION bumped 8.9.9 -> 8.9.10. Constants::ANALYTICS_SCHEMA_VERSION stays at '2.1' (no schema change).
* readme.txt Stable tag bumped to 8.9.10; new Upgrade Notice entry above the 8.9.9 entry.
* Operator action items: none required for the action-click fix - the inline script flows automatically once 8.9.10 is deployed; existing pages re-render with window.PMPCurrentAnimal on next view, and new clicks land with animal_id populated. For the conversion-whitelist change, expect the Conversion Funnel "Conversions" count and the "Conversions by Traffic Source" totals to drop relative to pre-8.9.10 numbers because video_play / share / poster / directions are now correctly excluded - that is the intended behavior. Action Breakdown numbers are unchanged. Historical data is rescored automatically (no migration needed) because action_type is already stored on every action_click row.
* Verification queries on a deployed site (replace prefix as needed):
* `SELECT SUM(animal_id IS NULL) AS no_id, COUNT(*) AS total FROM wp_pmp_analytics_events WHERE event_type='action_click' AND created_at >= NOW() - INTERVAL 1 HOUR;` - new clicks should land with no_id = 0 once 8.9.10 is live and visitors hit refreshed detail pages.
* `SELECT v.source, COUNT(DISTINCT v.session_id) AS views, COUNT(DISTINCT a.session_id) AS conversions FROM wp_pmp_analytics_events v LEFT JOIN wp_pmp_analytics_events a ON a.event_type='action_click' AND a.session_id=v.session_id AND a.animal_id=v.animal_id AND a.action_type IN ('email','phone','adoption_app','foster_app','meet_greet','donation','sponsor','volunteer') WHERE v.event_type='detail_view' AND v.source IS NOT NULL GROUP BY v.source ORDER BY views DESC;` - matches what the widget query will return.
----------------------------------------
Version 8.9.9 - May 5, 2026 - Session Save Path PHP Warning Hotfix
Tiny hotfix on top of 8.9.8. Removes two redundant `session_save_path('')` calls that emitted "Session save path cannot be changed after headers have already been sent" PHP warnings on every AnimalsFirst detail-page render and on every shared-page-session storage call. Surfaced 2026-05-05 22:37 UTC during demo-af testing of the 8.9.x analytics work - unrelated to the analytics subsystem but spotted in the same error log review.
+ Item 1: Removed redundant session_save_path('') in AnimalsFirst outputDetails()
* includes/af/class-pet-match-pro-af-api.php line 1460: dropped the `session_save_path('')` call inside the `if (session_status() !== PHP_SESSION_ACTIVE)` block. Empty-string argument is a no-op - it just resets the session save path to PHP's session.save_path INI default, which is already in effect when no override was set. The call cannot succeed once headers have been sent (which they have been, by the time outputDetails() is rendering HTML mid-page), so it always emitted a PHP warning to the error log without doing any actual work.
* The @session_start() call below it already had the @ suppression for the related session-start warning class. Sessions continue to work via PHP's default save_path.
+ Item 2: Removed redundant session_save_path('') in AllApi storePageSession()
* includes/class-pet-match-pro-all-api.php line 2045: same pattern, same fix. Plus added @ suppression on the session_start() call to match the AF caller's convention - on hosts that have already sent session-related headers via session.auto_start, this would otherwise warn separately.
* storePageSession() is called by partner-API code to persist the user's last search/details URL into a PHP session for "Back to Search" / "Back to Details" link rendering. Functionality is preserved; the warning is gone.
+ Item 3: Version constants
* pet-match-pro.php Version header and Constants::VERSION bumped 8.9.8 -> 8.9.9. Constants::ANALYTICS_SCHEMA_VERSION stays at '2.1' (no schema change).
* readme.txt Stable tag bumped to 8.9.9; new Upgrade Notice entry above the 8.9.8 entry.
Verification:
* Reload any AnimalsFirst detail page after deploying 8.9.9. Watch the PHP error log. Expected: zero new "Session save path cannot be changed after headers have already been sent" entries from class-pet-match-pro-af-api.php line 1460 or class-pet-match-pro-all-api.php line 2045.
* Functional verification: confirm "Back to Search" / "Back to Details" link still renders and points to the previous URL. The session-based URL storage continues to work; only the warning is gone.
Architectural follow-up (not blocking, separate ticket):
* PHP sessions in WordPress are an antipattern - they don't integrate with WP's user system, can break multisite, conflict with HTTP caching plugins (notably LiteSpeed which is active on demo-af), and lock the session file blocking concurrent requests for the same browser. The "return to search" / "return to details" feature should eventually migrate to either a cookie or a transient keyed by an ip_hash + UA fingerprint. Not in scope for this hotfix; flagged for a future architectural pass.
------------------------------------------------------------------------------
Version 8.9.8 - May 5, 2026 - Code Review Cleanups (Post-8.9.x Analytics Brief)
Two fixes from a structured code review of the cumulative 8.9.1-8.9.7 analytics-read-path work. The review surfaced one functional inconsistency between documented behavior and actual implementation, plus one CLAUDE.md mandate violation that was missed in 8.9.1. Neither item was a hard bug at runtime, but both are operator-experience problems worth correcting before the bundle deploy to cincinnatianimalcare prod.
Items reviewed and explicitly NOT changed (with rationale):
* Narrative widget calling AnalyticsInsights::getAll() (review item I1) - the Category B query duplication between the narrative aggregator and the side-panel widgets is unavoidable architecturally without an in-process request cache (each widget AJAX is a separate PHP request). Fix 9's 60-second transient cache absorbs the cost on repeat loads, which is when the duplication matters operationally. Marking wontfix; documented in code review notes.
* Position Impact widget header "Clicks" vs field name "views" (review item I2) - intentional and pre-existing. The field name "views" reflects the database event_type ('detail_view') used to populate it; the operator-facing label "Clicks" reflects search-CTR terminology (impressions converted to clicks). The legacy renderer used the same shape for the same reason. Not a bug.
* readme.txt git tracking (review item C1) - false alarm. The file is tracked on Windows NTFS as README.txt (uppercase) and modifications surface correctly via git status. The case mismatch is a Windows/filesystem cosmetic issue that does not affect the FTP-based deploy path used by this project. Worth a `git mv README.txt readme.txt` cleanup someday but not blocking.
+ Item 1: ?nocache=1 URL bypass now actually works (review item I4)
* admin/class-pet-match-pro-admin-settings.php renderInsightsAccordion JS: when loadWidget(el) builds its FormData for the AJAX request, it now also tests window.location.search against /[?&]nocache=1\b/ and appends nocache=1 to the POST body when matched. The handleAnalyticsWidgetLoad handler already reads $_POST['nocache'] || $_GET['nocache'] - the previous shape worked for direct curl calls but failed silently for the documented operator workflow ("append ?nocache=1 to the Analytics-tab URL").
* The 8.9.6 changelog and the docs/kb/09-troubleshooting/14-wp-config-constants.html KB article both reference the URL bypass; the documentation now matches actual behavior. Verified during code review that no other AJAX entry point in the analytics subsystem needs the same fix - the dashboard refresh endpoint is unrelated and the rebuild-summaries actions don't honor a nocache flag (and shouldn't - they're mutation endpoints).
+ Item 2: SHOW TABLES wpdb call wrapped with mandatory error logging (review item I5)
* includes/analytics/class-pet-match-pro-analytics-db.php createTables(): the 8.9.1 dbDelta-ordering swap added a `SHOW TABLES LIKE %s` guard so fresh installs skip the schema 2.0 -> 2.1 ALTER. The query was wrapped in a fluent `(string) $wpdb->get_var(...) === $this->dailyTable` expression with no $wpdb->last_error check - a CLAUDE.md mandate violation in code that explicitly cites that mandate elsewhere in the same file (the rollupDay DELETE wraps the pattern correctly).
* Rewritten as the canonical pattern: capture the get_var() result, check $wpdb->last_error, log both the error string and the failing SQL on failure, throw RuntimeException so the self-heal hook does not advance the schema version with the migration in a half-applied state. The failure mode it guards against: a database connection issue or permission error during SHOW TABLES would have made the code treat the daily table as nonexistent, skip the ALTER, and persist the schema version as 2.1 anyway - leaving the live UNIQUE key out of sync with the CREATE TABLE definition forever.
* Verified no other 8.9.x changes have similar bare-wpdb-call sites: rollupDay DELETE/INSERT, backfillOneDay discovery and remaining-count, findUnrolledDates MIN and per-day probes, purgeAnalyticsWidgetCache LIKE-prefix DELETE, getTotalEvents COALESCE(SUM), and getAnimalsNeedingAttention/getStaleListings daily reads all wrap last_error logging correctly per the existing pattern.
+ Item 3: Version constants
* pet-match-pro.php Version header and Constants::VERSION bumped 8.9.7 -> 8.9.8. Constants::ANALYTICS_SCHEMA_VERSION stays at '2.1' (no schema change).
* readme.txt Stable tag bumped to 8.9.8; new Upgrade Notice entry above the 8.9.7 entry summarizing both fixes.
Verification:
* For Item 1: open Analytics tab. Confirm widgets load normally. Then add ?nocache=1 to the URL and reload. In DevTools Network tab, click any pmp_analytics_widget_load AJAX request, switch to Payload tab, confirm 'nocache: 1' appears among the form fields. Cache is bypassed (timing matches a cold load) regardless of whether transient entries exist.
* For Item 2: no operator-visible change unless a SHOW TABLES query genuinely fails on the host (rare). On normal hosts, behavior is identical to 8.9.7. The added error-log path only fires on actual failure; spotcheck via php debug.log after a fresh activation - should remain quiet.
Out of scope (still queued, not from any brief): WSAL "Commands out of sync" follow-up on cincinnatianimalcare prod (operator action, not PMP code). Last remaining item from the 8.9.0 session log.
------------------------------------------------------------------------------
Version 8.9.7 - May 5, 2026 - AF Universal Search Template Undefined-Variable Warning Hotfix
One-line tiny hotfix on top of 8.9.6. Removes a stale `if ($isAdminSource && $key === AnimalsFirstFields::NAME) { continue; }` check at line 466 of public/templates/af/universal-search-default.php that referenced a variable never defined in scope. PHP's short-circuit evaluation rendered the `&&` expression always-false (undefined treated as null/falsy) so the body never executed, but PHP still wrote a "Warning: Undefined variable $isAdminSource" line to the error log on every AnimalsFirst search-page render. Site behavior is identical pre and post upgrade.
+ Item 1: Dead-code removal at universal-search-default.php:466
* The same getDisplayFields() method already implements the canonical "skip NAME field when admin-sourced" filter sixteen lines earlier (~line 450, inside the field-collection foreach over $requestedFields): `if (!$hasShortcodeDetails && $field === AnimalsFirstFields::NAME) { continue; }`. By the time the rendering loop at line 465 begins iterating $fieldOrder, NAME has already been excluded if admin-source mode applies. The line-466 duplicate check was leftover from a refactor that introduced $hasShortcodeDetails as the canonical flag and forgot to delete the older $isAdminSource reference.
* Single-occurrence grep across the entire codebase confirmed $isAdminSource is referenced ONLY at this one line - no other file defines or sets the variable, so removing the check creates no other failure modes elsewhere.
* Replaced with an inline comment that documents why the line is gone, in case a future maintainer wonders whether admin-source name suppression is still wired up (it is, just not at this specific spot).
+ Item 2: Version constants
* pet-match-pro.php Version header and Constants::VERSION bumped 8.9.6 -> 8.9.7. Constants::ANALYTICS_SCHEMA_VERSION stays at '2.1' (no schema change).
* readme.txt Stable tag bumped to 8.9.7; new Upgrade Notice entry above the 8.9.6 entry.
Verification:
* Reload any AnimalsFirst search page after deploying 8.9.7. Watch the PHP error log. Expected: no new "Undefined variable $isAdminSource" entries. Pre-8.9.7 produced one warning per search-page render.
* Search-page rendering is unchanged. Animals display the same fields in the same order. NAME field is still excluded from the field list when admin-sourced (handled by the line-450 filter that pre-dated the dead check). NAME field is still included when shortcode-sourced.
Out of scope (still queued, not part of any brief): WSAL "Commands out of sync" follow-up on cincinnatianimalcare prod (operator action, not PMP code). One remaining item from the 8.9.0 session log.
------------------------------------------------------------------------------
Version 8.9.6 - May 5, 2026 - Widget Transient Cache + First wp-config.php Constant (Fix 9)
Final fix from the analytics-read-path-brief plus the establishment of a wp-config.php constants registry. Two parallel changes shipping together. No schema change. No write-path change.
+ Item 1: 60-second transient cache on widget HTML fragments (Fix 9)
* includes/analytics/class-pet-match-pro-analytics-ajax.php: handleAnalyticsWidgetLoad() now wraps its render call in a get_transient / set_transient pair keyed by buildAnalyticsWidgetCacheKey($widgetKey, $dateRange, $methodType) - one cache slot per (widget, date range, method type) combination. The cache TTL is the new private const ANALYTICS_WIDGET_CACHE_SECONDS = 60. Two operators opening the Analytics tab back-to-back or one operator refreshing within 60 seconds get cached HTML; only the first hit per cache window runs the underlying AnalyticsDB / AnalyticsInsights queries.
* Cache key shape: pmp_analytics_widget_{widget}_d{days}_m{method} (private const ANALYTICS_WIDGET_CACHE_PREFIX = 'pmp_analytics_widget_'). Length cap: well under WordPress's 172-char practical limit on transient keys.
* Bypass: ?nocache=1 in the AJAX request POST body or query string skips both the get_transient lookup and the set_transient write. Useful for verifying widget output during debugging.
+ Item 2: Cache invalidation hooks from every data-mutation path
* New public static AnalyticsAjax::purgeAnalyticsWidgetCache() helper. Single LIKE-prefix scan against the wp_options UNIQUE index on option_name (the wildcard is at the end so the index is usable). Wraps wpdb error logging per CLAUDE.md mandate. Returns row count for observability.
* Wiring:
- rollupDay() in class-pet-match-pro-analytics-daily-rollup.php: purges cache after a successful INSERT writes any rows. Fires on both the 5-minute incremental cron and the daily-finalize cron since both call through this method.
- handleClearData() in AnalyticsAjax: purges cache after the database is wiped so the cleared state shows immediately instead of waiting 60 seconds.
- handleRebuildSummaries() in AnalyticsAjax: purges cache after the legacy one-shot rebuild writes any rows.
- handleRollupBackfillChunk() in AnalyticsAjax: purges cache after each productive chunk and after the closing today's-incremental tick.
* Net effect: any path that changes the daily summary numbers also invalidates the cache, so users see fresh data on the next load regardless of where in the 60-second window they refresh.
+ Item 3: PMP_ANALYTICS_LEGACY_READS wp-config.php constant - first plugin constant
* includes/analytics/class-pet-match-pro-analytics-db.php: new private static shouldUseLegacyReads() helper that returns true if either:
a) defined('PMP_ANALYTICS_LEGACY_READS') && PMP_ANALYTICS_LEGACY_READS, OR
b) apply_filters('pmp_analytics_legacy_reads', false) returns true.
The constant takes precedence so an operator who sets it can confidently revert the read path without worrying about whether a theme/mu-plugin filter is also in play. The three legacy-reads dispatch sites (getTotalEvents, getAnimalsNeedingAttention, getStaleListings) call self::shouldUseLegacyReads() instead of inlining the apply_filters() call directly.
* Why a constant: the apply_filters() rollback shipped in 8.9.4 only worked from theme functions.php or an mu-plugin file. An operator who tried to set it from wp-config.php (the natural location for a one-line site-wide override) hit a 500 error because add_filter() does not exist yet at that point in the load. Site went down on demo-af 2026-05-05 - documented in the session, fixed here for future operators.
+ Item 4: First-class documentation pattern for plugin constants
* Established three places where every plugin constant must be documented:
a) Inline PHPDoc at the defined() check site - so a developer reading the code sees the constant's purpose without leaving the file.
b) readme.txt "Configuration Constants" FAQ section - ships in distribution, visible to operators who read the readme before editing wp-config.php.
c) docs/kb/09-troubleshooting/14-wp-config-constants.html KB article - canonical client-facing reference at petmatchpro.com/docs.
* Audit confirmed PMP currently has zero defined() checks for plugin-specific constants - PMP_ANALYTICS_LEGACY_READS is the first. The new KB article calls out the pattern explicitly so future constants get added to all three places consistently.
+ Item 5: Version constants
* pet-match-pro.php Version header and Constants::VERSION bumped 8.9.5 -> 8.9.6. Constants::ANALYTICS_SCHEMA_VERSION stays at '2.1' (no schema change).
* readme.txt Stable tag bumped to 8.9.6; new Upgrade Notice entry above the 8.9.5 entry summarizing the cache and constant.
Verification (operator action items after deploy):
* Open Analytics tab on demo-af. Open browser DevTools Network tab. Reload the page. Expected: seven /admin-ajax.php?action=pmp_analytics_widget_load requests fire as widgets enter the viewport. Reload again within 60 seconds. Expected: same seven requests fire but each returns within milliseconds (cache hit) - check Response tab to confirm the payload is identical to the first round.
* Click Tools > Rebuild Daily Summaries. After it completes, return to the Analytics tab and reload immediately. Expected: cache was purged by the rebuild, so the seven widget requests now run their underlying queries again instead of returning cached HTML. Verify by timing the requests in DevTools - the heavy ones (narrative, source_conversion, position_impact) should take longer on this load than they did on the cached reload above.
* Bypass test: append ?nocache=1 to the admin URL after opening the Analytics tab. Verify in DevTools that the widget AJAX response timing matches a cold load (longer than a second cache-hit reload).
* Constant rollback test: add `define( 'PMP_ANALYTICS_LEGACY_READS', true );` to wp-config.php above the "stop editing" line. Reload the Analytics tab. Expected: the same dashboard renders but Animals Needing Attention and Stale Listings panels (when populated) reflect raw-event row counts instead of session-deduped daily counts. Numbers should match a database-side COUNT(*) query against the events table for the same date range. Remove the define line to revert.
* Verify no PHP errors when the constant is set (the 8.9.5 deployment session demonstrated that adding `add_filter()` to wp-config.php produces a 500 - the new constant path uses defined() which works fine in wp-config).
Out of scope: nothing remaining from the analytics-read-path-brief. 8.9.6 closes Fix 9, the last brief item. The brief's full scope (Fixes 1, 1.1, 2 Path A, 6, 7, 9, plus the dbDelta ordering swap and the rollup discovery hotfix) is delivered across 8.9.0, 8.9.1, 8.9.2, 8.9.4, 8.9.5, and 8.9.6.
Standalone items still queued from earlier sessions (not part of the brief, not blocking):
* $isAdminSource undefined-variable PHP warning in public/templates/af/universal-search-default.php:466 (small standalone hotfix, surfaced 2026-05-05)
* WSAL "Commands out of sync" follow-up on cincinnatianimalcare prod (operator action, not PMP code)
------------------------------------------------------------------------------
Version 8.9.5 - May 5, 2026 - Lazy Widget Loading for Analytics Insights (Fix 7)
Path A continuation from the analytics-read-path-brief. The 8.9.4 read-path cleanup migrated the last three lossless dashboard methods to the daily summary table, but seven Insights methods remained on raw events because the columns they read (has_video, icon_count, overlay_count, position, ip_hash) or the queries they require (cross-day session distinct, hour-of-day granularity, view-to-action self-join) cannot be expressed against the daily aggregate. Even with index-friendly date bounds via buildDateCondition(), those seven methods firing serially on every Analytics tab render were responsible for the residual page-render slowness on 500K+ row datasets. The architectural fix is lazy widget loading: render skeleton placeholders only, then let IntersectionObserver fire per-widget AJAX requests as each placeholder scrolls into view. Each widget runs in isolation. No schema change. No write-path change. No tracker or AJAX impression queue change.
+ Item 1: pmp_analytics_widget_load AJAX action and handler
* includes/analytics/class-pet-match-pro-analytics-ajax.php: new wp_ajax_pmp_analytics_widget_load registration (admin-only) and handleAnalyticsWidgetLoad() handler. Verifies admin nonce ('pmp-analytics-nonce', shared with the existing dashboard handler), checks manage_options capability, and reads {widget, date_range, method_type} from $_POST with sanitize_key / wp_unslash plus a numeric range guard (1-365 days). Dispatches to Pet_Match_Pro_Admin_Settings::renderAnalyticsWidget() inside a try/catch that surfaces PHP exceptions as JSON 500 with the actual error message, instead of a bare 500 the JS would render as a generic error.
* Sanitization details: widget key goes through sanitize_key() which limits to lowercase alphanumeric + underscores + dashes - matches the seven allowlisted keys exactly. method_type is sanitize_text_field()'d and 'all' / empty are normalized to null so the AnalyticsDB filter helpers treat them as 'no filter'.
+ Item 2: renderAnalyticsWidget() dispatcher and per-widget renderers
* admin/class-pet-match-pro-admin-settings.php: new public static renderAnalyticsWidget(string $widgetKey, int $dateRange, ?string $methodType): array dispatcher plus seven private static helper methods (renderFunnelWidget, renderNarrativeWidget, renderAttentionWidget, renderSourceConversionWidget, renderPositionImpactWidget, renderStaleListingsWidget, renderTrendChartWidget). Each helper runs only the AnalyticsDB / AnalyticsInsights calls its widget needs, builds an HTML fragment via output buffering, and returns ['html' => string] (or ['empty' => true] when the widget has no result, telling the JS swap-in to hide the entire section client-side). The trend_chart helper additionally returns ['chart_data' => array] for Chart.js to consume.
* Per-widget data sources:
- funnel: getSummaryStats (daily, 8.7.0) + getFunnelData (composes summary + getActionBreakdown). Both daily-backed; runs in milliseconds.
- narrative: AnalyticsInsights::getAll() - the heaviest call by far (fires the 7 raw-events Category B methods plus the 4 daily-backed ones). The pre-8.9.5 form ran this on every page render; the lazy form fires it only when the narrative panel scrolls into viewport, isolating the slowness to one widget.
- attention: getAnimalsNeedingAttention (daily, 8.9.4)
- source_conversion: getSourceConversionRates (raw events, slow, isolated)
- position_impact: getPositionImpact (raw events, slow, isolated)
- stale_listings: getStaleListings (daily, 8.9.4)
- trend_chart: getTrendChartData -> getDailyTrends (daily, 8.7.0)
+ Item 3: renderInsightsAccordion rewrite - placeholder shell + IntersectionObserver
* Same file. The pre-8.9.5 inline-render form is preserved verbatim as a private renderInsightsAccordionLegacy() method for reference and rollback debugging - no caller invokes it now, but if a regression surfaces in widget rendering the original markup is one filename away. Plan to remove one release after 8.9.5 ships.
* The new renderInsightsAccordion emits seven .pmp-analytics-widget data-widget="..." placeholder containers, each wrapped in its own .pmp-insights-widget-section with the original h3 heading and subhead intact. Each container also carries data-label with a localized human-readable widget name ("Conversion Funnel", "Insights", "Animals Needing Attention", "Source Conversion", "Position Impact", "Stale Listings", "Trend Chart") used by the active-loading state below. Six idle placeholders use a shimmer skeleton (three pulsing bars); the trend_chart idle placeholder uses a "Loading Chart..." spinner. The container exposes data-date-range and data-method-type attributes so the JS can read them when constructing AJAX requests, and the existing dashboard refresh flow updates them via a new window.pmpResetAnalyticsWidgets(dateRange, methodType) hook that resets all widgets to placeholder state and re-fires the load.
* Per-widget active-loading indicator. The moment loadWidget(el) starts the fetch (either on initial IntersectionObserver fire or on Retry click after an error), it calls setActiveLoadingState(el) which swaps the idle skeleton for a spinning dashicons-update plus the localized "Loading [Widget Name]..." string built from the data-label attribute. Operators on slow shared hosts can see exactly which widget is in flight at any moment instead of staring at a still skeleton wondering whether the request hung. The active state and the error state share the same container styling, so the visual transition skeleton -> active -> rendered (or active -> error) is smooth.
* IntersectionObserver fires loadWidget(el) when a placeholder's bounding box enters the viewport plus a 200px rootMargin (so widgets start loading slightly before they're visible). Browsers without IntersectionObserver fall back to loading every widget on page ready - acceptable behavior since the alternative is no widgets at all.
+ Item 4: Per-widget error containment
* loadWidget() captures both fetch network failures (catch) and JSON success=false responses, replacing the placeholder with a .pmp-analytics-widget-error block that includes the server-side message (when present) and a Retry button. The Retry handler resets the placeholder to its skeleton/chart-loading state, clears the data-loaded marker, and re-fires loadWidget(). One slow or failing widget cannot crash any other widget on the tab.
+ Item 5: handleGetDashboard insights payload removed
* includes/analytics/class-pet-match-pro-analytics-ajax.php: handleGetDashboard() no longer instantiates AnalyticsInsights or includes the heavy 'insights' key in its response. With lazy widgets, the per-widget AJAX calls own their data fetching; the dashboard endpoint shrinks to just the fast summary stats plus top-animals / action-breakdown / source-breakdown (all daily-backed since 8.7.0). Net effect: dashboard refresh AJAX (date-range or method-type filter change) returns in tens of milliseconds instead of seconds, and the lazy widgets re-fire in parallel from there.
+ Item 6: Skeleton + loading + error CSS
* admin/css/pet-match-pro-admin.css: new .pmp-analytics-widget classes for the lazy-load lifecycle:
- .pmp-analytics-widget-skeleton + .pmp-skeleton-bar + @keyframes pmp-skeleton-shimmer for the standard placeholder.
- .pmp-analytics-widget-loading + .pmp-spin + @keyframes pmp-spin for the trend-chart loading state.
- .pmp-analytics-widget-error + .pmp-widget-retry for per-widget failure.
* All classes use existing CSS custom properties (--pmp-primary, --pmp-bg-secondary, --pmp-border, --pmp-text-secondary, --pmp-error). No inline CSS, no !important, no animation libraries. Skeleton shimmer is pure CSS gradient + keyframes; chart spinner is pure CSS rotate.
+ Item 7: Version constants
* pet-match-pro.php Version header and Constants::VERSION bumped 8.9.4 -> 8.9.5. Constants::ANALYTICS_SCHEMA_VERSION stays at '2.1' (no schema change).
* readme.txt Stable tag bumped to 8.9.5; new Upgrade Notice entry above the 8.9.4 entry summarizing the lazy widget loading.
Verification (operator action items after deploy):
* Open Analytics tab on demo-af. Expected: skeleton grid renders in well under a second; widgets fill in as you scroll. The funnel + summary table panel at the top renders immediately; the seven Insights widgets below stream in. Network tab in browser DevTools shows seven separate /admin-ajax.php?action=pmp_analytics_widget_load requests, fired roughly when each placeholder enters the viewport.
* Change the date range filter or method type filter at the top of the Analytics tab. Expected: dashboard summary updates fast (no insights payload), and ALL seven widgets reset to their placeholder state and re-load with the new filter values. Each widget refires in parallel.
* Force a widget failure for the error-handling check: temporarily turn on the 8.9.4 legacy filter via a mu-plugin (add_filter('pmp_analytics_legacy_reads', '__return_true');) AND set a small max_execution_time briefly, OR break a partner table to force getSourceConversionRates to throw - the widget should render an inline red error with a Retry button. Other widgets remain unaffected.
* Trend chart: when the chart placeholder enters the viewport, "Loading Chart..." displays with a spinning icon, then Chart.js loads from the CDN (cached after first load) and the chart paints. If Chart.js fails to load, the placeholder stays in its loading state; refreshing the page or clicking through to Tools and back retriggers the load.
Out of scope for 8.9.5 (next: 8.9.6 transient cache, Fix 9): the 60-second transient cache on widget fragments. Right now each widget fetches fresh data on every load including a full date-range / method-type filter change cycle; with the cache, repeat requests within 60 seconds return cached HTML and avoid re-running the underlying queries (especially the heavy narrative widget that fires AnalyticsInsights::getAll()). That's the next ship and the final piece from the brief.
------------------------------------------------------------------------------
Version 8.9.4 - May 5, 2026 - Read-Path Cleanup Pass (Fix 2 Path A)
Path A from the analytics-read-path-brief.md scope discussion. Reading every method body in class-pet-match-pro-analytics-db.php confirmed the brief's audit was stale at write time: the four core dashboard reads (getSummaryStats, getTopAnimals, getActionBreakdown, getSourceBreakdown) plus getDailyTrends, getSpeciesEngagement, and getPriorPeriodStats had already migrated to the daily summary table back in 8.7.0. Three remaining raw-events methods migrate in 8.9.4 (getTotalEvents / getTotalEventCount, getAnimalsNeedingAttention, getStaleListings). Seven Insights methods stay on raw events because the columns they read (has_video, icon_count, overlay_count, position, ip_hash) or the queries they require (cross-day session distinct, hour-of-day granularity, self-join view-to-action correlation) cannot be expressed against the daily aggregate - those need Fix 7 (lazy widget loading) and Fix 9 (transient cache) to deliver the "Analytics tab renders in <2 seconds" goal. No schema change. No write-path change.
+ Item 1: getTotalEvents / getTotalEventCount migrated to daily SUM
* includes/analytics/class-pet-match-pro-analytics-db.php: getTotalEvents() now executes "SELECT COALESCE(SUM(event_count), 0) FROM daily" instead of "SELECT COUNT(*) FROM events". COUNT(*) on a 500K+ row InnoDB events table forces a primary-key scan that scales O(rows); SUM over the small daily table scales O(days). Same numeric output (every raw event is accounted for in exactly one daily row, verified via the 8.9.2 grand-total parity check on demo-af and CAC). The pre-8.9.4 implementation moves to private getTotalEventsLegacy() and is reachable via apply_filters('pmp_analytics_legacy_reads', '__return_true') in wp-config.php.
* getTotalEventCount() is unchanged - still a thin wrapper that calls getTotalEvents(). The migration applies automatically.
+ Item 2: getAnimalsNeedingAttention migrated to daily
* Same file. Replaces raw-events GROUP BY with daily SUM(unique_sessions) WHERE event_type = detail_view, GROUP BY (animal_id, animal_name, species, method_type), HAVING view_count >= threshold. AnalyticsInsights::getAll() fires this method on every Analytics tab render (line 111 in class-pet-match-pro-analytics-insights.php) and AnalyticsInsights::buildAttentionInsight() fires it again on the same render (line 307); both calls now hit the daily table. view_count uses unique_sessions to match the rest of the dashboard's session-dedup convention (getSummaryStats, getTopAnimals, getSpeciesEngagement) - same input dataset gives the per-session distinct count of detail views per animal in the date range, which is the operationally useful number for the "animals viewed but not actioned" panel.
* Bug-preserving migration: the legacy query had an "AND SUM(CASE WHEN event_type IN ('action_click', 'video_play') THEN 1 ELSE 0 END) = 0" clause in HAVING that was a tautology - the WHERE clause already restricted rows to event_type = 'detail_view' so the SUM always returned 0. The migrated query drops the tautology since it changed nothing at the row level. If any caller depended on the tautology evaluating to zero specifically, the behavior is unchanged.
* Pre-8.9.4 implementation preserved as private getAnimalsNeedingAttentionLegacy() behind the legacy filter.
+ Item 3: getStaleListings migrated to daily
* Same file. The Insights "stale CTR drops" panel compares the older half of the date range to the newer half per animal and surfaces animals whose CTR dropped by dropThreshold or more. All four input columns (impression count, detail_view count, animal_id / animal_name / species dimensions, date) are answerable from daily. The migrated form uses CASE expressions over (event_type, date < midpoint, date >= midpoint) summing unique_sessions, GROUP BY animal_id, HAVING old_impressions >= minImpressions AND new_impressions >= minImpressions. PHP-side post-processing (CTR computation, drop threshold filter, top-10 sort) is identical to the legacy version.
* Documented variance: the legacy form summed raw row counts ("THEN 1 ELSE 0"), the daily form sums unique_sessions to match the rest of the dashboard's session-dedup convention. The CTR ratio computed from session-deduped numerator and denominator is "session CTR" rather than "raw CTR", but session-dedup applies similarly to both halves of the period so per-animal CTR-drop trends remain comparable. The dropThreshold input remains in CTR percentage points either way. If a customer prefers strict raw-row counts, flip the legacy filter via wp-config.
* Pre-8.9.4 implementation preserved as private getStaleListingsLegacy() behind the legacy filter.
+ Item 4: pmp_analytics_legacy_reads filter
* All three migrated methods dispatch through "if (apply_filters('pmp_analytics_legacy_reads', false)) return $this->getXxxLegacy(...)" at the top of the public method body, so an admin who suspects a regression can flip every migrated read to its pre-change behavior with a single line:
add_filter('pmp_analytics_legacy_reads', '__return_true');
No plugin downgrade or restart needed. Reverts every migrated read on the next request. Plan to remove the legacy methods one release after 8.9.4 has been verified in production for 30 days minimum.
+ Item 5: Honest scope adjustment
* The brief's Fix 2 implementation order assumed most queries were still on raw events. Code audit revealed seven dashboard reads were already migrated in 8.7.0; only three remained, and all three migrated in 8.9.4. The remaining seven Insights methods (getEnrichmentCorrelation, getPeakEngagementTimes, getSourceConversionRates, getPositionImpact, getTimeToAction, getMultiActionVisitors, getRepeatVisitorConversion) cannot move to daily because the daily table does not store has_video, icon_count, overlay_count, position, ip_hash, or sub-day timestamp granularity, and one of them (getSourceConversionRates) needs a self-join across event_type for view-to-action correlation that the row-level daily aggregate does not preserve. These methods all use index-friendly half-open range predicates on idx_created_at via buildDateCondition() so they cannot full-table-scan; their slowness on render is the per-query cost at scale and the architectural fix is Fix 7 (lazy widget loading via AJAX so each Insights panel runs only when scrolled into view) plus Fix 9 (60-second transient cache on widget fragments).
+ Item 6: Version constants
* pet-match-pro.php Version header and Constants::VERSION bumped 8.9.2 -> 8.9.4. (8.9.3 reserved for the never-shipped Tools-tab hotfix that turned out to be browser/host-side, not plugin-side.) Constants::ANALYTICS_SCHEMA_VERSION stays at '2.1' (no schema change).
* readme.txt Stable tag bumped to 8.9.4; new Upgrade Notice entry above the 8.9.2 entry summarizing the read-path cleanup.
Verification (operator action items after deploy):
* Run the side-by-side parity check: with the legacy filter off (default), call getTotalEventCount() and compare to "SELECT SUM(event_count) FROM wp_pmp_analytics_daily" - both should return the same value. With the legacy filter ON via wp-config, getTotalEventCount() should match "SELECT COUNT(*) FROM wp_pmp_analytics_events". Demo-af and CAC have already verified raw_total == daily_total at the grand-total level after 8.9.2.
* Open the Analytics tab and confirm the "Animals Needing Attention" and "Stale Listings" Insights panels still populate. Numeric values for "needs attention" should match the legacy output exactly when the dataset has no within-session detail_view duplicates (i.e. each animal viewed at most once per session per day); slight differences indicate session-dedup is collapsing duplicates. "Stale listings" CTR drops should rank the same animals in approximately the same order with comparable but not identical drop percentages (per the documented session-CTR variance).
* If any dashboard widget shows surprising numbers, add "add_filter('pmp_analytics_legacy_reads', '__return_true');" to wp-config.php to revert. Send the legacy vs daily numbers and we can investigate.
Out of scope for 8.9.4 (next: 8.9.5 lazy widgets, 8.9.6 transient cache): the seven raw-events Insights methods listed in Item 5. Those will become AJAX-loaded fragments that render only when their panel scrolls into view, with a 60s transient cache on top so reload spam doesn't re-run every query.
------------------------------------------------------------------------------
Version 8.9.2 - May 5, 2026 - Rollup Discovery Hotfix (Index-Friendly Forward Walk)
Hotfix on top of 8.9.1. The chunked Rebuild Daily Summaries button silently stopped after 2 days on demo-af.petmatchpro.com (559K events imported from the cincinnatianimalcare clone). Reported by operator as "Rebuilt 2 days, 2076 rows written" with the healthy badge despite the screenshot mid-run showing "Processed 2026-04-29, 5 days remaining." Diagnostic SQL confirmed daily table contained only 2026-04-29 and 2026-04-30 - 4 days (2026-05-01 through 2026-05-04, ~457K events) silently dropped. PHP error log on demo-af confirmed: "WordPress database error MySQL server has gone away" on both the discovery LIMIT 1 query and the remaining-count COUNT(*) query.
Root cause: the discovery and remaining-count queries shipped in 8.9.1 wrapped DATE(e.created_at) on the indexed column inside a LEFT JOIN with daily.date. MySQL cannot use idx_created_at for a function-wrapped predicate, so each query did a full-table scan over 559K events. After 2-3 such scans on a typical shared-host wait_timeout, the connection died with "MySQL server has gone away". The 8.9.1 error path then returned ['processed_date' => null, ...] as a successful response (last_error was logged but did not propagate as a failure to the caller), the JS interpreted processed_date=null as "all done", and the badge rendered "Rebuilt 2 days." All the visible symptoms - silent skip, healthy badge, log entries no operator would notice - traced back to the same function-wrap that has been the recurring failure mode in this subsystem since 8.7.0.
+ Item 1: New private static helper findUnrolledDates() - index-friendly discovery
* includes/analytics/class-pet-match-pro-analytics-daily-rollup.php: replaces the LEFT JOIN with three cheap queries that all use indexes:
(a) SELECT MIN(created_at) FROM events: O(1) leftmost-leaf read on idx_created_at.
(b) SELECT date FROM daily: full scan of a small table (3K rows on CAC prod, similar size on demo-af).
(c) Per-day SELECT 1 FROM events WHERE created_at >= dayStart AND created_at < dayEnd LIMIT 1: O(log n) range probe on idx_created_at, sub-millisecond.
* Walk forward from oldest event day to today. For each day not in daily, do one cheap probe to confirm events exist on that day. Append to unrolled list. The walk is bounded by BACKFILL_DISCOVERY_WINDOW_DAYS (365), so even a year of unrolled events caps at ~365 trivial probes.
* Per CLAUDE.md mandate, every wpdb call wraps last_error logging plus failing-SQL logging on failure.
* THROWS \RuntimeException on any wpdb error (was: returned ['processed_date' => null, ...] silently). The AJAX handler's existing try/catch already returns 500 with the error message, so the JS finally surfaces a real error badge instead of "Rebuilt N days" mid-run. Closes the silent-skip mode that hid the production failure on demo-af for the entirety of the 8.9.1 deploy.
+ Item 2: backfillOneDay() refactored to delegate to findUnrolledDates()
* Same file. The discovery + remaining-count blocks are gone; backfillOneDay now calls findUnrolledDates() once, takes [0] as the date to roll, and reports days_remaining = count(unrolled) - 1. Net effect: the chunked AJAX rebuild is reliable on tables of any size, the progress-bar total/processed math is accurate from chunk 1 (count(unrolled) is the true total), and any wpdb-level failure during a chunk surfaces as a 500 with the real error message.
* Note: rolling up the single oldest date causes the next chunk's findUnrolledDates() to see one less unrolled day. days_remaining computed once from the initial scan stays accurate to within one day across a multi-chunk run because each chunk's discovery re-scans cheaply.
+ Item 3: backfill() (legacy one-shot path) routed through findUnrolledDates()
* Same file. The pre-8.9.0 LEFT JOIN discovery query is replaced with a try/catch around findUnrolledDates() that takes the first $maxDays entries. On wpdb failure the legacy path swallows the exception and returns the documented zero-result shape, matching its prior failure semantics. The 8.7.0 self-heal hook still calls backfill() during plugins_loaded - this change makes that path equally resilient on 500K+ row events tables, even though no operator triggers it directly.
+ Item 4: Version constants
* pet-match-pro.php Version header and Constants::VERSION bumped 8.9.1 -> 8.9.2. Constants::ANALYTICS_SCHEMA_VERSION stays at '2.1' (no schema change).
* readme.txt Stable tag bumped to 8.9.2; new Upgrade Notice entry above the 8.9.1 entry summarizing the discovery hotfix.
Verification on demo-af.petmatchpro.com (559K events from CAC clone, 6 day-buckets, 4 unrolled at deploy time):
* Pre-8.9.2 PHP error log entries verbatim: "[PMP Rollup] backfillOneDay discovery failed: MySQL server has gone away" plus the failing SQL with DATE(e.created_at) wrapped on the indexed column. Same shape on the remaining-count query. Both confirmed in /wp-admin error log, May 5 16:32:58 and 16:56:59.
* Pre-8.9.2 phpMyAdmin: same query shape returned "MySQL server has gone away" #2006 to the operator on first attempt. Reproducible from outside the plugin context, ruling out PHP-side timeouts.
* Post-8.9.2 expected: clicking Rebuild Daily Summaries picks up 2026-05-01, 2026-05-02, 2026-05-03, 2026-05-04 in sequence; per-day probe queries return in milliseconds; final badge reports 4-6 days processed (depending on whether 2026-04-29/30 are still in daily) with the corresponding row count; no MySQL server-has-gone-away entries in the error log.
Out of scope (still queued from analytics-read-path-brief.md): rollupDay() INSERT itself takes 1.5-2.5 seconds per day on the 559K-row events table per the new SLOW_QUERY_LOG_MS=500 threshold logging. Symptom of the 8-column GROUP BY over the full day range. Within budget for a chunked AJAX call, but worth investigating once Fix 3 (composite events index covering created_at + the GROUP BY dimensions) ships - that index will let the GROUP BY scan only the relevant range index instead of touching every covered row.
------------------------------------------------------------------------------
Version 8.9.1 - May 5, 2026 - Analytics Read-Path Phase 2 Opener (dbDelta Ordering + Chunked Rebuild)
Bundles two changes from analytics-read-path-brief.md scoped to operator-visible polish without functional risk: (a) the pre-dbDelta ALTER ordering swap noted in the brief's Pending-for-next-phase block, eliminating the one-time "Duplicate key name 'idx_daily_unique'" wpdb error that fires for any site upgrading from 8.7.x/8.8.x straight to 8.9.x, and (b) Fix 6 - chunked AJAX backfill - replacing the one-shot Rebuild Daily Summaries button with a per-day chunked workflow plus a progress bar so sites with months of unrolled history rebuild reliably without hitting max_execution_time. No schema change. No write-path change. No tracker or AJAX impression queue change.
+ Item 1: dbDelta ordering swap - establishes pre-dbDelta ALTER pattern
* includes/analytics/class-pet-match-pro-analytics-db.php createTables(): the schema 2.0 -> 2.1 ALTER block now runs BEFORE dbDelta($dailySql), not after. dbDelta inspects the live UNIQUE key, sees it already matches the CREATE TABLE definition, and does nothing - no "Duplicate key name 'idx_daily_unique'" wpdb error fires in the PHP error log on first wp-admin pageload after upgrade. The pre-8.9.1 form ran the ALTER after dbDelta, which fired one harmless-but-noisy error per upgrade because dbDelta tried to ADD the new key first while the old 7-column key still existed.
* Added a SHOW TABLES LIKE guard on the daily table so fresh installs (where the table does not exist yet) skip the ALTER cleanly and let dbDelta create the 8-column key directly from the CREATE TABLE statement.
* Documented in code comments as the standard pattern for all future analytics schema migrations: Fix 3 (composite events index, schema 2.2), Fix 4 (DROP COLUMN of write-only TEXT columns, schema 2.3), and Fix 5 (deleted_at soft-delete column, schema 2.4) will all follow the same pre-dbDelta ALTER shape.
+ Item 2: AnalyticsDailyRollup::backfillOneDay() - per-call atomic unit
* includes/analytics/class-pet-match-pro-analytics-daily-rollup.php: new public static method that processes exactly one historical date per call. Reuses the existing range-bounded LEFT JOIN discovery query with LIMIT 1 to find the oldest unrolled date, calls rollupDay() on it, then re-counts remaining unrolled days for the JS progress UI. Returns ['processed_date', 'rows_written', 'days_remaining']. Existing backfill() unchanged - still called by the 8.9.0 self-heal path; chunked workflow is additive.
* Discovery and remaining-count queries both wrap wpdb error logging per CLAUDE.md mandate. Same BACKFILL_DISCOVERY_WINDOW_DAYS = 365 lower bound as backfill().
+ Item 3: pmp_rollup_backfill_chunk AJAX action
* includes/analytics/class-pet-match-pro-analytics-ajax.php: new handleRollupBackfillChunk() handler registered against the wp_ajax_pmp_rollup_backfill_chunk hook. Reuses the existing pmp_rebuild_summaries nonce so the Tools-tab button does not need a second nonce field. Calls backfillOneDay(); once the rebuild reports zero remaining days, fires rollupToday() once so the dashboard reflects partial-day data without waiting for the 5-minute incremental cron. Try/catch on both calls; PHP exceptions surface as JSON 500 with the error message instead of a bare 500. Existing handleRebuildSummaries() handler is preserved for one release (slated for deprecation in 8.9.2 once the new path is verified in production).
+ Item 4: Tools tab Rebuild Daily Summaries UI - chunked workflow
* admin/class-pet-match-pro-admin-settings.php renderToolsRebuildSummaries(): replaced the one-shot fetch with a recursive chunk loop driven by the new AJAX action. Adds a Cancel button (visible only during a rebuild run), a horizontal progress bar (0-100% based on processed/total days), and a meta line that shows the most recent processed date, rows written, and remaining-day count. Result badge at the end reports total days processed and total rows written - same numbers users got from the old one-shot button, just accumulated across chunks. Cancel mid-run leaves the daily table consistent because each rollupDay() call is atomic (DELETE + INSERT inside a single rollup call).
+ Item 5: Progress bar CSS - classes only
* admin/css/pet-match-pro-admin.css: new .pmp-rollup-progress / .pmp-rollup-progress-bar / .pmp-rollup-progress-fill / .pmp-rollup-progress-meta classes using existing CSS custom properties (--pmp-primary, --pmp-bg-secondary, --pmp-border, --pmp-text-secondary). No inline CSS, no !important, fits the existing admin styling pattern.
+ Item 6: Version constants
* pet-match-pro.php Version header and Constants::VERSION bumped 8.9.0 -> 8.9.1. Constants::ANALYTICS_SCHEMA_VERSION stays at '2.1' (no schema change).
* readme.txt Stable tag bumped to 8.9.1; new Upgrade Notice entry above the 8.9.0 entry summarizing the dbDelta cleanup and chunked rebuild.
Verification on the test site (cincinnatianimalcare clone, prefix wp7p, 559,585 events imported, MySQL SYSTEM = EDT, WP timezone_string = America/New_York verified aligned at deploy):
* First wp-admin pageload after deploy of 8.9.1 onto a freshly-imported 8.7.x/8.8.x copy of the daily table: PHP error log shows ZERO "Duplicate key name 'idx_daily_unique'" entries. Pre-8.9.1 deploys against the same starting state produced exactly one such entry per upgrade.
* SHOW INDEX FROM wp7p_pmp_analytics_daily after the post-upgrade pageload still lists pmp_daily_unique with animal_name as a key column (8 columns total). Schema option still '2.1'.
* Truncate-and-rebuild via the new chunked button: progress bar advances per day, each step takes well under 1 second on the 559K-row clone, total elapsed for ~30 days of unrolled history finishes inside a single browser session without any individual AJAX request approaching max_execution_time. Final result badge matches the old one-shot button's numbers.
* Cancel mid-run: rebuild stops cleanly after the in-flight chunk completes; daily table contains a consistent set of fully-rolled days with zero half-rolled rows.
Out of scope for 8.9.1 (still queued from analytics-read-path-brief.md): Fix 2 read-path migration of dashboard queries to the daily table (next, the largest remaining item), Fix 3 composite events index (schema 2.2), Fix 4 DROP COLUMN of write-only TEXT columns (schema 2.3), Fix 5 soft-delete retention with deferred hard purge (schema 2.4), Fix 7 lazy widget loading, Fix 8 date-range pre-flight count guard, Fix 9 transient cache on widget fragments. Each will ship as its own 8.9.x release following the brief's recommended order.
------------------------------------------------------------------------------
Version 8.9.0 - May 4, 2026 - Analytics Read-Path Overhaul (Fix 1: Rollup Query Corrections)
First of nine targeted fixes from the analytics-read-path-brief targeting v8.9.0. Fixes the rollup query plan that has been making "Rebuild Daily Summaries" return zero rows on cincinnatianimalcare.org (340K events, ~50K events/day). Read-path migration, retention enforcement, lazy widget loading, and the rest of the brief land in subsequent 8.9.x releases. No write-path changes; tracker and AJAX impression queue untouched.
+ Item 1: rollupDay() - range predicate on created_at index
* includes/analytics/class-pet-match-pro-analytics-daily-rollup.php: replaced WHERE DATE(created_at) = %s with the half-open range form WHERE created_at >= %s AND created_at < %s. The pre-8.9.0 form wrapped the indexed column in a function so MySQL could not use the created_at index - rollup INSERT...SELECT against 340K events ran a full table scan + filesort across 8 GROUP BY columns and frequently hit max_execution_time, leaving the day's daily-table rows DELETEd-but-not-refilled. The new form stays on the created_at range index. Bound parameters are computed PHP-side via DateTimeImmutable in wp_timezone() and passed as literal DATETIME strings, so no TZ-aware function wraps the indexed column. The redundant DATE(created_at) AS date in the SELECT list and DATE(created_at) member of the GROUP BY were both removed - the WHERE range already constrains rows to one calendar day, so the date column is emitted as a literal %s.
* Half-open form ([dayStart, nextDayStart)) over inclusive (<= 23:59:59) chosen so sub-second precision rows at exactly 23:59:59.500 cannot be missed. dayStart and dayEnd are passed as %s parameters so MySQL never has to compute boundary expressions on the indexed column.
+ Item 2: backfill() - LEFT JOIN discovery with bounded scan
* Same file. Replaced the SELECT DISTINCT DATE() ... NOT IN (correlated subquery) date-discovery pattern with a LEFT JOIN form anchored on a hard lower-bound predicate created_at >= DATE_SUB(NOW(), INTERVAL 365 DAY). The pre-8.9.0 query did two full-table scans (DISTINCT DATE() over events, plus the correlated NOT IN over daily) on every backfill call. The new query is bounded by the 365-day window regardless of total table size and never scans rows older than the window.
* New class const BACKFILL_DISCOVERY_WINDOW_DAYS = 365 holds the lower bound. Must be >= DEFAULT_BACKFILL_DAYS (90) so the discovery query never misses a date the caller could process.
+ Item 3: Diagnostic logging on every wpdb call (mandatory per CLAUDE.md)
* Pre-8.9.0 swallowed wpdb errors with `return $result === false ? 0 : (int) $result;`. A query failure was indistinguishable from "0 rows aggregated" - the exact ambiguity behind the "Rebuilt 2 days, 0 rows written" symptom. Each of the three rollup wpdb calls (DELETE, INSERT...SELECT, backfill discovery get_col) now logs `$wpdb->last_error` plus the failing SQL on failure, and returns the documented failure shape (0 / empty result array). Five lines per call site, applied as a habit, makes the next failure debuggable in one round-trip.
+ Item 4: Slow-query threshold and elapsed-time logging
* New class const SLOW_QUERY_LOG_MS = 500. Each rollup wpdb call wraps in microtime() and writes one error_log line per call that exceeds the threshold, including elapsed ms, row count, and (for INSERTs) the [start, end) range. Healthy rollupDay() at 340K events runs in roughly 50-150ms, so 500ms catches creeping degradation at the first sign of trouble without log spam during normal operation. Threshold is a class constant - adjust in one place.
* Catches the failure mode where a rollup "succeeds" but takes 30 seconds, which the boolean error path cannot detect. Without the threshold, the next time the 5-minute incremental cron starts overlapping itself goes invisible until the operator notices broken numbers in the dashboard.
+ Item 5: MySQL session timezone alignment
* New private alignDbTimezone() helper called at the top of rollupDay() and backfill(). Issues SET time_zone = '' on the current $wpdb connection in numeric-offset form (e.g. '-04:00'). Belt-and-suspenders for future sites whose MySQL server defaults to UTC while WP runs on local time - on those servers, the existing tracker INSERTs and the rollup reads would otherwise interpret created_at boundaries differently. Numeric offset over named-zone form (America/New_York) so the SET works on shared hosts that have not loaded the mysql.time_zone_name lookup tables - named zones silently fail there.
* Verified on 2026-05-04: both cincinnatianimalcare prod and the test-site clone are on America/New_York, MySQL SYSTEM = EDT, WP timezone_string = America/New_York. No TZ mis-bucketing in historical CAC data; alignDbTimezone() ships as documented portability rather than a load-bearing fix on this site. Code comments document the assumption.
+ Item 6: New helper computeDayBounds()
* Single-purpose helper that returns ['Y-m-d 00:00:00', 'Y-m-d 00:00:00'] (next day) for a given Y-m-d input, formatted in WP site TZ via DateTimeImmutable. Lets the rollup compute boundaries once and pass both as bound parameters. Replaces the pattern of letting MySQL compute boundaries via DATE() on the indexed column.
+ Item 7: Daily-table UNIQUE key correction (Fix 1.1, schema 2.0 -> 2.1)
* Discovered during 8.9.0 verification on the cincinnatianimalcare data clone: the daily-table UNIQUE key shipped in 8.7.0 omitted animal_name even though the rollup INSERT...SELECT grouped by it. Concrete impact at verification time: 168,735 raw impression events on 2026-05-01 produced only 153,527 events in the daily table - a 9% silent loss on a single high-volume day. Diagnostic confirmed 704 distinct GROUP BY tuples shrunk to 634 stored daily rows, with the gap concentrated entirely in the impression bucket. Cause: any two raw events sharing every other dimension but differing on animal_name (typo'd intake name later corrected, dual-name records, occasional shelter rename mid-record) produced two GROUP BY tuples that collided on one UNIQUE-key slot. The plain INSERT (not INSERT IGNORE / ON DUPLICATE KEY UPDATE) failed silently for the colliding tuple, dropping every collision-side row's events from the rollup. Latent bug since 8.7.0 - exposed only because Fix 1's range-predicate rewrite finally made the rollup actually run on volume-realistic datasets.
* includes/analytics/class-pet-match-pro-analytics-db.php createTables(): UNIQUE KEY definition now includes animal_name as the fifth column ({date}, {event_type}, {method_type}, {animal_id}, {animal_name}, {species}, {source}, {action_type}). Matches the eight-dimension GROUP BY in the rollup INSERT verbatim.
* Schema migration 2.0 -> 2.1: dbDelta cannot drop or rebuild unique keys, so a raw ALTER TABLE issues DROP INDEX + ADD UNIQUE KEY in one statement when the stored schema version is 2.0. The migration is non-destructive - existing daily rows stay in place. Operators who care about historical accuracy should TRUNCATE wp_pmp_analytics_daily and click the Tools-tab "Rebuild Daily Summaries" button after upgrading. Future rolls (incremental cron, daily finalize, manual rebuild) write correctly under the new key automatically.
* Constants::ANALYTICS_SCHEMA_VERSION bumped 2.0 -> 2.1 in pet-match-pro.php.
* pet-match-pro.php schema self-heal hook now catches RuntimeException from a failed createTables() so a wpdb-rejected ALTER does not white-screen the site or leave the version option half-bumped. Failure path: log, skip the version bump, retry on the next request.
* Operator action items (added to readme.txt Upgrade Notice): (1) verify pmp_analytics_schema_version = 2.1 after the first wp-admin pageload post-upgrade, (2) verify SHOW INDEX FROM wp_pmp_analytics_daily lists pmp_daily_unique with animal_name as a key column, (3) optionally truncate-and-rebuild for historical accuracy.
+ Item 8: Version constants
* pet-match-pro.php Version header and Constants::VERSION bumped to 8.9.0.
* readme.txt Stable tag bumped to 8.9.0; new Upgrade Notice entry above the 8.8.0.1 entry summarizing this release.
Verification on the test site (cincinnatianimalcare clone, 559,585 events imported via phpMyAdmin):
* EXPLAIN on the new rollup INSERT...SELECT shows type: range and key: idx_created_at where the pre-change form showed type: index (covering full scan over 559K entries).
* "Rebuild Daily Summaries" returns non-zero total_rows on a TRUNCATEd daily table - the original "Rebuilt 2 days, 0 rows written" symptom is gone.
* rollupDay() for any single past day completes in well under 1 second on the 559K-row dataset (logged via the new threshold path - the first run after deploy should produce zero log lines if the fix is working).
* Schema 2.1 verified: SHOW INDEX FROM wp_pmp_analytics_daily lists pmp_daily_unique with animal_name as a key column. SELECT option_value FROM wp_options WHERE option_name = 'pmp_analytics_schema_version' returns '2.1'.
* Per-event-type spot-check after schema migration + truncate + rebuild: SUM(event_count) FROM daily WHERE date = '2026-05-01' equals COUNT(*) FROM events WHERE created_at >= '2026-05-01 00:00:00' AND created_at < '2026-05-02 00:00:00' exactly. Pre-Fix-1.1 spot-check on the same data showed a 15,208-event impression deficit (153,527 vs 168,735) - the UNIQUE-key collision.
Out of scope for 8.9.0 (covered by subsequent fixes in the same brief): the chunked AJAX backfill (Fix 6), read-path migration of dashboard queries to the daily table (Fix 2), composite events index (Fix 3), DROP COLUMN of the three write-only TEXT columns (Fix 4), soft-delete retention with deferred hard purge (Fix 5), lazy widget loading (Fix 7), date-range pre-flight count guard (Fix 8), transient cache on widget fragments (Fix 9). Fix 1 alone unblocks the rebuild button - subsequent fixes are required for sustainability as the customer base grows.
------------------------------------------------------------------------------
Version 8.8.0.1 - May 2, 2026 - Search Card Hover Text Discoverability Hotfix
Follow-up to 8.8.0 covering two oversights in the original release: the new card_hover_text shortcode parameter was wired into the level file, the AllApi helper, the admin field, and the search templates - but it was NOT registered in the per-partner Instructions tabs or the Shortcode Builder visual generator, which meant clients couldn't actually find the parameter from the surfaces they normally use. Plus a coaching-focused rewrite of both hover-text KB articles after a review surfaced that field-token discoverability was poor for both this release and the existing detail-button hover text feature. No code-path changes beyond catalog/instruction-array additions; no schema change; no template change.
+ Item 1: Shortcode Builder catalog - card_hover_text registered for search
* admin/partials/pmp-shortcode-builder-catalog.php: added Shortcodes::CARD_HOVER_TEXT entry under the Shortcodes::SEARCH section's Display group, immediately after Shortcodes::HIDE_EMPTY (matches the same paid/level pairing the two parameters share). Control type 'text' (free-text input), valueSource null, levelKey points at $levelShortcode . Shortcodes::CARD_HOVER_TEXT so the existing license-gating pipeline reads PREMIUM_LEVEL from pmp-option-levels-general.php without duplicating the value. appliesTo '*' so the parameter is available for every method type (adopt/lost/found/list/preferred/featured) - per-method default phrasing is handled inside AllApi::getDefaultCardHoverText() not the catalog. Help text references the {Name}, {Species}, and partner-field token semantics so the Builder's hover tooltip is informative.
+ Item 2: Per-partner Instructions tabs - card_hover_text documented inline
* admin/partials/pp/pmp-instructions-search-params.php
* admin/partials/af/pmp-instructions-search-params.php
* admin/partials/rg/pmp-instructions-search-params.php
* All three files gained a new $pmpSearchParms entry appended after the existing 'hide_empty' row. Same shape as the surrounding paid params (paid => true, PRO badge in the values column). Description text covers the three method-default behaviors ("Learn More About {Name}" / "Have You Seen {Name}?" / "Help Rehome This {Species}"), the {Name} -> "This Dog" fallback, and the per-method-admin override relationship. Identical text in all three partner files because the parameter behavior is identical - the partner-specific differences (raw field key naming) are documented in the KB article rather than duplicated three times in the Instructions tabs.
+ Item 3: KB article 11-search-card-hover-text.html - Field Interpolation rewrite
* docs/kb/02-shortcodes-configuration/11-search-card-hover-text.html: replaced the single Field Interpolation section with a five-subsection coaching layout designed to answer "how do people know what tokens to use" without requiring partner-API expertise.
- "Universal tokens (recommended)" - explains {Name} and {Species} with the empty-record fallback rationale (shelter records sometimes carry an intake number like "60100465" in the name field, which would otherwise render as "Have you seen 60100465?" - the fallback prevents that).
- "Raw partner field tokens" - explains case-sensitivity and the no-fallback contract.
- "Common tokens by partner" - a side-by-side reference table mapping the eight most common conceptual fields (name, species, breed, sex, age, color, location, fee) to the actual token across PetPoint, AnimalsFirst, and RescueGroups. This is the table that lets a writer who knows the concept ("I want breed") find the right token without reading three partner API docs.
- "How to confirm a token works on your site" - a practical view-source + Test: {YourTokenHere} sanity-check workflow so admins can self-verify any field key against the live page output.
- "Examples" - per-partner example blocks expanded to show mixed universal+raw combinations.
- "When to use which" - three-bullet decision rule: universal for nameless-stray safety, raw for specific data points, combined when framing should be safe but details should be specific. Worst-case-still-readable example: "Adopt {Name} the {primarybreed} {Species}" -> "Adopt This Dog the Labrador Retriever Dog" - awkward but never broken.
+ Item 4: KB article 10-button-hover-text.html - parallel coaching rewrite
* docs/kb/02-shortcodes-configuration/10-button-hover-text.html: same structural rewrite as article 11, with one important honesty caveat: detail-page button hover text uses AllApi::resolveButtonHoverText() which does NOT have the {Name}/{Species} decorator that AllApi::resolveCardHoverText() got in 8.8.0. The article now flags that distinction explicitly with a callout pointing readers to the search-card article if they want universal-token behavior. Same "Common tokens by partner" reference table, same view-source + Test: {token} verification workflow. New "When the field is empty" subsection warns about trailing-blank rendering ("Apply to adopt " with a literal trailing space when {AnimalName} is empty) and recommends defensive phrasing like "Apply to adopt this {Species}" for shelters that regularly take in unnamed strays.
+ Item 5: Version constants
* pet-match-pro.php Version header and Constants::VERSION bumped to 8.8.0.1.
* readme.txt Stable tag bumped to 8.8.0.1; new Upgrade Notice entry above the 8.8.0 entry summarizing this hotfix.
------------------------------------------------------------------------------
Version 8.8.0 - May 2, 2026 - Search Card Hover Text Standardization
Feature release that standardizes the tooltip/title text on search-result cards across every template, every partner, and every method type, with a matching aria-label for screen readers. New customization layer mirrors the existing detail-button hover text pattern (shortcode > admin > default). One new admin sub-accordion under General > Display Options. No DB schema change, no analytics-pipeline change, no breaking behavior on top of 8.7.2.1.
+ Item 1: Inconsistent hover text across search templates - standardized
* Pre-8.8.0 audit (Cat-09 KB review, 2026-04-30) found three different hover-text phrasings in production ("Click to View Details for X", "Learn More About X", "X - Species") plus three templates with no hover text at all (PP lost-search-default, PP found-search-default, AF universal-search-default). Most photo links also lacked aria-label, so screen readers did not announce the tooltip the way the title attribute promised.
* Resolution: new AllApi::resolveCardHoverText($methodType, $animalDetails) method in includes/class-pet-match-pro-all-api.php parallels the existing resolveButtonHoverText() and reuses interpolateFieldTokens() verbatim - same shortcode > admin > default priority chain, same "unrecognized tokens silently removed" semantics. Every search template now calls this helper once per card and applies the result identically to the name link (title + aria-label), the photo link (title + aria-label), and the bare img (title).
* Method-specific defaults (translatable):
adopt -> "Learn More About {Name}"
lost -> "Have You Seen {Name}?"
found -> "Help Rehome This {Species}"
list -> "Learn More About {Name}" (PetPoint only)
preferred -> "Learn More About {Name}" (AnimalsFirst only)
featured -> "Learn More About {Name}"
* {Name} resolves through getTooltipName() so empty animal-name records degrade to "This Dog" / "This Cat" / "This Animal" instead of leaving a blank token. {Species} normalizes the partner-specific species field with a fallback to "Animal". Both tokens are pre-decorated onto the animalDetails array inside resolveCardHoverText() before interpolateFieldTokens() runs, so partner data with raw field keys (e.g. PP "AnimalName", AF "name", RG "animalName") is unaffected and raw tokens still work alongside {Name}/{Species}.
+ Item 2: New constants and license gating
* pet-match-pro.php Settings::CARD_HOVER_TEXT = 'card_hover_text' for admin per-method keys (card_hover_text_adopt, _lost, _found, _list, _preferred, _featured) and Shortcodes::CARD_HOVER_TEXT for the new shortcode parameter.
* admin/partials/pmp-option-levels-general.php now includes LevelPrefix::LEVEL . 'shortcode_' . Shortcodes::CARD_HOVER_TEXT => Constants::PREMIUM_LEVEL, matching the existing button hover text shortcode-parameter level. Free tier (basic) silently strips the parameter via stripPaidShortcodeParams(). Junior+ in client-facing tier names sees the parameter honored.
+ Item 3: Admin UI - new sub-accordion under General > Display Options
* New "Search Card Hover Text" sub-accordion sits between "Icon Sizes" and "Search Icons & Overlays" in the Display Options sub-accordion stack. Conditionally renders one text input per enabled method type; Lost / Found / List / Preferred rows only appear when that method is enabled for the current partner. Adopt and Featured always render.
* registerCardHoverTextFields() in admin/class-pet-match-pro-admin-settings.php builds the field set. Each row's description echoes the English default so admins know exactly what they are overriding before typing anything.
* Two new KB_LINKS entries (general_card_hover, field_card_hover_text) both point at the new article slug "search-card-hover-text" with the "#admin-settings" anchor - the section header and per-row inline help icons render side-by-side, matching the existing field_hover_text / field_hide_empty pattern.
+ Item 4: Template touch-up across all three partners
* PetPoint (public/templates/pp/):
adopt-search-default.php: name link adds aria-label and uses $cardHoverText for title; buildPhotoSection() accepts cardHoverText and applies title+aria-label to and title to .
lost-search-default.php: previously had NO hover text - now resolves $cardHoverText once and applies to photo link and .
found-search-default.php: same as lost - new hover text added.
featured-search-default.php / -carousel.php / -compact.php: $cardHoverText resolved at top of buildResultItem/buildCarouselCard, applied to photo link, image, name link / name overlay span.
universal-search-default.php: PER-CARD method resolution - $methodType derived from animalType field starts with "lost" or "found"; $cardMethodType falls back to getMethodValue() when neither matches. Photo link swaps "Click to View Details for X." for $cardHoverText. buildPhotoSection now takes a $cardHoverText param and adds title to img.
universal-search-structured.php: name link inside buildDetailsSection and photo section both use $cardHoverText.
* AnimalsFirst (public/templates/af/):
universal-search-default.php: previously had NO hover text - now resolves per-card $cardMethodType (page-level methodValue when the type field doesn't disambiguate) and threads $cardHoverText through buildPhotoSection (adds title+aria-label to and title to ), the inline name link, and buildDetailsSection's name-field branch.
universal-search-structured.php: $cardMethodType inferred from AnimalsFirstFields::TYPE; $cardHoverText falls back to the existing "Learn More About {tooltipName}" string when no override is in play, so admins who set neither admin nor shortcode see no regression. Name link gets aria-label.
universal-search-filter-widget.php: $cardMethodType inferred from $type at top of buildAnimalCard; buildPhotoSection and buildNameSection both accept $cardHoverText. Replaces the previous "{Name} - {Species}" hard-coded title.
universal-search-no-filter.php: same pattern as filter-widget.
featured-search-default.php / -carousel.php / -compact.php: hardcoded to METHOD_TYPE_FEATURED; $cardHoverText threaded into photo buildPhotoSection (link, img) and the name link/name overlay span.
* RescueGroups (public/templates/rg/):
adopt-search-default.php: $cardHoverText resolved once; admin-source name link, photo section, and details-section name-field branch all now apply title+aria-label.
adopt-search-carousel.php: photo link and below-photo name link both use $cardHoverText.
adopt-search-compact.php: photo link, image, and name overlay span all use $cardHoverText.
adopt-search-structured.php: photo section and the in-loop name link inside buildDetailsSection both use $cardHoverText. The existing $clickText analytics fallback is preserved as the $effectiveHover fallback when $cardHoverText is empty (defensive for direct buildPhotoSection calls outside the new flow).
* Universal/combination templates (PP universal-search-default and AF universal-search-* variants) resolve method type PER CARD from the animal's type field, so on the same page a lost dog hovers as "Have You Seen Rex?" while a found cat next to it hovers as "Help Rehome This Cat" without any extra configuration.
+ Item 5: Accessibility
* Every / with a `title` attribute is now paired with a matching `aria-label` carrying the same string. Screen readers announce aria-label; `title` alone is mostly ignored. The existing pp/adopt-search-default.php line 509 photo-link already followed this pattern - it is now uniform across all 16 search templates.
* The bare inside the photo link picks up `title` matching the surrounding link's hover text. Alt text continues to describe the image content ("Photo of Buddy"); title text describes the action ("Learn More About Buddy"). Both are intentional and complementary.
+ Item 6: Documentation
* New KB article: docs/kb/02-shortcodes-configuration/11-search-card-hover-text.html. Mirrors the prose style and section structure of the companion article 10-button-hover-text.html so the feature pair is easy for admins to learn together. Covers default phrasing, admin location, shortcode parameter, field interpolation (with PP/AF/RG examples), priority chain, universal/combination behavior, and accessibility.
* Cross-references added:
02-shortcodes-configuration/01-pmp-search-reference.html - new card_hover_text row in the Junior parameter table.
02-shortcodes-configuration/04-parameters-by-license-tier.html - new card_hover_text row plus a new "Search Card Hover Text" bullet in the Junior Admin Settings list.
03-templates-design/01-search-templates-gallery.html - new "Card Hover Text" section before Next Steps and a new bullet linking to the article.
+ Item 7: Translations - DEFERRED
* Five new translatable strings ("Learn More About {Name}", "Have You Seen {Name}?", "Help Rehome This {Species}", "Search Card Hover Text" admin label, the per-row description sprintf) plus the individual method-row labels are pending POT regeneration. This is consistent with the existing "regenerate translations after Shortcode Builder Phase 4" backlog item - the next translation release will catch up the Shortcode Builder strings, the 8.7.0 analytics strings, and these.
+ Item 8: Out of scope (intentionally not changed in 8.8.0)
* Detail-page button hover text (already covered by AllApi::resolveButtonHoverText()).
* Filter widgets, pagination links, carousel navigation buttons.
* Icon overlay tooltips (already pair aria-label + title via the icon rendering pipeline).
------------------------------------------------------------------------------
Version 8.7.2.1 - May 1, 2026 - Theme Template Override Hotfix + Cat-09 Troubleshooting Articles
Hotfix release covering one code defect uncovered during cat-09 KB review plus the new troubleshooting articles and admin help-icon wire-ups that landed alongside it. No DB schema change, no analytics-pipeline change, no breaking behavior on top of 8.7.2.
+ Item 0: processTemplate() theme-suffix .php ordering bug
* includes/class-pet-match-pro-all-api.php processTemplate(): when a search shortcode supplied the template parameter as e.g. template="adopt-search-default-custom (theme)" without a .php extension, the existing auto-append logic placed .php at the END of the string ("adopt-search-default-custom (theme).php"). Downstream stripThemeSuffix() in resolveSearchTemplate() checks str_ends_with(..., ' (theme)') and therefore did not detect the suffix in the appended form. The cleaned filename retained the " (theme)" substring and file_exists() failed, surfacing as a "template not found" error even though the file existed in the theme override directory.
* Fix: both branches of processTemplate() (shortcode override path and admin-settings dropdown path) now strip the optional " (theme)" suffix BEFORE the basename() + ".php" append, then re-attach it once the filename is normalized. Result: all four shortcode forms now resolve identically:
template="myfile"
template="myfile.php"
template="myfile (theme)"
template="myfile.php (theme)"
* Reuses the existing AllApi::stripThemeSuffix() and Constants::THEME_TEMPLATE_SUFFIX - no new methods or constants.
+ Item 1: Admin help-icon wire-ups for new troubleshooting articles
* admin/class-pet-match-pro-admin-settings.php KB_LINKS array gained four new entries pointing at the new article slugs:
troubleshoot_shortcode_builder => 'shortcode-builder-issues'
troubleshoot_cron => 'analytics-queue-and-cron'
troubleshoot_premium => 'premium-feature-not-appearing'
troubleshoot_theme_template => 'theme-template-override'
* Five renderHelpIcon() wire-up changes:
- Shortcode Builder tab nav now points to troubleshoot_shortcode_builder
- Tools tab Cron Self-Test accordion now points to troubleshoot_cron
- Tools tab License Summary accordion now points to troubleshoot_premium
- Search Template / Detail Template field labels now render a SECOND help icon next to the existing gallery icon, pointing to troubleshoot_theme_template
* No KB_LINKS entries removed.
------------------------------------------------------------------------------------------------------------------------------------------------
Version 8.7.2 - April 30, 2026 - Cron Health Layer 3 (Activation-Time Self-Test + Admin Notice)
Defense-in-depth release on top of 8.7.0's analytics queue/cron architecture. 8.7.0 shipped Layer 1 (synchronous-flush fallback when WP-Cron is dead), Layer 2 (Analytics tab Queue Health badge), and Layer 2b (Tools tab "Flush Now" + "Run Cron Self-Test" buttons). What was missing: proactive detection. An operator on a broken-cron host who never visited the Analytics or Tools tabs would run for weeks in synchronous-flush fallback mode without knowing - they would just notice "the site feels slow" with no obvious cause. 8.7.2 closes that gap by auto-running the cron self-test at activation and surfacing a dismissible admin notice when it fails.
+ Item 0: CronHealth class extended with auto-trigger + notice path
* includes/analytics/class-pet-match-pro-cron-health.php: existing 8.7.0 manual-test surface (startTest, handleTestFire, resolveStatus, clearSchedule) preserved unchanged. Three new constants added: OPTION_AUTO_STATUS = 'pmp_cron_health_auto_status' (cached terminal status for the auto path so subsequent admin pageloads don't re-evaluate the marker), OPTION_AUTO_LAST_RESULT_AT = 'pmp_cron_health_auto_last_result_at' (timestamp the auto-test reached terminal), USER_META_NOTICE_DISMISSED_AT = 'pmp_cron_health_notice_dismissed_at' (per-user dismissal timestamp). New STATUS_UNKNOWN constant for "auto-trigger has not run yet on this site". NOTICE_DISMISSAL_SUPPRESSION_DAYS = 7 controls how long a dismissed notice stays hidden before re-appearing.
* resolveAutoStatus() method: idempotent resolver for the auto path. Short-circuits on cached terminal status. When pending, defers to resolveStatus() so both paths read the same SCHEDULED_AT/FIRED_AT markers - no parallel option family. Promotes pending -> healthy/failing into the auto cache when the live resolver reaches terminal. Returns STATUS_UNKNOWN before any auto-test has been started, otherwise mirrors resolveStatus() output.
* renderNotice() method: hooked to admin_notices. Gated on manage_options. Suppresses for 7 days after the current user dismisses (per-user, via user-meta). Renders WordPress is-dismissible warning notice with the "PetMatchPro: WP-Cron does not appear to be firing on this site..." copy and a "Learn more" link to the existing cron-requirement KB article (Constants::DOCS_URL . 'cron-requirement' - reuses the slug already in KB_LINKS, no new article).
* Inline dismiss script uses navigator.sendBeacon (with fetch keepalive fallback) to POST the dismiss action when the user clicks the X. Single-purpose script scoped to the notice via data-pmp-notice="cron-health" attribute selector.
* handleDismiss() method: AJAX endpoint that records the dismissal timestamp. Nonce-protected ('pmp_dismiss_cron_health'), manage_options-gated.
* clearSchedule() now also deletes OPTION_AUTO_STATUS and OPTION_AUTO_LAST_RESULT_AT on plugin deactivation, matching the existing manual-test cleanup.
+ Item 1: Activation-time auto-trigger
* pet-match-pro.php register_activation_hook block: after Activator::activate() runs successfully (PHP version check passed), require_once the cron-health class file and call CronHealth::startTest(). The existing wp_next_scheduled guard inside startTest() makes reactivation idempotent - back-to-back activations don't queue duplicate test events.
* The 60-second schedule offset and 120-second deadline from 8.7.0 are reused unchanged. After activation, a healthy host's notice never appears (test fires, marker lands, status caches as healthy). A broken-cron host's notice appears on whatever admin pageload happens >=120 seconds after activation, when resolveAutoStatus() promotes pending to failing.
+ Item 2: First-tracking-enable auto-trigger
* Two save handlers updated to detect off->on transitions on Settings::ANALYTICS_TRACKING_ENABLED:
- includes/analytics/class-pet-match-pro-analytics-ajax.php saveAnalyticsSettings(): captures $wasTrackingEnabled before the merge, reads $isTrackingEnabledNow after. When the transition is off->on AND CronHealth::resolveAutoStatus() returns STATUS_UNKNOWN, calls CronHealth::startTest().
- admin/class-pet-match-pro-admin-settings.php ajax_save_analytics_settings(): same pattern. Loads the cron-health class file lazily via require_once because this handler runs in the main admin namespace and the class is in PetMatchPro\Analytics.
* The STATUS_UNKNOWN guard prevents re-running the test on every subsequent toggle - once a terminal result has been recorded, only the manual Tools tab button can re-run.
* Customers who upgraded in-place (FTP overwrite, auto-update) and never deactivate/reactivate now get the test triggered the first time they enable analytics tracking on the upgraded version, instead of having to discover the Tools tab button on their own.
+ Item 3: Notice + dismiss wiring registered on plugins_loaded
* pet-match-pro.php plugins_loaded block (the same block that registers AnalyticsQueue::CRON_HOOK and CronHealth::TEST_HOOK from 8.7.0): two new add_action calls.
- admin_notices -> [CronHealth::class, 'renderNotice']
- wp_ajax_pmp_dismiss_cron_health_notice -> [CronHealth::class, 'handleDismiss']
* No changes to the 8.7.0 cron-handler registration or to the AnalyticsQueue/AnalyticsDailyRollup wiring; they sit alongside the new entries.
* Schema-self-heal block left untouched. The brief proposed re-running CronHealth::startTest() on every schema-version bump (so cron-config regressions between PMP releases would surface), but a patch-release cadence would re-spam the failing notice on every upgrade. Skipped per scoping discussion - manual Tools tab button covers the "host changed something" case explicitly.
+ Item 4: KB article reuse (no new article)
* The brief originally proposed a new docs/kb/.../cron-troubleshooting.html article. Repository already has docs/kb/08-analytics-tracking/05-wp-cron-requirement.html (slug: cron-requirement) which covers WP-Cron rationale, verification, and remediation - exactly the audience the new admin notice points at. The notice's "Learn more" link points at that existing slug rather than fork into a parallel article.
* KB_LINKS already contains 'cron_requirement' => 'cron-requirement'; no admin-settings changes for the notice path. Help icons on the existing Tools tab Cron Self-Test accordion and on the Tracking Settings "Enable Click Tracking" label continue to point at the same article.
+ Item 5: Versioning + readme
* pet-match-pro.php Plugin Header Version: 8.7.1.1 -> 8.7.2.
* pet-match-pro.php Constants::VERSION: 8.7.1.1 -> 8.7.2.
* readme.txt Stable tag: 8.7.1.1 -> 8.7.2.
* readme.txt == Upgrade Notice == new 8.7.2 section explaining what the activation-time test does, when the notice appears, the per-user 7-day dismissal grace, and that the manual Tools tab button is unchanged.
Verification matrix:
- Healthy host (default WP-Cron + traffic): activate the plugin. Wait 90 seconds. Refresh wp-admin. No admin notice appears. get_option('pmp_cron_health_auto_status') returns 'healthy'. resolveStatus() returns the same.
- Broken-cron host: define DISABLE_WP_CRON true in wp-config.php and ensure no real cron hits wp-cron.php. Activate the plugin. Wait 4 minutes. Refresh wp-admin. Yellow notice appears with the host-config guidance. get_option('pmp_cron_health_auto_status') returns 'failing'.
- Notice dismissal: click the X on the notice. Refresh wp-admin - notice does not return. get_user_meta(user_id, 'pmp_cron_health_notice_dismissed_at', true) is now a unix timestamp. After 7 days, notice returns (status still 'failing' until next manual test runs and the host is fixed).
- First tracking-enable: on a fresh upgrade with auto-status STATUS_UNKNOWN, toggle Settings::ANALYTICS_TRACKING_ENABLED off->on via the Analytics Admin save endpoint OR via the Admin Settings save endpoint. Test fires; resolveAutoStatus() advances through pending to terminal on the next admin pageload >=120s later.
- Manual re-test still works: Tools tab "Run Cron Self-Test" button. Spinner shows for ~2 minutes. Result reflects current state. If host was fixed since the auto-test ran, the manual button updates pmp_cron_self_test_status to 'healthy' but pmp_cron_health_auto_status stays cached at 'failing' (manual button is for the operator, not the notice path) - the notice continues to show until the operator dismisses it. Acceptable trade-off; the operator who just ran the manual button has clear context for the still-visible notice.
- Reactivation idempotency: deactivate, reactivate. clearSchedule() runs on deactivate and wipes both manual and auto status options; activation kicks a fresh test.
Files touched:
- includes/analytics/class-pet-match-pro-cron-health.php: extended with auto-trigger + notice + dismiss methods. Existing 8.7.0 manual-button surface unchanged.
- pet-match-pro.php: activation hook calls CronHealth::startTest(); plugins_loaded block adds admin_notices and wp_ajax_pmp_dismiss_cron_health_notice action wiring; Constants::VERSION + Plugin Header Version bumped.
- includes/analytics/class-pet-match-pro-analytics-ajax.php: saveAnalyticsSettings() captures prior tracking-enabled state and triggers CronHealth::startTest() on off->on transition when auto-status is unknown.
- admin/class-pet-match-pro-admin-settings.php: ajax_save_analytics_settings() same off->on trigger; lazy-loads the cron-health class file because this file is in the main admin namespace.
- readme.txt: Stable tag bump + new == Upgrade Notice == 8.7.2 section.
- CHANGE-LOG.txt: this entry.
Out of scope (explicit defer):
- Email alerts on cron failure. Some operators want notification by email. Defer until requested - not all customers want plugin email noise.
- Auto-remediation. PMP cannot start a system cron job for the customer. Best we can do is detect, warn, document.
- Continuous monitoring (re-test every 24 hours). Layer 1's stale-flush detection in 8.7.0 already catches mid-life cron failures via a different mechanism, so periodic re-testing would be defense-in-depth on top of defense-in-depth. Future enhancement if support tickets indicate one-time tests miss late-onset failures.
- KB_LINKS reset on schema-version bump. Re-running the test on every schema bump (and re-spamming the notice on every patch release) was rejected. Manual Tools tab button covers the "config changed mid-life" case.
------------------------------------------------------------
Version 8.7.1.1 - April 29, 2026 - KB Category 11 Retired and Folded into Category 8
Documentation hygiene release. The five new analytics-pipeline articles authored alongside 8.7.0 were originally placed in a brand-new KB category (11-analytics), but the project already had an Analytics Tracking category (08-analytics-tracking) - so the parallel category was redundant. This release folds the new content into category 8 and retires category 11 entirely.
+ Item 0: Two existing category-8 articles rewritten and expanded
* 01-understanding-pmp-analytics.html: original sections (Enabling Analytics, What Events Are Tracked, Data Captured Per Event, How Tracking Works, SEO Features) retained and reordered. Folded in the cat-11 Analytics Overview content as new sections: "What Counts as an Impression (8.7.0+)" describing the 50%-visible / 500ms-dwell model and the per-session deduplication semantics; "Metric Definitions" table covering Impressions, Detail Views, Actions, Search CTR, Engagement Rate, Source Breakdown; "How Tracking Works" rewritten to merge the legacy lightweight-JS description with the queue-and-flush pipeline (capture -> beacon -> queue -> 1-min cron drain -> 5-min rollup -> nightly finalize -> dashboard read); "Why a separate summary table?" subsection explaining the architectural payoff in operator-friendly language; "Dashboard Filters" describing date range and method type controls; "Interpreting Trends" listing four diagnostic patterns operators should watch for. Next Steps section now points at the consolidated set of cat-8 articles.
* 04-leveraging-analytics.html: original sections (Understanding Search Behavior, Detail Page Engagement, Action Engagement, Practical Optimization Ideas) retained. Folded in the cat-11 Interpreting Position Impact content as a new top-level section between "Understanding Search Behavior" and "Detail Page Engagement". Subsections: "How Position Is Recorded" (1-based, post-filter, per-page); "What Changed in 8.7.0" (dwell gating, dramatic impression-count drop, why position data is more meaningful now); "Reading the Data" (position-by-position counts, click-through rate by position); "Comparing Pre-8.7.0 and Post-8.7.0 Data" (rules of thumb for cross-upgrade reporting); "Actionable Uses for Position Data" (sort tuning, featured placement, pagination cadence, A/B testing); "Position Caveats" (filter-driven re-ranking, carousel DOM order, deep-link entries).
+ Item 1: Three new category-8 articles taking the next-available slots
* 05-wp-cron-requirement.html (slug: cron-requirement): unchanged content from cat-11 02. WP-Cron rationale, how to verify firing, common failure modes, fix steps for managed-host UI and shell-access setups, fallback behavior, end-to-end verification flow.
* 06-queue-health-and-tools.html (slug: queue-health-and-tools): unchanged content from cat-11 03. Queue Health card row-by-row, refresh button, warning-state badges, Flush Queue Now usage, Cron Self-Test usage, Rebuild Daily Summaries usage, what these tools do not do.
* 07-privacy-and-data-retention.html (slug: privacy-and-data-retention): unchanged content from cat-11 05. What is stored / not stored, retention windows + Clear Analytics Data behavior, GDPR/CCPA posture, data export.
* Cross-references inside the three new articles updated to point at the merged-in anchors (understanding-pmp-analytics/#metric-definitions and leveraging-analytics/#interpreting-position-impact) instead of the retired stand-alone slugs.
+ Item 2: docs/kb/11-analytics/ directory removed
* All five files in the 11-analytics directory deleted.
* Empty directory removed.
+ Item 3: KB_LINKS map updated
* admin/class-pet-match-pro-admin-settings.php: comment header for the 8.7.0 analytics group renamed from "11-Analytics KB articles" to "8.7.0 Analytics pipeline KB articles" to drop the category-number reference.
* 'analytics_overview' value changed from 'analytics-overview' to 'understanding-pmp-analytics/#metric-definitions' so it deep-links into the merged-in Metric Definitions section.
* 'position_impact' value changed from 'interpreting-position-impact' to 'leveraging-analytics/#interpreting-position-impact' so it deep-links into the merged-in section.
* The other three entries ('cron_requirement', 'queue_health_tools', 'analytics_privacy') keep their slugs unchanged - the new cat-8 05/06/07 articles preserve those slugs at petmatchpro.com.
+ Item 4: Customer-facing copy scrubbed of docs/kb/ path references
* readme.txt 8.7.0 Upgrade Notice: "five new KB articles under docs/kb/11-analytics/ covering ..." -> "five new KB articles covering ...". Reader-facing description unchanged in substance; the internal repo path is not exposed.
* CHANGE-LOG.txt 8.7.0 Item 0: position-impact methodology citation now references "the Interpreting Position Impact section of the Leveraging Analytics KB article" instead of the file path.
* CHANGE-LOG.txt 8.7.0 Items 28, 39, 40: KB-article file paths replaced with article names (Analytics Overview, Queue Health and Tools).
* CHANGE-LOG.txt 8.6.0 Item 7: Shortcode Builder KB article file path replaced with article name + category.
* CHANGE-LOG.txt 8.4.0 Item 12: CLAUDE.md note "Any new KB article added under docs/kb/" -> "Any new KB article added".
* CHANGE-LOG.txt 8.2.0 Item 2 + 8.1.5 Item 5 + 8.1.5 Item 2 + 8.1.5 Item 3 (button_consistency, [pmp-option] reference, exclude_buttons docs): file-path bullet lists replaced with KB article-name lists.
* Path internal to the repo are kept out of customer-facing copy; the project's owner-facing convention to use docs/kb/ for source-of-truth article files during creation/deployment remains in place.
+ Files modified
* pet-match-pro.php (Version 8.7.1 -> 8.7.1.1, Constants::VERSION)
* readme.txt (Stable tag 8.7.1 -> 8.7.1.1, new == Upgrade Notice == 8.7.1.1 entry, 8.7.0 entry path-scrubbed)
* CHANGE-LOG.txt (this entry; multiple historical entries path-scrubbed)
* admin/class-pet-match-pro-admin-settings.php (KB_LINKS comment + two value changes)
* docs/kb/08-analytics-tracking/01-understanding-pmp-analytics.html (rewritten + expanded)
* docs/kb/08-analytics-tracking/04-leveraging-analytics.html (rewritten + expanded)
+ Files added
* docs/kb/08-analytics-tracking/05-wp-cron-requirement.html
* docs/kb/08-analytics-tracking/06-queue-health-and-tools.html
* docs/kb/08-analytics-tracking/07-privacy-and-data-retention.html
+ Files deleted
* docs/kb/11-analytics/01-analytics-overview.html
* docs/kb/11-analytics/02-cron-requirement.html
* docs/kb/11-analytics/03-queue-health-and-tools.html
* docs/kb/11-analytics/04-interpreting-position-impact.html
* docs/kb/11-analytics/05-privacy-and-data-retention.html
* docs/kb/11-analytics/ (empty directory)
+ Translation impact
* No new user-facing strings. The KB_LINKS value changes are pointer-only (slug strings); admin labels referencing them are unchanged.
+ No live-site impact
* Article slugs at petmatchpro.com/docs/ are unchanged for the three preserved slugs (cron-requirement, queue-health-and-tools, privacy-and-data-retention). The two retired stand-alone slugs (analytics-overview, interpreting-position-impact) need to be redirected on the live site - either pointed at the consolidated articles' anchors via 301 redirect, or unpublished if they were never published.
================================================================================
Version 8.7.1 - April 29, 2026 - PP Species Shortcode Resilience + Detail Template Layout Standardization
Two bug fixes discovered while testing 8.7.0.
+ Item 0: PP species filter on [pmp-search] silently fell back to "all species" when builder save-pipelines stripped whitespace between attributes
* Reported behavior: [pmp-search type="adopt" species="dog"] on a Divi 5 page returned cats. Adding the same shortcode to a Classic-Editor page returned dogs correctly. Network tab gave no clue (the PetPoint API call is server-side curl from PHP - it never appears in the browser network tab; only the WordPress page request does, and that page request does not carry speciesid=).
* Root cause traced via temporary echo debug at three points: items=, species-name-branch, and outgoing PP API URL. Output showed items=["type=\"adopt\"species=\"dog\""] - a single positional string at index 0, not the parsed associative array WordPress normally hands shortcode handlers. The Divi 5 builder's save pipeline stripped the space before species=, leaving type="adopt"species="dog" in post content. WordPress' shortcode_parse_atts() regex requires whitespace (or end-of-string) after each closing quote; with the space gone, the entire attribute blob falls through unparsed and lands at numeric index 0. processSpecies() never saw species= at all and returned the '0' fallback. PP API was called with speciesid=0 (= All), so the page returned cats and dogs and everything else.
* Fix: new private helper PetMatchPro\PublicFacing::normalizeShortcodeAtts(array $atts): array. Detects positional (numeric-keyed) entries, runs preg_replace('/(["\'])([A-Za-z_][A-Za-z0-9_-]*)=/', '$1 $2=', ...) to re-insert spaces before every key= boundary inside each positional value, concatenates with explicit key="value" form for any already-associative entries, then re-parses through shortcode_parse_atts(). Idempotent on properly-formed input - returns the input unchanged when no positional keys are present.
* Wiring: handleSearchShortcode() pre-normalizes atts before createSearch(). handleDetailsShortcode() pre-normalizes before createDetails(). handleDetailShortcode()'s array path pre-normalizes before shortcode_atts() merges in defaults. handleOptionShortcode() pre-normalizes before its shortcode_atts() merge. handleDetailShortcode()'s simple-string path is untouched (it operates on a single field name, not a key=value attribute set, so the bug class does not apply).
* Author guidance unchanged - keep the space between attributes - but the plugin now self-heals when builders strip it. Tested by deliberately removing the space in a saved Divi 5 [pmp-search] and confirming the PP API call goes out with speciesid=1.
+ Item 1: Detail templates - social/instructions/print poster trio pushed far below main image when thumbnail strip grew tall
* Reported behavior: 8.7.0 raised the photo/video gallery cap from 6 to 25 (per 8.7.0's gallery-expansion work). On any detail template that placed renderSocialShare()/renderInstructions()/renderPrintPosterButton() *after* the image+thumbnail flex row, those blocks rendered at the bottom of the row's full height. With 25 thumbnails stacked vertically (~98px each), the row grew to ~2450px tall and the social/instructions trio appeared two screens below the main image.
* Root cause: the legacy markup placed the trio as a sibling of the image-row, so flex-row align-items: stretch made the trio sit at the bottom of whichever column was tallest (the thumbnails column).
* Fix: consolidated 18 detail templates onto the PP standard structure. New wrapper class .pmp-details-image-column wraps the main image + the instructions/social/print trio together, as a flex column with gap: 15px. The thumbnails (and videos when not in video-player mode) live as a sibling of that wrapper inside .pmp-details-image-row. The trio now sits directly under the main image regardless of how tall the thumbnail strip grows.
* CSS additions in public/css/pet-match-pro-styles.css near the existing .pmp-details-image-main rule: .pmp-details-image-column { flex: 1 1 calc(100% - 110px); min-width: 200px; display: flex; flex-direction: column; gap: 15px; }, plus a child override .pmp-details-image-column > .pmp-details-image-main { flex: none; min-width: unset; } so the nested image-main does not double-apply the row's flex sizing.
* Trio order standardized to PP's existing convention: renderInstructions() -> renderSocialShare() -> renderPrintPosterButton(). Previously AF used social -> print -> instructions and AF posters used social -> instructions -> print; both now match PP.
* Templates converted (18): PP adopt-default, adopt-conversion, adopt-conversion-no-app, adopt-conversion-similar, lost-default, found-default, lost-poster, found-poster; AF adopt-default, adopt-conversion, adopt-conversion-no-app, adopt-conversion-similar, lost-default, found-default, lost-poster, found-poster; RG adopt-default, adopt-similar.
* Templates intentionally not converted: *-details-navigation* (PP/AF/RG) already wrap the trio inside a column-structured pmp-details-media-column - structurally correct. AF adopt-profile-3-column / adopt-profile-3-column-similar use a vertical 3-column layout with the trio already inside the image column. PP/AF adopt-wide place the trio in the right pmp-wide-content section by design. RG adopt-cpa places the trio in the right pmp-details-content column by design (.pmp-template-cpa CSS was authored around that placement). PP/AF/RG *-celebration-similar templates have no media block. Pre-existing .pmp-details-media-row / .pmp-details-media-main / .pmp-details-media-column CSS rules retained because the navigation and 3-column-profile templates still consume them.
+ Files modified
* pet-match-pro.php (Version constant 8.7.0 -> 8.7.1, docblock Version: 8.7.0 -> 8.7.1)
* readme.txt (Stable tag 8.7.0 -> 8.7.1, new == Upgrade Notice == 8.7.1 entry)
* CHANGE-LOG.txt (this entry)
* public/class-pet-match-pro-public.php (new normalizeShortcodeAtts() helper; wiring in handleSearchShortcode, handleDetailsShortcode, handleDetailShortcode array path, handleOptionShortcode)
* public/css/pet-match-pro-styles.css (.pmp-details-image-column rule + nested image-main override)
* public/templates/pp/{adopt-default,adopt-conversion,adopt-conversion-no-app,adopt-conversion-similar,lost-default,found-default,lost-poster,found-poster}.php
* public/templates/af/{adopt-default,adopt-conversion,adopt-conversion-no-app,adopt-conversion-similar,lost-default,found-default,lost-poster,found-poster}.php
* public/templates/rg/{adopt-default,adopt-similar}.php
+ Translation impact
* No new user-facing strings. The normalizeShortcodeAtts() docblock is internal. CSS changes are presentational. Template changes are markup-only - all rendered text comes from helpers (renderInstructions, renderSocialShare, renderPrintPosterButton) whose translation strings are unchanged.
================================================================================
Version 8.7.0 - April 29, 2026 - Analytics Queue + IntersectionObserver Architecture
Architectural release. Replaces the page-render impression sweep with an IntersectionObserver that only counts dwelled cards, decouples server-side analytics writes from the visitor request via a queue + 1-minute WP-Cron flush, and adds operator instruments (Queue Health card, Flush Queue Now button, Cron Self-Test button) plus five new KB articles for the resulting pipeline. The brief that drove this release is preserved at analytics-queue-and-cron-brief.md in the repo root.
+ Item 0: IntersectionObserver-driven impression collection (Fix 1 in the brief)
* Reported behavior: 8.6.11.2 left the impression collector at "every animal card rendered to the page is an impression". On cincinnatianimalcare.org's "available dogs" page, this meant 362 impression rows POSTed in one batch on every page load - even though the visitor typically saw 8-20 cards before bouncing, filtering, or clicking. The data was structurally wrong: per-card position-impact analytics were diluted by 17x noise, and totals were inflated.
* Added a new PMPImpressionTracker module to public/js/pet-match-pro-public.js, placed inside the existing IIFE between PMPAnalytics and PMPFilterSort. It owns an IntersectionObserver with a 0.5 threshold (50% of card visible) and a 500ms dwell timer per card. When a card has been visible for the dwell duration, the tracker promotes it to a local batch, unobserves it (so re-scrolling does not double-count), and schedules a 2s flush-cadence timer.
* Each animal_id reports at most once per page session via a Set keyed on animal_id. Dedup happens client-side; the server queue trusts the client batch.
* The flush() method dispatches via navigator.sendBeacon only - no $.ajax fallback in the impression path. Pre-2017 browsers without sendBeacon drop the batch silently; the dwell-gating change matters far more than retaining a fallback for ~0.x% of visitors.
* The tracker subscribes to pmp:pagination:change, pmp:pagination:ready, and pmp:filtersort:updated so newly-rendered cards (pagination, filter changes) are auto-observed without needing a manual call.
* pagehide and visibilitychange listeners flush whatever is in the local batch when the visitor leaves the page, so closing the tab does not lose the last few impressions.
* Position is derived the same way as the legacy code - 1-based index within the visible (post-filter) result set - so position-impact analytics remain comparable in shape across the upgrade. Counts are not comparable because the dwell gate filters out the long tail; this is documented in the Interpreting Position Impact section of the Leveraging Analytics KB article.
* Removed the legacy pmp:pagination:ready / pmp:pagination:change sweep handlers and the no-pagination pmpDeferToIdle fallback from PMPAnalytics.init(). Those are now redundant - the observer self-manages.
* PMPAnalytics.trackImpressions() retained as a legacy entry point, body replaced with a thin call to PMPImpressionTracker.observeCards() so existing callers (notably PMPFilterSort.applyFiltersAndSort()) continue to work. Marked as deprecated in the comment but not removed because external themes / customizations may have wired into it.
* window.PMPImpressionTracker exposed at the bottom of $(document).ready, mirroring window.PMPAnalytics.
+ Item 1: Server-side queue + WP-Cron flush (Fix 2 in the brief)
* Even after Item 0, every impression batch still paid the LiteSpeed/Cloudflare host-stack baseline (~1.5s on cincinnatianimalcare.org) synchronously. On a session where the visitor scrolls through 60 cards spread across 4-5 batches, that compounded to 4-5 slow requests. The architectural fix: stop blocking the visitor request on database work entirely.
* New file includes/analytics/class-pet-match-pro-analytics-queue.php exposes PetMatchPro\Analytics\AnalyticsQueue with class constants OPTION_KEY ('pmp_impression_queue'), OPTION_LAST_FLUSH ('pmp_impression_queue_last_flush'), CRON_HOOK ('pmp_flush_impression_queue'), CRON_INTERVAL ('minute'), MAX_QUEUE_ENTRIES (5000), STALE_THRESHOLD_SECONDS (300), DEEP_THRESHOLD_ENTRIES (100). All settable per-site via class extension if a customer needs different thresholds.
* AnalyticsQueue::enqueue(array $records): int appends pre-built records to the queue option (autoload=false to keep it out of the WP options autoload set on every request) and applies overflow trim - drops oldest entries when count exceeds MAX_QUEUE_ENTRIES, with one error_log entry per overflow event.
* AnalyticsQueue::flush(): void snapshots-then-clears the queue (delete_option then bulk INSERT, so a racing enqueue between the get and the delete loses the smallest possible window - the next cron tick picks up the new entries 60s later, acceptable for analytics). Loads AnalyticsDb on demand and delegates the multi-row INSERT to the existing recordEventsBulk() shipped in 8.6.11.
* AnalyticsQueue::ensureScheduled() registers the cron event on the 'minute' interval if not already scheduled. AnalyticsQueue::clearSchedule() unschedules cleanly on plugin deactivation.
* cron_schedules filter in pet-match-pro.php adds the 'minute' interval (WP core ships hourly/twicedaily/daily only) with a translated display name and 60-second interval.
* The cron action is wired in pet-match-pro.php's existing plugins_loaded priority-5 hook (next to the schema self-heal added in 8.6.11.1) so it self-heals after upgrade-in-place. AnalyticsQueue::ensureScheduled() runs on every plugins_loaded - idempotent because wp_next_scheduled() returns false only when nothing is queued.
* Deactivation hook in pet-match-pro.php now also calls AnalyticsQueue::clearSchedule() and CronHealth::clearSchedule() so deactivating the plugin does not leave orphan cron events behind.
* AnalyticsAjax::handleTrackImpressions() refactored: validate input -> call AnalyticsTracker::buildImpressionRecords() -> AnalyticsQueue::enqueue() -> wp_send_json_success(). The 8.6.11.x canUseEarlyClose() / sendJsonAndClose() / synchronous-write branching is gone for impressions because the handler no longer does meaningful work - it returns ~5ms PHP plus the LSCache baseline that we cannot avoid.
* handleTrackEvent() (clicks, shares, poster prints, video plays) is unchanged - those events are user-gestured, low-frequency, and the dashboard counter benefits from being acknowledged synchronously. Only impressions get the queue treatment.
+ Item 2: AnalyticsTracker::buildImpressionRecords() extracted as public method (record-shape consolidation)
* The per-row builder previously lived inside AnalyticsTracker::trackImpressions(). When the queue path needed to build the same record shape without writing to the DB, two options existed: promote getSessionId/hashIp to public so the AJAX class could re-build, or extract the builder onto the tracker. Chose the extraction so session/IP plumbing stays encapsulated and the queue path and the legacy synchronous path share one record-building implementation that cannot drift.
* New public method AnalyticsTracker::buildImpressionRecords(array $impressions): array contains the entire previous body of trackImpressions() up to the recordEventsBulk() call. trackImpressions() now calls buildImpressionRecords() internally and adds the recordEventsBulk() write step.
* AnalyticsAjax::handleTrackImpressions() builds records via (new AnalyticsTracker())->buildImpressionRecords($impressions) and hands the result to AnalyticsQueue::enqueue(). One source of truth for impression record shape across the queue path and the rollback path.
+ Item 3: sendBeacon-only impression POSTs (Fix 3 in the brief)
* The legacy PMPAnalytics.trackImpressions() flush had a $.ajax fallback when navigator.sendBeacon was unavailable. In 2026 every browser shipping in the last 8 years supports sendBeacon, and the rare visitors without it are not the demographic shelter analytics dashboards serve. Drop the fallback to remove a code branch.
* In PMPImpressionTracker.flush(), if sendBeacon is unavailable the batch is dropped (with the array reset) and no XHR fires. Visitors without sendBeacon contribute zero analytics; everyone else contributes accurate dwell-gated data.
+ Item 4: sendBeacon for pmp_update_sorted_ids (Fix 4 in the brief)
* PMPFilterSort.updateSession() previously fired synchronous $.ajax on every user-triggered filter or sort change. The xhr blocked for ~1.98s on LiteSpeed/Cloudflare. The user saw the new card grid render while the request hung in flight in the network panel - perceived latency on top of actual latency.
* Switched to navigator.sendBeacon when available, with the existing $.ajax block retained as a fallback for ancient browsers without sendBeacon. Filter/sort clicks are now fire-and-forget on every modern browser.
* Race accepted: if the visitor clicks a filter and immediately clicks an animal card (within ~50ms) AND the server is still buffering the sendBeacon request, detail-page prev/next walks the OLD sort order. Three things must align in under a second; worst case is a misordered prev/next on one detail view that recovers on the next visit. Same trade-off accepted for impressions and consistent with analytics-style data quality expectations.
* The 8.6.11.2 pmpDeferToIdle initial-load wrapper stays in place - it still applies to the page-load case where a sort is active at render time.
+ Item 5: Synchronous-flush fallback for broken-cron hosts (Layer 1 in the brief)
* Item 1 trusts WP-Cron to drain the queue every 60 seconds. On hosts where WP-Cron is misconfigured, disabled without a host-level cron replacement, or silently failing under load, the cron handler never runs. Without a guard, the queue would grow unbounded until it hits MAX_QUEUE_ENTRIES and starts dropping oldest entries forever - customers would lose analytics with no indication anything was wrong.
* AnalyticsQueue::flushIfStale() runs from inside enqueue() after the queue has been updated. If OPTION_LAST_FLUSH is older than STALE_THRESHOLD_SECONDS (300s, 5x the cron interval) AND the queue is at least DEEP_THRESHOLD_ENTRIES (100) deep, it calls flush() inline. Costs one slow request per fallback firing; zero overhead on healthy sites where cron drains the queue faster than the staleness check trips.
* The DEEP_THRESHOLD prevents thrash. If cron is broken AND every enqueue triggered a sync flush, every analytics request would pay the full host-stack tax - worse than 8.6.11.x. Small batches just sit in the queue until either cron recovers or enough accumulates to justify the fallback.
* On first-ever enqueue (OPTION_LAST_FLUSH = 0), the timestamp is primed to the current time so an empty system is not immediately judged stale.
* AnalyticsQueue::flush() updates OPTION_LAST_FLUSH on every successful drain (including empty-queue drains - "we tried" still counts as a flush for staleness purposes).
* Race with a recovering cron: if the staleness check runs at T+301s and the cron tick fires at T+302s, both could try to flush the same queue. The delete_option(OPTION_KEY) in flush() is effectively atomic in MySQL (single UPDATE in wp_options), so whichever runs second sees an empty queue and no-ops. No double inserts, no lost rows.
+ Item 6: Analytics Queue Health admin card (Layer 2 in the brief)
* AnalyticsQueue::getHealthStatus(): array returns three operational metrics for the admin tab - depth (current queue count), last_flush_age (seconds since last successful flush), next_scheduled (seconds until the next cron tick). Plus is_stale (boolean: last flush older than STALE_THRESHOLD) and cron_scheduled (boolean: wp_next_scheduled returned a time vs false).
* New renderAnalyticsQueueHealth() method on the admin settings class renders a "Analytics Queue Health" accordion card on the Analytics tab (between Tracking Settings and Dashboard, opens when tracking is enabled). Three rows display the metrics with translated copy. Two warning badges fire when the metrics indicate broken cron - "Cron may be broken" appears on Last Flush when stale, and on Next Scheduled when no event is registered.
* Card includes a description paragraph linking to the new cron-requirement KB article via Constants::DOCS_URL + KB_LINKS['cron_requirement'].
+ Item 7: Tools tab "Flush Queue Now" + "Run Cron Self-Test" buttons (Layer 2b in the brief)
* New file includes/analytics/class-pet-match-pro-cron-health.php exposes PetMatchPro\Analytics\CronHealth with class constants TEST_HOOK ('pmp_cron_self_test'), OPTION_STATUS / OPTION_SCHEDULED_AT / OPTION_FIRED_AT, status enum (idle / pending / healthy / failing), SCHEDULE_OFFSET_SECONDS (60), DEADLINE_SECONDS (120). startTest() schedules a one-shot WP-Cron event and writes a pending marker; handleTestFire() (the cron callback) writes a healthy marker; resolveStatus() reports current state and promotes pending->failing once the deadline elapses without a fire.
* Three new admin AJAX handlers on AnalyticsAjax: handleFlushQueueManual (calls AnalyticsQueue::flush() and reports the depth delta), handleCronSelfTest (calls CronHealth::startTest() and returns the deadline), handleCronSelfTestStatus (polling endpoint). All three require manage_options + nonce verification (separate nonces 'pmp_flush_queue' and 'pmp_cron_self_test').
* Two new Tools tab accordions: "Analytics Queue" and "Cron Self-Test", both gated to PREFERRED_LEVEL via new entries in admin/partials/pmp-option-levels-tools.php. Free/Junior tier shows the locked-accordion badge (matches existing pattern for SEO Diagnostics, Cache, etc.).
* "Flush Queue Now" UI: button triggers a fetch() call, spinner runs, "Flushed N rows." text appears next to the button on success, queue depth display updates in place. JS is inline in the tools render method to keep the change self-contained.
* "Run Cron Self-Test" UI: button starts the test, polls every 5 seconds for up to 120 seconds (the deadline), shows a green "Cron is healthy" badge on success or a yellow "Cron does not appear to be firing" badge with a KB link on failure.
* The CronHealth class is sized to be reused by 8.7.1's planned activation-time auto-self-test (Layer 3 in the brief, parked in cron-self-test-brief.md). 8.7.0 ships only the manual button-driven path - no admin_notices rendering, no dismissal AJAX, no activation hook trigger. The same code path will gain those layers in 8.7.1 without changing the underlying class.
+ Item 8: KB articles + KB_LINKS wiring + Tracking Settings copy (Layer 4 in the brief)
* Five new KB articles (English only) under the Analytics Tracking category: Analytics Overview, WP-Cron Requirement, Queue Health and Tools, Interpreting Position Impact, and Privacy and Data Retention. Each follows the existing KB voice, includes anchors for deep-linking, and contains comments flagging spots where the owner intends to add screenshots, diagrams, or example walkthroughs in a future content pass.
* KB_LINKS constant in admin/class-pet-match-pro-admin-settings.php gains five new entries: 'analytics_overview', 'cron_requirement', 'queue_health_tools', 'position_impact', 'analytics_privacy'. All slug-only (no anchor) so the docs-site renders the full article TOC by default.
* Help icons wired: KB_LINKS['queue_health_tools'] on the Analytics tab Queue Health accordion AND the Tools tab Analytics Queue accordion. KB_LINKS['cron_requirement'] on the Tools tab Cron Self-Test accordion AND the Tracking Settings field's "Enable Click Tracking" label.
* The "Enable Click Tracking" toggle row gained a description paragraph (under the toggle, not in the title attribute) explaining the WP-Cron requirement and linking to the cron-requirement article. Visible to operators every time they look at the Tracking Settings accordion - they cannot enable analytics without seeing the cron note.
+ Item 9: Translations explicitly deferred
* 8.7.0 ships approximately 30 new user-facing strings: queue health labels (Queue depth / Last flush / Next scheduled), tools button copy (Flush Queue Now / Run Cron Self-Test / Flushed N rows / Cron is healthy / Cron does not appear to be firing / Running test - waiting for cron to fire / View KB article / Test failed to start / Flush failed), cron warning badges (Cron may be broken / Not scheduled / Now / Never), cron interval display name (Every Minute (PMP)), and the inline cron requirement description copy in the Tracking Settings card and Queue Health card. Plus translator-comment annotations on the human_time_diff %s ago format strings.
* Per owner direction, translation regeneration is deferred to a separate release that will bundle 8.7.0's strings together with the still-pending Shortcode Builder Phase 4 strings. The pre-existing memory note about deferring .pot/.po regen until after Builder Phase 4 stays in effect; this release adds to the pending pile instead of clearing it.
+ Item 10: Daily summary table + rollup cron (the "dashboard times out" fix)
* Reported behavior: cincinnatianimalcare.org's analytics tab failed to load (request timed out) after less than 24 hours of data collection on PMP 8.6.x. Same architecture would resurface within weeks on every Preferred-tier site as raw event count climbed. The dwell-gating change in Items 0+1 reduces row growth ~17x but does not change the underlying read pattern - the dashboard still GROUPed the events table on every render, so the eventual scale ceiling was just delayed, not solved.
* Root cause: AnalyticsDB::getSummaryStats(), getTopAnimals(), getActionBreakdown(), getSourceBreakdown(), getDailyTrends(), getPriorPeriodStats(), getSpeciesEngagement() each ran SELECT ... FROM wp_pmp_analytics_events GROUP BY ... over the full date window. O(rows) per render. A 90-day shelter on the post-8.7.0 model still produces hundreds of thousands of rows. The dashboard read pattern was structurally unscalable.
* Architectural fix: route every dashboard read through a denormalized daily summary table (wp_pmp_analytics_daily) populated by a 5-minute incremental cron and a daily 03:00 finalize cron. The summary table stores one row per (summary_date, event_type, method_type, animal_id, animal_name, species, source, action_type) combination with event_count and unique_sessions measures. Read queries become SUM(event_count) GROUP BY over an indexed table that holds ~90 rows for a 90-day window instead of millions of raw events.
* Schema bump: Constants::ANALYTICS_SCHEMA_VERSION raised from '1.0' to '2.0'. The 8.6.11.1 self-heal hook detects the bump and re-runs createTables() on the next request. The 1.0 daily-table schema (date, event_type, animal_id, action_type) was never populated in production - no rollup writer existed before 8.7.0 - so AnalyticsDB::createTables() does an explicit DROP TABLE IF EXISTS on the daily table when the stored version is below 2.0 before letting dbDelta build the 2.0 schema. Data-safe because nothing was ever written to the 1.0 table.
* Empty-string defaults on dimension columns: MySQL UNIQUE keys treat NULL as distinct, which would defeat the dedup pattern needed for INSERT ... ON DUPLICATE KEY UPDATE. Storing '' for missing method_type / animal_id / species / source / action_type lets the unique key (summary_date, event_type, method_type, animal_id, species, source, action_type) collapse identical dimensional rows correctly.
+ Item 11: AnalyticsDailyRollup class + cron wiring
* New file includes/analytics/class-pet-match-pro-analytics-daily-rollup.php exposes PetMatchPro\Analytics\AnalyticsDailyRollup. Public methods: rollupDay($date) (idempotent DELETE-then-INSERT for a single date), rollupToday() (cron handler for the 5-minute incremental tick), rollupYesterdayAndFinalize() (cron handler for the daily 03:00 finalize), backfill($maxDays) (loops over historical raw-event dates not yet in the summary, capped per call), ensureScheduled() (idempotent cron registration), clearSchedule() (deactivation cleanup).
* rollupDay() runs one DELETE + one INSERT...SELECT GROUP BY scoped to the target date. The DELETE is keyed on the indexed summary_date column. The INSERT uses COALESCE on every dimension to coerce NULL -> '' so the UNIQUE key dedups correctly. Pre-existing aggregates are dropped first because COUNT(DISTINCT session_id) cannot be merged additively across overlapping inserts; cleanest correctness pattern.
* Two cron hooks: pmp_rollup_today_incremental on a new 'five_minutes' cron interval (300 seconds), and pmp_rollup_yesterday_finalize on the WP core 'daily' interval scheduled for 03:00 site time. The 'five_minutes' interval is registered in pet-match-pro.php's existing cron_schedules filter alongside the 'minute' interval added for the queue flush.
* pet-match-pro.php's plugins_loaded priority-5 hook now requires the rollup class file alongside AnalyticsQueue and CronHealth, registers both rollup cron actions, and calls AnalyticsDailyRollup::ensureScheduled(). Deactivation hook calls AnalyticsDailyRollup::clearSchedule().
* AnalyticsDB::getEventsTable() and AnalyticsDB::getDailyTable() added as public getters so the rollup class can reach the table names without re-implementing the prefix logic.
+ Item 12: Dashboard read methods switched to summary table
* AnalyticsDB now exposes a private buildDailyDateCondition() helper that mirrors buildDateCondition() but compares against the DATE column (summary_date) instead of the DATETIME column (created_at). Used by every method below.
* Switched: getSummaryStats(), getTopAnimals(), getActionBreakdown(), getSourceBreakdown(), getDailyTrends(), getPriorPeriodStats(), getSpeciesEngagement(). Each method's FROM clause now points at $this->dailyTable, COUNT(*) becomes SUM(event_count), the date condition uses buildDailyDateCondition(), and dimension comparisons use <> '' instead of IS NOT NULL (because daily stores '' for missing values).
* getSourceBreakdown() additionally maps '' -> Constants::DB_ANALYTICS_SOURCE_UNKNOWN at read time via IF({$source} = '', %s, {$source}) so the existing UI labels still render.
* Stayed on raw events: getPositionImpact() (position breakdown excluded from daily because storing per-position rows would defeat the row-count win), getTimeToAction() and getMultiActionVisitors() and getRepeatVisitorConversion() (session-level joins on per-event timestamps), getSourceConversionRates() and getPeakEngagementTimes() (cross-cuts that don't fit the daily aggregation shape), getEnrichmentCorrelation() and getStaleListings() (need fields not present in daily), getAnimalsNeedingAttention() (HAVING clause with nested event-type counts). exportEvents() correctly stayed on raw events for full row export. These methods are far less hot than the dashboard four; revisit case-by-case in 8.7.x if any specific one becomes a problem.
* Read staleness: today's data is up to ~5 minutes behind real time on a healthy site (5-minute incremental cron). Closed days are stable. The Tools-tab Flush Queue Now and Rebuild Daily Summaries buttons let operators pull the freshest possible view on demand.
+ Item 13: Rebuild Daily Summaries button on Tools tab
* New AnalyticsAjax handler handleRebuildSummaries calls AnalyticsDailyRollup::backfill() with a default cap of 90 days plus a follow-up rollupToday() call so the dashboard reflects the latest activity. Returns days_processed, rows_written, today_rows, and the list of dates it touched.
* New Tools tab accordion "Rebuild Daily Summaries" gated to PREFERRED_LEVEL via admin/partials/pmp-option-levels-tools.php (level_tools_rebuild_summaries). UI button + spinner + result text, same pattern as Flush Queue Now and Run Cron Self-Test. Inline JS calls the handler via fetch() and displays "Rebuilt N days, M rows written." or "No historical days needed rebuilding. Today refreshed." depending on the result.
* Use cases: post-upgrade backfill (the daily table starts empty after the schema bump - clicking once reconciles all historical raw events), post-restore reconciliation, and diagnostic flow when the dashboard shows zero data despite raw events being present.
* The button caps backfills at 90 days per click; sites with longer history can re-click. Each click picks up where the previous left off because the backfill query selects dates that aren't yet in the summary table.
+ Item 14: KB article updates for daily summaries
* Analytics Overview KB article: extended the "How the Data Gets Into the Dashboard" section to include the daily summary rollup steps (5-minute incremental, nightly finalize) and added a "Why a separate summary table?" subsection explaining the architectural fix in operator-friendly language. Two new RICH content cues for diagrams.
* Queue Health and Tools KB article: added a "Rebuild Daily Summaries Button" section before the "What These Tools Do Not Do" list, covering the use cases (post-upgrade, post-restore, diagnostics), behavior (90-day cap per click, refreshes today after backfill), and operator playbook ordering. Two new RICH content cues.
+ Item 15: Verification of daily summaries (manual, per-test-site)
* Upgrade verification: upload all changed files. Visit any front-end page once so plugins_loaded fires. SQL `SELECT option_value FROM wp_options WHERE option_name = 'pmp_analytics_schema_version'` returns '2.0'. SQL `SHOW CREATE TABLE wp_pmp_analytics_daily` shows the new columns (method_type, animal_name, source) and the expanded UNIQUE key.
* Cron registration: WP-CLI `wp cron event list` shows pmp_rollup_today_incremental (every 5 minutes) and pmp_rollup_yesterday_finalize (every 24 hours at 03:00 site time) alongside the existing pmp_flush_impression_queue.
* Initial backfill: visit Tools tab, click "Rebuild Daily Summaries". Confirm the result text reports a non-zero days_processed if raw events existed pre-upgrade. SQL `SELECT COUNT(*) FROM wp_pmp_analytics_daily` is non-zero. SQL `SELECT DISTINCT summary_date FROM wp_pmp_analytics_daily ORDER BY summary_date` lists every date that has raw events.
* Dashboard load time: visit Analytics tab. The page should render within ~1 second regardless of how many raw events exist. Compare against pre-upgrade timing if available.
* Rollup correctness spot-check: pick a recent date with known raw event volume. SQL `SELECT SUM(event_count) FROM wp_pmp_analytics_daily WHERE summary_date = 'YYYY-MM-DD' AND event_type = 'impression'` should equal SQL `SELECT COUNT(*) FROM wp_pmp_analytics_events WHERE DATE(created_at) = 'YYYY-MM-DD' AND event_type = 'impression'`. Same for detail_view, action_click, etc.
* Today drift: trigger several impressions on a search page. Wait 5+ minutes (or click Rebuild Daily Summaries). Refresh dashboard. Today's counts should reflect the new activity.
+ Item 16: Polish round - dwell-drop bug, label cleanup, accordion ordering, cron-test layout
* Bug fix - impressions dropped on fast interaction: PMPImpressionTracker only promoted a card to the batch after MIN_VISIBLE_MS (500ms) of continuous dwell. If the visitor clicked a card within that window, the pending dwell timer was abandoned and the impression was never recorded - which means the very cards the visitor engaged with were the most likely to be lost. Added PMPImpressionTracker.commitPending() that force-promotes every pending dwell timer into the batch. Wired into the pagehide and visibilitychange handlers so any time the visitor leaves the page, in-flight pending impressions are committed before the sendBeacon flush. The dwell gate still applies to passive scrolling - this only fires when the visitor is actually leaving the page.
* Admin UI - "Enable Click Tracking" label renamed to "Enable Tracking" in the Tracking Settings accordion. The original label predated 8.7.0's IntersectionObserver impression collector and was misleading - the toggle controls clicks AND impressions AND share/poster events, not just clicks. Pure copy change; no logic impact.
* Admin UI - Analytics tab accordions reordered: Tracking Settings stays first (operator-flow priority), the rest sorted alphabetically by display title - Analytics Dashboard, Analytics Insights, Analytics Queue Health, Data Management, SEO Settings.
* Admin UI - Tools tab accordions reordered alphabetically by display title: Analytics Queue, API Diagnostics (default-open, Free), Cache Management, Cron Self-Test, Export / Import Settings, License Summary, Rebuild Daily Summaries, SEO Diagnostics, Setup Wizard, System Information, Template Audit. API Diagnostics retains the default-active state so Free-tier operators see content on first load instead of an Analytics Queue locked accordion.
* Admin UI - Queue Health table labels switched to mixed case for consistency with the rest of the admin: "Queue depth" -> "Queue Depth", "Last flush" -> "Last Flush", "Next scheduled" -> "Next Scheduled". Applied in both Analytics tab Queue Health card and the Tools tab Analytics Queue accordion. Two strings each, six total.
* Admin UI - All three Tools-tab action accordions (Analytics Queue / Flush Queue Now, Cron Self-Test, Rebuild Daily Summaries) place the spinner + result indicator immediately to the right of the action button via a shared .pmp-action-progress wrapper inside the .pmp-tools-action-row. The action row gets flex layout (display:flex, align-items:center, gap:8px) so the progress span pins next to the button regardless of WP core's default .spinner float:right behavior. The button-adjacent placement matches operator expectations for "I clicked, what happened?" - the eye already lives at the button.
* Admin UI - All three Tools-tab action results use shared green/red completion badges. Success states ("Cron is healthy", "Flushed N rows.", "Rebuilt N days, M rows written.") render with .pmp-badge.pmp-healthy (green #2e7d32). Failure states ("Cron does not appear to be firing", "Flush failed.", "Rebuild failed.") render with .pmp-badge.pmp-failing (red #c62828). The .pmp-warning class (yellow) is unchanged - still used on the Queue Health card for "stale but not necessarily broken" where amber is the right semantic.
* Admin CSS - All 8.7.0 admin styling lives in admin/css/pet-match-pro-admin.css alongside the owner-contributed .pmp-queue-health rules. Earlier 8.7.0 iterations had inline