Next healthcheck due date, plus a test suite (hc-nkq)
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.
This commit is contained in:
18
server/migrations/0002_next_due.sql
Normal file
18
server/migrations/0002_next_due.sql
Normal file
@@ -0,0 +1,18 @@
|
||||
-- Next healthcheck due date, recorded against the session that scheduled it.
|
||||
--
|
||||
-- Stored as a VARCHAR(10) 'YYYY-MM-DD' calendar date rather than a BIGINT unix
|
||||
-- timestamp on purpose: this is a diary date a human picked ("look at this site
|
||||
-- again in October"), not an instant. A timestamp would drag timezone handling
|
||||
-- into something that has no time-of-day component, and would render as the
|
||||
-- wrong day for anyone east or west of the server.
|
||||
--
|
||||
-- Kept per-session (not on a separate 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 — see Store::allSitesSummary().
|
||||
--
|
||||
-- Portable: both MySQL 8 and SQLite accept ALTER TABLE ... ADD COLUMN with a
|
||||
-- nullable typed column and no default. Neither supports ADD COLUMN IF NOT
|
||||
-- EXISTS in a form the other understands, which is fine — Migrations.php only
|
||||
-- ever applies each file once.
|
||||
|
||||
ALTER TABLE healthchecks ADD COLUMN next_due VARCHAR(10) NULL;
|
||||
@@ -149,6 +149,7 @@ final class Dashboard {
|
||||
$rows .= '<tr>
|
||||
<td><a href="' . $url . '">' . htmlspecialchars($s['site_key']) . '</a></td>
|
||||
<td>' . $badge . '</td>
|
||||
<td>' . self::nextDueCell($s['next_due'] ?? null) . '</td>
|
||||
<td>' . $lastDate . '</td>
|
||||
<td>' . $sessionStr . '</td>
|
||||
<td class="muted small">' . htmlspecialchars($s['last_reporting_url']) . '</td>
|
||||
@@ -158,7 +159,7 @@ final class Dashboard {
|
||||
<h2>All sites <span class="muted small">(' . count($sites) . ')</span></h2>
|
||||
<table>
|
||||
<thead><tr>
|
||||
<th>Site key</th><th>Latest</th><th>Last session started</th>
|
||||
<th>Site key</th><th>Latest</th><th>Next due</th><th>Last session started</th>
|
||||
<th>Sessions</th><th>Last reporting URL</th>
|
||||
</tr></thead>
|
||||
<tbody>' . $rows . '</tbody>
|
||||
@@ -187,6 +188,7 @@ final class Dashboard {
|
||||
<td>' . $badge . '</td>
|
||||
<td>' . ($finished ?? '<span class="muted">—</span>') . '</td>
|
||||
<td>' . ($duration !== '' ? $duration : '<span class="muted">—</span>') . '</td>
|
||||
<td>' . self::nextDueCell(isset($hc['next_due']) ? (string) $hc['next_due'] : null) . '</td>
|
||||
<td class="muted small">' . htmlspecialchars((string) $hc['reporting_url']) . '</td>
|
||||
</tr>';
|
||||
}
|
||||
@@ -196,7 +198,7 @@ final class Dashboard {
|
||||
<table>
|
||||
<thead><tr>
|
||||
<th>Started</th><th>Status</th><th>Finished</th>
|
||||
<th>Duration</th><th>Reporting URL</th>
|
||||
<th>Duration</th><th>Next due</th><th>Reporting URL</th>
|
||||
</tr></thead>
|
||||
<tbody>' . $rows . '</tbody>
|
||||
</table>';
|
||||
@@ -232,6 +234,7 @@ final class Dashboard {
|
||||
<span>Started: <strong>' . $started . '</strong></span>'
|
||||
. ($finished !== null ? '<span>Finished: <strong>' . $finished . '</strong></span>' : '')
|
||||
. ($duration !== null ? '<span>Duration: <strong>' . $duration . '</strong></span>' : '')
|
||||
. '<span>Next due: ' . self::nextDueCell(isset($hc['next_due']) ? (string) $hc['next_due'] : null) . '</span>'
|
||||
. '<span class="muted small">ID: ' . htmlspecialchars($hcId) . '</span>'
|
||||
. $reopenForm
|
||||
. '</div>';
|
||||
@@ -286,6 +289,32 @@ final class Dashboard {
|
||||
return '<span class="badge ' . $cls . '">' . $label . '</span>';
|
||||
}
|
||||
|
||||
/**
|
||||
* Next-due date with urgency styling, or an em-dash when nothing is scheduled.
|
||||
*
|
||||
* Comparison is a plain string compare on YYYY-MM-DD — that sorts correctly
|
||||
* by construction and keeps timezone maths out of a value that has no
|
||||
* time-of-day component.
|
||||
*/
|
||||
private static function nextDueCell(?string $nextDue): string {
|
||||
if ($nextDue === null || $nextDue === '') {
|
||||
return '<span class="muted">—</span>';
|
||||
}
|
||||
$safe = htmlspecialchars($nextDue);
|
||||
$today = date('Y-m-d');
|
||||
|
||||
if ($nextDue < $today) {
|
||||
return '<span class="badge due-overdue">' . $safe . ' · overdue</span>';
|
||||
}
|
||||
if ($nextDue === $today) {
|
||||
return '<span class="badge due-soon">' . $safe . ' · today</span>';
|
||||
}
|
||||
if ($nextDue <= date('Y-m-d', strtotime('+14 days'))) {
|
||||
return '<span class="badge due-soon">' . $safe . ' · soon</span>';
|
||||
}
|
||||
return '<span class="badge due-ok">' . $safe . '</span>';
|
||||
}
|
||||
|
||||
private static function formatStepId(string $id): string {
|
||||
return ucwords(str_replace(['-', '_'], ' ', $id));
|
||||
}
|
||||
@@ -333,6 +362,9 @@ a:hover { color: #135e96; text-decoration: underline; }
|
||||
.status-blocked { background: #fce8e8; color: #8c2020; }
|
||||
.status-na { background: #f0f0f1; color: #646970; }
|
||||
.status-not-started { background: #f6f7f7; color: #8c8f94; border: 1px solid #dcdcde; }
|
||||
.due-overdue { background: #fce8e8; color: #8c2020; }
|
||||
.due-soon { background: #fef9e7; color: #8a6d01; }
|
||||
.due-ok { background: #eef3fb; color: #1c4f8c; }
|
||||
.muted { color: #646970; }
|
||||
.small { font-size: .85em; }
|
||||
.error { color: #b32d2e; }
|
||||
|
||||
@@ -19,6 +19,7 @@ final class Healthchecks {
|
||||
'technician_id' => Validate::optionalInt($body, 'technician_id'),
|
||||
'wp_version' => Validate::optionalString($body, 'wp_version', 32),
|
||||
'php_version' => Validate::optionalString($body, 'php_version', 32),
|
||||
'next_due' => Validate::optionalDate($body, 'next_due'),
|
||||
];
|
||||
|
||||
try {
|
||||
@@ -49,6 +50,11 @@ final class Healthchecks {
|
||||
$finishedAt = Validate::optionalInt($body, 'finished_at');
|
||||
Store::updateHealthcheck($id, $finishedAt);
|
||||
}
|
||||
// Same missing-vs-null contract as finished_at: absent leaves the
|
||||
// scheduled date alone, explicit null (or '') clears it.
|
||||
if (array_key_exists('next_due', $body)) {
|
||||
Store::setNextDue($id, Validate::optionalDate($body, 'next_due'));
|
||||
}
|
||||
Http::json(200, ['ok' => true]);
|
||||
}
|
||||
|
||||
|
||||
@@ -11,8 +11,8 @@ final class Store {
|
||||
public static function insertHealthcheck(array $row): void {
|
||||
$now = time();
|
||||
$stmt = Db::pdo()->prepare('INSERT INTO healthchecks
|
||||
(id, site_key, started_at, finished_at, technician_id, reporting_url, wp_version, php_version, created_at, updated_at)
|
||||
VALUES (:id, :site_key, :started_at, NULL, :technician_id, :reporting_url, :wp_version, :php_version, :created_at, :updated_at)');
|
||||
(id, site_key, started_at, finished_at, technician_id, reporting_url, wp_version, php_version, next_due, created_at, updated_at)
|
||||
VALUES (:id, :site_key, :started_at, NULL, :technician_id, :reporting_url, :wp_version, :php_version, :next_due, :created_at, :updated_at)');
|
||||
$stmt->execute([
|
||||
':id' => $row['id'],
|
||||
':site_key' => $row['site_key'],
|
||||
@@ -21,6 +21,7 @@ final class Store {
|
||||
':reporting_url' => $row['reporting_url'],
|
||||
':wp_version' => $row['wp_version'] ?? null,
|
||||
':php_version' => $row['php_version'] ?? null,
|
||||
':next_due' => $row['next_due'] ?? null,
|
||||
':created_at' => $now,
|
||||
':updated_at' => $now,
|
||||
]);
|
||||
@@ -43,6 +44,22 @@ final class Store {
|
||||
return $stmt->rowCount() > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set (or clear, with null) the next-due date on one healthcheck.
|
||||
*
|
||||
* Deliberately separate from updateHealthcheck() so that finishing or
|
||||
* reopening a session never touches the scheduled date, and vice versa.
|
||||
*/
|
||||
public static function setNextDue(string $id, ?string $nextDue): bool {
|
||||
$stmt = Db::pdo()->prepare('UPDATE healthchecks SET next_due = :next_due, updated_at = :updated_at WHERE id = :id');
|
||||
$stmt->execute([
|
||||
':id' => $id,
|
||||
':next_due' => $nextDue,
|
||||
':updated_at' => time(),
|
||||
]);
|
||||
return $stmt->rowCount() > 0;
|
||||
}
|
||||
|
||||
public static function upsertStep(string $healthcheckId, string $stepId, array $row): void {
|
||||
$pdo = Db::pdo();
|
||||
$driver = $pdo->getAttribute(\PDO::ATTR_DRIVER_NAME);
|
||||
@@ -180,7 +197,12 @@ final class Store {
|
||||
/**
|
||||
* Summary of all sites for the dashboard: one row per site_key,
|
||||
* ordered by most-recently-active first.
|
||||
* @return array<int, array{site_key:string, total:int, finished:int, last_started_at:int, last_finished_at:int|null, last_reporting_url:string}>
|
||||
* `next_due` is taken from the most recent session, not from the most recent
|
||||
* session that happens to have one set. If the latest visit didn't schedule a
|
||||
* return, the site genuinely has nothing scheduled — carrying an older date
|
||||
* forward would show a visit as overdue immediately after it was completed.
|
||||
*
|
||||
* @return array<int, array{site_key:string, total:int, finished:int, last_started_at:int, last_finished_at:int|null, last_reporting_url:string, next_due:string|null}>
|
||||
*/
|
||||
public static function allSitesSummary(): array {
|
||||
$stmt = Db::pdo()->query(
|
||||
@@ -198,7 +220,7 @@ final class Store {
|
||||
$out = [];
|
||||
foreach ($rows as $r) {
|
||||
$detail = Db::pdo()->prepare(
|
||||
'SELECT reporting_url, finished_at FROM healthchecks
|
||||
'SELECT reporting_url, finished_at, next_due FROM healthchecks
|
||||
WHERE site_key = ? ORDER BY started_at DESC LIMIT 1'
|
||||
);
|
||||
$detail->execute([$r['site_key']]);
|
||||
@@ -210,6 +232,9 @@ final class Store {
|
||||
'last_started_at' => (int) $r['last_started_at'],
|
||||
'last_finished_at' => isset($last['finished_at']) && $last['finished_at'] !== null ? (int) $last['finished_at'] : null,
|
||||
'last_reporting_url' => (string) ($last['reporting_url'] ?? ''),
|
||||
'next_due' => isset($last['next_due']) && $last['next_due'] !== null && $last['next_due'] !== ''
|
||||
? (string) $last['next_due']
|
||||
: null,
|
||||
];
|
||||
}
|
||||
return $out;
|
||||
|
||||
@@ -38,6 +38,29 @@ final class Validate {
|
||||
return $body[$key];
|
||||
}
|
||||
|
||||
/**
|
||||
* A calendar date as 'YYYY-MM-DD', or null.
|
||||
*
|
||||
* Absent, null, and '' all mean "no date" (the plugin sends '' when the tech
|
||||
* clears the field). Anything else must be a real date in exactly that
|
||||
* format — the round-trip comparison rejects both sloppy input ('2026-2-3')
|
||||
* and impossible dates ('2026-02-30', which createFromFormat would silently
|
||||
* roll forward to March 2nd).
|
||||
*/
|
||||
public static function optionalDate(array $body, string $key): ?string {
|
||||
if (!isset($body[$key])) return null;
|
||||
if (!is_string($body[$key])) self::fail("{$key} must be a YYYY-MM-DD date string or null");
|
||||
|
||||
$value = trim($body[$key]);
|
||||
if ($value === '') return null;
|
||||
|
||||
$date = \DateTimeImmutable::createFromFormat('!Y-m-d', $value);
|
||||
if ($date === false || $date->format('Y-m-d') !== $value) {
|
||||
self::fail("{$key} must be a valid calendar date in YYYY-MM-DD format");
|
||||
}
|
||||
return $value;
|
||||
}
|
||||
|
||||
public static function status(string $status): string {
|
||||
$valid = ['not_started', 'done', 'skipped', 'blocked', 'n_a'];
|
||||
if (!in_array($status, $valid, true)) self::fail('status must be one of ' . implode(', ', $valid));
|
||||
|
||||
Reference in New Issue
Block a user