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:
2026-07-23 12:02:39 +01:00
parent f551b64e2e
commit 631385721f
17 changed files with 1116 additions and 12 deletions

View 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;

View File

@@ -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; }

View File

@@ -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]);
}

View File

@@ -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;

View File

@@ -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));