diff --git a/src/client/main.ts b/src/client/main.ts index 37284c7..ef72679 100644 --- a/src/client/main.ts +++ b/src/client/main.ts @@ -5,6 +5,7 @@ import { syncNow, type SyncStatus } from "./sync.ts"; import { dueMs, recordEnd, type TimeRecord } from "../shared/record.ts"; import { normalizeTag } from "../shared/tag.ts"; import { countdown, pad, pageTitle } from "./title.ts"; +import { readyToReload } from "./update.ts"; type View = "timer" | "log" | "insights" | "settings"; type Theme = "latte" | "mocha"; @@ -26,6 +27,8 @@ let editingId: string | null = null; let message: { text: string; kind: "success" | "error" } | null = null; let toastTimer = 0; let pushRegistration: ServiceWorkerRegistration | null = null; +let updateReady = false; +let busyCount = 0; function h(value: unknown): string { return String(value ?? "").replace(/[&<>"']/g, char => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[char]!); @@ -78,6 +81,20 @@ function showMessage(text: string, kind: "success" | "error" = "success") { toastTimer = window.setTimeout(() => { message = null; document.querySelector(".toast")?.remove(); }, 5000); } +/** Marks work that a reload must not interrupt, such as a save or an import. */ +async function busy(work: () => Promise): Promise { + busyCount++; + try { return await work(); } finally { busyCount--; } +} + +function applyUpdateIfSafe() { + if (readyToReload({ updateReady, hidden: document.hidden, editing: editingId !== null, busy: busyCount > 0 })) location.reload(); +} + +function checkForUpdate() { + void pushRegistration?.update().catch(() => {}); +} + function navButton(target: View, label: string, index: string) { return ``; } @@ -97,7 +114,7 @@ function frame(content: string) {
${statusText()}
-
${h(today)}
${statusText()}
+
${h(today)}
${updateReady ? `` : ""}${statusText()}
${content}
@@ -282,7 +299,9 @@ async function notifyFinished(record: TimeRecord) { } catch (error) { console.warn("Local notification failed", error); } } -async function settleDue() { +function settleDue() { return busy(settleDueNow); } + +async function settleDueNow() { const now = Date.now(); const due = records.filter(r => !r.deleted && r.endMs === null && r.targetSeconds !== null && dueMs(r) <= now); if (!due.length) return; @@ -427,7 +446,9 @@ async function dispatch(action: string, element: HTMLElement) { app.addEventListener("click", event => { const element = (event.target as HTMLElement).closest("[data-action]"); - if (element) void dispatch(element.dataset.action!, element).catch(error => showMessage(String(error), "error")); + // Check this before busy(), which would count the click itself as running work. + if (element?.dataset.action === "reload-update") { if (busyCount === 0) location.reload(); } + else if (element) void busy(() => dispatch(element.dataset.action!, element)).catch(error => showMessage(String(error), "error")); else if ((event.target as HTMLElement).classList.contains("modal-backdrop")) { editingId = null; render(); } }); @@ -445,7 +466,7 @@ app.addEventListener("change", event => { logTag = target.value; render(); } else if (target.id === "csv-file") { const file = (target as HTMLInputElement).files?.[0]; - if (file) void importCsvFile(file); + if (file) void busy(() => importCsvFile(file)); } }); @@ -462,10 +483,15 @@ app.addEventListener("input", event => { window.addEventListener("online", () => { syncStatus = "pending"; sync(); }); window.addEventListener("offline", () => { syncStatus = "offline"; render(); }); -document.addEventListener("visibilitychange", () => { if (!document.hidden) { void settleDue(); sync(); } }); +document.addEventListener("visibilitychange", () => { + if (document.hidden) applyUpdateIfSafe(); + else { void settleDue(); sync(); checkForUpdate(); } +}); document.addEventListener("keydown", event => { if (event.key === "Escape" && editingId) { editingId = null; render(); } }); setInterval(() => { + // Retry here, because a save can block the reload at the moment the page hides. + if (document.hidden) { applyUpdateIfSafe(); return; } void settleDue(); updateTitle(); const active = activeRecord(); @@ -482,6 +508,16 @@ async function initialize() { records = await allRecords(); render(); if ("serviceWorker" in navigator) { + // On the first install, the new worker also takes control. That is not an update. + let hadController = Boolean(navigator.serviceWorker.controller); + navigator.serviceWorker.addEventListener("controllerchange", () => { + if (!hadController) { hadController = true; return; } + updateReady = true; + render(); + applyUpdateIfSafe(); + }); + // An installed PWA can stay open for days without a navigation, so ask for updates. + setInterval(checkForUpdate, 60 * 60_000); try { await navigator.serviceWorker.register("/sw.js"); void navigator.serviceWorker.ready.then(registration => { pushRegistration = registration; render(); }); diff --git a/src/client/styles.css b/src/client/styles.css index 44f2c7f..b2fb968 100644 --- a/src/client/styles.css +++ b/src/client/styles.css @@ -71,6 +71,7 @@ button:focus-visible, input:focus-visible, select:focus-visible, a:focus-visible .topbar-date { font-size: 11px; color: var(--muted); text-transform: uppercase; letter-spacing: .1em; } .topbar-actions { display: flex; align-items: center; gap: 16px; } .topbar-status { color: var(--muted); font-size: 11px; white-space: nowrap; } +.update-btn { border: 1px solid var(--accent); background: var(--accent); color: var(--accent-ink); height: 35px; padding: 0 11px; font-size: 11px; letter-spacing: .08em; cursor: pointer; } .icon-btn { border: 1px solid var(--line); background: transparent; color: var(--ink); height: 35px; min-width: 35px; padding: 0 9px; } .icon-btn:hover { border-color: var(--accent); } .page { padding-top: 34px; } @@ -194,6 +195,7 @@ select.field { appearance: none; background-image: linear-gradient(45deg, transp .sidebar { display: none; } .main { padding: calc(28px + env(safe-area-inset-top, 0px)) calc(26px + env(safe-area-inset-right, 0px)) calc(104px + env(safe-area-inset-bottom, 0px)) calc(26px + env(safe-area-inset-left, 0px)); } .topbar .icon-btn { min-width: 48px; height: 48px; } + .topbar .update-btn { height: 48px; } .grid, .insight-grid { grid-template-columns: 1fr; } .mobile-nav { display: grid; grid-template-columns: repeat(3, 1fr); position: fixed; z-index: 10; left: 0; right: 0; bottom: 0; background: var(--bg); border-top: 1px solid var(--line-bright); padding: 7px 8px calc(7px + env(safe-area-inset-bottom)); } .mobile-nav .nav-btn { text-align: center; padding: 10px 3px; min-height: 43px; font-size: 10px; } diff --git a/src/client/update.ts b/src/client/update.ts new file mode 100644 index 0000000..082743c --- /dev/null +++ b/src/client/update.ts @@ -0,0 +1,18 @@ +export type UpdateState = { + /** A new service worker took control, so a newer app version is ready. */ + updateReady: boolean; + /** The page is not visible, so a reload does not interrupt the user. */ + hidden: boolean; + /** The entry editor is open and can have unsaved input. */ + editing: boolean; + /** A save or import is in progress. */ + busy: boolean; +}; + +/** + * Tells if the page can reload into the new version now. A running timer + * survives a reload, because it is stored with its start time in IndexedDB. + */ +export function readyToReload(state: UpdateState): boolean { + return state.updateReady && state.hidden && !state.editing && !state.busy; +} diff --git a/tests/update.test.ts b/tests/update.test.ts new file mode 100644 index 0000000..bad5519 --- /dev/null +++ b/tests/update.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, test } from "bun:test"; +import { readyToReload } from "../src/client/update.ts"; + +const safe = { updateReady: true, hidden: true, editing: false, busy: false }; + +describe("app update reload", () => { + test("reloads a hidden page when an update is ready and nothing is open", () => { + expect(readyToReload(safe)).toBe(true); + }); + + test("does not reload without an update", () => { + expect(readyToReload({ ...safe, updateReady: false })).toBe(false); + }); + + test("does not reload while the user looks at the page", () => { + expect(readyToReload({ ...safe, hidden: false })).toBe(false); + }); + + test("does not reload while the entry editor is open", () => { + expect(readyToReload({ ...safe, editing: true })).toBe(false); + }); + + test("does not reload while a save or import runs", () => { + expect(readyToReload({ ...safe, busy: true })).toBe(false); + }); +});