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

@@ -7,6 +7,7 @@ add_action('admin_post_att_hc_resume', 'att_hc_handle_resume');
add_action('wp_ajax_att_hc_step_history', 'att_hc_handle_step_history_ajax');
add_action('admin_post_att_hc_save_step', 'att_hc_handle_save_step');
add_action('admin_post_att_hc_finish', 'att_hc_handle_finish');
add_action('admin_post_att_hc_save_next_due', 'att_hc_handle_save_next_due');
add_action('admin_post_att_hc_discard', 'att_hc_handle_discard');
add_action('admin_post_att_hc_download_report', 'att_hc_handle_download_report');
add_action('admin_post_att_hc_refresh_checks', 'att_hc_handle_refresh_checks');
@@ -53,6 +54,9 @@ function att_hc_inline_css(): string {
.att-hc-status-skipped { background:#fff3cd; color:#856404; }
.att-hc-status-blocked { background:#fbeae8; color:#721c24; }
.att-hc-status-n_a { background:#e2e3e5; color:#41464b; }
.att-hc-next-due { margin:.5rem 0 .75rem; padding:.5rem .75rem; background:#f6f7f7; border:1px solid #dcdcde; border-radius:4px; }
.att-hc-next-due form { display:flex; flex-wrap:wrap; align-items:center; gap:.5rem; }
.att-hc-next-due .description { flex-basis:100%; }
.att-hc-step { padding:1rem 1.25rem; border:1px solid #dcdcde; border-radius:6px; margin-bottom:.75rem; background:#fff; }
.att-hc-step header { display:flex; justify-content:space-between; align-items:center; gap:1rem; margin-bottom:.5rem; }
.att-hc-step header h2 { margin:0; font-size:1.1rem; }
@@ -133,6 +137,12 @@ function att_hc_render_admin_page(): void {
echo '<div class="notice notice-error"><p><strong>Central history server:</strong> ' . esc_html($err) . '</p></div>';
}
// Local (non-server) problems — bad input and the like.
if ($err = get_transient('att_hc_error')) {
delete_transient('att_hc_error');
echo '<div class="notice notice-error is-dismissible"><p>' . esc_html($err) . '</p></div>';
}
if (!$session) {
att_hc_render_start_panel();
echo '</div>';
@@ -255,6 +265,7 @@ function att_hc_render_active_session(ATT_HC_Session $session): void {
<span class="att-hc-progress"><?php echo (int) $progress['done']; ?> / <?php echo (int) $progress['total']; ?> steps</span>
</p>
<p>WP <code><?php echo esc_html($session->wp_version()); ?></code> · PHP <code><?php echo esc_html($session->php_version()); ?></code> · Site <code><?php echo esc_html($session->site_url()); ?></code></p>
<?php att_hc_render_next_due_form($session); ?>
<div class="att-hc-actions">
<form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>" onsubmit="return confirm('Mark this healthcheck as finished?');" style="display:inline">
<?php wp_nonce_field('att_hc_finish'); ?>
@@ -295,6 +306,30 @@ function att_hc_render_active_session(ATT_HC_Session $session): void {
echo '</div></div>';
}
/**
* "Next healthcheck due" control. Shown on both the active and the finished
* panel — the point at which you know when to come back is often wrap-up, which
* may be after the report has already been generated.
*/
function att_hc_render_next_due_form(ATT_HC_Session $session): void {
$due = $session->next_due();
?>
<div class="att-hc-next-due">
<form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>">
<?php wp_nonce_field('att_hc_save_next_due'); ?>
<input type="hidden" name="action" value="att_hc_save_next_due">
<label for="att-hc-next-due"><strong>Next healthcheck due:</strong></label>
<input type="date" id="att-hc-next-due" name="next_due" value="<?php echo esc_attr((string) $due); ?>">
<button class="button">Save date</button>
<?php if ($due !== null) : ?>
<button class="button button-link" name="next_due_clear" value="1">Clear</button>
<?php endif; ?>
<span class="description">Shown against this site on the healthcheck dashboard.</span>
</form>
</div>
<?php
}
function att_hc_print_step_history_assets(ATT_HC_Session $session): void {
$cfg = [
'ajaxUrl' => admin_url('admin-ajax.php'),
@@ -546,6 +581,7 @@ function att_hc_render_finished_panel(ATT_HC_Session $session): void {
<div class="att-hc-card">
<h2>Healthcheck finished</h2>
<p>Started <?php echo esc_html(date('Y-m-d H:i', $session->started_at())); ?> · Finished <?php echo esc_html(date('Y-m-d H:i', (int) $session->finished_at())); ?></p>
<?php att_hc_render_next_due_form($session); ?>
<div class="att-hc-actions">
<form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>" style="display:inline">
<?php wp_nonce_field('att_hc_download_report'); ?>
@@ -601,6 +637,41 @@ function att_hc_handle_start(): void {
exit;
}
function att_hc_handle_save_next_due(): void {
if (!current_user_can('manage_options')) wp_die('Forbidden');
check_admin_referer('att_hc_save_next_due');
$redirect = admin_url('tools.php?page=att-site-healthcheck');
$session = ATT_HC_Session::current();
if (!$session) wp_die('No active session.');
$clear = !empty($_POST['next_due_clear']);
$raw = isset($_POST['next_due']) ? sanitize_text_field(wp_unslash((string) $_POST['next_due'])) : '';
$date = $clear ? null : ATT_HC_Session::sanitise_due_date($raw);
// Distinguish "cleared the field" (legitimate) from "typed something that
// isn't a date" — silently clearing on bad input would lose the existing date.
if (!$clear && $raw !== '' && $date === null) {
set_transient('att_hc_error', 'Could not save the next healthcheck date: "' . $raw . '" is not a valid date (expected YYYY-MM-DD).', 60);
wp_safe_redirect($redirect);
exit;
}
try {
$session->set_next_due($date);
set_transient(
'att_hc_install_message',
$date === null ? 'Next healthcheck date cleared.' : 'Next healthcheck due ' . $date . '.',
60
);
} catch (ATT_HC_Api_Exception $e) {
set_transient('att_hc_api_error', 'Could not save the next healthcheck date: ' . $e->getMessage(), 60);
}
wp_safe_redirect($redirect);
exit;
}
function att_hc_handle_step_history_ajax(): void {
if (!current_user_can('manage_options')) wp_send_json_error('Forbidden', 403);
if (!check_ajax_referer('att_hc_step_history', 'nonce', false)) {