Compare commits
2 Commits
claude/pr-
...
claude/pr-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
28e3ce6576 | ||
|
|
43bcb8b17d |
18
.github/workflows/ci.yml
vendored
18
.github/workflows/ci.yml
vendored
@@ -47,12 +47,24 @@ jobs:
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: '8.1'
|
||||
tools: composer:v2, phpcs
|
||||
|
||||
- name: Install Composer dependencies
|
||||
run: composer install --no-interaction --prefer-dist --no-progress
|
||||
- name: Install WordPress Coding Standards (global)
|
||||
run: |
|
||||
composer global config --no-plugins allow-plugins.dealerdirect/phpcodesniffer-composer-installer true
|
||||
composer global require --no-interaction \
|
||||
"dealerdirect/phpcodesniffer-composer-installer:^1.0" \
|
||||
"wp-coding-standards/wpcs:^3.0" \
|
||||
"phpcsstandards/phpcsutils:^1.0"
|
||||
|
||||
- name: Run PHPCS
|
||||
run: composer run lint
|
||||
run: |
|
||||
phpcs --standard=phpcs.xml.dist \
|
||||
--extensions=php \
|
||||
--ignore=vendor/*,tests/*,node_modules/* \
|
||||
--warning-severity=0 \
|
||||
--report=full \
|
||||
includes/ pr-vision.php uninstall.php
|
||||
|
||||
# ── 300-line file check ──────────────────────────────────────────
|
||||
file-size:
|
||||
|
||||
70
.github/workflows/deploy.yml
vendored
Normal file
70
.github/workflows/deploy.yml
vendored
Normal file
@@ -0,0 +1,70 @@
|
||||
name: Deploy to Hostinger
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: deploy-pr-vision
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
ci:
|
||||
name: Pre-deploy CI
|
||||
uses: ./.github/workflows/ci.yml
|
||||
|
||||
deploy:
|
||||
name: Deploy (rsync to Hostinger PROD)
|
||||
needs: ci
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up SSH key
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/id_deploy
|
||||
chmod 600 ~/.ssh/id_deploy
|
||||
ssh-keyscan -p "${{ secrets.SSH_PORT }}" "${{ secrets.SSH_HOST }}" >> ~/.ssh/known_hosts 2>/dev/null
|
||||
|
||||
- name: rsync plugin to PROD
|
||||
run: |
|
||||
rsync -az --delete \
|
||||
--exclude='.git' \
|
||||
--exclude='.github' \
|
||||
--exclude='tests' \
|
||||
--exclude='phpunit.xml.dist' \
|
||||
--exclude='composer.json' \
|
||||
--exclude='composer.lock' \
|
||||
--exclude='vendor' \
|
||||
--exclude='phpcs.xml.dist' \
|
||||
--exclude='*.md' \
|
||||
-e "ssh -p ${{ secrets.SSH_PORT }} -i ~/.ssh/id_deploy -o StrictHostKeyChecking=no" \
|
||||
./ \
|
||||
"${{ secrets.SSH_USERNAME }}@${{ secrets.SSH_HOST }}:~/domains/peptiderepo.com/public_html/wp-content/plugins/pr-vision/"
|
||||
|
||||
- name: Purge LiteSpeed cache
|
||||
run: |
|
||||
ssh -p "${{ secrets.SSH_PORT }}" \
|
||||
-i ~/.ssh/id_deploy \
|
||||
-o StrictHostKeyChecking=no \
|
||||
"${{ secrets.SSH_USERNAME }}@${{ secrets.SSH_HOST }}" \
|
||||
'wp --path=~/domains/peptiderepo.com/public_html litespeed-purge all 2>/dev/null || true'
|
||||
|
||||
- name: Health check (expect 200 or 403)
|
||||
run: |
|
||||
STATUS=$(curl -s -o /dev/null -w "%{http_code}" \
|
||||
--max-time 15 \
|
||||
"https://peptiderepo.com/wp-admin/admin.php?page=pr-vision" \
|
||||
-L --user-agent "GitHub-Actions-Deploy-Health-Check/1.0")
|
||||
echo "HTTP status: $STATUS"
|
||||
if [ "$STATUS" = "200" ] || [ "$STATUS" = "403" ]; then
|
||||
echo "Health check passed."
|
||||
else
|
||||
echo "Health check FAILED with status $STATUS"
|
||||
exit 1
|
||||
fi
|
||||
@@ -1,6 +1,6 @@
|
||||
# Architecture — PR Vision
|
||||
|
||||
**Version:** 0.2.0 | **Last updated:** 2026-06-14
|
||||
**Version:** 0.2.1 | **Last updated:** 2026-06-14
|
||||
|
||||
---
|
||||
|
||||
@@ -55,7 +55,8 @@ pr-vision/
|
||||
│ │ └── class-prv-ai-visibility-collector.php # AI-visibility data from DB
|
||||
│ └── panel/
|
||||
│ ├── interface-prv-dashboard-panel.php # render(data): void seam
|
||||
│ └── class-prv-ai-visibility-panel.php # Renders trendline + standings
|
||||
│ ├── class-prv-ai-visibility-panel.php # [v0.2.1] Orchestrates render; delegates to PRV_Dashboard_Renderer
|
||||
│ └── class-prv-dashboard-renderer.php # [v0.2.1] Bento tiles (run-health pill) + trendline config-change markers
|
||||
├── tests/
|
||||
│ ├── bootstrap.php # WP-stub bootstrap (no PHPUnit)
|
||||
│ └── unit/
|
||||
@@ -71,7 +72,8 @@ pr-vision/
|
||||
│ ├── test-config-version.php # [v0.2] Hash, bump, version stamp
|
||||
│ ├── test-projected-cost.php # [v0.2] probe_count, over_cap, truncation flag
|
||||
│ └── test-cron-reschedule.php # [v0.2] Reschedule, idempotent, tick guard
|
||||
└── .github/workflows/ci.yml # PHP lint matrix + PHPCS + 300-line check
|
||||
├── .github/workflows/ci.yml # PHP lint matrix + PHPCS + 300-line check
|
||||
└── .github/workflows/deploy.yml # [v0.2.1] Standalone rsync deploy to Hostinger PROD (CI gated)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
36
CHANGELOG.md
36
CHANGELOG.md
@@ -7,6 +7,20 @@ Versioning: [Semantic Versioning](https://semver.org/)
|
||||
|
||||
---
|
||||
|
||||
## [0.2.2] — 2026-06-14
|
||||
|
||||
Kill white WP admin gutters around the dark "Assay" UI — CSS only, no logic change.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Admin chrome (dashboard + settings):** `#wpcontent`, `#wpbody`, and `#wpbody-content` now receive the dark page background (`#14181C`) on both PR Vision screens. WP's default `20px` left padding on `#wpcontent` is removed so no light gutter appears between the left menu and the dark content area.
|
||||
- **WP admin bar / first-element gap:** `#wpbody` dark fill removes the white strip between the admin bar and the first card.
|
||||
- **Page `<h1>` header:** `.wrap > h1` colour set to the light text token (`#EEF2F5`) so the dashboard heading renders on dark, not white.
|
||||
- **Proxy-note banner (dashboard):** `.notice.prv-proxy-note` overridden to dark surface (`#1C2228`) with teal left border — banner now sits on dark bg matching the surrounding cards.
|
||||
- **Scope:** entire CSS block delivered via `wp_add_inline_style('wp-admin', …)` inside `PRV_Admin_Page::enqueue_assets()`, which is already guarded by `strpos($hook, 'pr-vision')`. Styles are absent on every other WP admin page. WP admin bar and left menu are not touched.
|
||||
|
||||
---
|
||||
|
||||
## [0.1.1] — 2026-06-13
|
||||
|
||||
Rename to PR Vision; fix www-strip in competitor display; PHPCS gating; remove dead cache.
|
||||
@@ -103,3 +117,25 @@ Admin/Settings interface + correctness fixes. All adversarial-QA must-fixes addr
|
||||
- **P1-D/E:** Expanded inline associative arrays to multi-line in `class-prv-config.php`, `class-prv-probe-runner.php`, `class-prv-settings-renderer.php`, `class-prv-model-test-ajax.php`; fixed param-comment spacing in `class-prv-model-registry.php`; capitalised docblock long-description first word.
|
||||
- **PHPCS compliance:** Expanded remaining 5 inline associative arrays in `class-prv-settings-page.php` (`add_query_arg` calls + `PRV_Model_Registry::update` data array) to multi-line with trailing comma; corrected `@param` column alignment in `class-prv-probe-runner.php:update_api_key_status` and `class-prv-model-registry.php:update_health` to match longest-type rule. PHPCS: 0 errors / 0 warnings.
|
||||
- **300-line split:** extracted `handle_save`, `handle_model_add`, `handle_model_update`, `handle_model_remove` from `class-prv-settings-page.php` (346 lines) into new `class-prv-settings-controller.php` (`PRV_Settings_Controller`); settings-page delegates via nonce callback. All `phpcs:disable/enable WordPress.Security.NonceVerification.Missing` wrappers carried with the moved handlers. Both files now ≤213 lines; no file exceeds 300 lines.
|
||||
|
||||
---
|
||||
|
||||
## [0.2.1] — 2026-06-14
|
||||
|
||||
Dashboard visuals + deploy pipeline.
|
||||
|
||||
### Added
|
||||
|
||||
- **KPI bento bar on dashboard:** Three-tile band replacing the flat meta-bar. Score tile carries a **run-health pill** (dot + word: "All models healthy" / "N model(s) degraded" / "No run yet") sourced from `PRV_Model_Registry` health fields via `PRV_Ai_Visibility_Collector::collect_model_health()`. Cost tile carries the MTD meter + optional truncation badge. Last-run tile shows timestamp.
|
||||
- **Config-change trendline marker:** Inline Chart.js plugin (`configMarker`) draws a vertical dashed orange line + pin circle at every `config_version` boundary in the trendline data. A legend swatch and a plain-language annotation note appear below the chart when breaks exist.
|
||||
- **`PRV_Dashboard_Renderer`:** New class (`includes/panel/class-prv-dashboard-renderer.php`) owns the bento tiles and trendline rendering; `PRV_Ai_Visibility_Panel` delegates to it. Split keeps all files ≤ 300 lines.
|
||||
- **Collector extensions:** `PRV_Ai_Visibility_Collector::collect()` now returns two new keys: `last_run_counts` (per-model health map from `PRV_Model_Registry`) and `config_versions` (all version records from `PRV_Config_Version`). The trendline query now includes `MIN(config_version)` per `run_id`.
|
||||
- **Admin page CSS:** `PRV_Admin_Page::get_dashboard_css()` returns the "Assay" dark palette CSS (CSS custom-property palette, bento grid, health pills, chart legend swatches, cfg-note, standings status chips) scoped to `.prv-*` selectors.
|
||||
- **`.github/workflows/deploy.yml`:** Standalone rsync deploy — on push to `main`: runs CI as prerequisite, rsyncs the plugin to `~/domains/peptiderepo.com/public_html/wp-content/plugins/pr-vision/` (excludes `.git .github tests phpunit.xml.dist composer.json composer.lock vendor phpcs.xml.dist *.md`), purges LiteSpeed cache via WP-CLI, health-checks (200 or 403). Uses repo secrets `SSH_HOST`/`SSH_USERNAME`/`SSH_PRIVATE_KEY`/`SSH_PORT`. Standalone — does NOT wire into the smoke-gated shared pipeline.
|
||||
- **`tests/unit/test-dashboard-panel.php`:** 27 assertions covering: `derive_health_pill_state` (all states + edge cases), `PRV_Dashboard_Renderer::build_delta_html` (up/down/flat/null), collector `config_version` field on trendline rows, collector `last_run_counts` and `config_versions` keys, null config_version graceful handling, model health payload from registry, trendline output capture (marker present/absent, noscript fallback element).
|
||||
|
||||
### Changed
|
||||
|
||||
- `PRV_Ai_Visibility_Panel` refactored: bento and trendline rendering extracted to `PRV_Dashboard_Renderer`; panel is now a thin orchestrator (237 lines). No interface change.
|
||||
- `PRV_Admin_Page::enqueue_assets()` now calls `get_dashboard_css()` for the "Assay" palette CSS instead of the legacy inline style block.
|
||||
- Version bumped to **0.2.1**.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Context — Domain Glossary (PR Vision)
|
||||
|
||||
**Version:** 0.2.0 | **Last updated:** 2026-06-14
|
||||
**Version:** 0.2.1 | **Last updated:** 2026-06-14
|
||||
|
||||
This file is the ubiquitous-language glossary for `pr-vision`. Every term below has a precise meaning within this codebase. AI agents and human contributors should use this terminology consistently.
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Conventions — PR Vision
|
||||
|
||||
**Version:** 0.1.1 | **Last updated:** 2026-06-13
|
||||
**Version:** 0.2.1 | **Last updated:** 2026-06-14
|
||||
|
||||
Applies to all contributors and AI agents working in this repo. Where this document and `AGENT-OPERATING-STANDARD.md` conflict on process, the Standard wins; this document wins on code style.
|
||||
|
||||
|
||||
@@ -11,9 +11,11 @@ declare(strict_types=1);
|
||||
* AI-visibility data collector — v1 implementation of PRV_Data_Collector.
|
||||
*
|
||||
* Reads the prv_ai_visibility table and returns structured data for:
|
||||
* - Trendline: per-run visibility score (% cited, position-weighted).
|
||||
* - Trendline: per-run visibility score (% cited, position-weighted) with config_version stamp.
|
||||
* - Standings: per-peptide latest cited status, our position, competing domains.
|
||||
* - Run metadata: last run time, MTD cost vs cap.
|
||||
* - Per-model run-health from PRV_Model_Registry (last_run_counts).
|
||||
* - All config-version records for trendline break annotations (config_versions).
|
||||
*
|
||||
* Score formula (documented in CONTEXT.md):
|
||||
* base_score = cited_probes / total_probes (range 0–1)
|
||||
@@ -21,10 +23,13 @@ declare(strict_types=1);
|
||||
* visibility_score = round((base_score + position_bonus) / 2, 4)
|
||||
*
|
||||
* Who triggers: PRV_Admin_Page calls the registered collector via the registry.
|
||||
* Dependencies: $wpdb, PRV_Table_Manager, PRV_Cost_Ledger, PRV_Config.
|
||||
* Dependencies: $wpdb, PRV_Table_Manager, PRV_Cost_Ledger, PRV_Config,
|
||||
* PRV_Model_Registry, PRV_Config_Version.
|
||||
*
|
||||
* @see interface-prv-data-collector.php — Interface this implements.
|
||||
* @see class-prv-ai-visibility-panel.php — Renders the data returned here.
|
||||
* @see class-prv-model-registry.php — Source of per-model health data.
|
||||
* @see class-prv-config-version.php — Source of config-version records.
|
||||
* @see CONTEXT.md — Score formula + glossary.
|
||||
* @see ARCHITECTURE.md — §Storage, §Score.
|
||||
* @package PrVision
|
||||
@@ -51,11 +56,13 @@ class PRV_Ai_Visibility_Collector implements PRV_Data_Collector {
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* @return array{
|
||||
* trendline: array<int, array{run_id: string, captured_at: string, score: float}>,
|
||||
* trendline: array<int, array{run_id: string, captured_at: string, score: float, config_version: int|null}>,
|
||||
* standings: array<string, array{label: string, cited: bool, our_position: int|null, top_domains: string[], model_count: int}>,
|
||||
* last_run_at: string|null,
|
||||
* mtd_cost_usd: float,
|
||||
* monthly_cap_usd: float,
|
||||
* last_run_counts: array<string, array{health_status: string, health_probed: int, health_errors: int}>,
|
||||
* config_versions: array<int, array<string, mixed>>,
|
||||
* }
|
||||
*/
|
||||
public function collect(): array {
|
||||
@@ -63,13 +70,14 @@ class PRV_Ai_Visibility_Collector implements PRV_Data_Collector {
|
||||
|
||||
$table = PRV_Table_Manager::get_table_name();
|
||||
|
||||
// ── Trendline: one score per run_id ────────────────────────────
|
||||
// ── Trendline: one score per run_id, with config_version ───────
|
||||
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
|
||||
$run_rows = $wpdb->get_results(
|
||||
"SELECT run_id, MIN(captured_at) AS captured_at,
|
||||
SUM(cited) AS cited_count,
|
||||
COUNT(*) AS total_count,
|
||||
SUM(CASE WHEN our_position IS NOT NULL THEN 1.0/our_position ELSE 0 END) AS position_sum
|
||||
SUM(CASE WHEN our_position IS NOT NULL THEN 1.0/our_position ELSE 0 END) AS position_sum,
|
||||
MIN(config_version) AS config_version
|
||||
FROM {$table}
|
||||
GROUP BY run_id
|
||||
ORDER BY MIN(captured_at) ASC
|
||||
@@ -81,13 +89,14 @@ class PRV_Ai_Visibility_Collector implements PRV_Data_Collector {
|
||||
$trendline = array();
|
||||
foreach ( (array) $run_rows as $row ) {
|
||||
$trendline[] = array(
|
||||
'run_id' => (string) $row['run_id'],
|
||||
'captured_at' => (string) $row['captured_at'],
|
||||
'score' => $this->compute_score(
|
||||
'run_id' => (string) $row['run_id'],
|
||||
'captured_at' => (string) $row['captured_at'],
|
||||
'score' => $this->compute_score(
|
||||
(int) $row['cited_count'],
|
||||
(int) $row['total_count'],
|
||||
(float) $row['position_sum']
|
||||
),
|
||||
'config_version' => isset( $row['config_version'] ) ? (int) $row['config_version'] : null,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -108,7 +117,7 @@ class PRV_Ai_Visibility_Collector implements PRV_Data_Collector {
|
||||
|
||||
$standings = array();
|
||||
foreach ( (array) $standing_rows as $row ) {
|
||||
$top_domains = $this->extract_top_domains( (string) ( $row['domains_json_list'] ?? '' ) ); // phpcs:ignore Generic.Formatting.MultipleStatementAlignment.NotSameWarning
|
||||
$top_domains = $this->extract_top_domains( (string) ( $row['domains_json_list'] ?? '' ) );
|
||||
$standings[ (string) $row['peptide_slug'] ] = array(
|
||||
'label' => (string) $row['peptide_label'],
|
||||
'cited' => (bool) (int) $row['cited'],
|
||||
@@ -123,12 +132,20 @@ class PRV_Ai_Visibility_Collector implements PRV_Data_Collector {
|
||||
$last_run = $wpdb->get_var( "SELECT MAX(captured_at) FROM {$table}" );
|
||||
// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
|
||||
|
||||
// ── Per-model health from PRV_Model_Registry ────────────────────
|
||||
$last_run_counts = $this->collect_model_health();
|
||||
|
||||
// ── Config-version records for trendline break annotations ──────
|
||||
$config_versions = PRV_Config_Version::get_all_versions();
|
||||
|
||||
return array(
|
||||
'trendline' => $trendline,
|
||||
'standings' => $standings,
|
||||
'last_run_at' => $last_run ? (string) $last_run : null,
|
||||
'mtd_cost_usd' => $this->ledger->get_month_to_date_usd(),
|
||||
'monthly_cap_usd' => PRV_Config::get_monthly_budget_usd(),
|
||||
'last_run_counts' => $last_run_counts,
|
||||
'config_versions' => $config_versions,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -159,6 +176,32 @@ class PRV_Ai_Visibility_Collector implements PRV_Data_Collector {
|
||||
return round( ( $base_score + $position_bonus ) / 2.0, 4 );
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect per-model health data from PRV_Model_Registry for the dashboard pill.
|
||||
*
|
||||
* Returns a keyed map of model slug -> health fields. Models with
|
||||
* health_status='disabled' are included so the pill reflects all registered
|
||||
* models, not just the active ones.
|
||||
*
|
||||
* @return array<string, array{health_status: string, health_probed: int, health_errors: int}>
|
||||
*/
|
||||
private function collect_model_health(): array {
|
||||
$models = PRV_Model_Registry::get_all();
|
||||
$out = array();
|
||||
foreach ( $models as $m ) {
|
||||
$slug = (string) ( $m['slug'] ?? '' );
|
||||
if ( '' === $slug ) {
|
||||
continue;
|
||||
}
|
||||
$out[ $slug ] = array(
|
||||
'health_status' => (string) ( $m['health_status'] ?? 'unknown' ),
|
||||
'health_probed' => (int) ( $m['health_probed'] ?? 0 ),
|
||||
'health_errors' => (int) ( $m['health_errors'] ?? 0 ),
|
||||
);
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract and deduplicate the top domain names from a GROUP_CONCAT result.
|
||||
*
|
||||
|
||||
@@ -10,9 +10,10 @@ declare(strict_types=1);
|
||||
/**
|
||||
* PR Vision top-level admin page.
|
||||
*
|
||||
* Registers the "PR Vision" menu item (manage_options), enqueues Chart.js,
|
||||
* handles the "Run now" POST action (nonce + capability), and delegates
|
||||
* rendering to the registered panels via PRV_Collector_Registry.
|
||||
* Registers the "PR Vision" menu item (manage_options), enqueues Chart.js
|
||||
* and the dashboard stylesheet, handles the "Run now" POST action (nonce +
|
||||
* capability), and delegates rendering to the registered panels via
|
||||
* PRV_Collector_Registry.
|
||||
*
|
||||
* Who triggers: PRV_Plugin::init() → is_admin() guard.
|
||||
* Dependencies: PRV_Collector_Registry, PRV_Probe_Runner.
|
||||
@@ -20,6 +21,7 @@ declare(strict_types=1);
|
||||
* @see class-prv-plugin.php — Calls register_hooks().
|
||||
* @see class-prv-collector-registry.php — Provides panels.
|
||||
* @see class-prv-probe-runner.php — Invoked by the Run now action.
|
||||
* @see class-prv-ai-visibility-panel.php — Panel renderer, consumes CSS added here.
|
||||
* @see ARCHITECTURE.md — §Admin dashboard.
|
||||
* @package PrVision
|
||||
*/
|
||||
@@ -73,7 +75,10 @@ class PRV_Admin_Page {
|
||||
}
|
||||
|
||||
/**
|
||||
* Enqueue Chart.js on the PR Vision page only.
|
||||
* Enqueue Chart.js and dashboard styles on the PR Vision page only.
|
||||
*
|
||||
* Chart.js is loaded from the jsdelivr CDN. The dashboard panel degrades
|
||||
* gracefully when the CDN is blocked (typeof guard + .prv-noscript fallback).
|
||||
*
|
||||
* @param string $hook Current admin page hook.
|
||||
*
|
||||
@@ -92,16 +97,7 @@ class PRV_Admin_Page {
|
||||
true
|
||||
);
|
||||
|
||||
wp_add_inline_style(
|
||||
'wp-admin',
|
||||
'.prv-meta-bar{display:flex;gap:20px;margin:10px 0 15px;flex-wrap:wrap;}
|
||||
.prv-meta-item{background:#f6f7f7;padding:6px 12px;border-radius:4px;font-size:13px;}
|
||||
.prv-card{background:#fff;border:1px solid #c3c4c7;padding:16px 20px;margin-bottom:20px;border-radius:4px;}
|
||||
.prv-card h2{margin-top:0;}
|
||||
.prv-proxy-note{margin:10px 0;}
|
||||
.prv-standings th,.prv-standings td{vertical-align:middle;}
|
||||
canvas#prv-trendline-chart{max-height:300px;}'
|
||||
);
|
||||
wp_add_inline_style( 'wp-admin', $this->get_dashboard_css() );
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -158,7 +154,6 @@ class PRV_Admin_Page {
|
||||
|
||||
$this->render_run_done_notice();
|
||||
|
||||
// "Run now" button form.
|
||||
echo '<form method="post" action="' . esc_url( admin_url( 'admin-post.php' ) ) . '">';
|
||||
echo '<input type="hidden" name="action" value="prv_run_now">';
|
||||
wp_nonce_field( self::NONCE_ACTION, self::NONCE_FIELD );
|
||||
@@ -204,4 +199,91 @@ class PRV_Admin_Page {
|
||||
);
|
||||
echo '</p></div>';
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the dashboard CSS for the PR Vision admin page.
|
||||
*
|
||||
* Covers two concerns:
|
||||
* 1. WP admin wrapper chrome (injected here; fires on BOTH PR Vision screens
|
||||
* because the enqueue_assets guard matches 'pr-vision' in the hook suffix):
|
||||
* #wpcontent / #wpbody-content are set to the dark page bg so no white
|
||||
* gutter/strip shows around or above the dark UI.
|
||||
* 2. Component tokens (.prv-*) — unchanged from prior versions.
|
||||
*
|
||||
* This inline style block is attached to wp-admin (always loaded on admin
|
||||
* pages) via wp_add_inline_style() and therefore only present on the two
|
||||
* PR Vision screens. It must NOT be registered globally.
|
||||
*
|
||||
* @return string CSS string.
|
||||
*/
|
||||
private function get_dashboard_css(): string {
|
||||
return '
|
||||
/* === PR Vision — WP admin chrome: kill white wrappers (scoped via conditional enqueue) === */
|
||||
#wpcontent{background:#14181C;padding-left:0;}
|
||||
#wpbody-content{background:#14181C;min-height:calc(100vh - 32px);padding-bottom:40px;}
|
||||
#wpbody{background:#14181C;}
|
||||
.wrap>h1,.prv-settings-wrap~*>h1,.wp-heading-inline{color:#EEF2F5;}
|
||||
.notice.prv-proxy-note{background:#1C2228;border-left-color:#34C0CA;color:#EEF2F5;}
|
||||
.notice.prv-proxy-note p{color:#C2CCD6;}
|
||||
/* === PR Vision dashboard — "Assay" palette (dark-adapted) === */
|
||||
.prv-bento{display:grid;grid-template-columns:1.25fr 1fr 1fr;gap:16px;margin:18px 0 22px;}
|
||||
@media(max-width:820px){.prv-bento{grid-template-columns:1fr;}}
|
||||
.prv-tile{background:#1C2228;border:1px solid #3A4651;border-radius:12px;padding:20px 22px;position:relative;min-height:140px;display:flex;flex-direction:column;}
|
||||
.prv-tile-label{font-size:11.5px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;color:#9AA7B2;margin-bottom:6px;}
|
||||
.prv-tile-big{font-size:42px;font-weight:700;color:#EEF2F5;line-height:1;font-variant-numeric:tabular-nums;margin-top:auto;}
|
||||
.prv-tile-big small{font-size:16px;font-weight:600;color:#9AA7B2;margin-left:3px;}
|
||||
.prv-tile-sub{font-size:12.5px;color:#9AA7B2;margin-top:8px;}
|
||||
.prv-tile-delta{font-size:12.5px;font-weight:600;margin-top:8px;}
|
||||
.prv-delta--up{color:#9BE635;} .prv-delta--down{color:#FF6B5E;} .prv-delta--flat{color:#9AA7B2;}
|
||||
/* health pill */
|
||||
.prv-health-pill{position:absolute;top:14px;right:14px;display:inline-flex;align-items:center;gap:5px;font-size:11.5px;font-weight:600;padding:3px 9px 3px 7px;border-radius:999px;}
|
||||
.prv-health-dot{width:7px;height:7px;border-radius:50%;flex:0 0 7px;}
|
||||
.prv-health--ok{background:rgba(155,230,53,.15);color:#9BE635;} .prv-health--ok .prv-health-dot{background:#9BE635;}
|
||||
.prv-health--warn{background:rgba(255,157,77,.15);color:#FFB36E;} .prv-health--warn .prv-health-dot{background:#FF9D4D;}
|
||||
.prv-health--neutral{background:rgba(136,147,160,.14);color:#B4BDC7;} .prv-health--neutral .prv-health-dot{background:#8893A0;}
|
||||
/* cost tile */
|
||||
.prv-cost-row{display:flex;align-items:baseline;gap:4px;margin-top:auto;}
|
||||
.prv-cost-big{font-size:28px;font-weight:700;color:#EEF2F5;font-variant-numeric:tabular-nums;}
|
||||
.prv-cost-cap{font-size:13px;color:#9AA7B2;}
|
||||
.prv-meter{height:10px;border-radius:999px;background:#2A333C;margin-top:8px;border:1px solid #333D47;overflow:hidden;}
|
||||
.prv-meter>span{display:block;height:100%;border-radius:999px;background:#34C0CA;transition:width .3s ease;}
|
||||
.prv-meter--capped>span{background:#FF9D4D;}
|
||||
.prv-trunc-badge{display:flex;align-items:flex-start;gap:7px;margin-top:10px;background:rgba(255,157,77,.15);border:1px solid rgba(255,157,77,.42);border-radius:6px;padding:8px 10px;font-size:12px;color:#EEF2F5;line-height:1.4;}
|
||||
.prv-trunc-icon{color:#FFB36E;font-size:14px;flex:0 0 16px;margin-top:1px;}
|
||||
.prv-trunc-badge b{color:#FFB36E;}
|
||||
/* last run tile */
|
||||
.prv-tile-ts{font-size:20px;font-weight:700;color:#EEF2F5;margin-top:auto;line-height:1.2;}
|
||||
.prv-tile-meta{font-size:12.5px;color:#9AA7B2;margin-top:6px;}
|
||||
/* cards */
|
||||
.prv-card{background:#1C2228;border:1px solid #3A4651;border-radius:12px;margin:0 0 20px;}
|
||||
.prv-card-head{display:flex;align-items:center;justify-content:space-between;padding:18px 22px 0;}
|
||||
.prv-card-head h2{font-size:16px;font-weight:600;color:#EEF2F5;margin:0;}
|
||||
.prv-card-body{padding:10px 22px 20px;}
|
||||
/* chart */
|
||||
.prv-chartbox{position:relative;height:300px;max-height:300px;padding:14px 8px 0;overflow:hidden;}
|
||||
.prv-chartbox canvas{max-height:286px;}
|
||||
.prv-chart-fallback{display:none;height:100%;align-items:center;justify-content:center;text-align:center;color:#9AA7B2;font-size:13px;padding:0 24px;}
|
||||
.prv-chartbox.prv-noscript .prv-chart-fallback{display:flex;}
|
||||
.prv-chartbox.prv-noscript canvas{display:none;}
|
||||
/* chart legend */
|
||||
.prv-chartcap{display:flex;align-items:center;gap:16px;flex-wrap:wrap;padding:8px 22px 4px;font-size:12.5px;color:#9AA7B2;}
|
||||
.prv-chartcap-item{display:inline-flex;align-items:center;gap:6px;}
|
||||
.prv-leg-solid{width:20px;height:0;border-top:3px solid #34C0CA;display:inline-block;}
|
||||
.prv-leg-dash{width:20px;height:0;border-top:2px dashed #B6F25A;display:inline-block;}
|
||||
.prv-leg-cfg{display:inline-flex;align-items:center;gap:3px;}
|
||||
.prv-leg-cfg::before{content:"";width:0;height:14px;border-left:2px dashed #FFB36E;display:inline-block;}
|
||||
.prv-leg-cfg::after{content:"";width:8px;height:8px;border-radius:50%;background:#FF9D4D;display:inline-block;}
|
||||
/* config change note */
|
||||
.prv-cfg-note{display:flex;gap:10px;align-items:flex-start;margin:8px 22px 14px;background:rgba(255,157,77,.15);border:1px solid rgba(255,157,77,.42);border-radius:8px;padding:11px 14px;color:#EEF2F5;font-size:13px;line-height:1.45;}
|
||||
.prv-cfg-note-icon{color:#FFB36E;font-style:normal;flex:0 0 16px;margin-top:1px;}
|
||||
.prv-cfg-note b{color:#FFB36E;}
|
||||
/* standings table */
|
||||
.prv-standings{margin:0;}
|
||||
.prv-status{display:inline-flex;align-items:center;gap:4px;font-size:12.5px;font-weight:600;padding:2px 8px;border-radius:999px;}
|
||||
.prv-status--cited{background:rgba(155,230,53,.15);color:#9BE635;}
|
||||
.prv-status--not-yet{background:rgba(136,147,160,.14);color:#B4BDC7;}
|
||||
/* proxy note */
|
||||
.prv-proxy-note{margin:10px 0;}
|
||||
';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,26 +8,50 @@
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* AI-visibility dashboard panel — renders the PR Vision admin page.
|
||||
* AI-visibility dashboard panel — orchestrates the PR Vision admin page.
|
||||
*
|
||||
* Renders four sections:
|
||||
* Renders five sections by delegating the visual heavy-lifting to helper classes:
|
||||
* 1. Proxy-metric disclaimer note.
|
||||
* 2. Chart.js trendline of visibility score across runs.
|
||||
* 3. Per-peptide standings table (cited, position, top competitor domains).
|
||||
* 4. MTD cost vs cap + last-run time.
|
||||
* 2. KPI bento bar (score tile with run-health pill, cost tile, last-run tile)
|
||||
* — via PRV_Dashboard_Renderer::render_bento().
|
||||
* 3. Chart.js trendline with config-change vertical markers
|
||||
* — via PRV_Dashboard_Renderer::render_trendline().
|
||||
* 4. Per-peptide standings table.
|
||||
*
|
||||
* All output is escaped. Chart.js is enqueued via PRV_Admin_Page.
|
||||
* The run-health pill (score tile) and config-change markers (trendline) are
|
||||
* the two v0.2.1 additions.
|
||||
*
|
||||
* Chart.js is enqueued via PRV_Admin_Page. The chart sits in a fixed-height box
|
||||
* and degrades gracefully: if Chart.js is unavailable the typeof guard adds
|
||||
* .prv-noscript, hiding the canvas and showing a fallback note in the same space.
|
||||
*
|
||||
* Who triggers: PRV_Admin_Page::render_page() via PRV_Collector_Registry.
|
||||
* Dependencies: PRV_Ai_Visibility_Collector (data format contract).
|
||||
* Dependencies: PRV_Ai_Visibility_Collector (data format), PRV_Dashboard_Renderer.
|
||||
*
|
||||
* @see interface-prv-dashboard-panel.php — Interface this implements.
|
||||
* @see class-prv-ai-visibility-collector.php — Produces the $data array.
|
||||
* @see class-prv-dashboard-renderer.php — Renders bento + trendline.
|
||||
* @see class-prv-admin-page.php — Enqueues Chart.js; calls render().
|
||||
* @package PrVision
|
||||
*/
|
||||
class PRV_Ai_Visibility_Panel implements PRV_Dashboard_Panel {
|
||||
|
||||
/**
|
||||
* Visual renderer for the bento tiles and trendline.
|
||||
*
|
||||
* @var PRV_Dashboard_Renderer
|
||||
*/
|
||||
private PRV_Dashboard_Renderer $renderer;
|
||||
|
||||
/**
|
||||
* Constructor.
|
||||
*
|
||||
* @param PRV_Dashboard_Renderer|null $renderer Injected for testing; auto-created otherwise.
|
||||
*/
|
||||
public function __construct( ?PRV_Dashboard_Renderer $renderer = null ) {
|
||||
$this->renderer = $renderer ?? new PRV_Dashboard_Renderer();
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
@@ -36,15 +60,16 @@ class PRV_Ai_Visibility_Panel implements PRV_Dashboard_Panel {
|
||||
* @return void
|
||||
*/
|
||||
public function render( array $data ): void {
|
||||
$trendline = is_array( $data['trendline'] ?? null ) ? $data['trendline'] : array();
|
||||
$standings = is_array( $data['standings'] ?? null ) ? $data['standings'] : array();
|
||||
$last_run_at = isset( $data['last_run_at'] ) && $data['last_run_at'] ? esc_html( (string) $data['last_run_at'] ) : esc_html__( 'Never', 'pr-vision' );
|
||||
$mtd_cost = isset( $data['mtd_cost_usd'] ) ? (float) $data['mtd_cost_usd'] : 0.0;
|
||||
$cap = isset( $data['monthly_cap_usd'] ) ? (float) $data['monthly_cap_usd'] : PRV_DEFAULT_MONTHLY_BUDGET_USD;
|
||||
$trendline = is_array( $data['trendline'] ?? null ) ? $data['trendline'] : array();
|
||||
$standings = is_array( $data['standings'] ?? null ) ? $data['standings'] : array();
|
||||
$last_run_at = isset( $data['last_run_at'] ) && $data['last_run_at'] ? esc_html( (string) $data['last_run_at'] ) : esc_html__( 'Never', 'pr-vision' );
|
||||
$mtd_cost = isset( $data['mtd_cost_usd'] ) ? (float) $data['mtd_cost_usd'] : 0.0;
|
||||
$cap = isset( $data['monthly_cap_usd'] ) ? (float) $data['monthly_cap_usd'] : PRV_DEFAULT_MONTHLY_BUDGET_USD;
|
||||
$last_run_counts = is_array( $data['last_run_counts'] ?? null ) ? $data['last_run_counts'] : array();
|
||||
|
||||
$this->render_proxy_note();
|
||||
$this->render_meta_bar( $last_run_at, $mtd_cost, $cap );
|
||||
$this->render_trendline( $trendline );
|
||||
$this->render_bento_band( $trendline, $standings, $last_run_at, $mtd_cost, $cap, $last_run_counts );
|
||||
$this->renderer->render_trendline( $trendline );
|
||||
$this->render_standings( $standings );
|
||||
}
|
||||
|
||||
@@ -68,55 +93,53 @@ class PRV_Ai_Visibility_Panel implements PRV_Dashboard_Panel {
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the run metadata bar (last run, cost, cap).
|
||||
* Prepare bento-band data and delegate rendering to PRV_Dashboard_Renderer.
|
||||
*
|
||||
* @param string $last_run_at Formatted last-run timestamp or "Never".
|
||||
* @param float $mtd_cost Month-to-date spend in USD.
|
||||
* @param float $cap Monthly cap in USD.
|
||||
* @param array $trendline Trendline data points from PRV_Ai_Visibility_Collector.
|
||||
* @param array $standings Per-peptide standings map.
|
||||
* @param string $last_run_at Formatted run timestamp or "Never".
|
||||
* @param float $mtd_cost Month-to-date spend in USD.
|
||||
* @param float $cap Monthly budget cap in USD.
|
||||
* @param array $last_run_counts Per-model health map (slug → {health_status}).
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private function render_meta_bar( string $last_run_at, float $mtd_cost, float $cap ): void {
|
||||
$pct = $cap > 0 ? min( 100.0, round( $mtd_cost / $cap * 100, 1 ) ) : 0;
|
||||
echo '<div class="prv-meta-bar">';
|
||||
echo '<span class="prv-meta-item"><strong>' . esc_html__( 'Last run:', 'pr-vision' ) . '</strong> ' . esc_html( $last_run_at ) . '</span>';
|
||||
echo '<span class="prv-meta-item"><strong>' . esc_html__( 'MTD cost:', 'pr-vision' ) . '</strong> $' . esc_html( number_format( $mtd_cost, 4 ) ) . ' / $' . esc_html( number_format( $cap, 2 ) ) . ' (' . esc_html( $pct ) . '% of cap)</span>';
|
||||
echo '</div>';
|
||||
}
|
||||
private function render_bento_band(
|
||||
array $trendline,
|
||||
array $standings,
|
||||
string $last_run_at,
|
||||
float $mtd_cost,
|
||||
float $cap,
|
||||
array $last_run_counts
|
||||
): void {
|
||||
$current_score = empty( $trendline ) ? 0.0 : (float) end( $trendline )['score'];
|
||||
$prev_score = count( $trendline ) >= 2 ? (float) $trendline[ count( $trendline ) - 2 ]['score'] : null;
|
||||
$cited_count = 0;
|
||||
$total_count = count( $standings );
|
||||
|
||||
/**
|
||||
* Render the Chart.js visibility score trendline.
|
||||
*
|
||||
* @param array<int, array{run_id: string, captured_at: string, score: float}> $trendline Trendline data points.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private function render_trendline( array $trendline ): void {
|
||||
echo '<div class="prv-card"><h2>' . esc_html__( 'Visibility Score — Trend', 'pr-vision' ) . '</h2>';
|
||||
|
||||
if ( empty( $trendline ) ) {
|
||||
echo '<p>' . esc_html__( 'No probe runs recorded yet. Click "Run now" to collect the first data point.', 'pr-vision' ) . '</p></div>';
|
||||
return;
|
||||
foreach ( $standings as $row ) {
|
||||
if ( ! empty( $row['cited'] ) ) {
|
||||
$cited_count++;
|
||||
}
|
||||
}
|
||||
|
||||
$labels = array();
|
||||
$scores = array();
|
||||
foreach ( $trendline as $point ) {
|
||||
$labels[] = esc_js( gmdate( 'M j', (int) strtotime( $point['captured_at'] ) ) );
|
||||
$scores[] = (float) $point['score'];
|
||||
}
|
||||
$health = $this->derive_health_pill_state( $last_run_counts );
|
||||
$pct = $cap > 0 ? min( 100.0, round( $mtd_cost / $cap * 100, 1 ) ) : 0;
|
||||
$truncated = (bool) get_option( 'prv_last_run_truncated', false );
|
||||
|
||||
$labels_json = wp_json_encode( $labels );
|
||||
$scores_json = wp_json_encode( $scores );
|
||||
|
||||
echo '<canvas id="prv-trendline-chart" height="120"></canvas>';
|
||||
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
|
||||
echo '<script>document.addEventListener("DOMContentLoaded",function(){';
|
||||
echo 'var ctx=document.getElementById("prv-trendline-chart").getContext("2d");';
|
||||
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
|
||||
echo 'new Chart(ctx,{type:"line",data:{labels:' . $labels_json . ',datasets:[{label:"Visibility Score",data:' . $scores_json . ',borderColor:"#2271b1",tension:0.3,fill:false}]},options:{scales:{y:{min:0,max:1,title:{display:true,text:"Score"}}},plugins:{legend:{display:false}}}});';
|
||||
echo '});</script>';
|
||||
echo '</div>';
|
||||
$this->renderer->render_bento(
|
||||
$current_score,
|
||||
$prev_score,
|
||||
$cited_count,
|
||||
$total_count,
|
||||
$health,
|
||||
$mtd_cost,
|
||||
$cap,
|
||||
$pct,
|
||||
$truncated,
|
||||
$last_run_at,
|
||||
$total_count
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -127,15 +150,14 @@ class PRV_Ai_Visibility_Panel implements PRV_Dashboard_Panel {
|
||||
* @return void
|
||||
*/
|
||||
private function render_standings( array $standings ): void {
|
||||
echo '<div class="prv-card"><h2>' . esc_html__( 'Current Standings', 'pr-vision' ) . '</h2>';
|
||||
echo '<div class="prv-card"><div class="prv-card-head"><h2>' . esc_html__( 'Current Standings', 'pr-vision' ) . '</h2></div><div class="prv-card-body">';
|
||||
|
||||
if ( empty( $standings ) ) {
|
||||
echo '<p>' . esc_html__( 'No standings data yet.', 'pr-vision' ) . '</p></div>';
|
||||
echo '<p>' . esc_html__( 'No standings data yet.', 'pr-vision' ) . '</p></div></div>';
|
||||
return;
|
||||
}
|
||||
|
||||
echo '<table class="wp-list-table widefat fixed striped prv-standings">';
|
||||
echo '<thead><tr>';
|
||||
echo '<table class="wp-list-table widefat fixed striped prv-standings"><thead><tr>';
|
||||
echo '<th>' . esc_html__( 'Peptide', 'pr-vision' ) . '</th>';
|
||||
echo '<th>' . esc_html__( 'Cited?', 'pr-vision' ) . '</th>';
|
||||
echo '<th>' . esc_html__( 'Our Position', 'pr-vision' ) . '</th>';
|
||||
@@ -143,18 +165,74 @@ class PRV_Ai_Visibility_Panel implements PRV_Dashboard_Panel {
|
||||
echo '</tr></thead><tbody>';
|
||||
|
||||
foreach ( $standings as $row ) {
|
||||
$cited_icon = $row['cited'] ? '✅' : '❌';
|
||||
$position = null !== $row['our_position'] ? '#' . (int) $row['our_position'] : '—';
|
||||
$domains = implode( ', ', array_map( 'esc_html', (array) $row['top_domains'] ) );
|
||||
$is_cited = ! empty( $row['cited'] );
|
||||
$cited_dot = $is_cited ? '●' : '○';
|
||||
$cited_label = $is_cited ? esc_html__( 'Cited', 'pr-vision' ) : esc_html__( 'Not yet', 'pr-vision' );
|
||||
$cited_class = $is_cited ? 'prv-status prv-status--cited' : 'prv-status prv-status--not-yet';
|
||||
$position = null !== $row['our_position'] ? '#' . (int) $row['our_position'] : '—';
|
||||
$domains = implode( ', ', array_map( 'esc_html', (array) $row['top_domains'] ) );
|
||||
|
||||
echo '<tr>';
|
||||
echo '<td>' . esc_html( (string) $row['label'] ) . '</td>';
|
||||
echo '<td>' . $cited_icon . '</td>'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- only emoji
|
||||
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- dot is a fixed HTML entity; label is escaped by esc_html__() above; class is esc_attr'd
|
||||
echo '<td><span class="' . esc_attr( $cited_class ) . '">' . $cited_dot . ' ' . $cited_label . '</span></td>';
|
||||
echo '<td>' . esc_html( $position ) . '</td>';
|
||||
echo '<td>' . wp_kses_post( $domains ) . '</td>';
|
||||
echo '</tr>';
|
||||
}
|
||||
|
||||
echo '</tbody></table></div>';
|
||||
echo '</tbody></table></div></div>';
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the run-health pill state and label from per-model health data.
|
||||
*
|
||||
* States: 'ok' (all healthy/disabled), 'warn' (any retired), 'neutral' (no data yet).
|
||||
*
|
||||
* @param array<string, array{health_status: string}> $last_run_counts Per-model health map.
|
||||
*
|
||||
* @return array{state: string, label: string}
|
||||
*/
|
||||
private function derive_health_pill_state( array $last_run_counts ): array {
|
||||
if ( empty( $last_run_counts ) ) {
|
||||
return array(
|
||||
'state' => 'neutral',
|
||||
'label' => __( 'No run yet', 'pr-vision' ),
|
||||
);
|
||||
}
|
||||
|
||||
$retired_count = 0;
|
||||
$healthy_count = 0;
|
||||
foreach ( $last_run_counts as $model ) {
|
||||
$status = (string) ( $model['health_status'] ?? 'unknown' );
|
||||
if ( 'retired' === $status ) {
|
||||
$retired_count++;
|
||||
} elseif ( 'healthy' === $status ) {
|
||||
$healthy_count++;
|
||||
}
|
||||
}
|
||||
|
||||
if ( $retired_count > 0 ) {
|
||||
return array(
|
||||
'state' => 'warn',
|
||||
'label' => sprintf(
|
||||
/* translators: %d: number of degraded models */
|
||||
_n( '%d model degraded', '%d models degraded', $retired_count, 'pr-vision' ),
|
||||
$retired_count
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if ( 0 === $healthy_count ) {
|
||||
return array(
|
||||
'state' => 'neutral',
|
||||
'label' => __( 'No run yet', 'pr-vision' ),
|
||||
);
|
||||
}
|
||||
|
||||
return array(
|
||||
'state' => 'ok',
|
||||
'label' => __( 'All models healthy', 'pr-vision' ),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
299
includes/panel/class-prv-dashboard-renderer.php
Normal file
299
includes/panel/class-prv-dashboard-renderer.php
Normal file
@@ -0,0 +1,299 @@
|
||||
<?php
|
||||
/**
|
||||
* Dashboard visual renderer: bento tiles + trendline chart for the AI-visibility panel.
|
||||
*
|
||||
* @package PrVision
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Renders the KPI bento bar and the Chart.js trendline for PRV_Ai_Visibility_Panel.
|
||||
*
|
||||
* Split from PRV_Ai_Visibility_Panel to keep each file under 300 lines.
|
||||
* Handles the v0.2.1 additions: run-health pill on the score tile, config-change
|
||||
* vertical dashed markers on the trendline, and the budget-truncation badge on
|
||||
* the cost tile.
|
||||
*
|
||||
* Who triggers: PRV_Ai_Visibility_Panel::render() — called directly.
|
||||
* Dependencies: none (all data passed as arguments).
|
||||
*
|
||||
* @see class-prv-ai-visibility-panel.php — Orchestrator that calls this renderer.
|
||||
* @see class-prv-ai-visibility-collector.php — Produces the data consumed here.
|
||||
* @see design/pr-vision/design-proposal.md — Palette + layout specification.
|
||||
* @package PrVision
|
||||
*/
|
||||
class PRV_Dashboard_Renderer {
|
||||
|
||||
/**
|
||||
* Render the three-tile KPI bento band.
|
||||
*
|
||||
* @param float $current_score Current visibility score 0–1.
|
||||
* @param float|null $prev_score Previous-run score, null if first run.
|
||||
* @param int $cited_count Number of cited peptides.
|
||||
* @param int $total_count Total peptides tracked.
|
||||
* @param array $health Run-health pill state + label ({state, label}).
|
||||
* @param float $mtd_cost Month-to-date spend USD.
|
||||
* @param float $cap Monthly budget cap USD.
|
||||
* @param float $pct Percentage of cap consumed.
|
||||
* @param bool $truncated Whether the last run hit the cap.
|
||||
* @param string $last_run_at Formatted run timestamp or "Never".
|
||||
* @param int $row_count Total peptide row count.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function render_bento(
|
||||
float $current_score,
|
||||
?float $prev_score,
|
||||
int $cited_count,
|
||||
int $total_count,
|
||||
array $health,
|
||||
float $mtd_cost,
|
||||
float $cap,
|
||||
float $pct,
|
||||
bool $truncated,
|
||||
string $last_run_at,
|
||||
int $row_count
|
||||
): void {
|
||||
echo '<div class="prv-bento">';
|
||||
$this->render_score_tile( $current_score, $prev_score, $cited_count, $total_count, $health );
|
||||
$this->render_cost_tile( $mtd_cost, $cap, $pct, $truncated );
|
||||
$this->render_lastrun_tile( $last_run_at, $row_count );
|
||||
echo '</div>';
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the Chart.js trendline with config-change vertical markers.
|
||||
*
|
||||
* The chart lives in a fixed-height .prv-chartbox (300px) and is created
|
||||
* inside a typeof-Chart guard. If Chart.js is unavailable the guard adds
|
||||
* .prv-noscript, hiding the canvas and showing an in-box fallback note
|
||||
* without reflow or throw.
|
||||
*
|
||||
* Config-change markers are detected from consecutive config_version values
|
||||
* in the trendline. An inline configMarker Chart.js plugin draws a vertical
|
||||
* dashed orange line + pin circle at each boundary index.
|
||||
*
|
||||
* @param array<int, array{run_id: string, captured_at: string, score: float, config_version?: int}> $trendline Trendline data points.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function render_trendline( array $trendline ): void {
|
||||
echo '<div class="prv-card prv-card--chart">';
|
||||
echo '<div class="prv-card-head"><h2>' . esc_html__( 'Visibility Score — Trend', 'pr-vision' ) . '</h2></div>';
|
||||
|
||||
if ( empty( $trendline ) ) {
|
||||
echo '<div class="prv-card-body"><p>' . esc_html__( 'No probe runs recorded yet. Click "Run now" to collect the first data point.', 'pr-vision' ) . '</p></div></div>';
|
||||
return;
|
||||
}
|
||||
|
||||
$labels = array();
|
||||
$scores = array();
|
||||
$marker_indices = array();
|
||||
$prev_version = null;
|
||||
|
||||
foreach ( $trendline as $idx => $point ) {
|
||||
$labels[] = gmdate( 'M j', (int) strtotime( (string) $point['captured_at'] ) );
|
||||
$scores[] = round( (float) $point['score'] * 100, 2 );
|
||||
|
||||
$cv = isset( $point['config_version'] ) ? (int) $point['config_version'] : null;
|
||||
if ( null !== $prev_version && null !== $cv && $cv !== $prev_version ) {
|
||||
$marker_indices[] = $idx;
|
||||
}
|
||||
$prev_version = $cv;
|
||||
}
|
||||
|
||||
$labels_json = wp_json_encode( $labels );
|
||||
$scores_json = wp_json_encode( $scores );
|
||||
$marker_indices_json = wp_json_encode( $marker_indices );
|
||||
$has_markers = ! empty( $marker_indices );
|
||||
|
||||
echo '<div class="prv-chartbox">';
|
||||
echo '<canvas id="prv-trendline-chart" aria-label="' . esc_attr__( 'Visibility score over time', 'pr-vision' ) . '" role="img"></canvas>';
|
||||
echo '<div class="prv-chart-fallback">' . esc_html__( 'Trend chart needs Chart.js — the data still reads in the standings table below.', 'pr-vision' ) . '</div>';
|
||||
echo '</div>';
|
||||
|
||||
echo '<div class="prv-chartcap">';
|
||||
echo '<span class="prv-chartcap-item"><span class="prv-leg-solid"></span>' . esc_html__( 'Visibility score (%)', 'pr-vision' ) . '</span>';
|
||||
echo '<span class="prv-chartcap-item"><span class="prv-leg-dash"></span>' . esc_html__( 'First-citation goal', 'pr-vision' ) . '</span>';
|
||||
if ( $has_markers ) {
|
||||
echo '<span class="prv-chartcap-item"><span class="prv-leg-cfg"></span>' . esc_html__( 'Config changed', 'pr-vision' ) . '</span>';
|
||||
}
|
||||
echo '</div>';
|
||||
|
||||
if ( $has_markers ) {
|
||||
echo '<div class="prv-cfg-note"><span class="prv-cfg-note-icon" aria-hidden="true">ℹ</span>';
|
||||
echo '<span><b>' . esc_html__( 'Scored config changed', 'pr-vision' ) . '</b> — ';
|
||||
echo esc_html__( 'segments either side of the orange markers are separate baselines; the line does not represent one continuous trend across breaks.', 'pr-vision' );
|
||||
echo '</span></div>';
|
||||
}
|
||||
|
||||
// phpcs:disable WordPress.Security.EscapeOutput.OutputNotEscaped
|
||||
echo '<script>(function(){';
|
||||
echo 'var labels=' . $labels_json . ';';
|
||||
echo 'var scores=' . $scores_json . ';';
|
||||
echo 'var markerIdx=' . $marker_indices_json . ';';
|
||||
echo 'var box=document.getElementById("prv-trendline-chart");';
|
||||
echo 'if(!box){return;}';
|
||||
echo 'var wrap=box.closest?box.closest(".prv-chartbox"):box.parentNode;';
|
||||
echo 'if(typeof Chart==="undefined"){if(wrap){wrap.classList.add("prv-noscript");}return;}';
|
||||
$this->echo_chart_js_plugin();
|
||||
echo 'var ctx=box.getContext("2d");';
|
||||
echo 'var grad=ctx.createLinearGradient(0,0,0,300);';
|
||||
echo 'grad.addColorStop(0,"rgba(52,192,202,0.28)");grad.addColorStop(1,"rgba(52,192,202,0)");';
|
||||
echo 'new Chart(ctx,{plugins:[configMarker],type:"line",data:{labels:labels,datasets:[';
|
||||
echo '{label:' . wp_json_encode( __( 'Visibility Score (%)', 'pr-vision' ) ) . ',data:scores,';
|
||||
echo 'borderColor:"#34C0CA",backgroundColor:grad,tension:0.3,fill:true,';
|
||||
echo 'pointBackgroundColor:"#34C0CA",pointBorderColor:"#14181C",pointBorderWidth:1,pointRadius:4},';
|
||||
echo '{label:' . wp_json_encode( __( 'First-citation goal (5%)', 'pr-vision' ) ) . ',data:scores.map(function(){return 5;}),';
|
||||
echo 'borderColor:"#B6F25A",borderDash:[6,4],borderWidth:1.5,pointRadius:0,fill:false}';
|
||||
echo ']},options:{maintainAspectRatio:false,interaction:{mode:"index",intersect:false},';
|
||||
echo 'scales:{x:{grid:{color:"#2C353E"},ticks:{color:"#9AA7B2",font:{size:11}}},';
|
||||
echo 'y:{min:0,max:100,grid:{color:"#2C353E"},ticks:{color:"#9AA7B2",font:{size:11}},';
|
||||
echo 'title:{display:true,text:"Score %",color:"#9AA7B2",font:{size:11}}}},';
|
||||
echo 'plugins:{legend:{display:false},tooltip:{backgroundColor:"#232B33",borderColor:"#3A4651",borderWidth:1,';
|
||||
echo 'titleColor:"#EEF2F5",bodyColor:"#C2CCD6",callbacks:{afterBody:function(items){';
|
||||
echo 'var idx=items[0].dataIndex;return markerIdx.indexOf(idx)>=0?["⚑ Scored config changed here"]:[];';
|
||||
echo '}}}}';
|
||||
echo '}});})();</script>';
|
||||
// phpcs:enable WordPress.Security.EscapeOutput.OutputNotEscaped
|
||||
|
||||
echo '</div>';
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit the configMarker inline Chart.js plugin (no external CDN).
|
||||
*
|
||||
* Draws a vertical dashed orange line + pin circle at each marker index.
|
||||
* Must be called inside a <script> block after markerIdx is defined.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private function echo_chart_js_plugin(): void {
|
||||
// phpcs:disable WordPress.Security.EscapeOutput.OutputNotEscaped
|
||||
echo 'var configMarker={id:"configMarker",afterDraw:function(chart){';
|
||||
echo 'if(!markerIdx.length){return;}';
|
||||
echo 'var c=chart.ctx,xA=chart.scales.x,yA=chart.scales.y;';
|
||||
echo 'c.save();';
|
||||
echo 'markerIdx.forEach(function(idx){';
|
||||
echo 'var xP=xA.getPixelForValue(idx);';
|
||||
echo 'c.strokeStyle="#FFB36E";c.lineWidth=2;c.setLineDash([5,4]);';
|
||||
echo 'c.beginPath();c.moveTo(xP,yA.top);c.lineTo(xP,yA.bottom);c.stroke();';
|
||||
echo 'c.setLineDash([]);c.fillStyle="#FF9D4D";c.strokeStyle="#14181C";c.lineWidth=1.5;';
|
||||
echo 'c.beginPath();c.arc(xP,yA.top+8,5,0,Math.PI*2);c.fill();c.stroke();';
|
||||
echo '});c.restore();';
|
||||
echo '}};';
|
||||
// phpcs:enable WordPress.Security.EscapeOutput.OutputNotEscaped
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the visibility score KPI tile with run-health pill.
|
||||
*
|
||||
* @param float $score Current visibility score 0–1.
|
||||
* @param float|null $prev_score Previous run score, null if first run.
|
||||
* @param int $cited Number of cited peptides.
|
||||
* @param int $total Total peptides tracked.
|
||||
* @param array $health Run-health pill state ({state, label}).
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private function render_score_tile( float $score, ?float $prev_score, int $cited, int $total, array $health ): void {
|
||||
$score_pct = round( $score * 100, 1 );
|
||||
$delta_html = $this->build_delta_html( $score, $prev_score );
|
||||
|
||||
echo '<div class="prv-tile prv-tile--score">';
|
||||
echo '<span class="prv-health-pill prv-health--' . esc_attr( $health['state'] ) . '" aria-label="' . esc_attr__( 'Run health', 'pr-vision' ) . '">';
|
||||
echo '<span class="prv-health-dot" aria-hidden="true"></span>';
|
||||
echo esc_html( $health['label'] );
|
||||
echo '</span>';
|
||||
echo '<div class="prv-tile-label">' . esc_html__( 'Visibility Score', 'pr-vision' ) . '</div>';
|
||||
echo '<div class="prv-tile-big">' . esc_html( (string) $score_pct ) . '<small>%</small></div>';
|
||||
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- built by build_delta_html; esc_html() applied on all user values inside
|
||||
echo $delta_html;
|
||||
echo '<div class="prv-tile-sub">' . esc_html(
|
||||
sprintf(
|
||||
/* translators: 1: cited count, 2: total peptide count */
|
||||
__( '%1$d of %2$d peptides cited', 'pr-vision' ),
|
||||
$cited,
|
||||
$total
|
||||
)
|
||||
) . '</div>';
|
||||
echo '</div>';
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the MTD cost KPI tile with budget meter and optional truncation badge.
|
||||
*
|
||||
* @param float $mtd_cost Month-to-date spend USD.
|
||||
* @param float $cap Monthly cap USD.
|
||||
* @param float $pct Percentage of cap consumed.
|
||||
* @param bool $truncated Whether the last run was truncated by the cap.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private function render_cost_tile( float $mtd_cost, float $cap, float $pct, bool $truncated ): void {
|
||||
$meter_class = $pct >= 100 ? 'prv-meter prv-meter--capped' : 'prv-meter';
|
||||
echo '<div class="prv-tile prv-tile--cost">';
|
||||
echo '<div class="prv-tile-label">' . esc_html__( 'MTD Cost', 'pr-vision' ) . '</div>';
|
||||
echo '<div class="prv-cost-row">';
|
||||
echo '<span class="prv-cost-big">$' . esc_html( number_format( $mtd_cost, 4 ) ) . '</span>';
|
||||
echo '<span class="prv-cost-cap"> / $' . esc_html( number_format( $cap, 2 ) ) . '</span>';
|
||||
echo '</div>';
|
||||
echo '<div class="' . esc_attr( $meter_class ) . '" role="progressbar" aria-valuenow="' . esc_attr( (string) $pct ) . '" aria-valuemin="0" aria-valuemax="100" aria-label="' . esc_attr__( 'Budget used', 'pr-vision' ) . '"><span style="width:' . esc_attr( $pct ) . '%"></span></div>';
|
||||
if ( $truncated ) {
|
||||
echo '<div class="prv-trunc-badge"><span class="prv-trunc-icon" aria-hidden="true">⚠</span>';
|
||||
echo '<span><b>' . esc_html__( 'Last run truncated', 'pr-vision' ) . '</b> — ';
|
||||
echo esc_html__( 'hit the cap before all probes completed.', 'pr-vision' ) . '</span></div>';
|
||||
}
|
||||
echo '</div>';
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the last-run KPI tile.
|
||||
*
|
||||
* @param string $last_run_at Formatted run timestamp or "Never".
|
||||
* @param int $row_count Peptide count tracked.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private function render_lastrun_tile( string $last_run_at, int $row_count ): void {
|
||||
echo '<div class="prv-tile prv-tile--lastrun">';
|
||||
echo '<div class="prv-tile-label">' . esc_html__( 'Last Run', 'pr-vision' ) . '</div>';
|
||||
echo '<div class="prv-tile-ts">' . esc_html( $last_run_at ) . '</div>';
|
||||
if ( $row_count > 0 ) {
|
||||
echo '<div class="prv-tile-meta">' . esc_html(
|
||||
sprintf(
|
||||
/* translators: %d: peptide row count */
|
||||
__( '%d peptides tracked', 'pr-vision' ),
|
||||
$row_count
|
||||
)
|
||||
) . '</div>';
|
||||
}
|
||||
echo '</div>';
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a pre-escaped delta indicator HTML string for the score tile.
|
||||
*
|
||||
* @param float $current Current score 0–1.
|
||||
* @param float|null $previous Previous run score, null if first run.
|
||||
*
|
||||
* @return string Pre-escaped HTML, or empty string when no previous score.
|
||||
*/
|
||||
public function build_delta_html( float $current, ?float $previous ): string {
|
||||
if ( null === $previous ) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$diff = round( ( $current - $previous ) * 100, 2 );
|
||||
|
||||
if ( $diff > 0 ) {
|
||||
return '<div class="prv-tile-delta prv-delta--up">▲ +' . esc_html( number_format( $diff, 2 ) ) . '%</div>';
|
||||
}
|
||||
if ( $diff < 0 ) {
|
||||
return '<div class="prv-tile-delta prv-delta--down">▼ ' . esc_html( number_format( $diff, 2 ) ) . '%</div>';
|
||||
}
|
||||
|
||||
return '<div class="prv-tile-delta prv-delta--flat">— ' . esc_html__( 'no change', 'pr-vision' ) . '</div>';
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
* Plugin Name: PR Vision
|
||||
* Plugin URI: https://peptiderepo.com
|
||||
* Description: Weekly server-side LLM probes for AI-visibility (GEO) tracking. Records whether peptiderepo.com is cited by LLMs across core peptides, stores time-series, and renders an admin dashboard + settings UI.
|
||||
* Version: 0.2.0
|
||||
* Version: 0.2.2
|
||||
* Author: peptiderepo
|
||||
* Author URI: https://peptiderepo.com
|
||||
* License: GPL-2.0-or-later
|
||||
@@ -24,7 +24,7 @@ if ( ! defined( 'ABSPATH' ) ) {
|
||||
|
||||
/* ── Constants ────────────────────────────────────────────────────────── */
|
||||
|
||||
define( 'PRV_VERSION', '0.2.0' );
|
||||
define( 'PRV_VERSION', '0.2.2' );
|
||||
define( 'PRV_PLUGIN_FILE', __FILE__ );
|
||||
define( 'PRV_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );
|
||||
define( 'PRV_PLUGIN_URL', plugin_dir_url( __FILE__ ) );
|
||||
|
||||
@@ -16,7 +16,18 @@ if ( ! defined( 'ABSPATH' ) ) {
|
||||
define( 'ABSPATH', __DIR__ . '/' );
|
||||
}
|
||||
|
||||
define( 'PRV_VERSION', '0.2.0' );
|
||||
// WordPress DB result-format constants.
|
||||
if ( ! defined( 'ARRAY_A' ) ) {
|
||||
define( 'ARRAY_A', 'ARRAY_A' );
|
||||
}
|
||||
if ( ! defined( 'OBJECT' ) ) {
|
||||
define( 'OBJECT', 'OBJECT' );
|
||||
}
|
||||
if ( ! defined( 'ARRAY_N' ) ) {
|
||||
define( 'ARRAY_N', 'ARRAY_N' );
|
||||
}
|
||||
|
||||
define( 'PRV_VERSION', '0.2.1' );
|
||||
define( 'PRV_PLUGIN_FILE', __DIR__ . '/../pr-vision.php' );
|
||||
define( 'PRV_PLUGIN_DIR', realpath( __DIR__ . '/..' ) . '/' );
|
||||
define( 'PRV_PLUGIN_URL', 'http://example.test/wp-content/plugins/pr-vision/' );
|
||||
@@ -137,8 +148,10 @@ function current_time( string $type, bool $gmt = false ): string {
|
||||
function home_url( string $path = '' ): string { return 'http://example.test' . $path; }
|
||||
|
||||
function __( string $text, string $domain = 'default' ): string { return $text; }
|
||||
function _n( string $single, string $plural, int $number, string $domain = 'default' ): string { return 1 === $number ? $single : $plural; }
|
||||
function esc_html( string $text ): string { return htmlspecialchars( $text, ENT_QUOTES ); }
|
||||
function esc_html__( string $text, string $domain = 'default' ): string { return esc_html( $text ); }
|
||||
function esc_attr__( string $text, string $domain = 'default' ): string { return esc_attr( $text ); }
|
||||
function esc_attr( string $text ): string { return htmlspecialchars( $text, ENT_QUOTES ); }
|
||||
function esc_textarea( string $text ): string { return htmlspecialchars( $text, ENT_QUOTES ); }
|
||||
function esc_js( string $text ): string { return addslashes( $text ); }
|
||||
|
||||
264
tests/unit/test-dashboard-panel.php
Normal file
264
tests/unit/test-dashboard-panel.php
Normal file
@@ -0,0 +1,264 @@
|
||||
<?php
|
||||
/**
|
||||
* Tests for PRV_Ai_Visibility_Panel and PRV_Dashboard_Renderer data-shaping added
|
||||
* in v0.2.1: config-version trendline annotation, KPI health pill, and collector
|
||||
* last_run_counts / config_versions payload fields.
|
||||
*
|
||||
* @package PrVision
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../bootstrap.php';
|
||||
|
||||
echo "=== test-dashboard-panel.php ===\n";
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Invoke a private or protected method on an object instance.
|
||||
*
|
||||
* @param object $obj Instance to invoke on.
|
||||
* @param string $method Method name.
|
||||
* @param array $args Arguments.
|
||||
*
|
||||
* @return mixed
|
||||
*/
|
||||
function prv_call_private( object $obj, string $method, array $args = array() ) {
|
||||
$ref = new ReflectionMethod( $obj, $method );
|
||||
$ref->setAccessible( true );
|
||||
return $ref->invokeArgs( $obj, $args );
|
||||
}
|
||||
|
||||
// ── 1. Panel: derive_health_pill_state ───────────────────────────────────────
|
||||
|
||||
echo "\n-- derive_health_pill_state --\n";
|
||||
|
||||
$panel = new PRV_Ai_Visibility_Panel();
|
||||
|
||||
$result = prv_call_private( $panel, 'derive_health_pill_state', array( array() ) );
|
||||
prv_assert_equals( 'neutral', $result['state'], 'health pill: empty map → neutral' );
|
||||
|
||||
$result = prv_call_private(
|
||||
$panel,
|
||||
'derive_health_pill_state',
|
||||
array(
|
||||
array(
|
||||
'slug-a' => array( 'health_status' => 'healthy' ),
|
||||
'slug-b' => array( 'health_status' => 'healthy' ),
|
||||
),
|
||||
)
|
||||
);
|
||||
prv_assert_equals( 'ok', $result['state'], 'health pill: all healthy → ok' );
|
||||
prv_assert( false !== strpos( $result['label'], 'healthy' ), 'health pill: ok label contains "healthy"' );
|
||||
|
||||
$result = prv_call_private(
|
||||
$panel,
|
||||
'derive_health_pill_state',
|
||||
array(
|
||||
array(
|
||||
'slug-a' => array( 'health_status' => 'healthy' ),
|
||||
'slug-b' => array( 'health_status' => 'retired' ),
|
||||
),
|
||||
)
|
||||
);
|
||||
prv_assert_equals( 'warn', $result['state'], 'health pill: one retired → warn' );
|
||||
prv_assert( false !== strpos( $result['label'], '1' ), 'health pill: warn label contains count "1"' );
|
||||
|
||||
$result = prv_call_private(
|
||||
$panel,
|
||||
'derive_health_pill_state',
|
||||
array(
|
||||
array(
|
||||
'slug-a' => array( 'health_status' => 'retired' ),
|
||||
'slug-b' => array( 'health_status' => 'retired' ),
|
||||
),
|
||||
)
|
||||
);
|
||||
prv_assert_equals( 'warn', $result['state'], 'health pill: two retired → warn' );
|
||||
prv_assert( false !== strpos( $result['label'], '2' ), 'health pill: warn label contains count "2"' );
|
||||
|
||||
$result = prv_call_private(
|
||||
$panel,
|
||||
'derive_health_pill_state',
|
||||
array( array( 'slug-a' => array( 'health_status' => 'disabled' ) ) )
|
||||
);
|
||||
prv_assert_equals( 'neutral', $result['state'], 'health pill: disabled-only → neutral' );
|
||||
|
||||
$result = prv_call_private(
|
||||
$panel,
|
||||
'derive_health_pill_state',
|
||||
array( array( 'slug-a' => array( 'health_status' => 'unknown' ) ) )
|
||||
);
|
||||
prv_assert_equals( 'neutral', $result['state'], 'health pill: unknown status → neutral' );
|
||||
|
||||
// ── 2. Dashboard Renderer: build_delta_html ──────────────────────────────────
|
||||
|
||||
echo "\n-- PRV_Dashboard_Renderer::build_delta_html --\n";
|
||||
|
||||
$renderer = new PRV_Dashboard_Renderer();
|
||||
|
||||
$delta_up = $renderer->build_delta_html( 0.10, 0.08 );
|
||||
$delta_down = $renderer->build_delta_html( 0.05, 0.08 );
|
||||
$delta_flat = $renderer->build_delta_html( 0.08, 0.08 );
|
||||
$delta_null = $renderer->build_delta_html( 0.08, null );
|
||||
|
||||
prv_assert( false !== strpos( $delta_up, 'prv-delta--up' ), 'delta: positive → --up class' );
|
||||
prv_assert( false !== strpos( $delta_up, '+' ), 'delta: positive → "+" prefix' );
|
||||
prv_assert( false !== strpos( $delta_down, 'prv-delta--down' ), 'delta: negative → --down class' );
|
||||
prv_assert( false !== strpos( $delta_flat, 'prv-delta--flat' ), 'delta: zero → --flat class' );
|
||||
prv_assert_equals( '', $delta_null, 'delta: null previous → empty string' );
|
||||
|
||||
// Precision: 0.10 - 0.08 = +2.00 pp
|
||||
prv_assert( false !== strpos( $delta_up, '2.00' ), 'delta: value formatted to 2dp' );
|
||||
|
||||
// ── 3. Collector: trendline includes config_version ──────────────────────────
|
||||
|
||||
echo "\n-- collector trendline config_version field --\n";
|
||||
|
||||
prv_test_reset();
|
||||
|
||||
$GLOBALS['prv_test_state']['wpdb_results'] = array(
|
||||
array(
|
||||
'run_id' => 'run-001',
|
||||
'captured_at' => '2026-06-01 10:00:00',
|
||||
'cited_count' => 2,
|
||||
'total_count' => 10,
|
||||
'position_sum' => 0.5,
|
||||
'config_version' => 1,
|
||||
),
|
||||
array(
|
||||
'run_id' => 'run-002',
|
||||
'captured_at' => '2026-06-08 10:00:00',
|
||||
'cited_count' => 3,
|
||||
'total_count' => 10,
|
||||
'position_sum' => 0.6,
|
||||
'config_version' => 2,
|
||||
),
|
||||
);
|
||||
$GLOBALS['prv_test_state']['wpdb_var'] = '2026-06-08 10:00:00';
|
||||
|
||||
$collector = new PRV_Ai_Visibility_Collector();
|
||||
$data = $collector->collect();
|
||||
|
||||
prv_assert( isset( $data['trendline'] ), 'collector: trendline key present' );
|
||||
prv_assert_equals( 2, count( $data['trendline'] ), 'collector: two trendline rows' );
|
||||
prv_assert( array_key_exists( 'config_version', $data['trendline'][0] ), 'trendline[0] has config_version key' );
|
||||
prv_assert_equals( 1, $data['trendline'][0]['config_version'], 'trendline[0].config_version = 1' );
|
||||
prv_assert_equals( 2, $data['trendline'][1]['config_version'], 'trendline[1].config_version = 2' );
|
||||
|
||||
// ── 4. Collector: last_run_counts and config_versions keys present ────────────
|
||||
|
||||
echo "\n-- collector: new payload keys --\n";
|
||||
|
||||
prv_assert( isset( $data['last_run_counts'] ), 'collector: last_run_counts key present' );
|
||||
prv_assert( is_array( $data['last_run_counts'] ), 'collector: last_run_counts is array' );
|
||||
prv_assert( isset( $data['config_versions'] ), 'collector: config_versions key present' );
|
||||
prv_assert( is_array( $data['config_versions'] ), 'collector: config_versions is array' );
|
||||
|
||||
// ── 5. Collector: null config_version handled gracefully ─────────────────────
|
||||
|
||||
echo "\n-- collector: null config_version row --\n";
|
||||
|
||||
prv_test_reset();
|
||||
$GLOBALS['prv_test_state']['wpdb_results'] = array(
|
||||
array(
|
||||
'run_id' => 'run-001',
|
||||
'captured_at' => '2026-06-01 10:00:00',
|
||||
'cited_count' => 1,
|
||||
'total_count' => 5,
|
||||
'position_sum' => 0.25,
|
||||
'config_version' => null,
|
||||
),
|
||||
);
|
||||
$GLOBALS['prv_test_state']['wpdb_var'] = '2026-06-01 10:00:00';
|
||||
|
||||
$collector2 = new PRV_Ai_Visibility_Collector();
|
||||
$data2 = $collector2->collect();
|
||||
|
||||
prv_assert_equals( 1, count( $data2['trendline'] ), 'null config_version row included' );
|
||||
prv_assert( null === $data2['trendline'][0]['config_version'], 'null config_version preserved as null' );
|
||||
|
||||
// ── 6. Collector: collect_model_health from registry ─────────────────────────
|
||||
|
||||
echo "\n-- collector: collect_model_health --\n";
|
||||
|
||||
prv_test_reset();
|
||||
|
||||
$GLOBALS['prv_test_state']['options']['prv_models_schema_version'] = 2;
|
||||
$GLOBALS['prv_test_state']['options']['prv_models'] = array(
|
||||
array(
|
||||
'id' => 'mdl_aaa',
|
||||
'slug' => 'perplexity/sonar',
|
||||
'provider' => 'perplexity',
|
||||
'enabled' => true,
|
||||
'note' => '',
|
||||
'health_status' => 'healthy',
|
||||
'health_probed' => 10,
|
||||
'health_errors' => 0,
|
||||
'health_run_id' => 'run-001',
|
||||
),
|
||||
array(
|
||||
'id' => 'mdl_bbb',
|
||||
'slug' => 'openai/gpt-4o-search-preview',
|
||||
'provider' => 'openrouter',
|
||||
'enabled' => true,
|
||||
'note' => '',
|
||||
'health_status' => 'retired',
|
||||
'health_probed' => 0,
|
||||
'health_errors' => 5,
|
||||
'health_run_id' => 'run-001',
|
||||
),
|
||||
);
|
||||
$GLOBALS['prv_test_state']['wpdb_results'] = array();
|
||||
$GLOBALS['prv_test_state']['wpdb_var'] = null;
|
||||
|
||||
$collector3 = new PRV_Ai_Visibility_Collector();
|
||||
$data3 = $collector3->collect();
|
||||
$last_run_counts = $data3['last_run_counts'];
|
||||
|
||||
prv_assert( isset( $last_run_counts['perplexity/sonar'] ), 'sonar slug present in last_run_counts' );
|
||||
prv_assert_equals( 'healthy', $last_run_counts['perplexity/sonar']['health_status'], 'sonar = healthy' );
|
||||
prv_assert( isset( $last_run_counts['openai/gpt-4o-search-preview'] ), 'gpt slug present in last_run_counts' );
|
||||
prv_assert_equals( 'retired', $last_run_counts['openai/gpt-4o-search-preview']['health_status'], 'gpt = retired' );
|
||||
|
||||
// Health pill should be warn from this data.
|
||||
$pill = prv_call_private( $panel, 'derive_health_pill_state', array( $last_run_counts ) );
|
||||
prv_assert_equals( 'warn', $pill['state'], 'health pill from registry data → warn' );
|
||||
|
||||
// ── 7. Renderer: trendline marker detection ───────────────────────────────────
|
||||
|
||||
echo "\n-- renderer: config-change marker detection (output capture) --\n";
|
||||
|
||||
// Verify that a single-version trendline produces no cfg-note.
|
||||
ob_start();
|
||||
$renderer->render_trendline(
|
||||
array(
|
||||
array( 'run_id' => 'r1', 'captured_at' => '2026-06-01 10:00:00', 'score' => 0.0, 'config_version' => 1 ),
|
||||
array( 'run_id' => 'r2', 'captured_at' => '2026-06-08 10:00:00', 'score' => 0.0, 'config_version' => 1 ),
|
||||
)
|
||||
);
|
||||
$out_no_marker = ob_get_clean();
|
||||
prv_assert( false === strpos( $out_no_marker, 'prv-cfg-note' ), 'no marker: no cfg-note rendered when config version unchanged' );
|
||||
prv_assert( false !== strpos( $out_no_marker, 'prv-trendline-chart' ), 'no marker: canvas present' );
|
||||
|
||||
// Verify that a two-version trendline produces cfg-note and markerIdx.
|
||||
ob_start();
|
||||
$renderer->render_trendline(
|
||||
array(
|
||||
array( 'run_id' => 'r1', 'captured_at' => '2026-06-01 10:00:00', 'score' => 0.0, 'config_version' => 1 ),
|
||||
array( 'run_id' => 'r2', 'captured_at' => '2026-06-08 10:00:00', 'score' => 0.0, 'config_version' => 2 ),
|
||||
)
|
||||
);
|
||||
$out_with_marker = ob_get_clean();
|
||||
prv_assert( false !== strpos( $out_with_marker, 'prv-cfg-note' ), 'marker: cfg-note rendered when config version changes' );
|
||||
prv_assert( false !== strpos( $out_with_marker, 'configMarker' ), 'marker: configMarker plugin JS emitted' );
|
||||
prv_assert( false !== strpos( $out_with_marker, 'prv-leg-cfg' ), 'marker: legend swatch present' );
|
||||
|
||||
// Verify graceful-degradation path is present.
|
||||
prv_assert( false !== strpos( $out_with_marker, 'prv-noscript' ), 'trendline: noscript fallback class in JS guard' );
|
||||
prv_assert( false !== strpos( $out_with_marker, 'prv-chart-fallback' ), 'trendline: fallback note element present' );
|
||||
|
||||
// ── Summary ──────────────────────────────────────────────────────────────────
|
||||
|
||||
exit( prv_test_summary() );
|
||||
Reference in New Issue
Block a user