# Decisions Log Record of architectural and technical decisions made during development. ## 2026-07-26 — Request persistent storage on startup (Step 29) **Decision:** `main.js` now calls `navigator.storage.persist()` once at startup, fire-and-forget (guarded so browsers without the API skip it, result only logged). No UI, no i18n strings, no blocking: whether the browser grants, denies, or prompts, startup proceeds unchanged. **Rationale:** User report (2026-07-26): after not using the app for a while, the Bootstrap Icons occasionally render as placeholder boxes. Without `persist()`, all site storage is "best-effort" and the browser may evict Cache Storage (the Workbox precache holding the icon font) after long inactivity or under disk pressure — a stale HTTP-cached page then references an old content-hashed font file that no longer exists on the server after a deploy → 404 → tofu glyphs. More importantly, the OPFS SQLite database lives in the same evictable bucket, so the same cleanup could delete user data. `persist()` asks the browser to exempt the origin from automatic eviction (Chromium decides silently by engagement heuristics; Firefox may show a permission doorhanger). Placed in `main.js` rather than the re-callable `bootstrapDatabase()` so a retry after a failed first load doesn't re-prompt. Tests stub `StorageManager.prototype.persist` since the real outcome depends on browser heuristics/permission UI. ## 2026-07-26 — First-load network failure: re-callable init + in-app retry (Step 28) **Decision:** A failed database startup is no longer final. `Database.init()` now resets itself when the worker never became usable (`db.info` still null): the dead worker is terminated and the memoized promise cleared, so a later `init()` call starts from scratch — required because the OPFS SAH pool only allows one connection per origin. The startup sequence (init + persisted language/theme + DOM attributes) moved from `main.js` into a re-callable `bootstrapDatabase()` (`src/db/bootstrap.js`), which App.vue's new **Try again** button on the error overlay reruns in place. The worker tags `sqlite3InitModule` failures as `DB_LOAD_FAILED` — that call is the only network-touching step (downloading `sqlite3.wasm` on a not-yet-cached first load) — and App.vue maps it to a "check your connection and try again" message instead of the misleading `browserNotCompatible`. `_handleError` only dispatches `db-connection-lost` after a successful init, so startup failures consistently land on the retryable error overlay rather than the reload-only connection-lost overlay. **Rationale:** User report (2026-07-26): a network timeout on the very first load left the app permanently stuck on "your browser is not compatible" — the init promise cached its rejection, nothing ever retried, and the overlay offered no way out. There is no timeout value to extend: the wasm download is a plain browser `fetch()` with no application-level timeout, so the fix is recoverability, not tuning. The reset deliberately does **not** trigger for failures after the DB opened (`DB_NEWER_THAN_APP`): that worker stays alive for export/diagnostics and the retry button is hidden since retrying cannot help (this also preserves the Step 16 test relying on a live worker after that error). Retry-in-place was chosen over a "reload the page" hint because it keeps the SPA state and proves recovery works without depending on cache behavior across reloads. Chromium-only tests for the wasm-fetch block (Playwright cannot intercept dedicated-worker requests on Firefox); a cross-browser test breaks worker creation instead so the retry machinery is verified on both browsers. ## 2026-07-24 — Check Updates section moved before Credits; shows last manual check date **Decision:** Renamed the Settings "Application" card to **Check Updates** and moved it to sit directly above **Credits** (previously between Sound and the restore-overlay/report block). Added a `last_update_check_at` settings key (ISO-8601 UTC, in-place addition) stamped by `SettingsView.onCheckForUpdate()` right after `checkForUpdate()` resolves; the section displays it via `formatDateTime` ("Last manual check: {{when}}") or a "Never checked manually" fallback. **Rationale:** User request (2026-07-24). The timestamp intentionally only reflects manual clicks — the Service Worker API has no event for the browser's own background SW checks, so there is nothing reliable to hook there; tracking manual checks only was confirmed with the user rather than guessing at the browser's schedule. ## 2026-07-22 — Manual "Check for updates" button, reusing the existing choice modal (Step 27) **Decision:** `useAppUpdate.js` now captures the service-worker `registration` via `useRegisterSW`'s `onRegisteredSW` callback and exposes `checkForUpdate()`, which calls `registration.update()` to force the browser to re-fetch the SW script instead of waiting for its own schedule. If the script changed, Workbox flips the existing `needRefresh` flag and `AppUpdateDialog` appears exactly as it does for a passively-detected update — same "Update now" / "Later" choice. `SettingsView.vue` gained an "Application" card with a "Check for updates" button; since the Service Worker API has no "no update found" event, a plain timeout heuristic shows a transient "You're up to date" message if `needRefresh` hasn't flipped a couple of seconds after the check. **Rationale:** The point of the manual button is only to force _detection_ sooner (Step 24's passive check only runs on the browser's own schedule, roughly on navigation/reload) — it must not become a second code path that could apply an update without asking. Routing the found-update case through the same `needRefresh`/`AppUpdateDialog` flow guarantees updating stays a user decision (user decision 2026-07-21) whether the update was found automatically or via the manual check, with no new modal or auto-apply branch to keep in sync. ## 2026-07-09 — Deleting an exercise/combo cleans session usage and warns about it (code review F4) **Decision:** `session_item.item_id` is polymorphic (exercise or combo) and carries no FK, so deleting an exercise or combo used in a session silently left orphaned `session_item` rows that the INNER JOIN in `sessionRepository.getItems` simply hid — the entry vanished from the session with no trace. Deletes in `exerciseRepository`/`comboRepository` now also remove the matching `session_item` rows (in one `db.batch` transaction), and the delete confirmations on the exercise/combo/session detail views show usage counts before the user confirms: sessions+combos using the exercise, sessions using the combo, collections containing the session (new repository count helpers, new `deleteConfirmUsage` i18n keys in EN/FR/DA). **Rationale:** The confirmation stays **informative, not blocking** — consistent with the existing history-cascade warning UX; the user can still delete, they just see the blast radius first. Collection/session junctions already have FK CASCADE so they need no cleanup, only the usage warning. (Chosen from the remediation plan's proposal without live validation — flagged for review.) ## 2026-07-09 — DB restore no longer copies the backup's schema_version stamp (code review F3) **Decision:** `handleImportDb` in `src/db/worker.js` now excludes `schema_version` from the tables copied out of a restored backup. The live DB keeps its own version stamp, which is correct by construction: any adapt/migration scripts run against the temp DB _before_ the copy, so the restored data always matches the current schema. **Rationale:** This was a latent time bomb, dormant only because `SCHEMA_VERSION` is still 1. Once a v2 migration ships, restoring a v1 backup would have copied the old stamp into the live DB; the next launch would read migration-pending and re-run the migration against an already-adapted schema (e.g. a duplicate `ALTER TABLE ADD COLUMN` fails and strands the user on the migration-failed screen). Excluding the table was chosen over re-stamping after restore because it keeps the restore loop generic and adds no write. A regression test simulates an older stamp (version 0) in a backup and asserts the live stamp survives the restore; a comment in `src/db/releases.js` requires the first real v2 migration to ship a full end-to-end older-version restore test. ## 2026-07-09 — Same-user re-import skips existing execution logs (code review F1) **Decision:** In `useJsonImport.js`, execution logs whose id already exists locally (`sameUserCollisions`) are now counted as skipped instead of being re-inserted with their original id. Previously each one hit a PRIMARY KEY violation whose message ended up in the import report's error list, making a routine "re-import my own full export" look like a partially failed import. **Rationale:** Execution logs are immutable historical records — unlike exercises/combos/sessions there is nothing to Replace or merge, so a skip is always the correct resolution and no Replace/Skip UI is warranted. A regression test covers the exact scenario the existing round-trip test missed: re-importing a full export _without_ deleting anything first must produce zero errors and no duplicate logs. ## 2026-07-06 — Voice announcements via the Web Speech API (Step 26) **Decision:** Added `useSpeech.js`, a thin wrapper around `window.speechSynthesis` exposing `init()` (reads the new `voice_enabled` settings key, default enabled), `speak(text, { volume })` (cancels any pending utterance first, so a new announcement always preempts a stale one, and maps the current i18next language to a BCP-47 tag: `en` → `en-US`, `fr` → `fr-FR`, `da` → `da-DK`), `cancel()`, and `isSupported`. `useSessionExecution.js` calls `speak()` with the exercise title whenever a step becomes current (`loadStepState()`), and pre-announces the _next_ exercise ("Next: X") in the two places the on-screen UI already does the same thing silently: 10 s before an EMOM slot ends (hooked into the existing `_emomSounds` tick handler) and the instant a TABATA rest phase opens (`_startRestPhase`). AMRAP and plain (NONE) combos only get the step-change announcement, matching their on-screen behavior of not showing a "next" preview until the step actually changes. To avoid the same exercise being spoken twice in quick succession, the queue index that was just pre-announced is tracked in a closure variable; `loadStepState()` skips the step-change announcement exactly once when it reaches that index. `speechSynthesis.cancel()` is also called on `exit()` and `_finish()` so nothing keeps talking after the user leaves the execution view. The Settings → Sound section gained a "Voice announcements" checkbox next to the existing volume slider, persisted the same way as `audio_volume`. **Rationale:** `speechSynthesis` is native, works offline, and needs no audio asset or dependency, fitting the PWA's existing Web Audio beep approach (Step 12) rather than replacing it — beeps and voice share the one `audio_volume` knob. Reusing the exact moments the UI already surfaces a "next" hint (EMOM's 10 s countdown, TABATA's rest-phase open) means the voice cue never says something the screen doesn't already imply. `'speechSynthesis' in window` feature-detection keeps the app working unchanged where the API is unsupported. **Testing note:** Firefox exposes `window.speechSynthesis` as a getter-only property, so a test mock must replace it with `Object.defineProperty(window, 'speechSynthesis', { configurable: true, value: ... })` — a plain `window.speechSynthesis = ...` assignment silently no-ops and the real (unmocked) API keeps running underneath, which looks identical to "nothing was called" from the test's point of view. The same caveat applies to the red-path test removing the API (`delete Window.prototype.speechSynthesis`, mirroring the Step 25 `navigator.mediaSession` mock). ## 2026-07-05 — Headset / media-key control of session execution (Step 25) **Decision:** Added `useMediaSession.js`, wiring the Media Session API (`navigator.mediaSession`) into `SessionExecuteView` so headphone buttons, OS media keys, and lock-screen controls can drive a workout: play/pause toggles the active timer, next/previous step through the queue, and stop exits immediately. Hardware media keys are only routed to a page actively playing audio through a media element — the Web Audio beeps from Step 12 don't qualify — so `useMediaSession.js` also starts a tiny silent looping `