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 '
'; echo ''; wp_nonce_field( self::NONCE_ACTION, self::NONCE_FIELD ); @@ -204,4 +199,77 @@ class PRV_Admin_Page { ); 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..ae4a0ae 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. + * @param array $standings Standings map. + * @param string $last_run_at Formatted timestamp. + * @param float $mtd_cost Month-to-date spend USD. + * @param float $cap Monthly cap USD. + * @param array $last_run_counts Per-model health. * * @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 ''; echo ''; echo ''; @@ -143,18 +165,73 @@ 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 ''; - echo ''; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- only emoji + echo ''; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped — dot is HTML entity; label escaped above; class esc_attr'd echo ''; echo ''; echo ''; } - echo '
' . esc_html__( 'Peptide', 'pr-vision' ) . '' . esc_html__( 'Cited?', 'pr-vision' ) . '' . esc_html__( 'Our Position', 'pr-vision' ) . '
' . esc_html( (string) $row['label'] ) . '' . $cited_icon . '' . $cited_dot . ' ' . $cited_label . '' . esc_html( $position ) . '' . wp_kses_post( $domains ) . '
'; + 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..9588e4b --- /dev/null +++ b/includes/panel/class-prv-dashboard-renderer.php @@ -0,0 +1,298 @@ +'; + $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