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:
1 parent
a6ca255835
commit
9fba5de6d7
4 files changed
+87
-5
No files matched your search
+41
-5
@@ -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<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(); });
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user