Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +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**; updates are picked up on page reload, not via a modal prompt.
**Timer**; waiting updates apply immediately when no Routine is active.
- **Automatic updates** — an active or paused Routine defers a waiting worker until Done or Reset. The
new build reloads automatically, then the bottom version shows green **NEW vx.x.x** for that reload only.
- **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.
Expand Down
2 changes: 1 addition & 1 deletion astro.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { defineConfig } from "astro/config";
export default defineConfig({
integrations: [
AstroPWA({
registerType: "autoUpdate",
registerType: "prompt",
manifest: {
name: "Timer",
short_name: "Timer",
Expand Down
26 changes: 21 additions & 5 deletions docs/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
`<input inputmode="numeric">`. 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.

---

Expand Down Expand Up @@ -416,11 +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 `/// <reference types="vite-plugin-pwa/client" />` 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 `<script>`.
- **`registerType: 'prompt'`, never `autoUpdate`** — activate a waiting worker immediately when no
Routine is active. The activation path is a thin Astro script around the service-worker registration wrapper.
- Precache everything: the app is static with no API. Measured cost: 1.3 KB `sw.js` + 15 KB workbox
(SW scope only), 1.27 KB gzip on the page.

#### Update activation

- Use `registerType: 'prompt'`, never `autoUpdate`. Capture `updateSW` from
`registerSW({ onNeedRefresh })`. When the callback fires, activate immediately if no Routine is active;
otherwise leave the worker waiting.
- A running or paused Routine counts as active. When it reaches Done or the user presses Reset, emit an
inactive event and retry `updateSW(true)`. This applies update without discarding an in-memory Clock.
- No update button, confirmation prompt, toast dependency, popover/polyfill, polling or custom
service-worker manager. Standalone Home Screen PWAs use the same service-worker lifecycle: on launch,
registration checks for an update; `updateSW(true)` activates it and reloads the page.
- Before activation, store a session marker. The new build consumes the marker on first render, prefixes
the bottom version with green **NEW vX.X.X**, then clears it. Version highlighting is optional; storage
failures do not block update activation or timer behavior.
- Registration errors are swallowed; offline use of the current version remains unaffected.

### Install and manifest

- Chromium installability requires `name`/`short_name`, **192px and 512px** icons, `start_url`,
Expand Down Expand Up @@ -578,8 +593,9 @@ Genuinely undecided. An implementer should raise these rather than assume.
app is live at `timer.marcosgilf.com`.
- **Default values for `Prefs`** — only `prepareMs` has a stated default (10s). Starting `muted`,
`volume`, `ticks` and `vibrate` values are unspecified.
- ~~**Update-prompt UI**~~ — resolved: no prompt. The service worker auto-updates and the new build
is picked up on page reload, so timing is never interrupted.
- ~~**Update-prompt UI**~~ — resolved: no update control. A waiting worker activates immediately when
idle, or after an active Routine reaches Done/Reset; the new build labels its bottom version **NEW vX.X.X**
in green for that reload only.
- **Install guidance copy for iOS** — decided that it must be static copy (no
`beforeinstallprompt`), but not what it says or which screen hosts it.
- ~~**App name, iconography, favicon/manifest icon set, `theme_color`**~~ — resolved: app name
Expand Down
124 changes: 124 additions & 0 deletions src/client/pwa-update.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import { describe, expect, it, vi } from "vitest";
import { createPwaUpdateController, type RegisterSW } from "./pwa-update.ts";

const createHarness = () => {
let routineActive = false;
let onNeedRefresh = () => {};
const updateSW = vi.fn<ReturnType<RegisterSW>>();
updateSW.mockResolvedValue(undefined);
const registerSW = vi.fn<RegisterSW>((options) => {
onNeedRefresh = options.onNeedRefresh;
return updateSW;
});
const onActivationStarted = vi.fn();
const onActivationFailed = vi.fn();
const controller = createPwaUpdateController({
registerSW,
isRoutineActive: () => routineActive,
onActivationStarted,
onActivationFailed,
});

return {
controller,
get onNeedRefresh() {
return onNeedRefresh;
},
updateSW,
onActivationStarted,
onActivationFailed,
setRoutineActive: (active: boolean) => {
routineActive = active;
},
};
};

describe("PWA update controller", () => {
it("activates waiting update immediately when no Routine is active", () => {
const harness = createHarness();
harness.controller.register();

harness.onNeedRefresh();

expect(harness.controller.ready).toBe(true);
expect(harness.onActivationStarted).toHaveBeenCalledOnce();
expect(harness.updateSW).toHaveBeenCalledOnce();
expect(harness.updateSW).toHaveBeenCalledWith(true);
});

it("keeps waiting update while Routine is active", async () => {
const harness = createHarness();
harness.setRoutineActive(true);
harness.controller.register();

harness.onNeedRefresh();

expect(harness.controller.ready).toBe(true);
expect(harness.updateSW).not.toHaveBeenCalled();
await expect(harness.controller.activate()).resolves.toBe(false);
expect(harness.updateSW).not.toHaveBeenCalled();
});

it("activates deferred update when Routine becomes inactive", async () => {
const harness = createHarness();
harness.setRoutineActive(true);
harness.controller.register();
harness.onNeedRefresh();

harness.setRoutineActive(false);

await expect(harness.controller.activate()).resolves.toBe(true);
expect(harness.onActivationStarted).toHaveBeenCalledOnce();
expect(harness.updateSW).toHaveBeenCalledOnce();
});

it("allows retry when waiting-version activation fails", async () => {
const harness = createHarness();
harness.updateSW.mockRejectedValueOnce(new Error("activation failed"));
harness.controller.register();
harness.onNeedRefresh();

await expect(harness.controller.activate()).resolves.toBe(false);
await expect(harness.controller.activate()).resolves.toBe(true);

expect(harness.onActivationFailed).toHaveBeenCalledOnce();
expect(harness.updateSW).toHaveBeenCalledTimes(2);
});

it("does not activate twice while first activation is in flight", async () => {
const harness = createHarness();
harness.setRoutineActive(true);
let resolveUpdate = () => {};
harness.updateSW.mockImplementation(
() =>
new Promise<void>((resolve) => {
resolveUpdate = resolve;
}),
);
harness.controller.register();
harness.onNeedRefresh();
harness.setRoutineActive(false);

const first = harness.controller.activate();
const second = harness.controller.activate();
resolveUpdate();

await expect(first).resolves.toBe(true);
await expect(second).resolves.toBe(false);
expect(harness.updateSW).toHaveBeenCalledOnce();
});

it("leaves timer behavior alone when registration fails", () => {
const registerSW = vi.fn(() => {
throw new Error("registration failed");
});
const controller = createPwaUpdateController({
registerSW,
isRoutineActive: () => false,
onActivationStarted: vi.fn(),
});

expect(() => controller.register()).not.toThrow();
expect(controller.ready).toBe(false);
});
});
62 changes: 62 additions & 0 deletions src/client/pwa-update.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
export type UpdateSW = (reloadPage?: boolean) => Promise<void>;

export type RegisterSW = (options: {
onNeedRefresh: () => void;
onRegisterError: (error: unknown) => void;
}) => UpdateSW;

type PwaUpdateControllerOptions = {
registerSW: RegisterSW;
isRoutineActive: () => boolean;
onActivationStarted: () => void;
onActivationFailed?: () => void;
};

export const createPwaUpdateController = ({
registerSW,
isRoutineActive,
onActivationStarted,
onActivationFailed,
}: PwaUpdateControllerOptions) => {
let updateSW: UpdateSW | null = null;
let updateReady = false;
let activationStarted = false;

const activate = async (): Promise<boolean> => {
if (!updateReady || !updateSW || activationStarted || isRoutineActive()) return false;

activationStarted = true;

try {
onActivationStarted();
await updateSW(true);
return true;
} catch {
activationStarted = false;
onActivationFailed?.();
return false;
}
};

const register = () => {
try {
updateSW = registerSW({
onNeedRefresh: () => {
updateReady = true;
if (!isRoutineActive()) void activate();
},
onRegisterError: () => {},
});
} catch {
// Service-worker support is an enhancement. Timer behavior stays independent.
}
};

return {
register,
activate,
get ready() {
return updateReady;
},
};
};
1 change: 1 addition & 0 deletions src/env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,6 @@ declare module "virtual:pwa-register" {
export function registerSW(options?: {
onNeedRefresh?: () => void;
onOfflineReady?: () => void;
onRegisterError?: (error: unknown) => void;
}): (reloadPage?: boolean) => Promise<void>;
}
45 changes: 42 additions & 3 deletions src/layouts/AppLayout.astro
Original file line number Diff line number Diff line change
Expand Up @@ -35,17 +35,56 @@ const deployLabel =
<link rel="manifest" href="/manifest.webmanifest" />
<title>{title}</title>
</head>
<body>
<body data-routine-active="false">
<slot />
<footer class="app-footer">
{deployLabel && <small class="deploy-clue" aria-label={`Deployment context ${deployLabel}`}>{deployLabel}</small>}
<small class="app-version" aria-label={`Timer version ${version}`}>v{version}</small>
<small class="app-version" data-app-version aria-label={`Timer version ${version}`}>v{version}</small>
</footer>

<script>
import { registerSW } from "virtual:pwa-register";
import { createPwaUpdateController, type RegisterSW } from "../client/pwa-update.ts";

registerSW();
const appVersion = document.querySelector<HTMLElement>("[data-app-version]")!;
const updateMarker = "timer:update-activated";

try {
if (sessionStorage.getItem(updateMarker) === "true") {
sessionStorage.removeItem(updateMarker);
const versionLabel = appVersion.getAttribute("aria-label") ?? "Timer version";
appVersion.textContent = `NEW ${appVersion.textContent ?? ""}`;
appVersion.dataset.updated = "true";
appVersion.setAttribute("aria-label", `New ${versionLabel}`);
}
} catch {
// Version highlighting is optional. Timer behavior stays independent.
}

const registerServiceWorker: RegisterSW = (options) => registerSW(options);
const update = createPwaUpdateController({
registerSW: registerServiceWorker,
isRoutineActive: () => document.body.dataset.routineActive === "true",
onActivationStarted: () => {
try {
sessionStorage.setItem(updateMarker, "true");
} catch {
// Session storage is optional. Update activation still proceeds.
}
},
onActivationFailed: () => {
try {
sessionStorage.removeItem(updateMarker);
} catch {
// Session storage is optional. Timer behavior stays independent.
}
},
});

window.addEventListener("timer:counter-inactive", () => {
void update.activate();
});
update.register();
</script>
</body>
</html>
9 changes: 6 additions & 3 deletions src/pages/down.astro
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -134,6 +135,7 @@ import AppLayout from "../layouts/AppLayout.astro";
setUrlState("done");
announce(`Done count down: ${announceElapsed(durationMs)}.`);
paint();
window.dispatchEvent(new Event("timer:counter-inactive"));
};

const paint = () => {
Expand Down Expand Up @@ -201,6 +203,7 @@ import AppLayout from "../layouts/AppLayout.astro";
stopTicking();
announce("Reset.");
paint();
window.dispatchEvent(new Event("timer:counter-inactive"));
});

const configurationUrl = new URL("/configuration", location.origin);
Expand All @@ -226,8 +229,8 @@ import AppLayout from "../layouts/AppLayout.astro";
let confirmedNavigation = false;
document.querySelectorAll<HTMLAnchorElement>("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;
}
Expand All @@ -237,7 +240,7 @@ import AppLayout from "../layouts/AppLayout.astro";
});

window.addEventListener("beforeunload", (event) => {
if (!isRunning(clock) || confirmedNavigation) return;
if (document.body.dataset.routineActive !== "true" || confirmedNavigation) return;
event.preventDefault();
});

Expand Down
Loading