# 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 `<audio>` element (a 592-byte base64 WAV data URI, no asset file) when execution begins; the user's ▶ tap to start the session satisfies the autoplay policy. `MediaMetadata` (title = exercise, artist/album = combo/session title) is refreshed on every step change so the lock screen shows the current exercise. The whole composable feature-detects `'mediaSession' in navigator` and no-ops silently where unsupported.

Action mapping mirrors the on-screen buttons rather than inventing new semantics: `nexttrack` calls the same handler as **Done** on a normal step and **Continue** on the final-round/AMRAP-time-up screens; `previoustrack` calls a new `goBack()` in `useSessionExecution.js` (decrements the queue index and reloads step state, restarting any timer from the top of its slot/phase — a no-op at the first step); `stop` calls `exit()` directly with no confirmation, since a headset button can't answer a confirm dialog (partial log saved, `status = 'aborted'`). A matching on-screen **Previous** button (`bi-skip-start-fill`, disabled on the first step) was added next to Done so the feature — and `goBack()` — is directly usable and testable without a headset.

`goBack()` deliberately does not rewrite history: already-saved log items for a step are left in place, and redoing that step appends a new log item (accepted duplication, simplest correct behavior — KISS over log-rewriting complexity for what should be a rare correction).

**Rationale:** A phone typically stays locked in a pocket or armband during a workout; headset/lock-screen control is the only realistic way to advance steps without unlocking and touching the screen. Reusing the existing Done/Continue/Exit handlers instead of parallel media-specific logic keeps behavior identical between touch and hardware-key control paths. Testing mocks `navigator.mediaSession` (capturing `setActionHandler` registrations and `metadata`/`playbackState` assignments) since simulating real hardware media keys in Playwright isn't possible.

## 2026-07-05 — App update modal: service worker switched from `autoUpdate` to `prompt` (Step 24)

**Decision:** Switched `vite-plugin-pwa`'s `registerType` from `autoUpdate` to `prompt` and added an update dialog. With `autoUpdate`, a new deployment made the new service worker download the full precache (including the ~8 MB `sqlite3.wasm`), take control, and reload — experienced by the user as an unexplained freeze (the Step 16 migration screen only covers database schema changes, not app-code updates). With `prompt`, the new SW still precaches in the background but waits; a new `AppUpdateDialog.vue` (reusing `ModalDialog.vue`) offers **Update now** / **Later**. "Update now" switches the modal to a non-dismissible spinner state and calls `updateServiceWorker(true)` — the page reloads when the new SW takes control, so the spinner simply explains the wait. "Later" dismisses for the current session only (same pattern as `InstallPrompt.vue`, no persisted flag), so the prompt returns on the next app load. The modal is suppressed while the current route is the execution view (`session-execute`) and shows automatically once the user navigates away — an update prompt must never interrupt a workout.

The non-dismissible updating state needed no change to `ModalDialog`: passing an empty title removes the header close button, and the component's `close` event (Escape/backdrop) is simply ignored while updating.

