From 3fa691e78d37a7aafe2ff6e38bf3e25b2978ca21 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 8 Mar 2026 22:30:33 +0000 Subject: [PATCH 1/2] Fix rest timer duplicate and missing notifications Two bugs fixed: 1. Race condition: async updateTimer callback could fire multiple times simultaneously when browser resumes from suspension, causing duplicate notifications. Added a guard flag to prevent re-entry. 2. Missing notification: when page re-renders and finds an already-expired timer, it was silently cleared without notifying. Now fires notification. https://claude.ai/code/session_01Fh28cVSx69AKfkhsNNKwMC --- workout-tracker/src/ui/workout.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/workout-tracker/src/ui/workout.ts b/workout-tracker/src/ui/workout.ts index 5b046c1..f3a4703 100644 --- a/workout-tracker/src/ui/workout.ts +++ b/workout-tracker/src/ui/workout.ts @@ -333,7 +333,11 @@ export async function renderWorkout(container: HTMLElement): Promise { if (timerInterval) clearInterval(timerInterval); + let timerCompleting = false; + const updateTimer = async () => { + if (timerCompleting) return; + const savedTimer = await getTimerState(); if (!savedTimer) { timerEl.classList.add('hidden'); @@ -347,6 +351,7 @@ export async function renderWorkout(container: HTMLElement): Promise { } if (remaining <= 0) { + timerCompleting = true; if (timerInterval) clearInterval(timerInterval); timerInterval = null; await putTimerState(null); @@ -525,7 +530,9 @@ export async function renderWorkout(container: HTMLElement): Promise { if (remaining > 0) { timerEl.classList.remove('hidden'); setDoneButtonDisabled(true); + let recoveryCompleting = false; timerInterval = setInterval(async () => { + if (recoveryCompleting) return; const saved = await getTimerState(); if (!saved) { timerEl.classList.add('hidden'); @@ -536,6 +543,7 @@ export async function renderWorkout(container: HTMLElement): Promise { const tv = document.getElementById('timer-value'); if (tv) tv.textContent = formatTime(r); if (r <= 0) { + recoveryCompleting = true; if (timerInterval) clearInterval(timerInterval); timerInterval = null; await putTimerState(null); @@ -546,6 +554,7 @@ export async function renderWorkout(container: HTMLElement): Promise { }, 250); } else { await putTimerState(null); + fireTimerNotification(); } } From 6bd639a3041e4a03ff4423139eac0b3911a104d8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 8 Mar 2026 23:01:34 +0000 Subject: [PATCH 2/2] Add regression tests for rest timer notification bugs Tests verify: 1. Expired timer found on page re-render fires a notification (not silently cleared) 2. Timer expiry fires notification exactly once (no duplicates from race condition) https://claude.ai/code/session_01Fh28cVSx69AKfkhsNNKwMC --- .../e2e/timer-notification.spec.ts | 107 ++++++++++++++++++ 1 file changed, 107 insertions(+) create mode 100644 workout-tracker/e2e/timer-notification.spec.ts diff --git a/workout-tracker/e2e/timer-notification.spec.ts b/workout-tracker/e2e/timer-notification.spec.ts new file mode 100644 index 0000000..d3a3c63 --- /dev/null +++ b/workout-tracker/e2e/timer-notification.spec.ts @@ -0,0 +1,107 @@ +import { test, expect } from '@playwright/test'; + +/** + * Regression tests for rest timer notification bugs: + * 1. Duplicate notifications when browser resumes from suspension + * 2. Missing notification when page re-renders with an already-expired timer + */ +test.describe('Rest Timer Notification Reliability', () => { + /** + * Bug: When the page re-renders and finds an expired timer in IndexedDB, + * the timer was silently cleared without firing a notification. + */ + test('fires notification when re-rendering with an already-expired timer', async ({ page }) => { + // Install vibrate counter that persists across navigations + await page.addInitScript(() => { + (window as any).__vibrateCount = 0; + Object.defineProperty(navigator, 'vibrate', { + value: () => { (window as any).__vibrateCount++; return true; }, + writable: true, + configurable: true, + }); + }); + + await page.goto('/'); + await page.waitForSelector('#app'); + + // Start a workout + await page.click('#start-workout-btn'); + await page.waitForSelector('.workout-screen'); + + // Complete a set to trigger the timer, then skip it + await page.click('[data-testid="done-set-btn"]'); + await page.click('#skip-timer-btn'); + + // Seed an already-expired timer into IndexedDB + await page.evaluate(async () => { + const { putTimerState } = await import('/src/db/database.ts'); + await putTimerState({ + expectedEndTime: Date.now() - 5000, // expired 5s ago + durationMs: 90000, + }); + }); + + // Reload — the recovery code should find the expired timer and notify + await page.reload(); + await page.waitForSelector('.workout-screen'); + + // Wait for the recovery code to fire notification + await page.waitForFunction(() => (window as any).__vibrateCount > 0, null, { timeout: 5000 }); + + const vibrateCount = await page.evaluate(() => (window as any).__vibrateCount); + expect(vibrateCount).toBe(1); + + // Timer should be hidden (cleaned up) + await expect(page.locator('#rest-timer')).toBeHidden(); + }); + + /** + * Bug: When browser resumes from suspension, multiple queued setInterval + * ticks fire simultaneously. Each async tick reads the timer before any + * clears it, causing duplicate fireTimerNotification() calls. + */ + test('fires notification exactly once when timer expires (no duplicates)', async ({ page }) => { + // Install vibrate counter that persists across navigations + await page.addInitScript(() => { + (window as any).__vibrateCount = 0; + Object.defineProperty(navigator, 'vibrate', { + value: () => { (window as any).__vibrateCount++; return true; }, + writable: true, + configurable: true, + }); + }); + + await page.goto('/'); + await page.waitForSelector('#app'); + await page.click('#start-workout-btn'); + await page.waitForSelector('.workout-screen'); + + // Complete a set — this starts a real rest timer + await page.click('[data-testid="done-set-btn"]'); + await expect(page.locator('#rest-timer')).toBeVisible(); + + // Reset count after the click (done button itself may trigger vibration on some configs) + await page.evaluate(() => { (window as any).__vibrateCount = 0; }); + + // Mutate the timer in IndexedDB to expire it + await page.evaluate(async () => { + const { putTimerState } = await import('/src/db/database.ts'); + await putTimerState({ + expectedEndTime: Date.now() - 1000, // already expired + durationMs: 90000, + }); + }); + + // Wait for the interval to pick up the expired timer and notify + await page.waitForFunction(() => (window as any).__vibrateCount > 0, null, { timeout: 5000 }); + + // Give extra time for any duplicate ticks to fire + await page.waitForTimeout(500); + + const vibrateCount = await page.evaluate(() => (window as any).__vibrateCount); + expect(vibrateCount).toBe(1); + + // Timer should be hidden + await expect(page.locator('#rest-timer')).toBeHidden(); + }); +});