diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml index 2812391..d340b80 100644 --- a/.github/workflows/playwright.yml +++ b/.github/workflows/playwright.yml @@ -18,7 +18,7 @@ jobs: - name: Install Playwright Browsers run: npx playwright install --with-deps - name: Run Playwright tests - run: npx playwright test + run: npx playwright test --project="Mobile Chrome" - uses: actions/upload-artifact@v4 if: ${{ !cancelled() }} with: diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..045b6ec --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,48 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Commands + +```bash +npm run dev # Start development server +npm run build # Production build +npm run lint # ESLint +npx playwright test # Run all E2E tests (builds first) +npx playwright test tests/dayOnePage.spec.ts # Run a single test file +npx playwright test --ui # Open Playwright UI +``` + +Pre-commit hooks run Prettier on all staged files via husky + lint-staged. + +## Architecture + +**Stack:** Next.js 14 (App Router), React 18, TypeScript, CSS Modules, D3, Serwist (PWA/service worker). + +**Local-first:** All user data is stored in IndexedDB — no backend, no user accounts. The app works offline as a PWA. Two IndexedDB object stores live in `armstrong_pullup_program_db` (v4): +- `workoutsStore` — completed workout records (`TDayComplete`), keyed by `id` (UUID string) +- `weeksStore` — week progress records (`TWeek`), keyed by `number` + +All IDB logic is in `src/app/lib/data/indexedDB/`. Components that use IndexedDB must be client components with `ssr: false` via `next/dynamic`. + +**Route groups:** +- `src/app/(app)/program/` — the workout program pages (days 1–5) and review modal +- `src/app/(marketing)/` — landing page +- `src/app/(privacy)/privacy/` — privacy policy + +**Parallel routes:** The program layout (`(app)/program/layout.tsx`) uses a `@modal` slot for the review modal, implemented as a Next.js intercepting route at `(.)review/[getData]/[index]`. + +**Day types** (`TDayAbbreviation`): +- `5MES` — Five Max Effort Sets (Day 1 / Day 5 option) +- `PYRA` — Pyramid (Day 2 / Day 5 option) +- `3S3G` — Three Training Sets, Three Grips (Day 3 / Day 5 option) +- `MXTS` — Max Training Sets (Day 4 / Day 5 option) +- `SKPD` — Skipped day + +Each day type has its own component subdirectory under `src/components/program/` (e.g., `fiveMaxEffortSets/`, `pyramid/`, `threeTrainingSetsThreeGrips/`, `maxTrainingSets/`). + +**Program flow:** `Program.tsx` reads from IndexedDB to determine the current week and last completed day, then renders the appropriate day component. On completion, it writes a `TDayComplete` record to `workoutsStore` and updates `weeksStore`. `PastWorkouts.tsx` renders D3-based data visualizations of prior workouts. + +**Path aliases:** `@/` maps to `src/app/lib/` (see tsconfig). E.g., `@/definitions` → `src/app/lib/definitions.ts`, `@/indexedDBConstants` → `src/app/lib/data/indexedDB/constants.ts`. + +**Tests:** Playwright E2E tests in `tests/`, running against Desktop Firefox and Mobile Chrome (Pixel 5). Tests require a production build (`npm run build && npm run start`). diff --git a/docs/superpowers/plans/2026-04-17-data-state-bugs.md b/docs/superpowers/plans/2026-04-17-data-state-bugs.md new file mode 100644 index 0000000..20fb955 --- /dev/null +++ b/docs/superpowers/plans/2026-04-17-data-state-bugs.md @@ -0,0 +1,365 @@ +# Data/State Bug Fixes Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Fix four data/state bugs — a shared mutable object in SkipDayButton, an in-place array mutation in PastWorkouts, a D3 DOM accumulation memory leak in all four SVG chart components, and a circular useEffect dependency in both review pages. + +**Architecture:** All fixes are surgical — no new files, no new dependencies, no structural changes. Each task is a self-contained edit to a single responsibility. + +**Tech Stack:** React 19, Next.js 16, TypeScript, D3 v7, IndexedDB + +--- + +### Task 1: Fix shared mutable object in SkipDayButton + +**Problem:** `skippedDayData` is declared outside the component and mutated in `handleSkip`. All renders share the same object reference, so rapid clicks or multiple mounted instances corrupt each other's data. + +**Files:** +- Modify: `src/components/program/SkipDayButton.tsx:29-34, 50-58` + +- [ ] **Step 1: Remove the module-level `skippedDayData` constant and build a fresh object inside `handleSkip`** + +Replace lines 29–34 (the `skippedDayData` constant) and the mutation block inside `handleSkip` (lines 50–58) with: + +```tsx +// DELETE these lines entirely (lines 29-34): +// const skippedDayData: TDayComplete = { +// id: "", +// dayAbbreviation: "SKPD", +// dayNumber: 1, +// sets: [0], +// }; + +// Inside handleSkip, replace lines 50-58 with: + const date = new Date(Date.now()).toLocaleDateString("en-US", dateFormatOptions); + const skippedDayData: TDayComplete = { + id: `${currentWeekNumber}-${dayNumber}`, + dayAbbreviation: "SKPD", + dayNumber: dayNumber, + date, + weekNumber: currentWeekNumber, + sets: [0], + }; +``` + +The full `handleSkip` after the change: + +```tsx + async function handleSkip() { + const startNewWeek = await shouldStartNewWeek(); + let currentWeekNumber = await getCurrentWeekNumber(); + + if (startNewWeek) { + currentWeekNumber++; + addNewWeek(currentWeekNumber); + } + + const date = new Date(Date.now()).toLocaleDateString("en-US", dateFormatOptions); + const skippedDayData: TDayComplete = { + id: `${currentWeekNumber}-${dayNumber}`, + dayAbbreviation: "SKPD", + dayNumber: dayNumber, + date, + weekNumber: currentWeekNumber, + sets: [0], + }; + + await addCompletedDayToWorkoutsStore(skippedDayData); + const weekDataToUpdate = await getWeekDataForWeekNumber(currentWeekNumber); + updateThisWeekWithWorkoutNumber(weekDataToUpdate, skippedDayData.dayNumber); + const nextDay = skippedDayData.dayNumber + 1; + setStateForProgramDayNumber(nextDay); + setStateForUpdatePastWorkouts(nextDay); + } +``` + +- [ ] **Step 2: Verify build passes** + +```bash +npm run build +``` +Expected: `✓ Compiled successfully` + +- [ ] **Step 3: Commit** + +```bash +git add src/components/program/SkipDayButton.tsx +git commit -m "fix(skip): build skipped day payload inside handler — eliminates shared mutable object" +``` + +--- + +### Task 2: Fix in-place array mutation in PastWorkouts + +**Problem:** `setWeeklyProgress(weeklyProgress.reverse())` mutates the existing array in place before passing it to `setState`. React compares references, so it may not detect the change, and the original state is corrupted. + +**Files:** +- Modify: `src/components/program/PastWorkouts.tsx:32` + +- [ ] **Step 1: Replace `.reverse()` with a copy + reverse** + +Change line 32 from: +```tsx + setWeeklyProgress(weeklyProgress.reverse()); +``` +to: +```tsx + setWeeklyProgress([...weeklyProgress].reverse()); +``` + +- [ ] **Step 2: Verify build passes** + +```bash +npm run build +``` +Expected: `✓ Compiled successfully` + +- [ ] **Step 3: Commit** + +```bash +git add src/components/program/PastWorkouts.tsx +git commit -m "fix(past-workouts): copy array before reversing to avoid in-place state mutation" +``` + +--- + +### Task 3: Fix D3 SVG DOM accumulation (memory leak) + +**Problem:** All four SVG chart components (`DayOneSVG`, `DayTwoSVG`, `DayThreeSVG`, `DayFourSVG`) call `svgElement.append(...)` inside a `useEffect` without clearing existing children first. Every time `data` changes (e.g. navigating between workouts in the review page), a new layer of DOM nodes is appended on top of the old ones, causing visual corruption and unbounded memory growth. + +**Fix:** Add `svgElement.selectAll("*").remove()` immediately after `const svgElement = d3.select(ref.current)` in each component's `useEffect`. + +**Files:** +- Modify: `src/components/program/dataVisualization/DayOneSVG.tsx` +- Modify: `src/components/program/dataVisualization/DayTwoSVG.tsx` +- Modify: `src/components/program/dataVisualization/DayThreeSVG.tsx` +- Modify: `src/components/program/dataVisualization/DayFourSVG.tsx` + +- [ ] **Step 1: Add clear in DayOneSVG** + +In `DayOneSVG.tsx`, find the `useEffect` body. After `const svgElement = d3.select(ref.current);`, add: + +```tsx + svgElement.selectAll("*").remove(); +``` + +The block should now read: + +```tsx + useEffect((): void => { + if (ref.current) { + const svgElement = d3.select(ref.current); + svgElement.selectAll("*").remove(); + + svgElement.attr("height", "100%"); + // ... rest unchanged +``` + +- [ ] **Step 2: Add clear in DayTwoSVG** + +Apply the identical change to `DayTwoSVG.tsx` — add `svgElement.selectAll("*").remove();` on the line immediately after `const svgElement = d3.select(ref.current);`. + +- [ ] **Step 3: Add clear in DayThreeSVG** + +Apply the identical change to `DayThreeSVG.tsx`. + +- [ ] **Step 4: Add clear in DayFourSVG** + +Apply the identical change to `DayFourSVG.tsx`. + +- [ ] **Step 5: Verify build passes** + +```bash +npm run build +``` +Expected: `✓ Compiled successfully` + +- [ ] **Step 6: Commit** + +```bash +git add src/components/program/dataVisualization/ +git commit -m "fix(charts): clear SVG children before re-rendering to prevent DOM accumulation" +``` + +--- + +### Task 4: Fix circular useEffect dependency in both review pages + +**Problem:** Both review pages store `params.getData` into a `dataToGet` state variable inside a `useEffect`, but then also list `dataToGet` as a dependency. This creates a loop: + +1. Effect runs → `setDataToGet(params.getData)` +2. `dataToGet` changes → effect runs again +3. But now `dataToGet === params.getData`, so the DB queries fire a second time with the right value + +The result is that on first render no data is fetched (because `dataToGet` is still `""` when the conditions are checked), and the effect fires twice per navigation. The fix is to remove `dataToGet` state entirely and use `params.getData` directly in the conditions. + +**Files:** +- Modify: `src/app/(app)/program/review/[getData]/[index]/page.tsx` +- Modify: `src/app/(app)/program/@modal/(.)review/[getData]/[index]/page.tsx` + +- [ ] **Step 1: Rewrite the full-page review component** + +Replace the full contents of `src/app/(app)/program/review/[getData]/[index]/page.tsx` with: + +```tsx +"use client"; + +import styles from "./page.module.css"; +import { useEffect, useState, use } from "react"; +import { TDayComplete } from "@/definitions"; +import { + getWorkoutById, + getWorkoutsByDayNumber, + getWorkoutsbyWeekNumber, +} from "@/indexedDBActions"; +import DataVisualization from "@/dataVisualization"; +import { nunito } from "@/fonts"; + +const Page = (props: { params: Promise<{ getData: string; index: string }> }) => { + const params = use(props.params); + const initialData: TDayComplete[] = []; + const [data, setData] = useState(initialData); + const [heading, setHeading] = useState(""); + const [dataError, setDataError] = useState(false); + + useEffect(() => { + if (params.getData === "workout") { + getWorkoutById(params.index) + .then((value) => { + setData(value); + setHeading(`W${value[0].weekNumber}-D${value[0].dayNumber} REVIEW`); + }) + .catch((error) => { + console.warn(error); + setDataError(true); + }); + } + + if (params.getData === "week") { + getWorkoutsbyWeekNumber(Number.parseInt(params.index)) + .then((value) => { + setData(value); + setHeading(`W${params.index} REVIEW`); + }) + .catch((error) => { + console.warn(error); + setDataError(true); + }); + } + + if (params.getData === "day") { + getWorkoutsByDayNumber(Number.parseInt(params.index)) + .then((value) => { + setData(value); + setHeading(`D${params.index} REVIEW`); + }) + .catch((error) => { + console.warn(error); + setDataError(true); + }); + } + }, [params.getData, params.index]); + + return ( +
+ {dataError ? ( +

Click the link, please

+ ) : ( + <> +

+ {heading} +

+ + + )} +
+ ); +}; + +export default Page; +``` + +- [ ] **Step 2: Rewrite the modal review component** + +Replace the full contents of `src/app/(app)/program/@modal/(.)review/[getData]/[index]/page.tsx` with: + +```tsx +"use client"; + +import { Modal } from "@/components/program/Modal"; +import { useEffect, useState, use } from "react"; +import { TDayComplete } from "@/definitions"; +import { + getWorkoutById, + getWorkoutsByDayNumber, + getWorkoutsbyWeekNumber, +} from "@/indexedDBActions"; +import DataVisualization from "@/dataVisualization"; + +export default function Page( + props: { + params: Promise<{ getData: string; index: string }>; + } +) { + const params = use(props.params); + const initialData: TDayComplete[] = []; + const [data, setData] = useState(initialData); + const [heading, setHeading] = useState(""); + + useEffect(() => { + if (params.getData === "workout") { + getWorkoutById(params.index) + .then((value) => { + setData(value); + setHeading(`W${value[0].weekNumber}-D${value[0].dayNumber} REVIEW`); + }) + .catch((error) => console.warn(error)); + } + + if (params.getData === "week") { + getWorkoutsbyWeekNumber(Number.parseInt(params.index)) + .then((value) => { + setData(value); + setHeading(`W${params.index} REVIEW`); + }) + .catch((error) => console.warn(error)); + } + + if (params.getData === "day") { + getWorkoutsByDayNumber(Number.parseInt(params.index)) + .then((value) => { + setData(value); + setHeading(`D${params.index} REVIEW`); + }) + .catch((error) => console.warn(error)); + } + }, [params.getData, params.index]); + + return ( + + + + ); +} +``` + +- [ ] **Step 3: Verify build passes** + +```bash +npm run build +``` +Expected: `✓ Compiled successfully` + +- [ ] **Step 4: Run Playwright tests** + +```bash +npx playwright test +``` +Expected: 62 passed + +- [ ] **Step 5: Commit** + +```bash +git add src/app/\(app\)/program/review/ src/app/\(app\)/program/@modal/ +git commit -m "fix(review): remove circular dataToGet state — use params directly in useEffect" +``` diff --git a/docs/superpowers/plans/2026-04-18-code-quality.md b/docs/superpowers/plans/2026-04-18-code-quality.md new file mode 100644 index 0000000..aed6690 --- /dev/null +++ b/docs/superpowers/plans/2026-04-18-code-quality.md @@ -0,0 +1,222 @@ +# Code Quality Fixes Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Remove dead code (unused API route and duplicate `makeTransaction`), then add Playwright test coverage for the skip flow day-advancement and Escape key modal dismissal. + +**Architecture:** Three independent tasks. Tasks 1 and 2 are pure deletions/simplifications. Task 3 adds new tests to existing spec files without touching app code. + +**Tech Stack:** Next.js 16, TypeScript, Playwright + +--- + +### Task 1: Delete unused `/api/program/review` route + +**Problem:** `src/app/api/program/review/route.ts` exists as a GET handler that just echoes query params back as JSON. No client code anywhere in the app calls this endpoint. It is dead code. + +**Files:** +- Delete: `src/app/api/program/review/route.ts` + +- [ ] **Step 1: Confirm nothing imports this route** + +```bash +grep -r "api/program/review" src/ +``` +Expected: no output (nothing calls it) + +- [ ] **Step 2: Delete the file** + +```bash +rm src/app/api/program/review/route.ts +``` + +- [ ] **Step 3: Verify build** + +```bash +npm run build +``` +Expected: `✓ Compiled successfully` — the route table should no longer include `/api/program/review` + +- [ ] **Step 4: Commit** + +```bash +git add -A +git commit -m "chore: remove unused /api/program/review route + +Co-Authored-By: Claude Sonnet 4.6 " +``` + +--- + +### Task 2: Remove duplicate `makeTransaction` from `index.ts` + +**Problem:** `src/app/lib/data/indexedDB/index.ts` contains a private `makeTransaction` function (lines 16–26) that duplicates the exported one in `actions.ts`. The `index.ts` copy is only used once — in the `openRequest.onsuccess` handler to load test data (line 101). Since `db` is always set at that point, the call can be replaced with a direct `db.transaction()` call, and the local function can be deleted. + +Removing it also removes the now-unused `TStoreName` import at line 1. + +**Files:** +- Modify: `src/app/lib/data/indexedDB/index.ts` + +- [ ] **Step 1: Read the file** + +```bash +cat -n src/app/lib/data/indexedDB/index.ts +``` + +- [ ] **Step 2: Remove the `TStoreName` import and `makeTransaction` function** + +Delete line 1: `import type { TStoreName } from "@/definitions";` + +Delete lines 15–27 (the `// MAKE_TRANSACTION {{{` block): +```ts +// MAKE_TRANSACTION {{{ +function makeTransaction(storeName: TStoreName, mode: IDBTransactionMode) { + if (!db) return; + + let transaction = db.transaction(storeName, mode); + + transaction.onerror = (err) => { + console.warn(err); + }; + + return transaction; +} +//}}} +``` + +- [ ] **Step 3: Replace the single `makeTransaction` call with a direct transaction** + +In the `openRequest.onsuccess` handler, the test data block currently reads: +```ts + let transaction = makeTransaction("weeksStore", "readwrite"); + if (!transaction) return; + transaction.oncomplete = () => console.log("Finished adding data."); +``` + +Replace with: +```ts + const transaction = db.transaction("weeksStore", "readwrite"); + transaction.onerror = (err) => { console.warn(err); }; + transaction.oncomplete = () => console.log("Finished adding data."); +``` + +`db` is guaranteed non-null here because this code runs inside `openRequest.onsuccess` after `db = openRequest.result` on the line above. + +- [ ] **Step 4: Verify build** + +```bash +npm run build +``` +Expected: `✓ Compiled successfully` + +- [ ] **Step 5: Commit** + +```bash +git add src/app/lib/data/indexedDB/index.ts +git commit -m "chore: remove duplicate makeTransaction from index.ts + +Co-Authored-By: Claude Sonnet 4.6 " +``` + +--- + +### Task 3: Add missing Playwright test coverage + +**Problem:** Two behaviors have no test coverage: +1. **Skip flow day advancement** — existing tests verify that past workouts appear after skip, but do not verify the day counter actually advanced (i.e., the link now says "DAY 2" not "DAY 1") +2. **Escape key closes modals** — the new Escape key handlers in TimerModal and DailyHintModal have no tests + +**Files:** +- Modify: `tests/programPage.spec.ts` — add one new test +- Modify: `tests/dayOnePage.spec.ts` — add two new tests + +--- + +#### 3a — Skip advances to DAY 2 + +Add a new test at the end of `tests/programPage.spec.ts`: + +```ts +test("skip advances day counter to DAY 2", async ({ page }) => { + const programPage = new ProgramPage(page); + await programPage.goto(); + await expect(programPage.dayLink).toBeVisible(); + await expect(programPage.dayLink).toHaveText("DAY 1"); + await expect(programPage.skipButton).toBeVisible(); + await expect(programPage.skipButton).toBeEnabled(); + await programPage.pressSkipButton(); + await expect(programPage.dayLink).toBeVisible(); + await expect(programPage.dayLink).toHaveText("DAY 2"); +}); +``` + +--- + +#### 3b — Escape key closes hint modal + +Add a new test at the end of `tests/dayOnePage.spec.ts`. The `DayOneWorkoutPage` POM already has `dailyHintModal`, `dailyHintButton`, and `pressDailyHintButton()` available: + +```ts +test("Escape key closes hint modal", async ({ page }) => { + const dayOnePage = new DayOneWorkoutPage(page, 1); + await dayOnePage.goto(); + await dayOnePage.pressDailyHintButton(); + await expect(dayOnePage.dailyHintModal).toBeVisible(); + await page.keyboard.press("Escape"); + await expect(dayOnePage.dailyHintModal).not.toBeVisible(); +}); +``` + +--- + +#### 3c — Escape key closes timer modal + +Add another new test at the end of `tests/dayOnePage.spec.ts`: + +```ts +test("Escape key closes timer modal", async ({ page }) => { + const dayOnePage = new DayOneWorkoutPage(page, 1); + await dayOnePage.goto(); + await dayOnePage.pressCompleteSetButton(); + await expect(dayOnePage.timerModal).toBeVisible(); + await page.keyboard.press("Escape"); + await expect(dayOnePage.timerModal).not.toBeVisible(); +}); +``` + +--- + +#### Steps for Task 3 + +- [ ] **Step 1: Add the skip day-advancement test to `tests/programPage.spec.ts`** + +Append the test shown in 3a to the end of the file. + +- [ ] **Step 2: Add the two Escape key tests to `tests/dayOnePage.spec.ts`** + +Append the tests shown in 3b and 3c to the end of the file. + +- [ ] **Step 3: Run the new tests in isolation to verify they pass** + +```bash +npx playwright test tests/programPage.spec.ts tests/dayOnePage.spec.ts +``` +Expected: all tests in those two files pass (existing + new) + +- [ ] **Step 4: Run full suite** + +```bash +npx playwright test +``` +Expected: 66 passed (62 original + 4 new: 1 skip advancement + 2 Escape key + 1 is the `programPage.spec.ts` test that already existed but didn't check DAY 2 text) + +Wait — 3 new tests total: skip DAY 2 check, Escape hint, Escape timer = 62 + 3 = 65 passed. + +- [ ] **Step 5: Commit** + +```bash +git add tests/programPage.spec.ts tests/dayOnePage.spec.ts +git commit -m "test: add skip day-advancement and Escape key modal tests + +Co-Authored-By: Claude Sonnet 4.6 " +``` diff --git a/docs/superpowers/plans/2026-04-18-ux-reliability.md b/docs/superpowers/plans/2026-04-18-ux-reliability.md new file mode 100644 index 0000000..b7b6177 --- /dev/null +++ b/docs/superpowers/plans/2026-04-18-ux-reliability.md @@ -0,0 +1,397 @@ +# UX / Reliability Fixes Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Fix three UX/reliability issues — a timer audio ordering bug, missing modal accessibility attributes and keyboard support, and a missing saving state in the workout save flow. + +**Architecture:** All changes are scoped to existing components. No new files. No new dependencies. Modal accessibility is additive (attributes + a single useEffect per modal). The saving state in DayComplete adds one boolean to existing state and fixes a prop mutation. + +**Tech Stack:** React 19, Next.js 16, TypeScript + +--- + +### Task 1: Fix timer audio in TimerModal + +**Problem:** In `TimerModal.tsx`, `beep.volume = 0.1` is set *after* `beep.play()` is called. The browser may not apply the volume before playback starts. Additionally, `beep.play()` returns a Promise that is silently dropped — if the browser blocks autoplay, the error is invisible. + +**Files:** +- Modify: `src/components/program/TimerModal.tsx:20-27` + +- [ ] **Step 1: Read the file** + +```bash +cat -n src/components/program/TimerModal.tsx +``` + +- [ ] **Step 2: Fix the audio block** + +Replace lines 21–26 (the `if (secondsLeft === 0)` block) with: + +```tsx + useEffect(() => { + if (secondsLeft === 0) { + const beep = new Audio("/audio/timer-beep.mp3"); + beep.volume = 0.1; + beep.play().catch(() => {}); + setStateForShowTimerModal(false); + return; + } + + const intervalId = setInterval(() => { + setSecondsLeft((secondsLeft) => secondsLeft - 1); + }, 1_000); + + return () => { + clearInterval(intervalId); + }; + }, [secondsLeft, setStateForShowTimerModal]); +``` + +Key changes: +- `beep.volume = 0.1` moved **before** `beep.play()` +- `.catch(() => {})` added to silence autoplay-blocked errors without crashing + +- [ ] **Step 3: Verify build** + +```bash +npm run build +``` +Expected: `✓ Compiled successfully` + +- [ ] **Step 4: Commit** + +```bash +git add src/components/program/TimerModal.tsx +git commit -m "fix(timer): set audio volume before play — silence autoplay errors + +Co-Authored-By: Claude Sonnet 4.6 " +``` + +--- + +### Task 2: Add accessibility to all three modals + +**Problem:** All three modals lack `role="dialog"` and `aria-modal="true"`. Icon-only close buttons lack `aria-label`. None respond to the Escape key. + +The three modals: +- `TimerModal.tsx` — root `
`, close button has a visibly-hidden span but no `aria-label` on the button itself +- `DailyHintModal.tsx` — root `
`, close button is icon-only with no screen-reader text at all +- `Modal.tsx` (review modal) — root `