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 <[email protected]>
This commit is contained in:
minhtrannhatandClaude Opus 5.5 committed 2026-11-08 14:49:29 -05:00
1 parent a6ca255835
commit 9fba5de6d7
4 files changed
+87 -5

No files matched your search

+41 -5
View File
@@ -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 => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[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<T>(work: () => Promise<T>): Promise<T> {
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 `<button class="nav-btn ${view === target ? "active" : ""}" data-action="nav" data-view="${target}" type="button"><span class="nav-prefix">${index}</span>${label}</button>`;
}
@@ -97,7 +114,7 @@ function frame(content: string) {
<div class="side-bottom"><div class="status-row"><span class="status-dot ${syncStatus}"></span>${statusText()}</div></div>
</aside>
<main class="main">
<header class="topbar"><span class="topbar-date">${h(today)}</span><div class="topbar-actions"><span class="topbar-status">${statusText()}</span><button class="icon-btn" title="Settings" aria-label="Settings" data-action="nav" data-view="settings" type="button">⚙</button></div></header>
<header class="topbar"><span class="topbar-date">${h(today)}</span><div class="topbar-actions">${updateReady ? `<button class="update-btn" data-action="reload-update" title="Reload into the new version. A running timer continues." type="button">UPDATE</button>` : ""}<span class="topbar-status">${statusText()}</span><button class="icon-btn" title="Settings" aria-label="Settings" data-action="nav" data-view="settings" type="button">⚙</button></div></header>
${content}
</main>
<nav class="mobile-nav" aria-label="Mobile navigation">${navButtons()}</nav>
@@ -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<HTMLElement>("[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(); });
+2
View File
@@ -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; }
+18
View File
@@ -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;
}