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
107 changes: 107 additions & 0 deletions workout-tracker/e2e/timer-notification.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
9 changes: 9 additions & 0 deletions workout-tracker/src/ui/workout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -333,7 +333,11 @@ export async function renderWorkout(container: HTMLElement): Promise<void> {

if (timerInterval) clearInterval(timerInterval);

let timerCompleting = false;

const updateTimer = async () => {
if (timerCompleting) return;

const savedTimer = await getTimerState();
if (!savedTimer) {
timerEl.classList.add('hidden');
Expand All @@ -347,6 +351,7 @@ export async function renderWorkout(container: HTMLElement): Promise<void> {
}

if (remaining <= 0) {
timerCompleting = true;
if (timerInterval) clearInterval(timerInterval);
timerInterval = null;
await putTimerState(null);
Expand Down Expand Up @@ -525,7 +530,9 @@ export async function renderWorkout(container: HTMLElement): Promise<void> {
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');
Expand All @@ -536,6 +543,7 @@ export async function renderWorkout(container: HTMLElement): Promise<void> {
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);
Expand All @@ -546,6 +554,7 @@ export async function renderWorkout(container: HTMLElement): Promise<void> {
}, 250);
} else {
await putTimerState(null);
fireTimerNotification();
}
}

Expand Down