Lets a technician record when a site should next be looked at, and surfaces that in the dashboard so it can be used for planning. Server: - migration 0002 adds healthchecks.next_due, a VARCHAR(10) 'YYYY-MM-DD' calendar date rather than a timestamp — it's a diary date with no time-of-day, and a timestamp would render as the wrong day off-server. - Kept per-session rather than on a sites table, so the history of what was scheduled when is preserved. A site's current next-due is the value on its most recent session: if the latest visit scheduled nothing, the site reads as unscheduled rather than showing the just-completed visit as overdue. - Validate::optionalDate() round-trips through createFromFormat, which rejects both '2026-2-3' and '2026-02-30' (silently rolled to March 2nd). - next_due follows the same missing-vs-null PUT contract as finished_at, and lives in its own Store::setNextDue() so finishing or reopening a session never disturbs the date and vice versa. - Dashboard shows it on the site list, the per-site session table and the session detail, flagged overdue / today / soon (within a fortnight). Plugin: - Date control on both the active and finished panels — the moment you know when to return is often wrap-up, after the report is generated. - Write-through like every other mutation. Bad input is rejected with a notice rather than silently clearing an existing date. - Included in both the Markdown and HTML reports. Tests (new — tests/, export-ignored from the plugin zip): - Hand-rolled harness, no composer/PHPUnit, in keeping with the repo. - 42 tests: plugin-side date parsing units, plus server integration tests driven over real HTTP against a temp `php -S` instance with a throwaway SQLite DB, so a real server/config.php is never touched. - Mutation-checked: dropping the array_key_exists guard on PUT fails three tests, as intended.
221 lines
9.5 KiB
PHP
221 lines
9.5 KiB
PHP
<?php
|
||
if (!defined('ABSPATH')) exit;
|
||
|
||
/**
|
||
* Build a Markdown report for a healthcheck session.
|
||
* Per bead hc-5ix.7: pure Markdown, downloadable. No DB-side report storage
|
||
* (plugin may be uninstalled at the end of the engagement).
|
||
*/
|
||
function att_hc_build_markdown_report(ATT_HC_Session $session): string {
|
||
$tech = get_userdata($session->technician_id());
|
||
$tech_name = $tech ? $tech->display_name : '#' . $session->technician_id();
|
||
|
||
$lines = [];
|
||
$lines[] = '# Site Healthcheck — ' . $session->site_url();
|
||
$lines[] = '';
|
||
$lines[] = '- **Started:** ' . date('Y-m-d H:i', $session->started_at());
|
||
if ($session->is_finished()) {
|
||
$lines[] = '- **Finished:** ' . date('Y-m-d H:i', (int) $session->finished_at());
|
||
$minutes = max(1, (int) round(((int) $session->finished_at() - $session->started_at()) / 60));
|
||
$lines[] = '- **Duration:** ~' . $minutes . ' minute(s)';
|
||
}
|
||
$lines[] = '- **Technician:** ' . $tech_name;
|
||
$lines[] = '- **Site:** ' . $session->site_url();
|
||
$lines[] = '- **WordPress:** ' . $session->wp_version();
|
||
$lines[] = '- **PHP:** ' . $session->php_version();
|
||
if ($next_due = $session->next_due()) {
|
||
$lines[] = '- **Next healthcheck due:** ' . $next_due;
|
||
}
|
||
$lines[] = '- **Session ID:** ' . $session->id();
|
||
$lines[] = '';
|
||
|
||
// Summary table
|
||
$lines[] = '## Summary';
|
||
$lines[] = '';
|
||
$lines[] = '| Step | Status |';
|
||
$lines[] = '|---|---|';
|
||
foreach (ATT_HC_Steps::instance()->all() as $step) {
|
||
$state = $session->step_state($step->id());
|
||
$lines[] = '| ' . $step->title() . ' | ' . att_hc_status_label($state['status']) . ' |';
|
||
}
|
||
$lines[] = '';
|
||
|
||
// Per-step detail
|
||
$lines[] = '## Detail';
|
||
$lines[] = '';
|
||
foreach (ATT_HC_Steps::instance()->all() as $step) {
|
||
$state = $session->step_state($step->id());
|
||
$lines[] = '### ' . $step->title();
|
||
$lines[] = '';
|
||
$lines[] = '_Status: ' . att_hc_status_label($state['status']) . '_';
|
||
if ($state['updated_at']) {
|
||
$lines[] = '_Saved: ' . date('Y-m-d H:i', $state['updated_at']) . '_';
|
||
}
|
||
if ($blurb = $step->blurb()) {
|
||
$lines[] = '';
|
||
$lines[] = $blurb;
|
||
}
|
||
if ($items = $step->sub_items()) {
|
||
$lines[] = '';
|
||
foreach ($items as $item) {
|
||
$lines[] = '- [ ] ' . $item;
|
||
}
|
||
}
|
||
if (!empty($state['notes'])) {
|
||
$lines[] = '';
|
||
$lines[] = '**Notes:**';
|
||
$lines[] = '';
|
||
foreach (preg_split('/\R/', (string) $state['notes']) as $nl) {
|
||
$lines[] = '> ' . $nl;
|
||
}
|
||
}
|
||
|
||
$auto = $session->get_autocheck($step->id());
|
||
if ($auto && !empty($auto['findings'])) {
|
||
$lines[] = '';
|
||
$lines[] = '**Automated checks** (run ' . date('Y-m-d H:i', (int) ($auto['checked_at'] ?? 0)) . '):';
|
||
$lines[] = '';
|
||
foreach ($auto['findings'] as $finding) {
|
||
$icon = ['ok' => '✅', 'warn' => '⚠️', 'bad' => '❌', 'info' => 'ℹ️'][$finding['level']] ?? '·';
|
||
$bits = [$icon, '**' . $finding['label'] . '**'];
|
||
if (!empty($finding['value'])) $bits[] = $finding['value'];
|
||
if (!empty($finding['detail'])) $bits[] = '— ' . $finding['detail'];
|
||
$lines[] = '- ' . implode(' ', $bits);
|
||
}
|
||
}
|
||
if ($state['status'] === ATT_HC_Session::STATUS_BLOCKED && ($esc = $step->escalation())) {
|
||
$lines[] = '';
|
||
$lines[] = '> ⚠ **Escalation:** ' . $esc;
|
||
}
|
||
$lines[] = '';
|
||
}
|
||
|
||
$lines[] = '---';
|
||
$lines[] = '_Generated by Site Healthcheck plugin v' . ATT_HC_VERSION . '_';
|
||
|
||
return implode("\n", $lines) . "\n";
|
||
}
|
||
|
||
function att_hc_build_html_report(ATT_HC_Session $session): string {
|
||
$tech = get_userdata($session->technician_id());
|
||
$tech_name = $tech ? $tech->display_name : '#' . $session->technician_id();
|
||
|
||
ob_start();
|
||
?>
|
||
<!doctype html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<title>Site Healthcheck — <?php echo esc_html($session->site_url()); ?></title>
|
||
<style>
|
||
body { font: 14px/1.55 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; color: #1d1d1f; background: #f7f7f8; margin: 0; padding: 2rem; }
|
||
.wrap { max-width: 820px; margin: 0 auto; background: #fff; border-radius: 10px; padding: 2rem 2.4rem; box-shadow: 0 1px 3px rgba(0,0,0,.06); }
|
||
h1 { margin: 0 0 .25rem; font-size: 1.6rem; }
|
||
h2 { margin: 1.5rem 0 .5rem; font-size: 1.15rem; border-bottom: 1px solid #e5e5ea; padding-bottom: .25rem; }
|
||
h3 { margin: 1.2rem 0 .35rem; font-size: 1rem; }
|
||
table { width: 100%; border-collapse: collapse; font-size: .92em; margin: .4rem 0; }
|
||
th, td { text-align: left; padding: .35rem .55rem; border-bottom: 1px solid #f0f0f1; vertical-align: top; }
|
||
th { background: #fafafb; font-weight: 600; }
|
||
.meta { color: #646970; }
|
||
.pill { display: inline-block; padding: 1px 8px; border-radius: 10px; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: .04em; }
|
||
.pill.done { background: #def7e3; color: #155724; }
|
||
.pill.skipped { background: #fff3cd; color: #856404; }
|
||
.pill.blocked { background: #fbeae8; color: #721c24; }
|
||
.pill.n_a { background: #e2e3e5; color: #41464b; }
|
||
.pill.not_started { background: #f0f0f1; color: #646970; }
|
||
blockquote { margin: .5rem 0; padding: .35rem .9rem; border-left: 3px solid #c3c4c7; background: #fafafa; color: #444; }
|
||
ul.findings { list-style: none; padding-left: 0; }
|
||
ul.findings li { padding: .15rem 0; }
|
||
.lvl-ok::before { content: '✓ '; color: #1a8917; font-weight: 700; }
|
||
.lvl-warn::before { content: '⚠ '; color: #b07a00; font-weight: 700; }
|
||
.lvl-bad::before { content: '✗ '; color: #c0392b; font-weight: 700; }
|
||
.lvl-info::before { content: '· '; color: #646970; font-weight: 700; }
|
||
.detail { color: #646970; font-size: .92em; }
|
||
footer { margin-top: 2rem; color: #646970; font-size: .85em; text-align: center; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="wrap">
|
||
<h1>Site Healthcheck</h1>
|
||
<p class="meta"><?php echo esc_html($session->site_url()); ?></p>
|
||
|
||
<table>
|
||
<tr><th>Started</th><td><?php echo esc_html(date('Y-m-d H:i', $session->started_at())); ?></td></tr>
|
||
<?php if ($session->is_finished()): $mins = max(1, (int) round(((int) $session->finished_at() - $session->started_at()) / 60)); ?>
|
||
<tr><th>Finished</th><td><?php echo esc_html(date('Y-m-d H:i', (int) $session->finished_at())); ?> (≈<?php echo $mins; ?> min)</td></tr>
|
||
<?php endif; ?>
|
||
<tr><th>Technician</th><td><?php echo esc_html($tech_name); ?></td></tr>
|
||
<tr><th>WordPress</th><td><?php echo esc_html($session->wp_version()); ?></td></tr>
|
||
<tr><th>PHP</th><td><?php echo esc_html($session->php_version()); ?></td></tr>
|
||
<?php if ($next_due = $session->next_due()) : ?>
|
||
<tr><th>Next healthcheck due</th><td><?php echo esc_html($next_due); ?></td></tr>
|
||
<?php endif; ?>
|
||
</table>
|
||
|
||
<h2>Summary</h2>
|
||
<table>
|
||
<thead><tr><th>Step</th><th>Status</th></tr></thead>
|
||
<tbody>
|
||
<?php foreach (ATT_HC_Steps::instance()->all() as $step):
|
||
$state = $session->step_state($step->id());
|
||
?>
|
||
<tr>
|
||
<td><?php echo esc_html($step->title()); ?></td>
|
||
<td><span class="pill <?php echo esc_attr($state['status']); ?>"><?php echo esc_html(str_replace('_', ' ', $state['status'])); ?></span></td>
|
||
</tr>
|
||
<?php endforeach; ?>
|
||
</tbody>
|
||
</table>
|
||
|
||
<h2>Detail</h2>
|
||
<?php foreach (ATT_HC_Steps::instance()->all() as $step):
|
||
$state = $session->step_state($step->id());
|
||
$auto = $session->get_autocheck($step->id());
|
||
?>
|
||
<h3><?php echo esc_html($step->title()); ?></h3>
|
||
<p><span class="pill <?php echo esc_attr($state['status']); ?>"><?php echo esc_html(str_replace('_', ' ', $state['status'])); ?></span></p>
|
||
<?php if ($blurb = $step->blurb()): ?>
|
||
<p><?php echo esc_html($blurb); ?></p>
|
||
<?php endif; ?>
|
||
<?php if ($items = $step->sub_items()): ?>
|
||
<ul>
|
||
<?php foreach ($items as $i): ?>
|
||
<li><?php echo esc_html($i); ?></li>
|
||
<?php endforeach; ?>
|
||
</ul>
|
||
<?php endif; ?>
|
||
<?php if (!empty($state['notes'])): ?>
|
||
<blockquote><?php echo nl2br(esc_html($state['notes'])); ?></blockquote>
|
||
<?php endif; ?>
|
||
<?php if ($auto && !empty($auto['findings'])): ?>
|
||
<ul class="findings">
|
||
<?php foreach ($auto['findings'] as $f): ?>
|
||
<li class="lvl-<?php echo esc_attr($f['level']); ?>">
|
||
<strong><?php echo esc_html($f['label']); ?></strong>
|
||
<?php if ($f['value'] !== ''): ?>— <?php echo esc_html($f['value']); ?><?php endif; ?>
|
||
<?php if ($f['detail'] !== ''): ?><span class="detail"> — <?php echo esc_html($f['detail']); ?></span><?php endif; ?>
|
||
</li>
|
||
<?php endforeach; ?>
|
||
</ul>
|
||
<?php endif; ?>
|
||
<?php endforeach; ?>
|
||
|
||
<footer>Generated by Site Healthcheck plugin v<?php echo esc_html(ATT_HC_VERSION); ?> · <?php echo esc_html(date('Y-m-d H:i')); ?></footer>
|
||
</div>
|
||
</body>
|
||
</html>
|
||
<?php
|
||
return (string) ob_get_clean();
|
||
}
|
||
|
||
function att_hc_status_label(string $status): string {
|
||
switch ($status) {
|
||
case ATT_HC_Session::STATUS_DONE: return '✅ Done';
|
||
case ATT_HC_Session::STATUS_SKIPPED: return '⏭ Skipped';
|
||
case ATT_HC_Session::STATUS_BLOCKED: return '🛑 Blocked';
|
||
case ATT_HC_Session::STATUS_NA: return '— N/A';
|
||
case ATT_HC_Session::STATUS_NOT_STARTED:
|
||
default: return '◻ Not started';
|
||
}
|
||
}
|