// SPDX-License-Identifier: GPL-3.0-or-later /* === Schedule strip: is the daily check actually scheduled? === The dashboard used to know only "Re-run now". This strip answers the follow-up question — will the machine check itself tomorrow? Read-only: installing stays a CLI decision; the strip shows state plus the exact command to copy. Hidden when the backend has no schedule to report (static --html exports, unreachable API). */ async function fetchSchedule() { if (STATIC_DATA) return null; try { const res = await fetch("/api/schedule", { cache: "no-store" }); if (!res.ok) return null; return (await res.json()).schedule || null; } catch { return null; } } function browserNotifyState() { try { if (!("Notification" in window)) return "unavailable"; if (Notification.permission === "granted") return "on"; return "off"; } catch { return "unavailable"; } } /* Pure (string in, string out) so dashboard-state.test.js can pin it. */ function scheduleHtml(s, notify) { if (!s) return ""; const dot = (on) => ''; // Dot and label wrap as one unit — a separator stranded at line end reads // as a dangling bullet on narrow screens. const notifyBit = notify === "unavailable" ? "" : '' + dot(notify === "on") + "Browser alerts " + (notify === "on" ? "on" : "off") + ""; if (!s.installed) { return dot(false) + "Daily check off — install the timer and the machine notifies you only when something new breaks." + '' + notifyBit; } if (s.active) { return dot(true) + "Daily check on — runs after boot, then daily · notifies only on new findings." + notifyBit; } return dot(false) + "Daily check needs attention — timer units exist but the timer isn't active. Re-run linux-doctor --install-timer." + '' + notifyBit; } /* The shared notices row is visible when at least one notice is. Both renderers call this after updating, so async schedule fetches and sync posture renders converge on the same visibility. */ function syncNotices() { const wrap = $("#notices"); if (!wrap) return; const kids = [...wrap.children]; wrap.hidden = kids.length === 0 || kids.every((k) => k.hidden); syncNoticeGrid(); } function renderSchedule(s) { const el = $("#schedstrip"); if (!el) return; const html = scheduleHtml(s, browserNotifyState()); if (!html) { el.hidden = true; el.innerHTML = ""; } else { el.hidden = false; el.innerHTML = html; } syncNotices(); } /* Schedule view: the full picture — last run, next run, timer state, and the exact commands as recipes. Pure builders kept separate for tests. */ /* Coarse relative freshness for the "last run" cell. */ function schedAgo(iso) { const t = Date.parse(iso || ""); if (!Number.isFinite(t)) return "\u2014"; const s = Math.max(0, Math.round((Date.now() - t) / 1000)); if (s < 60) return "just now"; if (s < 3600) return Math.floor(s / 60) + "m ago"; if (s < 86400) return Math.floor(s / 3600) + "h ago"; return Math.floor(s / 86400) + "d ago"; } /* The next run is read as a distance first ("in 24h") and a date second — the same reading order as "just now" for the last one. */ function schedAhead(ms) { if (!Number.isFinite(ms)) return "\u2014"; const h = Math.round((ms - Date.now()) / 3600000); if (h <= 0) return "any moment"; if (h < 48) return "in " + h + "h"; return "in " + Math.round(h / 24) + "d"; } /* The command IS the information, so it is printed, not hidden behind a button: an accent-ruled mono slip — the same recipe language as the findings' Recommended next step — with a quiet copy next to it. */ function recipeHtml(cmd, primary) { return '
' + esc(cmd) + "" + '
'; } function scheduleViewHtml(s, notify, generatedAt) { const notifyBit = notify === "unavailable" ? "" : 'Browser alerts ' + (notify === "on" ? "on" : "off") + ""; if (!s) { return '
Scheduled checks
' + '
Schedule state is unavailable (static export or unreachable API).
'; } const on = !!s.active; const needs = s.installed && !s.active; const state = on ? "on" : needs ? "needs attention" : "off"; const lastMs = Date.parse(generatedAt || ""); const nextTxt = on && Number.isFinite(lastMs) ? schedAhead(lastMs + 86400000) + " \u00b7 " + fmtWhen(new Date(lastMs + 86400000).toISOString()) : "\u2014"; const nextTitle = "The installed timer runs 24h after its last activation; Persistent catches up runs missed while the machine was off."; /* The state band is the instrument: times are mono and tabular so they read as readings, not prose. "Nothing to do" is the instrument's own footer — the outcome of the three readings above it, not a sentence floating in the empty half of the view. */ const idle = on ? '
' + "Nothing to do \u2014 the timer runs the checks by itself and speaks up only when something new breaks.
" : ""; const instr = '
' + '
Last run' + esc(schedAgo(generatedAt)) + "
" + '
Next run' + esc(nextTxt) + "
" + '
Timer' + esc(state) + "
" + idle + "
" + '
' + (on ? "runs" : needs ? "once active, it runs" : "installing makes it run") + " shortly after boot, then every 24h \u00b7 Persistent \u2014 catches up runs missed while the machine was off" + notifyBit + "
"; const setup = on ? "" : '
Set it up
' + recipeHtml("linux-doctor --install-timer", true) + "
"; const manage = '
Manage
' + recipeHtml("linux-doctor --install-timer") + recipeHtml("linux-doctor --uninstall-timer") + "
"; const pro = '
Remote delivery [Pro]
' + recipeHtml("linux-doctor --alert https://ntfy.sh/your-topic") + recipeHtml("linux-doctor --heartbeat https://hc-ping.com/your-uuid") + "
"; return '
Scheduled checks
' + instr + "
" + setup + '
' + manage + pro + "
"; } async function renderScheduleView() { const box = $("#schedview"); if (!box) return; box.innerHTML = scheduleViewHtml(await fetchSchedule(), browserNotifyState(), (lastReportData || {}).generatedAt); } function refreshSchedule() { fetchSchedule().then(renderSchedule); }