feat: kanban board dashboard + SQL bug fix (v0.19.0)
- Add kanban board as primary admin landing screen (M1, Phase 2) - Four columns: Generating / In Review / Published / Failed - Live AJAX polling (prautoblogger_board_status), settings-backed interval + window - Fix cost-reporter SQL inline-comment bug in get_daily_spend + get_spend_by_stage - New settings: prautoblogger_board_poll_interval (default 5s), prautoblogger_board_published_window_days (default 7) - PHPUnit: BoardDataProviderTest (8 tests) + CostReporterSqlBugFixTest (3 tests) - Docs: ARCHITECTURE.md §22 + options table + file tree; CHANGELOG v0.19.0; app-map updated Agent-Session: phase2-admin-m1-engineer
This commit is contained in:
249
assets/css/board.css
Normal file
249
assets/css/board.css
Normal file
@@ -0,0 +1,249 @@
|
||||
/**
|
||||
* PRAutoBlogger — Kanban Board Styles
|
||||
*
|
||||
* Warm editorial style (Proposal C / Direction C).
|
||||
* Inside wp-admin chrome; uses WP admin vars where available.
|
||||
* No build step — plain CSS, no preprocessor.
|
||||
*
|
||||
* Palette:
|
||||
* Teal #1B8A92 (brand primary — Generating pill, spinner)
|
||||
* Cream #FAF6EE (card background)
|
||||
* Ink #0D0D0D (primary text)
|
||||
* Orange #FF8A3D (In Review pill)
|
||||
* Lime #7FD600 (Published pill — success)
|
||||
* Red #D94F4F (Failed pill)
|
||||
* Mid-grey #8A8A8A (meta text, border)
|
||||
*
|
||||
* @see templates/admin/board-page.php — Rendered HTML structure.
|
||||
* @see assets/js/board.js — DOM updates that match these selectors.
|
||||
*/
|
||||
|
||||
/* ── Page wrapper ──────────────────────────────────────────────────────── */
|
||||
.prab-board-wrap {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.prab-board-heading {
|
||||
font-size: 1.4rem;
|
||||
font-weight: 700;
|
||||
color: #0d0d0d;
|
||||
margin: 20px 0 18px 0;
|
||||
padding: 0 20px;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.prab-board-heading-sub {
|
||||
font-size: 1rem;
|
||||
font-weight: 400;
|
||||
color: #8a8a8a;
|
||||
}
|
||||
|
||||
/* ── Four-column board grid ────────────────────────────────────────────── */
|
||||
.prab-board {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 16px;
|
||||
padding: 0 20px 24px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.prab-board {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.prab-board {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Column ────────────────────────────────────────────────────────────── */
|
||||
.prab-board-column {
|
||||
background: #f8f7f5;
|
||||
border: 1px solid #e2ddd6;
|
||||
border-radius: 8px;
|
||||
min-height: 180px;
|
||||
}
|
||||
|
||||
.prab-col-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 14px 10px;
|
||||
border-bottom: 1px solid #e2ddd6;
|
||||
}
|
||||
|
||||
.prab-col-title {
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: #4a4540;
|
||||
}
|
||||
|
||||
.prab-col-cards {
|
||||
padding: 10px 10px 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.prab-col-empty {
|
||||
font-size: 0.82rem;
|
||||
color: #b0a898;
|
||||
text-align: center;
|
||||
padding: 20px 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ── Status pills ──────────────────────────────────────────────────────── */
|
||||
.prab-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1px 8px;
|
||||
border-radius: 12px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.5;
|
||||
color: #fff;
|
||||
min-width: 24px;
|
||||
}
|
||||
|
||||
.prab-pill--generating {
|
||||
background: #1b8a92;
|
||||
}
|
||||
|
||||
.prab-pill--review {
|
||||
background: #ff8a3d;
|
||||
}
|
||||
|
||||
.prab-pill--published {
|
||||
background: #5a9e28;
|
||||
}
|
||||
|
||||
.prab-pill--failed {
|
||||
background: #d94f4f;
|
||||
}
|
||||
|
||||
/* ── Card ──────────────────────────────────────────────────────────────── */
|
||||
.prab-board-card {
|
||||
background: #faf6ee;
|
||||
border: 1px solid #e0d8cc;
|
||||
border-radius: 6px;
|
||||
padding: 11px 12px 10px;
|
||||
position: relative;
|
||||
transition: box-shadow 0.15s ease, border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.prab-board-card:hover {
|
||||
box-shadow: 0 2px 8px rgba(27, 138, 146, 0.12);
|
||||
border-color: #b8cfd2;
|
||||
}
|
||||
|
||||
.prab-board-card--generating {
|
||||
border-left: 3px solid #1b8a92;
|
||||
}
|
||||
|
||||
.prab-card-title {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: #0d0d0d;
|
||||
line-height: 1.4;
|
||||
margin-bottom: 6px;
|
||||
padding-right: 20px; /* room for spinner */
|
||||
}
|
||||
|
||||
.prab-card-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.prab-card-cost {
|
||||
font-size: 0.75rem;
|
||||
color: #5a8a50;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.prab-card-stage {
|
||||
font-size: 0.74rem;
|
||||
color: #1b8a92;
|
||||
font-weight: 500;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.prab-card-error {
|
||||
font-size: 0.74rem;
|
||||
color: #d94f4f;
|
||||
margin-bottom: 5px;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.prab-card-actions {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.prab-card-link {
|
||||
display: inline-block;
|
||||
font-size: 0.75rem;
|
||||
color: #1b8a92;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
border: 1px solid #b8d8db;
|
||||
border-radius: 4px;
|
||||
padding: 2px 8px;
|
||||
transition: background 0.1s ease;
|
||||
}
|
||||
|
||||
.prab-card-link:hover {
|
||||
background: #e8f4f5;
|
||||
color: #136870;
|
||||
border-color: #8ac4c9;
|
||||
}
|
||||
|
||||
/* ── Generating spinner ────────────────────────────────────────────────── */
|
||||
.prab-card-spinner {
|
||||
position: absolute;
|
||||
top: 11px;
|
||||
right: 11px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 2px solid #c8e6e8;
|
||||
border-top-color: #1b8a92;
|
||||
border-radius: 50%;
|
||||
animation: prab-spin 0.9s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes prab-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* ── Board footer ──────────────────────────────────────────────────────── */
|
||||
.prab-board-footer {
|
||||
padding: 0 20px 16px;
|
||||
font-size: 0.78rem;
|
||||
color: #a0998f;
|
||||
}
|
||||
|
||||
/* ── Poll-error banner ─────────────────────────────────────────────────── */
|
||||
.prab-board-error {
|
||||
background: #fff4f4;
|
||||
border: 1px solid #f5c4c4;
|
||||
color: #8b2020;
|
||||
border-radius: 6px;
|
||||
padding: 8px 14px;
|
||||
margin: 0 20px 12px;
|
||||
font-size: 0.82rem;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.prab-board-error.is-visible {
|
||||
display: block;
|
||||
}
|
||||
238
assets/js/board.js
Normal file
238
assets/js/board.js
Normal file
@@ -0,0 +1,238 @@
|
||||
/**
|
||||
* PRAutoBlogger — Kanban Board Poller
|
||||
*
|
||||
* Live-polls the board status AJAX endpoint and updates the four columns
|
||||
* (Generating | In Review | Published | Failed) in-place without a full
|
||||
* page reload.
|
||||
*
|
||||
* Poll behaviour:
|
||||
* - Polls at `prabBoard.pollInterval` ms when any article is generating.
|
||||
* - Doubles the interval (backoff) when `has_active_runs` is false, capping
|
||||
* at 4× the base interval — prevents needless AJAX on an idle board.
|
||||
* - Resets to base interval as soon as an active run is detected again.
|
||||
*
|
||||
* Click-throughs (M1):
|
||||
* - Generating → Activity Log
|
||||
* - In Review → Review Queue
|
||||
* - Published → Post edit screen
|
||||
* - Failed → Activity Log
|
||||
* (M2 rewires all to Article Dossier)
|
||||
*
|
||||
* Localised config: `prabBoard` object (ajaxUrl, nonce, pollInterval, action,
|
||||
* strings) injected by PRAutoBlogger_Board_Page::on_enqueue_assets().
|
||||
*
|
||||
* @see includes/admin/class-board-page.php — AJAX handler + localise.
|
||||
* @see assets/css/board.css — Card and column styles.
|
||||
* @see templates/admin/board-page.php — Initial server-rendered HTML.
|
||||
*/
|
||||
( function ( $ ) {
|
||||
'use strict';
|
||||
|
||||
var cfg = window.prabBoard || {};
|
||||
var baseInterval = cfg.pollInterval || 5000;
|
||||
var maxBackoff = 4;
|
||||
var backoffFactor = 1;
|
||||
var pollTimer = null;
|
||||
var $board = null;
|
||||
var $errorBanner = null;
|
||||
|
||||
/** Escape HTML for safe DOM insertion. */
|
||||
function escHtml( str ) {
|
||||
return $( '<div>' ).text( String( str ) ).html();
|
||||
}
|
||||
|
||||
/** Format cost to 4 decimal places with a leading $. */
|
||||
function formatCost( cost ) {
|
||||
var n = parseFloat( cost ) || 0;
|
||||
return '$' + n.toFixed( 4 );
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a card HTML string from a card data object.
|
||||
*
|
||||
* @param {Object} card Card data from server.
|
||||
* @param {string} columnKey Column identifier.
|
||||
* @returns {string} HTML string.
|
||||
*/
|
||||
function buildCardHtml( card, columnKey ) {
|
||||
var isGenerating = ( columnKey === 'generating' );
|
||||
var cls = 'prab-board-card' + ( isGenerating ? ' prab-board-card--generating' : '' );
|
||||
var html = '';
|
||||
|
||||
html += '<div class="' + escHtml( cls ) + '"';
|
||||
html += ' data-run-id="' + escHtml( card.run_id || '' ) + '"';
|
||||
html += ' data-post-id="' + escHtml( card.post_id || 0 ) + '">';
|
||||
|
||||
if ( isGenerating ) {
|
||||
html += '<div class="prab-card-spinner" aria-label="' + escHtml( cfg.strings.generating ) + '"></div>';
|
||||
}
|
||||
|
||||
html += '<div class="prab-card-title">' + escHtml( card.title || cfg.strings.empty ) + '</div>';
|
||||
|
||||
if ( card.cost_total ) {
|
||||
html += '<div class="prab-card-meta"><span class="prab-card-cost">';
|
||||
html += escHtml( formatCost( card.cost_total ) );
|
||||
html += '</span></div>';
|
||||
}
|
||||
|
||||
if ( isGenerating && card.stage_current ) {
|
||||
html += '<div class="prab-card-stage">';
|
||||
/* translators: stage name */
|
||||
html += escHtml( cfg.strings.generating + ': ' + card.stage_current );
|
||||
html += '</div>';
|
||||
}
|
||||
|
||||
if ( card.error_message ) {
|
||||
var errShort = String( card.error_message ).split( ' ' ).slice( 0, 12 ).join( ' ' );
|
||||
if ( card.error_message.split( ' ' ).length > 12 ) {
|
||||
errShort += '…';
|
||||
}
|
||||
html += '<div class="prab-card-error">' + escHtml( errShort ) + '</div>';
|
||||
}
|
||||
|
||||
// Click-through link.
|
||||
var linkUrl = '';
|
||||
var linkText = '';
|
||||
if ( card.click_action === 'review' && card.review_url ) {
|
||||
linkUrl = card.review_url;
|
||||
linkText = cfg.strings.view;
|
||||
} else if ( card.click_action === 'edit' && card.edit_url ) {
|
||||
linkUrl = card.edit_url;
|
||||
linkText = cfg.strings.edit;
|
||||
} else if ( card.click_action === 'logs' && card.log_url ) {
|
||||
linkUrl = card.log_url;
|
||||
linkText = cfg.strings.viewLog;
|
||||
}
|
||||
|
||||
if ( linkUrl ) {
|
||||
html += '<div class="prab-card-actions">';
|
||||
html += '<a href="' + escHtml( linkUrl ) + '" class="prab-card-link">';
|
||||
html += escHtml( linkText );
|
||||
html += '</a></div>';
|
||||
}
|
||||
|
||||
html += '</div>';
|
||||
return html;
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-render a single column from the server snapshot.
|
||||
*
|
||||
* Updates the pill count and replaces card HTML. Avoids full DOM teardown
|
||||
* to prevent flicker on the running spinner.
|
||||
*
|
||||
* @param {string} colKey Column identifier.
|
||||
* @param {Array} cards Array of card objects.
|
||||
* @returns {void}
|
||||
*/
|
||||
function renderColumn( colKey, cards ) {
|
||||
var $col = $board.find( '[data-column="' + colKey + '"]' );
|
||||
var $cards = $col.find( '[data-column-cards]' );
|
||||
var $pill = $col.find( '.prab-col-count' );
|
||||
|
||||
$pill.text( cards.length );
|
||||
|
||||
if ( ! cards.length ) {
|
||||
$cards.html( '<p class="prab-col-empty">' + escHtml( cfg.strings.empty ) + '</p>' );
|
||||
return;
|
||||
}
|
||||
|
||||
// Build new card HTML.
|
||||
var newHtml = '';
|
||||
for ( var i = 0; i < cards.length; i++ ) {
|
||||
newHtml += buildCardHtml( cards[ i ], colKey );
|
||||
}
|
||||
$cards.html( newHtml );
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a server snapshot to all four columns.
|
||||
*
|
||||
* @param {Object} snapshot Board snapshot from AJAX.
|
||||
* @returns {void}
|
||||
*/
|
||||
function applySnapshot( snapshot ) {
|
||||
renderColumn( 'generating', snapshot.generating || [] );
|
||||
renderColumn( 'in_review', snapshot.in_review || [] );
|
||||
renderColumn( 'published', snapshot.published || [] );
|
||||
renderColumn( 'failed', snapshot.failed || [] );
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the next poll delay based on whether there are active runs.
|
||||
*
|
||||
* @param {boolean} hasActiveRuns
|
||||
* @returns {number} Milliseconds until next poll.
|
||||
*/
|
||||
function nextDelay( hasActiveRuns ) {
|
||||
if ( hasActiveRuns ) {
|
||||
backoffFactor = 1;
|
||||
return baseInterval;
|
||||
}
|
||||
backoffFactor = Math.min( backoffFactor + 1, maxBackoff );
|
||||
return baseInterval * backoffFactor;
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule the next poll after a given delay.
|
||||
*
|
||||
* @param {number} delay Milliseconds.
|
||||
* @returns {void}
|
||||
*/
|
||||
function schedulePoll( delay ) {
|
||||
clearTimeout( pollTimer );
|
||||
pollTimer = setTimeout( doPoll, delay );
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute one board poll: AJAX POST → apply snapshot → reschedule.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
function doPoll() {
|
||||
$.ajax( {
|
||||
url: cfg.ajaxUrl,
|
||||
method: 'POST',
|
||||
data: {
|
||||
action: cfg.action,
|
||||
nonce: cfg.nonce
|
||||
},
|
||||
success: function ( response ) {
|
||||
if ( $errorBanner ) {
|
||||
$errorBanner.removeClass( 'is-visible' );
|
||||
}
|
||||
if ( response && response.success && response.data ) {
|
||||
applySnapshot( response.data );
|
||||
schedulePoll( nextDelay( !! response.data.has_active_runs ) );
|
||||
} else {
|
||||
schedulePoll( nextDelay( false ) );
|
||||
}
|
||||
},
|
||||
error: function () {
|
||||
if ( $errorBanner ) {
|
||||
$errorBanner.addClass( 'is-visible' );
|
||||
}
|
||||
schedulePoll( nextDelay( false ) );
|
||||
}
|
||||
} );
|
||||
}
|
||||
|
||||
/** Initialise the board poller on DOM ready. */
|
||||
$( function () {
|
||||
$board = $( '#prab-board' );
|
||||
if ( ! $board.length ) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Inject the error banner after the heading.
|
||||
$errorBanner = $( '<div class="prab-board-error" role="alert">' + escHtml( cfg.strings.pollError || '' ) + '</div>' );
|
||||
$board.before( $errorBanner );
|
||||
|
||||
// Check if any generating card is present in the initial server render.
|
||||
var hasActive = ( $board.find( '[data-column="generating"] .prab-board-card' ).length > 0 );
|
||||
backoffFactor = hasActive ? 1 : 1; // Always start at base on first load.
|
||||
|
||||
schedulePoll( nextDelay( hasActive ) );
|
||||
} );
|
||||
|
||||
}( jQuery ) );
|
||||
Reference in New Issue
Block a user