From 43bcb8b17d3e9eaf40826cbcce6f60832fac870c Mon Sep 17 00:00:00 2001
From: peptiderepo <273788462+peptiderepo@users.noreply.github.com>
Date: Sun, 14 Jun 2026 20:20:44 +0700
Subject: [PATCH] PR Vision v0.2.1: dashboard config-marker + KPI health pill +
auto-deploy pipeline (#4)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Wire the deferred dashboard visuals (trendline config-change marker from config_version data; KPI per-model health pill) + add .github/workflows/deploy.yml (CI-gated rsync to the prod plugin dir + LiteSpeed purge + health check on push to main) replacing manual rsync.
QA: APPROVE — qa-reviews/pr-vision/2026-06-14-f5cfa39f60db22ac9f223ca1d95ea4954b43d71f.md
Agent-Session: pr-vision v0.2.1 merge
---
.github/workflows/ci.yml | 18 +-
.github/workflows/deploy.yml | 70 ++++
ARCHITECTURE.md | 8 +-
CHANGELOG.md | 22 ++
CONTEXT.md | 2 +-
CONVENTIONS.md | 2 +-
.../class-prv-ai-visibility-collector.php | 61 +++-
includes/core/class-prv-admin-page.php | 98 +++++-
.../panel/class-prv-ai-visibility-panel.php | 206 ++++++++----
.../panel/class-prv-dashboard-renderer.php | 299 ++++++++++++++++++
pr-vision.php | 4 +-
tests/bootstrap.php | 15 +-
tests/unit/test-dashboard-panel.php | 264 ++++++++++++++++
13 files changed, 970 insertions(+), 99 deletions(-)
create mode 100644 .github/workflows/deploy.yml
create mode 100644 includes/panel/class-prv-dashboard-renderer.php
create mode 100644 tests/unit/test-dashboard-panel.php
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 295b155..e564520 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -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:
diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
new file mode 100644
index 0000000..1fb476e
--- /dev/null
+++ b/.github/workflows/deploy.yml
@@ -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
diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 47472c0..8ee87c5 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -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)
```
---
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2b6303a..870cd7d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -103,3 +103,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**.
diff --git a/CONTEXT.md b/CONTEXT.md
index a1fb770..61fbf52 100644
--- a/CONTEXT.md
+++ b/CONTEXT.md
@@ -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.
diff --git a/CONVENTIONS.md b/CONVENTIONS.md
index 0fae72c..25ea632 100644
--- a/CONVENTIONS.md
+++ b/CONVENTIONS.md
@@ -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.
diff --git a/includes/collector/class-prv-ai-visibility-collector.php b/includes/collector/class-prv-ai-visibility-collector.php
index 3e808de..26aab12 100644
--- a/includes/collector/class-prv-ai-visibility-collector.php
+++ b/includes/collector/class-prv-ai-visibility-collector.php
@@ -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,
+ * trendline: array,
* standings: array,
* last_run_at: string|null,
* mtd_cost_usd: float,
* monthly_cap_usd: float,
+ * last_run_counts: array,
+ * config_versions: array>,
* }
*/
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
+ */
+ 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.
*
diff --git a/includes/core/class-prv-admin-page.php b/includes/core/class-prv-admin-page.php
index b155c70..b069c73 100644
--- a/includes/core/class-prv-admin-page.php
+++ b/includes/core/class-prv-admin-page.php
@@ -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 '
';
}
+
+ /**
+ * Return the dashboard CSS for the PR Vision admin page.
+ *
+ * Uses CSS custom properties matching the "Assay" dark design palette from
+ * design/pr-vision/design-proposal.md. Scoped to .prv-* selectors so it
+ * cannot leak into WP admin chrome.
+ *
+ * @return string CSS string.
+ */
+ private function get_dashboard_css(): string {
+ return '
+/* === 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;}
+';
+ }
}
diff --git a/includes/panel/class-prv-ai-visibility-panel.php b/includes/panel/class-prv-ai-visibility-panel.php
index b8fe27a..752845d 100644
--- a/includes/panel/class-prv-ai-visibility-panel.php
+++ b/includes/panel/class-prv-ai-visibility-panel.php
@@ -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 '';
- echo '' . esc_html__( 'Last run:', 'pr-vision' ) . ' ' . esc_html( $last_run_at ) . '';
- echo '' . esc_html__( 'MTD cost:', 'pr-vision' ) . ' $' . esc_html( number_format( $mtd_cost, 4 ) ) . ' / $' . esc_html( number_format( $cap, 2 ) ) . ' (' . esc_html( $pct ) . '% of cap)';
- echo '
';
- }
+ 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 $trendline Trendline data points.
- *
- * @return void
- */
- private function render_trendline( array $trendline ): void {
- echo '' . esc_html__( 'Visibility Score — Trend', 'pr-vision' ) . '
';
-
- if ( empty( $trendline ) ) {
- echo '
' . esc_html__( 'No probe runs recorded yet. Click "Run now" to collect the first data point.', 'pr-vision' ) . '
';
- 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 '';
- // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
- echo '';
- echo '';
+ $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 '' . esc_html__( 'Current Standings', 'pr-vision' ) . '
';
+ echo '
' . esc_html__( 'Current Standings', 'pr-vision' ) . '
';
if ( empty( $standings ) ) {
- echo '
' . esc_html__( 'No standings data yet.', 'pr-vision' ) . '
';
+ echo '
' . esc_html__( 'No standings data yet.', 'pr-vision' ) . '
';
return;
}
- echo '';
- echo '';
+ echo '';
echo '| ' . esc_html__( 'Peptide', 'pr-vision' ) . ' | ';
echo '' . esc_html__( 'Cited?', 'pr-vision' ) . ' | ';
echo '' . esc_html__( 'Our Position', 'pr-vision' ) . ' | ';
@@ -143,18 +165,74 @@ class PRV_Ai_Visibility_Panel implements PRV_Dashboard_Panel {
echo '
';
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 '';
echo '| ' . esc_html( (string) $row['label'] ) . ' | ';
- echo '' . $cited_icon . ' | '; // 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 '' . $cited_dot . ' ' . $cited_label . ' | ';
echo '' . esc_html( $position ) . ' | ';
echo '' . wp_kses_post( $domains ) . ' | ';
echo '
';
}
- echo '
';
+ echo '
';
+ }
+
+ /**
+ * 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 $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' ),
+ );
}
}
diff --git a/includes/panel/class-prv-dashboard-renderer.php b/includes/panel/class-prv-dashboard-renderer.php
new file mode 100644
index 0000000..4fc0925
--- /dev/null
+++ b/includes/panel/class-prv-dashboard-renderer.php
@@ -0,0 +1,299 @@
+';
+ $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 '';
+ }
+
+ /**
+ * 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 $trendline Trendline data points.
+ *
+ * @return void
+ */
+ public function render_trendline( array $trendline ): void {
+ echo '';
+ echo '
' . esc_html__( 'Visibility Score — Trend', 'pr-vision' ) . '
';
+
+ if ( empty( $trendline ) ) {
+ echo '
' . esc_html__( 'No probe runs recorded yet. Click "Run now" to collect the first data point.', 'pr-vision' ) . '
';
+ 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 '';
+ echo '
';
+ echo '
' . esc_html__( 'Trend chart needs Chart.js — the data still reads in the standings table below.', 'pr-vision' ) . '
';
+ echo '
';
+
+ echo '';
+ echo '' . esc_html__( 'Visibility score (%)', 'pr-vision' ) . '';
+ echo '' . esc_html__( 'First-citation goal', 'pr-vision' ) . '';
+ if ( $has_markers ) {
+ echo '' . esc_html__( 'Config changed', 'pr-vision' ) . '';
+ }
+ echo '
';
+
+ if ( $has_markers ) {
+ echo 'ℹ';
+ echo '' . esc_html__( 'Scored config changed', 'pr-vision' ) . ' — ';
+ 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 '
';
+ }
+
+ // phpcs:disable WordPress.Security.EscapeOutput.OutputNotEscaped
+ echo '';
+ // phpcs:enable WordPress.Security.EscapeOutput.OutputNotEscaped
+
+ echo '';
+ }
+
+ /**
+ * 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