From 3811791daad098271d2005831c3953f15295bd68 Mon Sep 17 00:00:00 2001 From: marcosgilf Date: Fri, 14 Aug 2026 17:56:00 +0200 Subject: [PATCH 1/4] feat(pwa): prompt before activating updates --- README.md | 5 +- astro.config.mjs | 2 +- docs/spec.md | 23 +++++- src/client/pwa-update.test.ts | 149 ++++++++++++++++++++++++++++++++++ src/client/pwa-update.ts | 69 ++++++++++++++++ src/env.d.ts | 1 + src/layouts/AppLayout.astro | 26 +++++- src/pages/down.astro | 7 +- src/pages/index.astro | 7 +- src/styles/app.css | 18 ++++ 10 files changed, 294 insertions(+), 13 deletions(-) create mode 100644 src/client/pwa-update.test.ts create mode 100644 src/client/pwa-update.ts diff --git a/README.md b/README.md index 577c48c..23aa20d 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,10 @@ is a Tabata whether or not anything is labelled "Tabata". - **Accurate by construction** — state is derived from a wall-clock timestamp with a pure `phaseAt(elapsed, routine)`; nothing accumulates ticks, so nothing drifts. - **Offline-first** — precached service worker, no backend, no accounts, no analytics. Installable as - **Timer**; updates are picked up on page reload, not via a modal prompt. + **Timer**; waiting updates stay pending until user activates **Reload to update**. +- **Prompted updates** — the native update button appears on every page, including standalone display. + Running or paused Routines require one leave confirmation; Cancel keeps current version and Routine. + No update ever reloads automatically. - **Progressive enhancement** — audio session, Wake Lock and vibration are feature-detected and no-ops where absent. The app is fully correct without any of them. - **Semantic and accessible** — [Pico CSS](https://picocss.com/) classless, WCAG 2.2 AA in every slice. diff --git a/astro.config.mjs b/astro.config.mjs index 2035987..fd86400 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -6,7 +6,7 @@ import { defineConfig } from "astro/config"; export default defineConfig({ integrations: [ AstroPWA({ - registerType: "autoUpdate", + registerType: "prompt", manifest: { name: "Timer", short_name: "Timer", diff --git a/docs/spec.md b/docs/spec.md index 2c278c9..9f5a8a3 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -417,10 +417,26 @@ layer below is feature-detected, adds capability where present, and is a no-op w pnpm, or the build fails with `Rolldown failed to resolve import "workbox-window"`. - Add `/// ` to `src/env.d.ts` for the virtual-module types. - **`registerType: 'prompt'`, never `autoUpdate`** — an auto-reload mid-Routine would destroy the - running clock. The update prompt is a plain `.astro` component with a ` diff --git a/src/pages/down.astro b/src/pages/down.astro index d93c520..c3f03b9 100644 --- a/src/pages/down.astro +++ b/src/pages/down.astro @@ -111,6 +111,7 @@ import AppLayout from "../layouts/AppLayout.astro"; configuredDuration.textContent = formatElapsed(durationMs); configuredDuration.dateTime = `PT${durationMs / 1000}S`; const status = isFinished ? "Done" : isRunning(clock) ? "Running" : clock.startedAt === null ? "" : "Paused"; + document.body.dataset.routineActive = String(!isFinished && clock.startedAt !== null); const nextTitle = status ? `${status} · ${visible} - Down Timer` : `${visible} - Down Timer`; if (nextTitle !== pageTitle) { pageTitle = nextTitle; @@ -226,8 +227,8 @@ import AppLayout from "../layouts/AppLayout.astro"; let confirmedNavigation = false; document.querySelectorAll("a[href]").forEach((link) => { link.addEventListener("click", (event) => { - if (!isRunning(clock) || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return; - if (!window.confirm("Leave the running Routine?")) { + if (document.body.dataset.routineActive !== "true" || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return; + if (!window.confirm("Leave the active Routine?")) { event.preventDefault(); return; } @@ -237,7 +238,7 @@ import AppLayout from "../layouts/AppLayout.astro"; }); window.addEventListener("beforeunload", (event) => { - if (!isRunning(clock) || confirmedNavigation) return; + if (document.body.dataset.routineActive !== "true" || confirmedNavigation || document.body.dataset.navigationConfirmed === "true") return; event.preventDefault(); }); diff --git a/src/pages/index.astro b/src/pages/index.astro index 42d4f84..7d98fbf 100644 --- a/src/pages/index.astro +++ b/src/pages/index.astro @@ -99,6 +99,7 @@ import AppLayout from "../layouts/AppLayout.astro"; display.dateTime = `PT${state.displayMs / 1000}S`; display.setAttribute("aria-label", announceElapsed(state.displayMs)); const status = isFinished ? "Done" : isRunning(clock) ? "Running" : clock.startedAt === null ? "" : "Paused"; + document.body.dataset.routineActive = String(!isFinished && clock.startedAt !== null); const nextTitle = status ? `${status} · ${visible} - Timer` : `${visible} - Timer`; if (nextTitle !== pageTitle) { pageTitle = nextTitle; @@ -209,8 +210,8 @@ import AppLayout from "../layouts/AppLayout.astro"; let confirmedNavigation = false; document.querySelectorAll("a[href]").forEach((link) => { link.addEventListener("click", (event) => { - if (!isRunning(clock) || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return; - if (!window.confirm("Leave the running Routine?")) { + if (document.body.dataset.routineActive !== "true" || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return; + if (!window.confirm("Leave the active Routine?")) { event.preventDefault(); return; } @@ -220,7 +221,7 @@ import AppLayout from "../layouts/AppLayout.astro"; }); window.addEventListener("beforeunload", (event) => { - if (!isRunning(clock) || confirmedNavigation) return; + if (document.body.dataset.routineActive !== "true" || confirmedNavigation || document.body.dataset.navigationConfirmed === "true") return; event.preventDefault(); }); diff --git a/src/styles/app.css b/src/styles/app.css index a8cd3b3..ee1bd2a 100644 --- a/src/styles/app.css +++ b/src/styles/app.css @@ -23,6 +23,7 @@ body > main { width: 100%; max-width: none; padding: 1rem max(0.5rem, env(safe-area-inset-left)) 1rem max(0.5rem, env(safe-area-inset-right)); + padding-bottom: calc(7rem + env(safe-area-inset-bottom)); overflow-x: hidden; text-align: center; } @@ -228,6 +229,23 @@ body > main { font-variant-numeric: tabular-nums; } +.update-control { + position: fixed; + z-index: 2; + inset-inline: max(0.5rem, env(safe-area-inset-left)) max(0.5rem, env(safe-area-inset-right)); + bottom: calc(3rem + env(safe-area-inset-bottom)); + width: fit-content; + max-width: 100%; + margin: 0 auto; + padding-inline: 0.75rem; + white-space: normal; +} + +.update-control:focus-visible { + outline: 3px solid var(--pico-primary); + outline-offset: 3px; +} + .app-footer { display: flex; align-items: center; From 2d9cb85eaf01a68fa151ec233fca155a1c2c2ea9 Mon Sep 17 00:00:00 2001 From: marcosgilf Date: Fri, 14 Aug 2026 21:38:37 +0200 Subject: [PATCH 2/4] feat(pwa): activate updates on counter start --- README.md | 7 +++-- docs/spec.md | 37 +++++++++++++------------ src/client/pwa-update.test.ts | 51 +++++++---------------------------- src/client/pwa-update.ts | 45 +++++++++++++------------------ src/layouts/AppLayout.astro | 41 +++++++++++++++++++--------- src/pages/down.astro | 3 ++- src/pages/index.astro | 3 ++- src/styles/app.css | 25 +++++------------ 8 files changed, 88 insertions(+), 124 deletions(-) diff --git a/README.md b/README.md index 23aa20d..b14c253 100644 --- a/README.md +++ b/README.md @@ -26,10 +26,9 @@ is a Tabata whether or not anything is labelled "Tabata". - **Accurate by construction** — state is derived from a wall-clock timestamp with a pure `phaseAt(elapsed, routine)`; nothing accumulates ticks, so nothing drifts. - **Offline-first** — precached service worker, no backend, no accounts, no analytics. Installable as - **Timer**; waiting updates stay pending until user activates **Reload to update**. -- **Prompted updates** — the native update button appears on every page, including standalone display. - Running or paused Routines require one leave confirmation; Cancel keeps current version and Routine. - No update ever reloads automatically. + **Timer**; waiting updates stay pending until a counter activates them. +- **Automatic updates** — starting or restarting any counter activates a waiting worker and reloads into + the new build. The bottom version shows green **NEW vx.x.x** for the current app session. - **Progressive enhancement** — audio session, Wake Lock and vibration are feature-detected and no-ops where absent. The app is fully correct without any of them. - **Semantic and accessible** — [Pico CSS](https://picocss.com/) classless, WCAG 2.2 AA in every slice. diff --git a/docs/spec.md b/docs/spec.md index 9f5a8a3..3a43834 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -328,7 +328,7 @@ No theme override — `prefers-color-scheme` only, and nothing is stored for it. The whole prototype is one `.astro` file: plain DOM, CSS custom properties, `rAF`, native ``. Nothing in the UI justifies an island, and nothing in the PWA layer -does either — registration, update prompt, Wake Lock, audio session and haptics are all plain DOM APIs. +does either — registration, update activation, Wake Lock, audio session and haptics are all plain DOM APIs. --- @@ -416,26 +416,26 @@ layer below is feature-detected, adds capability where present, and is a no-op w `virtual:pwa-register` by hand; (2) **`workbox-window` must be an explicit devDependency** under pnpm, or the build fails with `Rolldown failed to resolve import "workbox-window"`. - Add `/// ` to `src/env.d.ts` for the virtual-module types. -- **`registerType: 'prompt'`, never `autoUpdate`** — an auto-reload mid-Routine would destroy the - running clock. The update control is plain Astro markup with a thin `