PR Vision v0.2.1: dashboard config-marker + KPI health pill + auto-deploy pipeline (#4)

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
This commit is contained in:
peptiderepo
2026-06-14 20:20:44 +07:00
committed by GitHub
parent 2fcef7a323
commit 43bcb8b17d
13 changed files with 970 additions and 99 deletions

View File

@@ -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'] ? '&#x2705;' : '&#x274C;';
$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 ? '&#x25CF;' : '&#x25CB;';
$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' ),
);
}
}

View 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">&#x2139;</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">&#x26A0;</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">&#x25B2; +' . esc_html( number_format( $diff, 2 ) ) . '%</div>';
}
if ( $diff < 0 ) {
return '<div class="prv-tile-delta prv-delta--down">&#x25BC; ' . esc_html( number_format( $diff, 2 ) ) . '%</div>';
}
return '<div class="prv-tile-delta prv-delta--flat">&#x2014; ' . esc_html__( 'no change', 'pr-vision' ) . '</div>';
}
}