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:
@@ -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)) {
|
||||
|
||||
@@ -70,6 +70,7 @@ final class ATT_HC_Session {
|
||||
'site_url' => $reporting_url,
|
||||
'wp_version' => get_bloginfo('version'),
|
||||
'php_version' => PHP_VERSION,
|
||||
'next_due' => null,
|
||||
'steps' => [],
|
||||
];
|
||||
|
||||
@@ -180,6 +181,12 @@ final class ATT_HC_Session {
|
||||
public function php_version(): string { return (string) ($this->data['php_version'] ?? ''); }
|
||||
public function data(): array { return $this->data; }
|
||||
|
||||
/** Scheduled date of the next healthcheck for this site, as 'YYYY-MM-DD', or null. */
|
||||
public function next_due(): ?string {
|
||||
$raw = $this->data['next_due'] ?? null;
|
||||
return is_string($raw) && $raw !== '' ? $raw : null;
|
||||
}
|
||||
|
||||
public function step_state(string $step_id): array {
|
||||
return $this->data['steps'][$step_id] ?? [
|
||||
'status' => self::STATUS_NOT_STARTED,
|
||||
@@ -214,6 +221,40 @@ final class ATT_HC_Session {
|
||||
update_option(ATT_HC_OPT_SESSION, $this->data, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule (or clear, with null) the next healthcheck for this site.
|
||||
*
|
||||
* Write-through like every other mutation: the server PUT has to succeed
|
||||
* before the local cache moves. Allowed on a finished session too — the
|
||||
* natural moment to decide when to come back is at wrap-up, which may be
|
||||
* after the report has been generated.
|
||||
*
|
||||
* @param string|null $date 'YYYY-MM-DD', or null to clear.
|
||||
* @throws ATT_HC_Api_Exception
|
||||
*/
|
||||
public function set_next_due(?string $date): void {
|
||||
$date = $date === null ? null : self::sanitise_due_date($date);
|
||||
ATT_HC_Api::update_healthcheck($this->id(), ['next_due' => $date]);
|
||||
$this->data['next_due'] = $date;
|
||||
update_option(ATT_HC_OPT_SESSION, $this->data, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalise a user-entered date to 'YYYY-MM-DD', or null if it isn't a real
|
||||
* calendar date in that format.
|
||||
*
|
||||
* The round-trip comparison is what makes this strict: createFromFormat
|
||||
* happily accepts '2026-2-3' and rolls '2026-02-30' forward into March, and
|
||||
* both re-format to something other than what was typed.
|
||||
*/
|
||||
public static function sanitise_due_date(string $raw): ?string {
|
||||
$raw = trim($raw);
|
||||
if ($raw === '') return null;
|
||||
$date = DateTimeImmutable::createFromFormat('!Y-m-d', $raw);
|
||||
if ($date === false || $date->format('Y-m-d') !== $raw) return null;
|
||||
return $raw;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark the session finished. Server PUT first, then cache update.
|
||||
*
|
||||
@@ -294,6 +335,9 @@ final class ATT_HC_Session {
|
||||
'site_url' => (string) $remote['reporting_url'],
|
||||
'wp_version' => (string) ($remote['wp_version'] ?? ''),
|
||||
'php_version' => (string) ($remote['php_version'] ?? ''),
|
||||
'next_due' => isset($remote['next_due']) && $remote['next_due'] !== null && $remote['next_due'] !== ''
|
||||
? (string) $remote['next_due']
|
||||
: null,
|
||||
'steps' => [],
|
||||
'autocheck' => [],
|
||||
];
|
||||
|
||||
@@ -23,6 +23,9 @@ function att_hc_build_markdown_report(ATT_HC_Session $session): string {
|
||||
$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[] = '';
|
||||
|
||||
@@ -144,6 +147,9 @@ function att_hc_build_html_report(ATT_HC_Session $session): string {
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user