The SW state is owned by a new singleton composable `useAppUpdate.js` wrapping `useRegisterSW` from `virtual:pwa-register/vue` (importing the virtual module also replaces the plugin's auto-injected registration script). Because simulating a real SW update in Playwright would require two production builds, the composable exposes a dev-only hook (`window.__appUpdateTest`, guarded by `import.meta.env.DEV`) letting tests force `needRefresh` and mock the update call; the real flow (deploy old build → deploy new build → prompt → update → reload) is validated manually.

**Rationale:** An update should be a user decision with visible feedback, not a silent multi-megabyte download followed by an unexplained reload — especially in a fitness app where the freeze could land mid-workout. `prompt` is the vite-plugin-pwa-supported way to get exactly that behavior while keeping the background precache (the update itself stays fast once accepted).

## 2026-07-04 — First-launch warning modal (single tab / no private browsing)

**Decision:** Added `FirstLoadWarning.vue`, a one-time modal shown on first app launch warning that TrainUs (1) can only be open in one browser tab at a time and (2) does not work in private/incognito browsing. Modeled on `BackupReminder.vue`'s conditional-modal pattern: checks a new `first_load_warning_seen` settings key on mount, shows the modal if unset, and persists the flag permanently on dismiss (no session-only snooze — this is a one-time notice, not a recurring reminder). Both warnings share a single modal and a single "Got it" dismiss button per the original request, rather than two separate dialogs.

Because every Playwright test runs in its own fresh browser context (a fresh OPFS database), the "first launch" condition the modal keys off is true for every test, not just a real user's actual first run — left unhandled, this would have popped a blocking modal in front of roughly 30 existing test files. Rather than adding test-only branches to the production component, `tests/conftest.py`'s `page` fixture now injects a `MutationObserver`-based init script that auto-dismisses the modal the instant it appears, for every test except the dedicated `tests/test_step23_first_load_warning.py`, which opts out via a new `keep_first_load_warning` pytest marker to exercise the real modal. Verified against the full existing suite plus the two trickiest interactions (the Step 3c backup startup modal, which can appear stacked with this one, and the Step 16 migration flow, where DB-readiness — and so this modal — can appear mid-test after the migration completes).

**Rationale:** The DB layer's `opfs-sahpool` VFS only supports a single active connection per origin, and OPFS may be unavailable in private browsing; both previously only surfaced as the generic `error.browserNotCompatible` message after the failure already happened. A proactive one-time warning is cheaper for users than a confusing post-hoc error screen. The `MutationObserver` approach (over a fixed-timeout wait) was chosen for the test-infra fix because it reacts the instant the modal enters the DOM regardless of when — the initial page load, or later during Step 16's mid-test dbReady transition — so it doesn't add fixed wait time to any of the ~30 unrelated test files.

## 2026-06-18 — Clear (×) button added to list search fields

**Decision:** Added a clear button inside the search input on the four list views with a search bar (`ExerciseListView`, `ComboListView`, `SessionListView`, `CollectionsView`). The button (Bootstrap `bi-x-lg` icon) only renders when the field has a value, sits absolutely positioned inside the input (`padding-right` added to make room), and on click empties `searchQuery` and refocuses the input via a new `clearSearch()` function in each view. The click handler uses `@mousedown.prevent` instead of `@click` so the button doesn't steal focus from the input first (which would otherwise fire `onSearchBlur` while the field still holds its old, non-empty value). Added a shared i18n key `common.clearSearch` (EN/FR/DA) for its `title`/`aria-label` rather than per-entity keys, since the action is identical across all four views. Each view gets its own `data-testid="<entity>-search-clear"` to match the existing per-view `data-testid` convention for the search bar/input/toggle. The pattern is duplicated across the four `.vue` files rather than extracted into a shared component, consistent with how the rest of the search bar (debounce, toggle, blur-to-close) is already duplicated there.

**Rationale:** Requested by the user as a small UX improvement — clearing the field previously required selecting and deleting the text manually, or toggling search off and back on (which also closes the bar). `@mousedown.prevent` was chosen over reordering the blur/clear logic because it's the standard idiom for "clear button inside a focused input" and avoids a state-machine edge case where blur could close the search bar before the field is cleared.

## 2026-06-16 — "Add a New Theme" how-to now covers all supported languages

**Decision:** Fixed `website/content/developers/how-to-guides.{en,fr}.md`'s "Add a New Theme" recipe, which only had the new contributor add the theme's display label to `en.json` and `fr.json` — missing `da.json`, even though Danish is a real, shipped language (same gap just fixed elsewhere in "Add a New Language", see the entry above). Merged the two language-specific steps into one ("add the display label in every supported language"), added the `da.json` example, noted that `i18next`'s `fallbackLng: 'en'` (from `src/i18n/index.js`) means a missing label degrades to English silently rather than erroring, and added a step in the smoke-test checklist to verify the label in all three languages before considering the theme done. Renumbered the touch-point list and step headings accordingly (6 steps → 5).

**Rationale:** A theme contributed by following the old recipe would ship with a Danish UI silently showing an English theme name — a real, easy-to-miss gap given `fallbackLng` doesn't surface it as an error. Caught by the user immediately after the equivalent fix to "Add a New Language".

## 2026-06-16 — Corrected language-count and COOP/COEP claims in developer docs

**Decision:** Fixed two inaccuracies in `website/content/developers/how-to-guides.{en,fr}.md` and `technical.{en,fr}.md`, both inherited unchanged from the pre-split single-page `technical.en.md`:

1. "Add a New Language" used Danish (`da`) as the worked example for a language being added, with an `// ← new` comment — misleading, since `da` is already a real, shipped locale (`src/i18n/locales/da.json`, registered in `src/i18n/index.js`, and selectable today in Settings per `src/views/SettingsView.vue`). Reworked the recipe to use Spanish (`es`) as the example instead, and added a note up front that TrainUs already ships three languages (English, French, Danish), not just the EN/FR pair the project website itself emphasizes. Also corrected the Reference page's "Available settings" table, which still listed the language selector as "(English / Français)" only.
2. "Deploy TrainUs" stated the COOP/COEP headers **must** be present "or the browser blocks OPFS access" — contradicted by the same reference page's own §2 ("Does not require COOP/COEP headers") and by the R17 README correction (2026-06-12, see below). Reworded to match README's accurate framing: not strictly required by the `opfs-sahpool` VFS actually used, recommended as defense-in-depth, and the dev server already sets them as a precaution.

**Rationale:** Both inaccuracies were latent in the original single-page `technical.en.md` and simply carried forward during the Diataxis split rather than introduced by it; the user caught them after reading the new how-to pages. Fixed in both languages since both were translated from the same (incorrect) source text.

## 2026-06-16 — French developer documentation ported to the EN Diataxis split

**Decision:** Ported the four-page developer docs Diataxis split (see the "English developer documentation split Diataxis-style" entry below) to French, completing the deferred follow-up: `getting-started.fr.md` (tutorial, slug `demarrage-rapide`), `how-to-guides.fr.md` (how-to, slug `guides-pratiques`), `technical.fr.md` (reference — replaced the old "moved to EN" stub with the full translated content, title changed from "Documentation technique" to "Référence" and weight corrected from 5 to 3 to match the EN page's position, mirroring `user-guide/index.fr.md`'s "Référence" title), `understanding-the-architecture.fr.md` (explanation, slug `comprendre-l-architecture`). Basenames match the EN files for Hugo's translation pairing (confirmed via the language switcher rendering correctly on each page); French-friendly URLs come from `slug:` front matter, the same technique used for the EN user-docs port. Internal cross-page anchors (e.g. `technical.fr.md#1-architecture`, `how-to-guides.fr.md#déployer-trainus`) were verified against Hugo's actual generated heading IDs after a full `hugo --minify` build — no `REF_NOT_FOUND` and no broken anchors.

**Rationale:** The English split was done first specifically because the project already had two real how-to documents in English; with that done and validated, finishing the French side closes out the deferred TODO in `docs/website-plan.md` §8.1 and brings developer docs to parity with user docs (both now fully bilingual, 4-page Diataxis splits).

## 2026-06-16 — English developer documentation split Diataxis-style

**Decision:** Restructured the English developer documentation from a single page (`website/content/developers/technical.en.md`) into a four-page Diataxis split, mirroring the structure already used for user docs: `getting-started.en.md` (tutorial — new content: prerequisites, clone/install/dev, a one-line-per-folder tour of `src/`, running the Playwright suite, and one small guided edit to close the dev loop before sending the reader to the how-to page), `how-to-guides.en.md` (how-to — four recipes: "Add a New Language" and "Add a New Theme" ported from the pre-existing `docs/how-to-add-a-language.md` and `docs/how-to-add-a-theme.md`, plus two new recipes, "Deploy TrainUs" extracted from the old §7 and "Add a Database Migration" synthesized from the old §2/§6 narrative into actual steps), `technical.en.md` (reference — kept the filename/weight-3 slot, trimmed of rationale prose and the deploy header recipes, otherwise unchanged: tech stack, project structure, schema, ER diagram, composables, the full §3 feature-by-feature description, routing, i18n, versioning, build commands, Annex A), and `understanding-the-architecture.en.md` (explanation — new: why no backend, why SQLite runs in a Worker, why the `opfs-sahpool` VFS with no fallback, why the migration system is designed the way it is, plus a condensed pass over a handful of notable per-feature design decisions — the save dialog vs. native picker, partial logs on exit, asymmetric side-flip strategies, per-combo-type rounds calculation — and the content-width-cap rationale). The two superseded `docs/how-to-add-a-*.md` files were replaced with "moved to the website" stubs, matching the existing pattern for `docs/technical-documentation.md` / `docs/user-documentation.md` (whose own stub was also corrected — it still pointed at the pre-split `website/content/docs/technical.en.md` path). `docs/release-procedure.md` and `AGENTS.md`'s Definition of Done checklist had the same stale path and were updated to `website/content/developers/technical.en.md` / `website/content/docs/user-guide/index.en.md`.

French (`technical.fr.md`) is **not** restructured in this step — the user explicitly asked to start with English, since the existing how-to content was already English-only. Porting the split to French is left for later, the reverse order of how the user docs split was done (FR first, then EN).

**Rationale:** The user-docs Diataxis split improved discoverability enough that the same treatment was requested for developer docs. Reusing the two pre-existing, real how-to documents as the seed for the how-to page (rather than writing recipes from scratch) kept the content grounded in material the project owner had already written and validated, and matches the precedent of `docs/technical-documentation.md`/`docs/user-documentation.md` being retired into stubs once their content moved to the website.

## 2026-06-16 — English user documentation ported to the FR Diataxis split

**Decision:** Restructured the English user documentation from a single page (`user-guide.en.md`) into the same four-page Diataxis split already used for French (see the 2026-06-16 "French user documentation split Diataxis-style" entry below): `demarrage-rapide/index.en.md` (tutorial — what TrainUs is, local-first principles and trade-offs, GUI overview, the two modes; also kept the browser-requirements and PWA-install note that used to open the old single-page guide, since that's tutorial-appropriate content), `guides-pratiques.en.md` (how-to — same six recipes as the FR page, written natively rather than translated literally), `user-guide/index.en.md` (reference — the existing EN content reordered to match the FR section order, with the EMOM/AMRAP/TABATA combo screenshots and a Markdown cheat-sheet appendix added, both previously FR-only), and `comprendre.en.md` (explanation — why local-first, JSON vs SQLite, import behaviour, timer logic). The reference page kept the `user-guide` bundle name (no language-specific renaming) so it still pairs as a Hugo translation with `user-guide/index.fr.md`; the other three pages also share their basename with the FR files for the same reason, but get an English-friendly rendered URL via `slug:` front matter (`/en/docs/getting-started/`, `/en/docs/how-to-guides/`, `/en/docs/understanding-trainus/`) instead of inheriting the French words. Screenshots are genuinely English-UI captures (not reused FR images) — generated via `scripts/screenshots/all_pages.py --lang en` and `scripts/screenshots/combo_timers.py --lang en`, which already supported a `--lang` flag added during the FR work specifically for this future EN port.

**Rationale:** The FR rewrite's decision log explicitly deferred this ("English documentation is **not** restructured ... porting it to the same split is left for later"); the user asked for that follow-up now that they're happy with the FR result. Reusing the FR page structure (rather than re-deriving an EN-specific organization) keeps the two languages structurally interchangeable, which matters for the lang-switcher and for anyone maintaining both versions going forward. English slugs were chosen over French-derived ones because the rendered URL is user-facing and worth getting right, while the underlying filename is purely an internal pairing mechanism Hugo already required to match.

## 2026-06-16 — Corrected user-guide claims about delete cascade behaviour

**Decision:** Fixed `website/content/docs/user-guide.en.md` and `website/content/docs/user-guide/index.fr.md`: the "Deleting a Session" section wrongly claimed execution history is "preserved" with a "missing session reference" after deleting a session — that described the `ON DELETE SET NULL` alternative considered (and rejected) in R15 of `docs/improvement-plan.md`, not the cascade behaviour actually shipped. Corrected both pages to state that deleting a session also permanently deletes its execution history, and that the confirmation dialog shows the affected count beforehand. Also added the equivalent missing detail to "Deleting an Exercise" (EN had no mention of history impact at all; FR already had a partial note, now aligned with the same confirm-count wording). No code changed — `src/db/schema.js` keeps `ON DELETE CASCADE` on `execution_log.session_id` and `execution_log_item.exercise_id`, confirmed still in effect via a live Playwright/Firefox check against the actual app.

**Rationale:** R15 (`docs/improvement-plan.md`, implemented, see `docs/plan.md` R15 row) chose the "minimum" fix — count-aware confirm dialogs (`exercises.deleteConfirmWithHistory` / `sessions.deleteConfirmWithHistory`) — over the more invasive `SET NULL` schema change, but the user-guide was never updated to match and kept describing the rejected option. Leaving it as-is would mislead users into believing deleted sessions' history survives.

---

## 2026-06-16 — Separate user docs and developer docs into two Hugo sections

**Decision:** Replaced the `audience: "user"|"contributor"` front matter tag (added earlier the same day, see entry below) with an actual structural split: contributor docs moved from `website/content/docs/` to a new `website/content/developers/` section (`technical.fr.md`, `technical.en.md`, unchanged content), each with its own top-level nav entry — "Documentation" / "Développeurs" (FR) and "Documentation" / "Developers" (EN) in `hugo.toml`. The `audience` field is now redundant (the section itself is the signal) and was removed from all six docs/developers pages. The shared list rendering (sort by weight, front-matter description, top-level heading preview) was extracted from `layouts/docs/list.html` into `layouts/partials/doc-list.html` so the new `layouts/developers/list.html` doesn't duplicate it. `content/docs/_index.*.md` descriptions were updated to drop the now-inaccurate "technical reference" mention; new `content/developers/_index.fr.md` / `_index.en.md` were added.

**Rationale:** A front-matter tag with no visible effect doesn't actually separate the two audiences for a visitor — landing on `/docs/` still mixed user guides with technical/architecture content under one menu link. Two sections with distinct nav entries make the split obvious without requiring readers to know the tag exists. Nothing was deployed yet, so breaking the old `/docs/technical...` URLs has no cost.

---

## 2026-06-16 — French user documentation split Diataxis-style

**Decision:** Rewrote the French user documentation (previously a stub redirecting to the English page) as four pages under `website/content/docs/`: `demarrage-rapide/` (tutorial — local-first principles, GUI overview, the two modes), `guides-pratiques.fr.md` (how-to — sending a session to a friend, importing JSON, backing up data, syncing two devices, Yoga/running tips), `user-guide/` (reference — kept this filename for continuity with `user-guide.en.md`; full screen-by-screen description, keyboard shortcuts, a Markdown cheat-sheet table), and `comprendre.fr.md` (explanation — why local-first, JSON vs SQLite, how EMOM/AMRAP/TABATA timers work). Added `audience: "user"` / `"contributor"` front matter on all docs pages as a foundation for future audience tagging (no visual badge built yet). The two pages with screenshots (`demarrage-rapide/`, `user-guide/`) are Hugo leaf bundles (`index.fr.md` + images alongside) so image references stay relative, avoiding the `baseURL` prefix bug that affects absolute `/static/...` paths. `website/layouts/docs/list.html` already sorts by `.Pages.ByWeight`, so the five FR docs pages order correctly without further changes. English documentation is **not** restructured — `user-guide.en.md` stays a single page; porting it to the same split is left for later.

Screenshots were generated with `scripts/screenshots/all_pages.py` (fixed: it hung on the native `window.confirm()` shown when leaving an in-progress execution screen — added a `page.on("dialog", ...)` auto-accept handler; also added a `--lang en|fr` flag so screenshots can be captured in either UI language) and a new `scripts/screenshots/combo_timers.py` script (seeds an EMOM and a TABATA combo to capture the EMOM/TABATA running screens and the TABATA rest screen, none of which `all_pages.py` covers since it only seeds an AMRAP combo).

**Rationale:** `docs/specification.md`'s "Réécriture Documentation" chapter asked for a Diataxis-inspired structure distinguishing contributor vs. user docs, and explicitly requested the two new how-to guides (backup, device sync) plus combo timer screenshots including the TABATA rest screen. Page bundles were chosen over `static/` to sidestep a known baseURL bug rather than fix it as part of an unrelated doc change. The reference page kept the `user-guide` filename (vs. renaming to something Diataxis-flavored) to preserve the Hugo translation pairing with `user-guide.en.md` and avoid touching `AGENTS.md`'s named deliverable.

---

## 2026-06-12 — Single deployment + startup DB migration (Step 16)

**Decision:** Replaced the "versioned deployments with isolated data" model. The latest build is deployed alone at the site root, the OPFS database lives at a stable location (`.trainus` directory, no app-version suffix), and schema changes are applied by an automatic migration step at startup. The `RELEASES` registry (`src/db/releases.js`) is the single source of truth: `adaptScript` was renamed `migrationScript` (one SQL script per `schemaVersion` bump) and is shared by the startup migration and the restore-adaptation pipeline. Each migration runs in a transaction together with its `schema_version` stamp; on failure it rolls back and the app shows a blocking error. Before any migration runs, a blocking screen forces the user to download a backup — the Update button is enabled only after the download. A DB newer than the app (`dbVersion > SCHEMA_VERSION`) fails safe with a blocking `DB_NEWER_THAN_APP` error, data untouched. Existing `.trainus-x.y.z` directories are orphaned on purpose (nothing has shipped). `SCHEMA_VERSION` stays at 1.

**Rationale:** OPFS is _origin_-scoped, not path-scoped, so versioned deployment folders never truly isolated data — they only added deployment complexity while orphaning user data on every release, even without a schema change. The restore pipeline already paid the migration-code cost; reusing its scripts for startup migration removes the manual backup/restore UX on updates. Transactions guarantee a failed migration leaves the DB at the previous version; the forced pre-migration backup covers semantic corruption.

---

## 2026-06-09 — License display: dialog instead of dedicated page

**Decision:** Replaced the `/license` route and `LicenseView.vue` page with a stacked modal approach. The About dialog now shows the notice text inline (rendered as Markdown from `src/assets/notice.md?raw`) and an added **License: GNU AGPL v3** row. Clicking "View License" opens `LicenseDialog.vue` on top of the About dialog; closing it returns to About without any navigation.

**Rationale:** A full page navigation broke the "About" context and was disproportionate for legal text. A stacked modal keeps the user in place, matches the existing modal pattern, and the full license text is still bundled offline via `src/assets/license.md?raw`.

---

## 2026-06-08 — License: GNU AGPL v3

**Decision:** The project is licensed under the **GNU Affero General Public License v3 (AGPL v3)**. The full license text is in `LICENSE.md` at the repository root and `src/assets/license.md` (bundled offline via Vite `?raw`). The copyright notice is in `notice.md` / `src/assets/notice.md`. Contact: trainus.steadfast012@passmail.net.

**Rationale:** AGPL v3 ensures that any modified version of the app served over a network must also publish its source. Bundling both files into the JS build guarantees offline availability, consistent with the PWA-first philosophy.

---

## 2026-06-08 — About dialog added to Settings

**Decision:** Added an **About TrainUs** card at the bottom of the Settings page. Clicking it opens `AboutDialog.vue`, a modal that displays the app version (`__APP_VERSION__`), the DB schema version (`SCHEMA_VERSION`), the author (David Florance), placeholder links for the repository and website, and a disabled "View License" button with a "License to be determined." note.

**Rationale:** Centralises version and authorship information in a discoverable place without cluttering the main UI. Placeholder links and the disabled license button provide the correct structure now so they can be filled in once the repository is published and a license is chosen, requiring only a URL swap rather than a structural change.

---

## 2026-06-07 — EMOM repetitions semantics corrected

**Decision:** For EMOM combo items in a session, `repetitions` now means **total duration in minutes** (not number of rounds). The queue builder computes `totalRounds = floor(repetitions / numExercises)`, so each exercise still occupies exactly one 60-second slot and the total execution time equals `repetitions` minutes. The session form now labels the field **Duration (min)** for EMOM items, consistent with AMRAP.

**Rationale:** The previous behaviour (`totalRounds = repetitions`) made the total EMOM duration grow with the number of exercises in the combo, which was counter-intuitive and inconsistent with how AMRAP works. Users think in minutes when setting up a timed workout. The new formula keeps the interface label and the actual timer duration consistent.

---

## 2026-06-07 — Home page: history section, inline exercise summaries, HTML export

**Decision:**

- Increased recent sessions from 3 to 4.
- Added a **History section** below recent sessions: all execution logs loaded at mount, grouped by calendar day (descending), filtered client-side by two date pickers (default last 30 days). No extra DB query on filter change.
- Each session card (recent and history) now shows an inline compact summary of exercises performed (reps, weight, timing) and a **play button (▶)** to re-execute.
- Added a **HTML export button** in the action bar (visible only when data exists) via `useHtmlExport.js`. The generated file is fully self-contained: app icon embedded as base64, inline CSS, no external dependencies. Play buttons, navigation links, and date pickers are excluded from the export.

**Rationale:** The history section turns the home page into a lightweight training journal without a dedicated log view. Client-side filtering avoids extra DB queries on every date change. The HTML export provides a shareable, printable snapshot of activity that works in any browser without the app.

---

## 2026-06-02 — Add Danish locale

**Decision:** Added Danish (`da`) as a supported UI language, with a full translation of all keys in `src/i18n/locales/da.json`, registered in `src/i18n/index.js`, and selectable via the Settings language dropdown.

**Rationale:** Demonstrates the language addition workflow described in `docs/how-to-add-a-language.md` and broadens the app's reach to Danish speakers.

---

## 2026-06-02 — Test fixture strategy for import/export tests

**Decision:** Static JSON files in `tests/fixtures/` for cross-user scenarios with fixed IDs; dynamic construction (via `page.evaluate`) for same-user roundtrip scenarios.

**Rationale:** Cross-user tests require known, stable UUIDs so assertions can reference them directly — a static file is self-documenting and removes multi-line `json.dumps` blocks from test code. Same-user roundtrip tests need the live `user_uuid` from the browser DB at test time, so they must be built dynamically. Two fixture files: `cross_user_id_collision.json` (extracted from the existing combo FK test) and `cross_user_full.json` (exercises + combo + session with both exercise and combo items + collection + execution log, all referencing fixture UUIDs in the `facade00-*` range).

---

## 2026-05-31 — Add TrainUs theme

**Decision:** Add a third theme ("TrainUs") that uses the app logo colours — blue `#1E7FC0` (navbar, primary button, input border) and teal `#12A892` (action bar, hover state, success colour) — on a light blue-tinted background.

**Rationale:** Provides a branded colour experience beyond the generic light/dark pair, matching the visual identity already present in the SVG icon.

---

## 2026-05-26 — Step 10: PWA Finalization & Polish

### Service worker: vite-plugin-pwa (Workbox) over hand-rolled sw.js

- **Context:** The original `public/sw.js` used `self.__APP_VERSION__` which Vite never substitutes in a plain static file, causing the cache name to always fall back to `1.0.0`. No precaching meant the app did not work offline.
- **Decision:** Replace with `vite-plugin-pwa` + Workbox. `registerType: 'autoUpdate'` silently activates new SW versions. `maximumFileSizeToCacheInBytes: 10 MB` to cover `sqlite3.wasm`. `manifest: false` to keep our hand-authored `public/manifest.json`.
- **Rationale:** Workbox handles content-hash-based cache busting automatically; the app shell and WASM binary are all precached at install time, enabling true offline support.

### Install prompt: composable singleton + action bar button

- **Context:** The `beforeinstallprompt` event must be captured at page load, before it fires.
- **Decision:** Module-level event listeners in `useInstallPrompt.js` (singleton ref `canInstall`). `InstallPrompt.vue` teleports a download icon + dismiss × into the action bar when `canInstall` is true; dismissed for the session only (no persistence needed).
- **Rationale:** Singleton ensures the event is captured regardless of when the composable is called. Session-only dismiss avoids noisy storage writes; user will see it again on next visit if they haven't installed.

### Accessibility scope: targeted, not exhaustive

- **Context:** "ARIA labels, keyboard nav, focus management" could mean auditing every component.
- **Decision:** Focus on critical flows — navigation, all forms, execution stepper, modal dialogs. Every icon-only actionbar button across all views also gets `aria-label` (mechanical addition of 1 attribute per button).
- **Rationale:** Screen reader coverage for the flows a user actually executes is more valuable than a theoretical full audit. Full ARIA audit deferred to Step 11.

## 2026-05-26 — Step 9: Statistics

### Chart library: Chart.js 4 + vue-chartjs 5

- **Context:** Plan specified Chart.js via vue-chartjs; needed Vue 3 Composition API support.
- **Decision:** `chart.js@^4` + `vue-chartjs@^5`. Registered only the needed Chart.js components (tree-shaking) in `StatsView.vue` rather than globally.
- **Rationale:** vue-chartjs 5 is the Vue 3-compatible major version; registering locally avoids side effects in other routes that don't use charts.

### Streak calculation: UTC midnight for date comparison

- **Context:** SQLite `DATE(started_at)` extracts the UTC calendar date from ISO-8601 timestamps. JavaScript `new Date().setHours(0,0,0,0)` uses local time, creating a mismatch in non-UTC timezones.
- **Decision:** Use `setUTCHours(0,0,0,0)` and `toISOString().slice(0,10)` for streak iteration.
- **Rationale:** Ensures the JS streak walk and the SQLite date grouping use the same calendar boundary; avoids streak being under-reported by one day in timezones ahead of UTC.

### Volume metric: reps × weight

- **Context:** Need a single "effort" number per session to chart over time.
- **Decision:** Volume = `SUM(reps_done × weight_used)` for completed items. Bodyweight exercises (weight=0) contribute 0, so the chart only shows weight-bearing volume.
- **Rationale:** Standard fitness volume definition; keeps the metric meaningful. Users can still track bodyweight frequency via the Frequency chart.

### Statistics export: deferred to Step 9b

- **Context:** User requested statistics export be planned.
- **Decision:** Defer CSV/summary export to Step 9b; not included in Step 9.
- **Rationale:** Step 9 scope is already substantial; export logic is independent and can be added without touching existing stats code.

## 2026-03-10 — Initial Decisions

### Framework: Vue 3 + Vite

- **Context:** App has complex CRUD forms, reactive i18n/theme, timers, drag-reorder, charts.
- **Decision:** Vue 3 with Composition API over Vanilla JS.
- **Rationale:** Vanilla JS would require reinventing reactivity and component lifecycle. Vue provides reactive data binding, composables, and a rich ecosystem while staying lightweight.

### i18n: i18next + i18next-vue

- **Context:** Spec requires multi-language support, easy to translate.
- **Decision:** Use i18next (open-source, MIT) rather than a custom solution.
- **Rationale:** Proven library with JSON locale files, pluralization, interpolation, reactive Vue integration.

### Database: Official SQLite WASM + OPFS

- **Context:** Spec requires SQLite with OPFS persistence, local-first.
- **Decision:** Official SQLite WASM build with OPFS VFS.
- **Rationale:** Best maintained, native OPFS support, Apache-licensed.

### App version strategy

- **Context:** Need version at build time (OPFS namespacing, SW) and runtime (export metadata).
- **Decision:** Source of truth in `package.json`, injected by Vite `define`, stored in DB `settings` on init.

### Single user per device

- **Context:** Spec says one user per device.
- **Decision:** No `users` table. UUID + name stored in `settings` key/value table.

### Charts: Chart.js via vue-chartjs

- **Context:** Statistics page needs progression, volume, frequency charts.
- **Decision:** Chart.js — good balance of features (~60KB gzipped) and ease of use.

### Routing: Hash mode

- **Context:** App deployed as static files, no server-side routing control.
- **Decision:** Vue Router with `createWebHashHistory()`.
- **Rationale:** Hash routes (`#/home`) work without server configuration.

### ID collision on import: UUID prefix

- **Context:** Users exchange data files; IDs may collide.
- **Decision:** When imported entity ID exists and belongs to a different user (by UUID), prefix imported ID with source user's short UUID (first 8 chars).

---

## 2026-03-13 — Step 1: Database Layer

### OPFS VFS: SAH Pool over standard OPFS

- **Context:** Two OPFS VFS options available: standard `opfs` VFS (requires sub-worker + SharedArrayBuffer) and `opfs-sahpool` (SyncAccessHandle pool).
- **Decision:** Use `opfs-sahpool` VFS.
- **Rationale:** Highest performance, works on all major browsers since March 2023, no COOP/COEP requirement (though we have them), simpler architecture (no sub-worker). Trade-off: no concurrent connections, which is fine for a single-user PWA.

### Worker-based architecture

- **Context:** OPFS APIs are only available in Web Worker threads.
- **Decision:** Run SQLite in a dedicated Web Worker (`src/db/worker.js`), communicate from main thread via `postMessage()` with Promise-based wrappers.
- **Rationale:** Clean separation of concerns, non-blocking main thread, required by OPFS.

### Table naming: `collection` instead of `group`

- **Context:** `GROUP` is a SQL reserved keyword. Originally named `grp`, but that was confusing.
- **Decision:** Rename `grp` → `collection` and `group_session` → `collection_session`.
- **Rationale:** `collection` clearly describes a set of sessions without SQL keyword conflicts.

### WASM file serving strategy

- **Context:** The `sqlite3.wasm` binary must be accessible at a known URL for the worker to load it.
- **Decision:** Copy to `public/` via a Vite plugin (`copySqliteWasm`), serve at `/sqlite3.wasm`. Added to `.gitignore`.
- **Rationale:** Works identically in dev and production. No complex asset pipeline needed.

### Repository + composable pattern

- **Context:** Need CRUD access from Vue components through an async worker-based DB.
- **Decision:** Two layers — repositories (pure async CRUD) and composables (reactive `ref` wrappers).
- **Rationale:** Repositories are reusable outside Vue (e.g., import/export). Composables provide reactive state for components. Clean separation per SOLID.

### No in-memory fallback — block app if OPFS unavailable

- **Context:** Originally the worker fell back to `:memory:` if OPFS was unavailable, meaning data would be silently lost on browser close.
- **Decision:** Remove the fallback. If OPFS init fails, propagate the error and display a full-screen error message asking the user to update their browser or exit private browsing.
- **Rationale:** Silent data loss is worse than a clear error. OPFS is supported on all major browsers since March 2023 (Firefox 111, Chrome 102, Safari 16.4). The only realistic failure cases are private browsing mode or very outdated browsers.

### Loading screen during DB initialization

- **Context:** Database initialization is async and may take a moment.
- **Decision:** Block the app with a spinner overlay until the DB is ready. Three states: loading → ready → error.
- **Rationale:** Prevents users from interacting with the app before data is available.

### Rename `asymmetric_mode` to `alternate`

- **Context:** The `asymmetric_mode` TEXT field was vague. The intent is a boolean: should the exercise alternate sides at each repetition?
- **Decision:** Replace `asymmetric_mode TEXT` with `alternate INTEGER NOT NULL DEFAULT 0` (boolean, 0/1).
- **Rationale:** Simpler, clearer semantics. Boolean stored as INTEGER per SQLite convention.

### COMBO type: added NONE

- **Context:** Combos can be SUPERSET, CIRCUIT, or EMOM, but standalone exercises (not grouped) also appear as session items.
- **Decision:** Add `'NONE'` as a valid combo type.
- **Rationale:** Allows a "combo" of a single exercise, simplifying session item management — every item is a combo reference.

### Session item repetitions

- **Context:** A session may include the same combo/exercise multiple times (e.g., 3 rounds of a circuit).
- **Decision:** Add `repetitions INTEGER NOT NULL DEFAULT 1` to `session_item`.
- **Rationale:** Avoids duplicating rows for repeated items. Keeps data normalized.

### Home page: activity summary instead of collection list

- **Context:** Originally the Home page was the collection list.
- **Decision:** Home shows last 3 executed sessions and session counts for the last 7/30 days.
- **Rationale:** More useful landing page — quick overview of recent activity.

### Collections get their own page

- **Context:** Collections were displayed on the Home page.
- **Decision:** Move collections to a dedicated route (`#/collections`) with its own nav link.
- **Rationale:** Cleaner information architecture. Home focuses on activity summary.

---

## 2026-03-15 — Step 2: Settings

### Settings page: v-model for select elements

- **Context:** Using `:value` binding on `<select>` elements did not properly update the selected option when the reactive value changed.
- **Decision:** Switch to `v-model` for all form inputs (select, text input).
- **Rationale:** Vue 3's `v-model` handles two-way binding on native form elements correctly. `:value` alone doesn't reliably drive `<select>` option selection.

### App shell: guard content behind DB readiness

- **Context:** The `<div class="content">` section (containing `<router-view>`) was outside the `v-if/v-else` chain in `App.vue`, so views mounted before the DB was ready. This caused `onMounted` hooks in views to fail when calling DB queries.
- **Decision:** Wrap both `<nav>` and `<div class="content">` inside a `<template v-else>` block, so the entire app shell only renders after DB initialization completes.
- **Rationale:** Prevents race conditions where components query the DB before it's ready. The loading overlay already covers the screen during initialization.

### Settings startup restoration in main.js

- **Context:** Language and theme settings must be applied before the first render to avoid a flash of default values.
- **Decision:** Read `language` and `theme` from the DB during the `db.init()` promise chain, before setting `data-db-ready`. Apply `i18next.changeLanguage()` and `data-theme` attribute before the Vue app renders.
- **Rationale:** Eliminates visual flash of English text or light theme when the user has a different preference. Settings are available synchronously when components mount.

### Code formatting: Prettier

- **Context:** No code formatter was configured. Inconsistent formatting across files.
- **Decision:** Add Prettier with `semi: false`, `singleQuote: true`, `trailingComma: "all"`, `printWidth: 100`.
- **Rationale:** Enforces consistent formatting automatically. No semicolons and single quotes align with Vue ecosystem conventions. `format:check` script can be used in CI.

### Export/Import: disabled placeholder

- **Context:** Settings page needed Export/Import buttons, but the feature is planned for Step 7.
- **Decision:** Show disabled buttons with a "coming soon" message.
- **Rationale:** Establishes the UI structure early without implementing the feature prematurely.

---

## 2026-03-25 — Step 3b Planning: Exercise JSON Export/Import

### New step: Step 3b — Exercise JSON Export/Import

- **Context:** Exercise management (Step 3) would benefit from immediate export/import capability. Waiting until Step 7 (full data export) delays useful functionality. Exercises have no foreign key dependencies, making them the simplest entity to start with.
- **Decision:** Add Step 3b after Step 3 to build the JSON export/import infrastructure on exercises first. The full metadata envelope format is implemented now (`appName`, `appVersion`, `schemaVersion`, `exportDate`, `exportedBy`). Step 7 then extends this to all entities.
- **Rationale:** Incremental validation of the export/import pattern on the simplest entity. Infrastructure is reusable in Step 7 without rewriting.

### ID collision strategy: suffix on title instead of UUID prefix on ID

- **Context:** Original plan (Step 7) prefixed the internal ID with the source user's 8-char UUID on collision. This breaks alphabetical ordering of entity titles and is not visible to the user in the UI.
- **Decision:** On collision with a different user (`created_by` differs), generate a fresh UUID and rename the title to `"Original Title from SUFFIX"`. The suffix is prompted to the user, pre-filled with the exporter's `name` from JSON metadata. Same-user collisions offer Replace/Skip instead.
- **Rationale:** User-visible naming, preserves alphabetical sort order, flexible suffix, clear data provenance. Distinguishing same-user (Replace/Skip) from different-user (suffix) avoids unnecessary prompts for re-imports.

### Export/Import UI: three locations

- **Context:** Need to decide where export/import controls are placed in the UI.
- **Decision:** Wire buttons into three locations: exercise list view (bulk), exercise detail view (single export), and Settings page (with entity filter dialog — only "Exercises" active in Step 3b, others disabled with "Coming soon").
- **Rationale:** List view covers bulk operations, detail view enables sharing a single exercise, Settings page provides a centralized data management hub that scales to all entities in Step 7.

### Download Database (raw SQLite file)

- **Context:** Users need a way to back up their data as a raw database file.
- **Decision:** Add a "Download Database" button that exports the SQLite database using `sqlite3_js_db_export()` and triggers a browser download of the `.sqlite3` file.
- **Rationale:** Simple, reliable backup mechanism. The exported file is a standard SQLite database that can be restored or inspected with any SQLite tool.

### Restore Database: `sqlite3_deserialize` over `sqlite3_js_db_import`

- **Context:** Need to import a `.sqlite3` file back into the running OPFS database. The obvious choice `sqlite3_js_db_import` does not exist in sqlite-wasm v3.51.2-build7.
- **Decision:** Use `sqlite3.capi.sqlite3_deserialize()` to load the uploaded file into a temporary `:memory:` DB, then validate and copy table by table into the production DB.
- **Rationale:** `sqlite3_deserialize` is available through the CAPI and works reliably. Loading into `:memory:` first allows full validation before touching production data.
- **Implementation notes:** Requires manual WASM heap allocation: `wasm.alloc(n)` + `wasm.heap8u().set(bytes, ptr)` + deserialize with `FREEONCLOSE | RESIZEABLE` flags.

### Restore Database: validation pipeline with version compatibility

- **Context:** Restoring arbitrary files into the production DB is risky — the file could be corrupted, incomplete, or from an incompatible schema version.
- **Decision:** Multi-step validation pipeline: (1) deserialize into `:memory:`, (2) check required tables, (3) compare schema versions, (4) run compatibility check with potential adapt scripts, (5) table-by-table copy inside a transaction.
- **Rationale:** Each step catches a specific failure mode with a clear error code. The transaction ensures atomicity — if any table fails to copy, the entire operation is rolled back.

### Release registry (`src/db/releases.js`)

- **Context:** Need to track schema compatibility between database versions for the restore flow.
- **Decision:** A `RELEASES` array where each entry declares `schemaVersion`, `appVersion`, `dbBreak` (boolean), and `adaptScript` (SQL or null). A `checkCompatibility()` function walks the release history to determine if migration is possible.
- **Rationale:** Single source of truth for version compatibility. The `dbBreak` flag provides an explicit signal for breaking schema changes. Adapt scripts allow incremental migration without rejecting slightly older databases.

### Restore UI: in-place update instead of page reload

- **Context:** After a successful database restore, the UI needs to reflect the restored data (e.g., username, language, theme).
- **Decision:** After restore, re-fetch settings from the DB and apply language/theme changes in-place. No `window.location.reload()`.
- **Rationale:** Page reload would lose the restore report. In-place update provides better UX — the user sees the report and the restored settings simultaneously.

### Restore report with translated error codes

- **Context:** The restore flow can fail in multiple ways (invalid file, incomplete DB, incompatible version, etc.).
- **Decision:** Return a structured report object from the worker and display it in a dedicated card with translated error codes.
- **Rationale:** Specific error messages help users understand what went wrong. Translation keys follow the pattern `settings.restoreErrorCodes.{CODE}` for easy i18n.

---

## 2026-03-27 — Step 2b: Schema Hardening & Documentation Restructuring

### UNIQUE constraints on entity titles

- **Context:** Entity tables (`exercise`, `combo`, `session`, `collection`) allowed duplicate titles, which would cause confusion in lists and during import/export collision resolution.
- **Decision:** Add `UNIQUE` constraint on `exercise.title`, `combo.title`, `session.title`, and `collection.label` directly in the schema (no version bump — app not yet released).
- **Rationale:** Enforces data integrity at the database level. Prevents the need for application-level deduplication logic. Since the app hasn't been released yet, this is a safe in-place change.

### Suffix table for import disambiguation

- **Context:** When importing data from another user, title collisions need resolution. The chosen strategy appends a suffix to imported titles (e.g., `"Push-ups [JD]"`). Need to persist the mapping between external user UUIDs and their assigned suffixes.
- **Decision:** Add a `suffix` table: `user_uuid TEXT PRIMARY KEY, user_name TEXT NOT NULL, suffix TEXT NOT NULL UNIQUE`. This is part of the v1 schema (no version bump).
- **Rationale:** Persisting suffixes ensures consistency across multiple imports from the same user. The UNIQUE constraint on `suffix` prevents two users from having the same tag. The table also serves as a registry of known external users.

### Documentation file renaming

- **Context:** `docs/technical-overview.md` and `docs/user-guide.md` had names that didn't match the conventions described in `AGENTS.md` (`technical-documentation.md`, `user-documentation.md`).
- **Decision:** Rename the files to match the canonical names and update all references across the project (README.md, AGENTS.md, .opencode/ configs, commands).
- **Rationale:** Consistent naming reduces confusion when the project conventions reference `technical-documentation.md` but the actual file is `technical-overview.md`.

### README documentation section with bilingual tutorial links

- **Context:** The README had a minimal documentation section with only 4 links and no reference to tutorials or codelabs.
- **Decision:** Restructure the section into "Project" (all doc files with descriptions) and "Tutorials" (table with en/fr links for each step).
- **Rationale:** Makes all documentation discoverable from the README. Bilingual links using `(en / fr)` format are concise and match the project's dual-language approach.

---

## 2026-03-28 — Step 3: Exercise Management (Edit Mode)

### Action bar buttons via Vue Teleport with `defer`

- **Context:** The action bar is defined in `App.vue` but individual views need to inject their own buttons (New, Search, Edit, Delete, Back).
- **Decision:** Add a `<div id="actionbar-actions">` target in `App.vue`. Views use `<Teleport to="#actionbar-actions" defer>` to inject buttons.
- **Rationale:** Keeps the action bar shell in `App.vue` while letting each view own its buttons. The `defer` attribute (Vue 3.5+) is critical — it resolves the Teleport target in the next render cycle, avoiding the race condition where the target doesn't exist yet when the component mounts within a `v-else` block.

### Validation utility module

- **Context:** Exercise form needs field validation (required, min value, URL format). Future entity forms will need the same validators.
- **Decision:** Create `src/utils/validation.js` with reusable validation functions (`validateRequired`, `validateMin`, `validateUrl`) that return i18n error keys.
- **Rationale:** DRY approach — validators are decoupled from components and return translation keys instead of hardcoded strings, making them language-independent. Reusable across all future entity forms.

### Three exercise views instead of one

- **Context:** Exercises need list, create/edit, and detail views. Could use a single view with conditional rendering or multiple dedicated views.
- **Decision:** Three separate views: `ExerciseListView.vue` (list + search), `ExerciseFormView.vue` (create/edit, distinguished by route name), `ExerciseDetailView.vue` (read-only + media).
- **Rationale:** Single Responsibility Principle. Each view has one purpose. The form view handles both create and edit via `route.name` check, reducing duplication while keeping concerns separate.

### Search with debounced reactive filtering

- **Context:** Exercise search needs to filter the list as the user types.
- **Decision:** Use a `watch` on the search query with a 250ms `setTimeout` debounce, calling the repository's `search()` method (server-side SQL `LIKE` filter).
- **Rationale:** Debouncing prevents excessive database queries on every keystroke. The SQL `LIKE` query is performed in the worker thread, keeping the main thread responsive. Clearing the search input triggers `fetchAll()` to restore the full list.

### YouTube video embedding with privacy-enhanced mode

- **Context:** Exercise detail view needs to display videos. YouTube URLs are the most common video format users will paste.
- **Decision:** Extract YouTube video IDs from various URL formats (`youtube.com/watch?v=`, `youtu.be/`) and embed using `youtube-nocookie.com` (privacy-enhanced mode). Non-YouTube URLs get a link fallback.
- **Rationale:** Privacy-enhanced mode avoids third-party cookies. The fallback link ensures any video URL is accessible even if it's not YouTube.

---

## 2026-03-30 — Step 3b: Exercise JSON Export/Import

### JSON envelope format for export/import

- **Context:** Need a structured format for exporting and importing exercise data that can be extended to all entity types.
- **Decision:** Extensible metadata+data envelope: `{ metadata: { appName, appVersion, schemaVersion, exportDate, exportedBy }, data: { exercises: [...] } }`. Designed to be extended for all entity types in Step 7.
- **Rationale:** Clean separation of metadata and payload. The envelope carries provenance information (who exported, when, which version) enabling validation and collision detection on import.

### Reusable ModalDialog component

- **Context:** Export/import flows require multiple dialogs (import confirmation, export filter, collision resolution).
- **Decision:** Created first reusable Vue component (`src/components/ModalDialog.vue`) for all dialogs: import, export filter, collision resolution. Consistent pattern for future dialogs.
- **Rationale:** DRY approach — a single modal component with slots avoids duplicating dialog boilerplate across views. Establishes a consistent UX pattern for all future dialogs.

### Import collision strategy implementation

- **Context:** When importing exercises, ID or title collisions may occur between the local database and the imported data.
- **Decision:** Same-user: Replace/Skip per item with batch Replace All/Skip All. Different-user: suffix-based title disambiguation with `[SUFFIX]` pattern, new UUID on ID collision, update-in-place for same external user same ID.
- **Rationale:** Distinguishing same-user from different-user collisions provides the right level of control. Same-user imports are likely re-imports (Replace/Skip is sufficient). Different-user imports need disambiguation to preserve both versions.

### Suffix rename cascade

- **Context:** When a user renames a suffix assigned to an external user, all titles containing the old suffix must be updated consistently across all entity types.
- **Decision:** Renaming a suffix uses SQL `REPLACE()` across exercise, combo, session, and collection tables in a single operation, keeping titles consistent.
- **Rationale:** A single SQL operation per table ensures atomicity and consistency. Using `REPLACE()` avoids loading all entities into memory for string manipulation.

---

## 2026-04-21 — Database Creation Date & Centralized Date Formatting

### `db_created_at` setting for backup reminder fallback and stats display

- **Context:** The backup reminder (Step 3c) showed immediately on fresh databases because `last_backup_at` was absent, treating "never backed up" as stale. This was jarring for new users who just installed the app. Additionally, the Statistics page needed a way to show when the user started using TrainUs.
- **Decision:** Store `db_created_at` (ISO-8601 UTC timestamp) on first launch in the `settings` table. The backup reminder uses `db_created_at` as a fallback when `last_backup_at` is absent, giving new users a grace period equal to the reminder interval (default 7 days). The Statistics page displays "You are using TrainUs since [date]" using this value.
- **Rationale:** Provides a sensible grace period for new users while still reminding them to back up after a reasonable time. The creation date is also useful information for the user to see in Statistics.

### Centralized `dateFormatter.js` utility

- **Context:** Date formatting was scattered across components (`SettingsView`, `ImportDialog`, `backupFilename.js`) with inconsistent approaches — manual padding, `toLocaleString()`, `toLocaleDateString()`, and ISO string slicing. Adding date formatting to StatsView would have added yet another implementation.
- **Decision:** Create `src/utils/dateFormatter.js` with two pure functions: `formatDate(date, lang?)` for date-only and `formatDateTime(date, lang?)` for date + time. Both accept an optional `lang` parameter (falling back to `i18next.language`), returning `DD/MM/YYYY` (French) or `MM/DD/YYYY` (English). Components pass the reactive language ref as the second argument to ensure date formatting updates immediately on language switch.
- **Rationale:** DRY — single source of truth for date formatting. Consistent behavior across all components. The optional `lang` parameter enables reactive updates without requiring the utility to depend on i18next directly (it's a fallback, not a requirement).

---

## 2026-04-20 — Step 3c: Backup Filename & Reminder

### Backup filename: versioned timestamp pattern

- **Context:** Manual database downloads used a static filename (`trainus-{version}.sqlite3`), making it impossible to distinguish between backups or sort them chronologically.
- **Decision:** Rename to `trainus-{version}-backup-YYYYMMDD-HHhMMmSSs.sqlite3` using local time. Letters `h`, `m`, `s` replace colons for filesystem compatibility (Windows does not allow colons in filenames).
- **Rationale:** Sortable, version-stamped, and filesystem-safe. Local time is more intuitive for users than UTC in filenames.

### Backup reminder: persistent warning button

- **Context:** Users may forget to back up their data, especially after making significant changes.
- **Decision:** Add a non-intrusive warning button in the action bar that appears when no backup exists or the last backup is older than a configurable threshold (default 7 days). The button is not dismissible — it persists until a backup is made or the threshold is adjusted.
- **Rationale:** A persistent reminder ensures the user is never left without a visible prompt to back up. Removing the dismiss option avoids the risk of users permanently ignoring the reminder.

### No automatic backups

- **Context:** Could implement scheduled or event-driven automatic backups stored in OPFS.
- **Decision:** Keep backups manual-only. The Download / Restore buttons from Step 2 remain the only backup mechanism.
- **Rationale:** Avoids surprise downloads, OPFS quota concerns, and complexity. Users control when and where backups are saved (browser Downloads folder).

### "Click = success" assumption for download tracking

- **Context:** Browsers do not expose a reliable "download completed" event for anchor-based downloads.
- **Decision:** Treat `a.click()` as success and set `last_backup_at` immediately after. If the export throws before `a.click()`, the timestamp is not updated.
- **Rationale:** Pragmatic approach. The download is triggered synchronously, and the user's save action is the best available signal of success. Logged as a known limitation.

### Shared state in useBackupStatus composable

- **Context:** The backup reminder needs to be visible across all pages, mounted once in `App.vue`.
- **Decision:** Use module-level `ref` variables for `lastBackupAt`, `backupReminderDays`, and `dismissed` state, shared across all calls to `useBackupStatus()`.
- **Rationale:** Ensures a single source of truth for backup status across the entire app. The composable provides reactive state that updates consistently regardless of which component calls it.

---

## 2026-05-01 — Step 4: Combo Management

### Combo type NONE for plain supersets

- **Context:** Combos can be EMOM, AMRAP, or TABATA, but users also need plain grouped sets (supersets) without a timed format.
- **Decision:** Add `'NONE'` as the default combo type. Combos with type NONE behave as plain ordered exercise groups.
- **Rationale:** Avoids forcing users to choose a timed format when they just want to group exercises. A single neutral type keeps the data model consistent.

### Per-combo exercise reps and weight stored in junction table

- **Context:** Each exercise in a combo may need different reps and weight (e.g., 12 push-ups followed by 8 squats at 50 kg).
- **Decision:** Store `default_reps` and `default_weight` on the `combo_exercise` junction row, not on the exercise itself.
- **Rationale:** The junction table is the natural place for combo-specific parameters. Exercise defaults remain unchanged and are used as fallback when no combo context exists.

### `setExercises()` replace-all strategy

- **Context:** Updating exercises in a combo requires handling reordering, additions, and removals simultaneously.
- **Decision:** `comboRepository.setExercises()` deletes all existing `combo_exercise` rows for the combo and re-inserts the provided array in order.
- **Rationale:** Simpler than diffing the old and new sets. The operation is wrapped in a single transaction, so it's atomic and fast for the small number of exercises a combo typically contains.

---

## 2026-05-11 — Step 7: Full Import / Export

### Extend JSON envelope to all entity types

- **Context:** Step 3b built the export/import infrastructure for exercises only. Step 7 extends it to combos, sessions, collections, execution logs, and settings.
- **Decision:** Add `combos`, `sessions`, `collections`, `executionLogs`, and `settings` arrays to the `data` section of the JSON envelope. The format is backward-compatible — single-entity exercise files continue to work with the legacy flow.
- **Rationale:** One envelope format for all entities. Consumers only need to handle the keys that are present.

### Cross-entity reference remapping on import

- **Context:** When an entity's ID changes during import (new UUID generated due to collision), all entities that reference it must also be updated.
- **Decision:** After resolving all ID mappings, update `combo_exercise.exercise_id`, `session_item.item_id`, `collection_session.session_id`, `execution_log.session_id`, and `execution_log_item.exercise_id` in a single pass before committing.
- **Rationale:** Without remapping, importing an exercise that gets a new ID would break any combos or sessions that reference it by the original ID.

### Settings import with identity exclusions

- **Context:** Importing settings from another user's export should not overwrite the local user's identity.
- **Decision:** Always skip `user_uuid`, `app_version`, `db_created_at`, and `last_backup_at` during settings import. Import `user_name`, `language`, `theme`, and `backup_reminder_days`.
- **Rationale:** UUID and creation date identify the local database. Overwriting them would corrupt provenance tracking for future exports.

### Execution log import: session-dependent

- **Context:** Execution logs reference sessions by ID. If the session doesn't exist in the target database, the log is meaningless.
- **Decision:** Execution logs are imported only if the referenced session exists locally or was imported in the same batch.
- **Rationale:** Orphaned execution logs would appear in the history without a matching session, breaking the UI. Silently skipping them is preferable to importing garbage data.

### Generalized suffix strategy across all entity types

- **Context:** The suffix system (Step 3b) was designed for exercises. With full export/import, the same collision strategy must apply to combos, sessions, and collections.
- **Decision:** Apply the same `[SUFFIX]` disambiguation to all entity types that have a title/label field. The suffix is prompted once per external user and reused across all entity types from that user in the same import.
- **Rationale:** A consistent UX regardless of which entity types are in the imported file. One suffix prompt per user keeps the dialog simple.

---

## 2026-05-05 — Step 4b: Combo Exercise Reps & Weight

### Per-exercise reps and weight in combos

- **Context:** Combos allow grouping exercises, but all exercises in a combo shared the same default reps and weight from the exercise definition. Users need different reps/weight for each exercise within a combo (e.g., 12 reps of push-ups followed by 8 reps of squats with 50kg).
- **Decision:** Add `default_reps` (INTEGER, default 1) and `default_weight` (REAL, default 0.0) columns to the `combo_exercise` junction table. These override the exercise's own defaults when the combo is used. No schema version bump since the app has not been released yet.
- **Rationale:** Allows flexible per-exercise configuration within combos while keeping the exercise definitions unchanged. The junction table is the natural place for combo-specific exercise parameters.

### Renamed "series" to "reps" in combo context

- **Context:** The original plan used "series" (sets) for the count of exercise repetitions within a combo. This was potentially confusing since "series" has a specific meaning in fitness (a group of repetitions), and the exercise table already uses "reps" for repetitions.
- **Decision:** Rename `default_series` to `default_reps` throughout the combo feature. The field represents the number of repetitions for each exercise within the combo, consistent with the exercise table's terminology.
- **Rationale:** Consistency with existing terminology. "Reps" is more universally understood in fitness contexts and aligns with the exercise table's `default_reps` field.

---

## 2026-05-11 — Initialize Database Feature

### Initialize DB: delete all data and reinitialize defaults

- **Context:** Users need a way to completely reset the database to its initial state without reinstalling the app or manually clearing browser storage.
- **Decision:** Add an "Initialize Database" button in Settings that deletes all data from all tables and reinitializes default settings (UUID, user name, language, theme). A confirmation dialog with a mandatory checkbox ensures the user understands the action is irreversible. After confirmation, the page reloads to reflect the fresh state.
- **Rationale:** Simpler than deleting the OPFS directory and reinitializing the worker. Deleting table contents preserves the database connection and schema, then `_initDefaults()` recreates the essential settings. The checkbox confirmation prevents accidental data loss.

### Initialize DB: table deletion order respects foreign keys

- **Context:** Tables have foreign key relationships that must be respected when deleting data.
- **Decision:** Delete tables in dependency order: child tables first (`execution_log_item`, `execution_log`, `collection_session`, `session_item`, `combo_exercise`), then parent tables (`suffix`, `collection`, `session`, `combo`, `exercise`), and finally `settings`. Foreign keys are temporarily disabled during the operation.

---

## 2026-05-11 — Step 6: Collection Management & Home Page

### Collection form: session selector with ordered list

- **Context:** Collections need to group sessions in a specific order. Users need to add, remove, and reorder sessions.
- **Decision:** CollectionFormView uses a dropdown to select sessions, an "Add Session" button, and an ordered list with move up/down/remove controls. The same session cannot be added twice to a collection.
- **Rationale:** Mirrors the pattern from SessionFormView (exercise/combo selector) and ComboFormView (exercise selector), maintaining consistency across the app. Preventing duplicate sessions avoids confusion in execution order.

### Collection list: session count badge

- **Context:** Users should see at a glance how many sessions a collection contains.
- **Decision:** `collectionRepository.getAll()` performs a COUNT query on `collection_session` for each collection, returning a `sessionCount` field displayed as a badge on the card.
- **Rationale:** A lightweight COUNT query is more efficient than loading all session data for the list view. The badge provides useful context without requiring the user to open the detail view.

### Home page: stat cards + recent sessions list

- **Context:** The Home page should provide a quick overview of training activity.
- **Decision:** Two stat cards (sessions in last 7 days, sessions in last 30 days) and a list of the 3 most recent execution logs with session titles and dates.
- **Rationale:** Stat cards give at-a-glance metrics. Recent sessions provide quick access to re-execute or review. The 3-session limit keeps the page concise while still showing meaningful history.

### Home page: execution logs as the data source

- **Context:** Need to determine which sessions have been "executed" for the Home page.
- **Decision:** Use the `execution_log` table as the source of truth. `getRecent(3)` returns the 3 most recent logs with session titles via JOIN. `getCountSince(isoDate)` counts logs since a given date.
- **Rationale:** Execution logs are already created when sessions are executed (Step 8). The Home page reads from existing data without requiring new tables or fields.

---

## 2026-05-11 — Step 5: Session Management

### Session items: weight column added to session_item

- **Context:** Sessions need per-item weight configuration, similar to how combos have per-exercise reps and weight. The `session_item` table already had `repetitions` but no weight field.
- **Decision:** Add `weight REAL NOT NULL DEFAULT 0.0` to `session_item` table. No schema version bump since the app has not been released yet.
- **Rationale:** Allows users to specify the weight for each exercise or combo within a session, overriding the exercise's default weight. Consistent with the combo exercise pattern.

## 2026-05-26 — Step 8: Execution Mode

### Flat queue model for session execution

- **Context:** A session can contain exercises and combos. Combos can have multiple rounds (`repetitions` on the session item). Execution needs to step through each item in order, expanding combos into individual exercise steps.
- **Decision:** Build a flat array of step objects at execution start. A combo with N rounds and M exercises expands into N×M steps. The composable manages a `currentIndex` cursor.
- **Rationale:** A flat queue is simple to iterate, easy to inspect for "next step" preview, and straightforward to splice when adding extra rounds. Alternatives (tree structure, recursive state machine) added complexity without benefit.

### Final-round screen only at the last round

- **Context:** Combos have multiple rounds. Between rounds the experience should feel continuous; only after the last exercise of the last round should the user see options.
- **Decision:** Each step carries `isFinalComboStep: boolean`. Only when this flag is true does pressing Done show the final-round screen instead of auto-advancing.
- **Rationale:** Auto-advancing between non-final rounds reduces interruptions. The final-round pause allows the user to decide to continue, add another round, or exit.

### Prefill from most recent execution log

- **Context:** Users benefit from seeing their previous reps/weight when re-running a session.
- **Decision:** On execution start, query the most recent `execution_log` for the session and build a map `exerciseId → {reps_done, weight_used, side}`. Steps prefill from this map, falling back to session item defaults.
- **Rationale:** Per-session history is more accurate than per-exercise history — a session may use the same exercise at different weights in different sessions.

### Schema: combo_id and round_number added to execution_log_item (in-place)

- **Context:** Statistics (Step 9) need to distinguish exercise log entries that came from combos vs. standalone, and to know which round they came from.
- **Decision:** Add nullable `combo_id TEXT` and `round_number INTEGER` columns to `execution_log_item` directly in SCHEMA_SQL. No version bump — the app has not been released.
- **Rationale:** In-place schema changes are acceptable before any release. Schema version bumps (and the migration system they require) are reserved for changes made after a version has been deployed to users.

### Session items: polymorphic JOIN for titles

- **Context:** `session_item` references either an exercise or a combo via `item_id` + `item_type`. Getting item titles requires joining with the correct table.
- **Decision:** `getItems()` in `sessionRepository` executes two separate queries (one joining with `exercise`, one joining with `combo`) and merges the results sorted by position.
- **Rationale:** SQLite doesn't support conditional JOINs on different tables in a single query. Two queries are simple, efficient, and easy to maintain.
- **Rationale:** Prevents foreign key constraint violations. Disabling foreign keys during the bulk delete is safe since all tables are being cleared atomically.

### Markdown rendering in description fields

- **Date:** 2026-06-02
- **Context:** Users wanted richer formatting in exercise, combo, and session descriptions.
- **Decision:** Use `marked` (GFM parser) + `DOMPurify` (HTML sanitizer) to render descriptions as HTML in the three detail views. Descriptions are **not** shown in list views (removed for cleaner cards). A "Basic Markdown supported" hint appears next to the description label in all three form views.
- **Rationale:** `marked` is lightweight and defaults to GFM. `DOMPurify` is required when using `v-html` to prevent XSS. The hint says "basic" rather than "GitHub Flavored Markdown" because some GFM extensions (e.g., alerts) are not supported by `marked` out of the box.

### Schema: status column added to execution_log (in-place, R4-B)

- **Date:** 2026-06-12
- **Context:** `exit()` and route-abandon both terminate a session early, but the log was indistinguishable from a completed session. Stats counted all started sessions regardless of completion.
- **Decision:** Add `status TEXT NOT NULL DEFAULT 'completed' CHECK(status IN ('completed','aborted'))` to `execution_log` in-place (no version bump — pre-release).
- **Rationale:** Honest data model. Stats queries filter `WHERE status = 'completed'`. An aborted session where 0 exercises were done should not count as a "workout".

### ESLint added (R8)

- **Date:** 2026-06-12
- **Context:** Only Prettier was configured. Bugs like duplicate object keys (`no-dupe-keys`) shipped silently.
- **Decision:** Flat ESLint config with `@eslint/js` recommended + `eslint-plugin-vue/flat/recommended` + `eslint-config-prettier`. `vue/no-v-html` disabled globally — all `v-html` uses go through `renderMarkdown()` (DOMPurify).
- **Rationale:** Vue-specific rules catch unused refs and missing `:key`s. Prettier config ensures no formatting conflicts.

### LIKE wildcard escaping in search (R10)

- **Date:** 2026-06-12
- **Context:** All repository `search()` methods passed user input directly into `LIKE '%…%'`. Searching for `100%` matched everything starting with `100`; `Push_up` couldn't be found with a literal underscore.
- **Decision:** Add `escapeLike(s)` utility (escapes `\`, `%`, `_`) and append `ESCAPE '\\'` to all LIKE clauses.
- **Rationale:** Minimal change; correct semantics. The parameterization already prevented injection; this fixes result correctness.

### JSON import: title conflicts folded into the Replace/Skip screen

- **Date:** 2026-06-16
- **Context:** Import conflict detection was `id`-only. An imported item whose title matched an existing item with a _different_ id either failed silently (same-user) or only offered a forced rename (cross-user, after suffixing) — no Replace/Skip choice, and same-batch references to the conflicting imported id (e.g. a combo's `exercise_id`) were never redirected.
- **Decision:** Detect title conflicts (different id, same title) during analysis and bucket them into the same `sameUserCollisions` array as id conflicts, so the existing Replace/Skip UI (including batch Replace All/Skip All) handles both without new screens. Resolve same-user collisions against `collision.existing.id` instead of `collision.imported.id` so Replace updates the existing row in place and Skip redirects same-batch references to it via the existing `idMap` mechanism. For cross-user imports, the suffixed-title conflict (almost always a re-import from the same external user) now resolves the same way instead of forcing a rename.
- **Decision:** Removed `analyzeExercises`, `applySameUserImport`, `applyDifferentUserImport`, and `checkTitleConflict` from `useJsonImport.js`, and the "legacy exercise-only" branch in `ImportDialog.vue`. These turned out to be dead code: the apply step already always went through `applyAllImport`, and `analyzeAll` already produces an identical analysis shape for exercise-only envelopes.
- **Rationale:** Reusing the existing collision UI avoids inventing a parallel "merge" concept the backlog explicitly wanted to avoid. Deleting confirmed-dead code prevents the next reader (the backlog text itself cited the dead `applySameUserImport` as if it were live) from being misled about which code path actually runs.

### Save filename prompt on every disk save

- **Date:** 2026-06-16
- **Context:** All file downloads (JSON exports, the home HTML export, the SQLite backup) saved silently under an auto-generated name with no chance to rename before saving.
- **Decision:** Add a custom Vue modal (`SaveFileDialog.vue`, mounted once in `App.vue`) that prompts for a filename — prefilled with the previous auto-generated default, text-selected, with a fixed/read-only extension — before any download fires. Backed by a singleton composable `useSaveFilePrompt.js` (`promptFilename(defaultFilename)` → `Promise<string|null>`, `null` on cancel). The three existing download chokepoints (`downloadJson`, `downloadBackup`, the local `downloadHtml` in `useHtmlExport.js`) call it and no-op on cancel, so no view-level call site changed. The forced pre-migration backup screen goes through the same dialog; cancelling it leaves the Update button disabled.
- **Rationale:** A native `showSaveFilePicker()` dialog was rejected — unsupported in Firefox, the project's primary test browser. Fixing the extension (rather than letting the whole filename be edited) avoids users accidentally producing a file the app can't recognize by type. Centralizing the prompt in the 3 existing low-level download functions, instead of at each of the ~15 export-button call sites, kept the change minimal-impact.
- **Found/fixed along the way:** `ModalDialog`'s `z-index: 1000` sat below the blocking `.db-overlay`'s `z-index: 9999`, so the dialog was unclickable when opened from the migration screen — bumped to `10000`. `useJsonExport.js`'s `downloadJson` calls, and the `onExportAll`/`onExportHtml` view handlers, weren't awaited — added `await` throughout since the call now blocks on user input.

### Export respects active search filter and bundles referenced entities

- **Date:** 2026-06-18
- **Context:** Two related export bugs. (1) Clicking export on the exercises/combos/sessions/collections list views always exported every row, ignoring an active search filter — exporting the unfiltered list when the user clearly intended to export just the matches. (2) Exporting a single combo/session/collection (or a filtered list of them), or using the Settings export-filter dialog with a partial selection, only included lightweight FK references (id/title) to the exercises/combos/sessions it depends on, not the full entities — so importing that file on a device that didn't already have those dependencies left dangling references (e.g. a session item pointing at an exercise that was never imported).
- **Decision:** `exportAllExercises/Combos/Sessions/Collections` now take an optional `query` and use the corresponding `search`/new `searchWithExercises`/`searchWithItems`/`searchWithSessions` repository method when one is active; the four list views pass their current trimmed search query. Added `collectComboDependencies`/`collectSessionDependencies`/`collectCollectionDependencies` helpers in `useJsonExport.js` that walk the nested FK references to fetch full dependent entities, used by `exportCombo(s)`, `exportSession(s)`, `exportCollection(s)`, and cascaded into `exportByFilter` (collections → sessions → combos → exercises) so a partial checkbox selection still produces a self-contained, importable file.
- **Rationale:** Matches user intent (export = "what I'm looking at") and makes every export entry point produce a file that imports correctly standalone, without changing the import side at all — existing collision detection already handles re-importing entities that turn out to already exist locally.

### COOP/COEP headers clarification (R17)

- **Date:** 2026-06-12
- **Context:** README and technical docs stated that COOP/COEP headers were "required" for OPFS. The SAH-pool VFS (`opfs-sahpool`) does not use `SharedArrayBuffer` and does not require these headers.
- **Decision:** Update README and technical docs to say headers are "not required" for the SAH-pool VFS, kept as a precaution.
- **Rationale:** Accurate documentation prevents unnecessary server configuration burden for deployers.
