data = $data; } public static function current(): ?self { $raw = get_option(ATT_HC_OPT_SESSION); if (!is_array($raw) || empty($raw['id'])) return null; return new self($raw); } /** * Start a new session. POSTs to the central server first; on success * caches the session locally so subsequent renders don't refetch. * * @param int $technician_id WP user id * @param string|null $site_key logical engagement identifier. Falls back to * normalised get_site_url() when null — the * start-screen UI (hc-m1a) lets the tech edit it. * @throws ATT_HC_Api_Exception when the server rejects or is unreachable. */ public static function start(int $technician_id, ?string $site_key = null): self { $reporting_url = get_site_url(); $site_key = $site_key !== null && $site_key !== '' ? $site_key : self::normalise_site_url($reporting_url); $data = [ 'id' => uniqid('att_hc_', true), 'started_at' => time(), 'finished_at' => null, 'technician_id' => $technician_id, 'site_key' => $site_key, 'reporting_url' => $reporting_url, // Kept as a display-only echo of where this install reports from. // Pre-existing report templates still read `site_url` — keep populated. 'site_url' => $reporting_url, 'wp_version' => get_bloginfo('version'), 'php_version' => PHP_VERSION, 'next_due' => null, 'steps' => [], ]; ATT_HC_Api::create_healthcheck([ 'id' => $data['id'], 'site_key' => $data['site_key'], 'started_at' => $data['started_at'], 'reporting_url' => $data['reporting_url'], 'technician_id' => $data['technician_id'], 'wp_version' => $data['wp_version'], 'php_version' => $data['php_version'], ]); update_option(ATT_HC_OPT_SESSION, $data, false); $session = new self($data); // Carry handover notes from the prior session into the new "before" // notes. Best-effort: a failure here must not block session creation, // since the session is already registered on the server above. self::seed_before_notes_from_prior_handover($session); return $session; } /** * If the prior session for this site left "Notes for next time" (id=handover), * pre-fill the new session's "Before You Start" (id=before) notes with them, * prefixed with the prior session's date so it's clear they're carried over. */ private static function seed_before_notes_from_prior_handover(self $session): void { try { $resp = ATT_HC_Api::step_history('handover', $session->site_key(), 1, $session->id()); } catch (ATT_HC_Api_Exception $e) { return; } $rows = $resp['history'] ?? []; if (!$rows) return; $notes = trim((string) ($rows[0]['notes'] ?? '')); if ($notes === '') return; $prefix = 'From previous session (' . date('Y-m-d', (int) $rows[0]['started_at']) . "):\n"; try { $session->update_step('before', self::STATUS_NOT_STARTED, $prefix . $notes); } catch (ATT_HC_Api_Exception $e) { // Already-rare degrade path — the session is alive, just no auto-seed. } } /** * Resume an existing server-side session by id. Pulls the full state down * (including step history) and writes it into the local cache as the active * session. Used by the start-screen "Resume" button (hc-m1a). * * @throws ATT_HC_Api_Exception */ public static function resume(string $id): self { $remote = ATT_HC_Api::get_healthcheck($id); $data = self::hydrate_from_remote($remote); update_option(ATT_HC_OPT_SESSION, $data, false); return new self($data); } /** Drop the local cache. The server keeps the record — history is the point. */ public static function discard(): void { delete_option(ATT_HC_OPT_SESSION); } /** * The most recent FINISHED server-side session for this site, excluding the * active one. Used for the existing diff/review-items panel. * * Returns null when there's no prior history (or when the server is * unreachable — we degrade silently here because the diff is a nice-to-have, * not a blocker for completing a healthcheck). */ public static function previous(?string $site_key = null, ?string $exclude_id = null): ?self { if ($site_key === null) { $current = self::current(); if (!$current) return null; $site_key = $current->site_key(); $exclude_id = $exclude_id ?? $current->id(); } try { $response = ATT_HC_Api::list_healthchecks($site_key, true, 10); } catch (ATT_HC_Api_Exception $e) { return null; } foreach ($response['healthchecks'] ?? [] as $hc) { if (!empty($hc['finished_at']) && ($exclude_id === null || $hc['id'] !== $exclude_id)) { return new self(self::hydrate_from_remote($hc)); } } return null; } public function id(): string { return (string) $this->data['id']; } public function started_at(): int { return (int) $this->data['started_at']; } public function finished_at(): ?int { return isset($this->data['finished_at']) ? (int) $this->data['finished_at'] : null; } public function is_finished(): bool { return $this->finished_at() !== null; } public function technician_id(): int { return (int) ($this->data['technician_id'] ?? 0); } public function site_url(): string { return (string) ($this->data['site_url'] ?? $this->data['reporting_url'] ?? get_site_url()); } public function site_key(): string { return (string) ($this->data['site_key'] ?? self::normalise_site_url(get_site_url())); } public function reporting_url(): string { return (string) ($this->data['reporting_url'] ?? get_site_url()); } public function wp_version(): string { return (string) ($this->data['wp_version'] ?? ''); } 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, 'notes' => '', 'updated_at' => null, ]; } /** * Write-through step update. Sends the full step state (status + notes + * any prior autocheck) to the server first, then updates the local cache. * * @throws ATT_HC_Api_Exception */ public function update_step(string $step_id, string $status, string $notes): void { if (!in_array($status, self::VALID_STATUSES, true)) { $status = self::STATUS_NOT_STARTED; } $autocheck = $this->data['autocheck'][$step_id] ?? null; ATT_HC_Api::upsert_step($this->id(), $step_id, [ 'status' => $status, 'notes' => $notes, 'reporting_url' => $this->reporting_url(), 'autocheck' => $autocheck, ]); $this->data['steps'][$step_id] = [ 'status' => $status, 'notes' => $notes, 'updated_at' => time(), ]; 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. * * @throws ATT_HC_Api_Exception */ public function finish(): void { $finished_at = time(); ATT_HC_Api::update_healthcheck($this->id(), ['finished_at' => $finished_at]); $this->data['finished_at'] = $finished_at; update_option(ATT_HC_OPT_SESSION, $this->data, false); } /** * Store the result of running autocheck() on a step. Write-through: * we PUT the step row (preserving any existing status + notes) with the * new autocheck blob attached. * * @throws ATT_HC_Api_Exception */ public function set_autocheck(string $step_id, array $findings): void { $blob = ['checked_at' => time(), 'findings' => $findings]; $state = $this->step_state($step_id); ATT_HC_Api::upsert_step($this->id(), $step_id, [ 'status' => $state['status'], 'notes' => (string) $state['notes'], 'reporting_url' => $this->reporting_url(), 'autocheck' => $blob, ]); $this->data['autocheck'][$step_id] = $blob; update_option(ATT_HC_OPT_SESSION, $this->data, false); } /** Returns ['checked_at'=>int, 'findings'=>array] or null. */ public function get_autocheck(string $step_id): ?array { return $this->data['autocheck'][$step_id] ?? null; } public function progress(): array { $steps = ATT_HC_Steps::instance()->all(); $total = count($steps); $done = 0; foreach ($steps as $step) { $st = $this->step_state($step->id()); if (in_array($st['status'], [self::STATUS_DONE, self::STATUS_SKIPPED, self::STATUS_NA], true)) { $done++; } } return ['done' => $done, 'total' => $total]; } /** * lowercase, strip scheme + leading www., trim trailing slash. The default * site_key — the tech can override at start time (hc-m1a). */ public static function normalise_site_url(string $url): string { $host = parse_url($url, PHP_URL_HOST) ?: $url; $host = strtolower($host); if (substr($host, 0, 4) === 'www.') $host = substr($host, 4); $path = parse_url($url, PHP_URL_PATH) ?: ''; $path = rtrim($path, '/'); return $host . $path; } /** * Turn a server `GET /healthchecks/{id}` (or a `?include=steps` list row) * into the local cache shape. */ private static function hydrate_from_remote(array $remote): array { $data = [ 'id' => (string) $remote['id'], 'started_at' => (int) $remote['started_at'], 'finished_at' => isset($remote['finished_at']) && $remote['finished_at'] !== null ? (int) $remote['finished_at'] : null, 'technician_id' => isset($remote['technician_id']) ? (int) $remote['technician_id'] : 0, 'site_key' => (string) $remote['site_key'], 'reporting_url' => (string) $remote['reporting_url'], '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' => [], ]; foreach ($remote['steps'] ?? [] as $step_row) { $sid = (string) $step_row['step_id']; $data['steps'][$sid] = [ 'status' => (string) $step_row['status'], 'notes' => (string) ($step_row['notes'] ?? ''), 'updated_at' => (int) $step_row['updated_at'], ]; if (!empty($step_row['autocheck']) && is_array($step_row['autocheck'])) { $data['autocheck'][$sid] = $step_row['autocheck']; } } return $data; } }