From 9fba5de6d787266d21aa4d6fbd753b48c7fb5d94 Mon Sep 17 00:00:00 2001 From: minhtrannhat Date: Sun, 8 Nov 2026 14:49:29 -0500 Subject: [PATCH] Update the app while a timer runs When a new service worker takes control, the app shows an UPDATE button. If the page is hidden, the app reloads into the new version. It does not reload while the entry editor is open or while a save or import runs. A running timer continues after the reload, because it is stored with its start time in IndexedDB. The app asks for a new version when it becomes visible and one time each hour, because an installed PWA can stay open for days. Co-Authored-By: Claude Opus 5.5 --- src/client/main.ts | 46 ++++++++++++++++++++++++++++++++++++++----- src/client/styles.css | 2 ++ src/client/update.ts | 18 +++++++++++++++++ tests/update.test.ts | 26 ++++++++++++++++++++++++ 4 files changed, 87 insertions(+), 5 deletions(-) create mode 100644 src/client/update.ts create mode 100644 tests/update.test.ts 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); + }); +});