# TrainUs — Implementation Plan (v3)

> **Last updated:** 2026-07-26
> **Status:** Step 24 (app-update modal), Step 25 (headset media controls), Step 26 (voice announcements), and Step 27 (manual "Check for updates" button) all manually validated by the user. Code-review remediation F1–F12 (see `docs/code-review-remediation-plan.md`) manually validated by the user. Step 28 (first-load network failure: clear error + in-app retry) and Step 29 (request persistent storage) implemented, awaiting manual validation.

## Overview

Greenfield PWA fitness app using **Vue 3 + Vite**, SQLite WASM (official) on OPFS, **i18next** (+ vue-i18next), Chart.js. Single user per device. English (default), French & Danish. Version sourced from `package.json`, injected by Vite at build time, stored in DB. Two modes: Edit (CRUD) and Execution (timers, logging). Single deployment (latest version at site root) with a stable database location and automatic startup migration.

## Steps

### Step 0 — Project Scaffolding & Infrastructure

**Status:** Done

- Initialize Vue 3 + Vite project in `src/`
- Configure Vite: dev server with `Cross-Origin-Opener-Policy` / `Cross-Origin-Embedder-Policy` headers, build output to `dist/`, inject app version via `define` from `package.json`
- Set up folder structure: `src/components/`, `src/composables/`, `src/db/`, `src/i18n/`, `src/styles/`, `src/views/`, `src/router/`, `src/utils/`
- Port CSS from sample into `src/styles/` — CSS variable system, add dark mode variable set
- Integrate Bootstrap Icons (via `bootstrap-icons` npm package)
- Set up Vue Router (hash mode): `#/home`, `#/exercises`, `#/combos`, `#/sessions`, `#/stats`, `#/settings`
- Create app shell layout component: sidebar nav (desktop ≥ 700px) / bottom nav (mobile), action bar, `<router-view>` content area — faithful to sample
- Set up **i18next** + **vue-i18next**: `src/i18n/locales/en.json`, `src/i18n/locales/fr.json`, reactive language switching
- Create `manifest.json` (PWA) with app name, icons placeholder, versioned start URL
- Set up basic service worker (precache shell)
- Write initial `README.md`, create `docs/technical-documentation.md`, `docs/decisions-log.md`
- Set up Playwright Python project in `tests/` with `conftest.py`, targeting Firefox

**Verification:** `npm run dev` serves the app, responsive shell renders, routes switch views, i18n renders English keys, Playwright can launch in Firefox

---

### Step 1 — Database Layer (SQLite WASM + OPFS)

**Status:** Done

- Integrate official SQLite WASM build
- Create `src/db/database.js` — initializes SQLite in a Web Worker with OPFS VFS, namespaces DB file by app version (e.g., `trainus-1.0.0.sqlite3`)
- SQL schema: `exercise`, `combo`, `combo_exercise`, `session`, `session_item`, `collection`, `collection_session`, `settings` (key/value), `execution_log`, `execution_log_item`, `schema_version`
- On first launch: generate UUID, store `user_uuid`, `user_name`, `app_version`, `db_created_at` in `settings` table
- Repository layer (`src/db/repositories/`) — one per entity with `create`, `getById`, `getAll`, `update`, `delete`, `search`
- Vue composables (`src/composables/`) wrapping repositories for reactive data in components

**Verification:** Playwright tests — DB initializes, UUID + app version persisted, CRUD records, data survives reload

---

### Step 2 — Settings

**Status:** Done

- Create `SettingsView.vue` at `#/settings`
- **Language selector:** dropdown (English / French) — calls i18next `changeLanguage()`, persisted in `settings` table, loaded at startup
- **User name editor:** text input, persisted in `settings`
- **UUID display:** read-only info field
- **Dark/light mode toggle:** switches CSS variable set on `<html>`, persisted in `settings`
- **Download Database:** export raw SQLite file via `sqlite3_js_db_export()`
- **Restore Database:** file picker → confirm dialog → validation pipeline (deserialize into `:memory:` → check required tables → version compatibility → adapt scripts if needed → table-by-table copy in transaction) → spinner during process → detailed report card (success with table/row counts, or translated error codes)
- **Release registry** (`src/db/releases.js`): tracks schema versions and breaking changes for restore compatibility
- Placeholder sections for Export/Import (wired in Step 7)

**Verification:** Playwright tests — change language (all labels update reactively), change name (persists on reload), toggle theme (colors switch), red path: empty name rejected, download DB, restore flow (round-trip), restore with invalid file, restore with incomplete DB, cancel dialog aborts restore

---

### Step 2b — Schema Hardening & Documentation Restructuring

**Status:** Done

- **Schema changes (in-place, no version bump — not yet released):**
  - Add `UNIQUE` constraint on `exercise.title`, `combo.title`, `session.title`, `collection.label`
  - Add `suffix` table: `user_uuid TEXT PRIMARY KEY`, `user_name TEXT NOT NULL`, `suffix TEXT NOT NULL UNIQUE` — registry of known external users for import disambiguation
- **Documentation restructuring:**
  - Rename `docs/technical-overview.md` → `docs/technical-documentation.md`; enrich with full DB schema (ER diagram), architecture details, data flows
  - Rename `docs/user-guide.md` → `docs/user-documentation.md`
  - Update all references across project files: `README.md`, `AGENTS.md`, `docs/plan.md`, `.opencode/` config files
  - Rewrite `README.md` Documentation section: link all docs and all tutorials/codelabs with short descriptions; bilingual links use `(en / fr)` format

**Verification:** Playwright tests — UNIQUE constraints enforced, suffix table exists, all doc links resolve correctly

---

### Step 3 — Exercise Management (Edit Mode)

**Status:** Done

- `ExerciseListView.vue`: card/list display, search bar (reactive filter by title)
- `ExerciseFormView.vue`: form with all fields (title, description, asymmetric toggle + sub-option, image URLs, video URLs, default reps, default weight) — uses `v-model` bindings
- `ExerciseDetailView.vue`: read-only, embedded images/videos (iframe for YouTube)
- Routes: `#/exercises`, `#/exercises/new`, `#/exercises/:id`, `#/exercises/:id/edit`
- Input validation (title required, reps ≥ 0, weight ≥ 0, URL format)
- Action bar: New, Search buttons (via Vue Teleport with `defer`)
- Validation utility module (`src/utils/validation.js`)
- i18n keys for all labels, validation messages, and form elements (EN + FR)

**Verification:** Playwright tests — create, view, edit, delete, search, validation rejects bad input — 17 tests passing on Firefox and Chromium

---

### Step 3b — Exercise JSON Export/Import

**Status:** Done

- **JSON envelope format:** `{ metadata: { appName, appVersion, schemaVersion, exportDate, exportedBy: { name, uuid } }, data: { exercises: [...] } }` — extensible for all entities in Step 7
- **Export service** (`src/composables/useJsonExport.js`): builds envelope, serializes selected entity arrays
- **Import service** (`src/composables/useJsonImport.js`): parses envelope, validates `appName` + `schemaVersion`, detects ID collisions, applies import
- **Export features:**
  - Exercise list view: "Export" action bar button — exports all exercises
  - Exercise detail view: "Export" button — exports single exercise
  - Settings page: enable "Export Data" button with entity filter dialog (checkboxes per entity type; only "Exercises" active, others disabled with "Coming soon")
  - Downloaded file: `trainus-exercises-YYYY-MM-DD.json`
- **Import features:**
  - Exercise list view: "Import" action bar button — file picker for `.json`
  - Settings page: enable "Import Data" button — same file picker
  - Import flow: parse → validate envelope → preview summary ("X exercises from NAME on DATE") → collision detection → result report
- **ID collision strategy:**
  - **No collision:** insert directly
  - **Same user** (`created_by` = local `user_uuid`):
    - Different ID: import as new exercise
    - Same ID: show confirmation dialog with export date ("Overwrite 'Exercise X'? Exported on DATE") — options: Replace / Skip / Replace All / Skip All (batch)
    - If an update causes a title conflict with a different existing exercise: prompt user to enter a new title
  - **Different user** (`created_by` ≠ local `user_uuid`):
    - If user UUID exists in `suffix` table: inform user the existing suffix will be used
    - If user UUID not in `suffix` table: prompt for a new suffix (pre-filled with exporter's name), validate uniqueness against existing suffixes, save `(user_uuid, user_name, suffix)` to `suffix` table
    - Same `created_by` as existing object AND same ID: treat as update (no new ID generated)
    - Different `created_by` from existing object AND same ID: generate a new UUID for import
    - Title format: `"Original Title [SUFFIX]"` — if this title is already taken, prompt user to enter a new base title (suffix is always appended)
  - Single dialog handles all collisions: grouped by type, batch actions (Replace All / Skip All), one suffix input per external user
- **Settings — Suffix management:**
  - New section in Settings: list all entries from `suffix` table (user name, UUID, suffix)
  - Edit suffix: changing a suffix updates all titles containing `[OLD_SUFFIX]` to `[NEW_SUFFIX]` across `exercise`, `combo`, `session`, and `collection` tables
- **i18n:** all new labels in `en.json` / `fr.json`

**Verification:** Playwright tests — export/import round-trip, same-user collision (replace + skip + batch Replace All / Skip All), different-user collision (suffix prompt, title with [SUFFIX], new ID generated), update from same external user (no new ID), title conflict prompts, suffix rename cascades to titles, invalid file rejection, Settings suffix management, Settings page export filter, single exercise export from detail view

---

### Step 3c — Backup Filename & Reminder

**Status:** Done

Scope is deliberately minimal: no automatic backups, no OPFS-stored backups, no schema change. The manual Download / Restore buttons from Step 2 remain the only backup mechanism. This step only:

1. Renames the downloaded file so each manual backup is unique, sortable, and carries the app version.
2. Tracks when the user last downloaded (or restored) a backup.
3. Adds a non-intrusive warning indicator reminding the user to back up after a configurable number of days.

- **Filename convention:** manual DB download renamed from `trainus-{version}.sqlite3` to `trainus-{version}-backup-YYYYMMDD-HHhMMmSSs.sqlite3` using local time (example for `1.0.0` at 2026-03-23 15:24:06 local → `trainus-1.0.0-backup-20260323-15h24m06s.sqlite3`). Letters `h`, `m`, `s` replace the Windows-invalid colon.
- **New settings keys (no schema change; uses existing `settings` key/value table):**
  - `last_backup_at` — ISO-8601 UTC timestamp (`...Z`). Set on successful Download **and** on successful Restore. Absent = never backed up.
  - `backup_reminder_days` — integer stored as string, default `"7"`. User-configurable in Settings (valid range 1–365).
  - `db_created_at` — ISO-8601 UTC timestamp set on first launch. Used as fallback for backup reminder when `last_backup_at` is absent, so fresh databases don't immediately show a stale warning. Also displayed on the Statistics page.
- **Backup reminder indicator:**
  - Warning-styled button, leftmost in the action bar (`#actionbar-actions`), visible on every page when `now - last_backup_at > backup_reminder_days` OR when `last_backup_at` is absent.
  - Icon: `bi-exclamation-triangle-fill`. Tooltip: "Last backup {{days}} day(s) ago — click to back up" or "No backup yet — click to create one" (translated).
  - Click: navigate to `#/settings` and focus/scroll the Database section.
  - A secondary `×` dismisses the reminder for the current session only (not persisted). Reloads re-show it if still stale.
  - Hidden entirely when backup is fresh.
- **Settings UI additions (Database section):**
  - Display "Last backup: {{localDateTime}}" or "Never backed up".
  - Number input "Remind me every N days" (1–365; default 7) with inline validation.
- **i18n:** new EN + FR keys under `settings.backup.*` and `backup.reminder.*`.
- **New files:**
  - `src/utils/backupFilename.js` — pure `formatBackupFilename(version, date)`.
  - `src/composables/useBackupStatus.js` — reactive `lastBackupAt`, `daysSinceBackup`, `isStale`, `dismissed`, `markBackedUp()`, `dismiss()`.
  - `src/components/BackupReminder.vue` — teleported warning button.
  - `tests/test_step3c_backup.py` — Playwright tests (Firefox + Chromium).
- **Modified files:**
  - `src/views/SettingsView.vue` — use new filename, set `last_backup_at` on Download/Restore success, render Database section additions.
  - `src/App.vue` — mount `<BackupReminder>` once at app level so it teleports into each page's action bar.
  - `src/i18n/locales/en.json`, `fr.json` — new translation keys.

**Verification:** Playwright tests — download uses new filename pattern, `last_backup_at` set on download, reminder appears after threshold, reminder hidden when fresh, reminder hidden after dismiss within same session, reminder reappears on reload if still stale, clicking reminder navigates to Settings, Settings shows last backup date, changing `backup_reminder_days` re-evaluates reminder visibility, restore updates `last_backup_at` to now, invalid threshold rejected, filename is filesystem-safe (no colons, zero-padded), download failure does not update `last_backup_at`.

---

### Step 4 — Combo Management (Edit Mode)

**Status:** Done

- `ComboListView.vue`, `ComboFormView.vue`, `ComboDetailView.vue`
- Type picker (EMOM / AMRAP / TABATA / NONE), exercise selector (pick from existing, reorder with up/down buttons), timer config (varies by type: duration for EMOM/AMRAP, rounds for TABATA)
- Routes: `#/combos`, `#/combos/new`, `#/combos/:id`, `#/combos/:id/edit`
- `comboRepository` updated with `createWithId` and `replaceAll` for import support

**Verification:** Playwright tests — create combo with exercises, reorder, type change, validation, edit, delete, search

---

### Step 4b — Combo Exercise Reps & Weight

**Status:** Done

Add `default_reps` and `default_weight` fields to exercises within combos. Each exercise in a combo has its own reps count and weight, shown in edit mode and used as defaults during execution.

- **Schema migration:**
  - Add `default_reps INTEGER NOT NULL DEFAULT 1` and `default_weight REAL NOT NULL DEFAULT 0.0` columns to `combo_exercise` table
  - Increment `SCHEMA_VERSION` from 1 to 2
  - Add migration script for existing databases (`ALTER TABLE combo_exercise ADD COLUMN ...`)
- **Repository changes (`src/db/repositories/comboRepository.js`):**
  - Update `setExercises()` to accept array of objects `{ exerciseId, position, defaultReps, defaultWeight }` instead of plain IDs
  - Update `getExercises()` to return `default_reps` and `default_weight` columns alongside existing fields
- **ComboFormView.vue:**
  - Add inline reps and weight inputs per exercise in the ordered list
  - Reps: number input, default 1, validation ≥ 1
  - Weight: number input, default 0.0, validation ≥ 0
  - Display format in list: exercise title + "× {reps}" + " @ {weight}kg" (or just title if both are defaults)
- **ComboDetailView.vue:**
  - Display reps and weight for each exercise in the list (e.g., "3 × 50 kg" format next to exercise name)
- **i18n (`en.json` / `fr.json`):**
  - `combos.form.reps` / `combos.form.repsLabel`
  - `combos.form.weight` / `combos.form.weightLabel`
  - `combos.exercise.repsWeight` — display format string
  - Validation messages for reps < 1 and weight < 0
- **Import/Export impact:**
  - `useJsonExport.js`: include `default_reps` and `default_weight` in combo exercise export data
  - `useJsonImport.js`: handle these fields on import (no special collision logic needed)
- **Modified files:**
  - `src/db/schema.js` — add columns, bump version
  - `src/db/repositories/comboRepository.js` — update `setExercises` and `getExercises`
  - `src/views/ComboFormView.vue` — add reps/weight inputs per exercise
  - `src/views/ComboDetailView.vue` — display reps/weight
  - `src/composables/useJsonExport.js` — include new fields
  - `src/composables/useJsonImport.js` — handle new fields
  - `src/i18n/locales/en.json`, `fr.json` — new translation keys

**Verification:** Playwright tests — create combo with exercises and set reps/weight (saved correctly), edit combo and change reps/weight (updated), detail view displays reps/weight, validation rejects reps < 1 and weight < 0, export/import round-trip preserves reps/weight values

---

### Step 5 — Session Management (Edit Mode)

**Status:** Done

- `SessionListView.vue`, `SessionFormView.vue`, `SessionDetailView.vue`
- Ordered list of items (add exercise OR combo), reorder with up/down buttons
- **Per-item reps and weight:** each session item has its own `repetitions` (already in schema) and a new `weight` field, similar to combo exercises. These override the exercise's defaults when the session is executed.
  - Add `weight REAL NOT NULL DEFAULT 0.0` to `session_item` table (in-place, no version bump — not yet released)
  - Inline weight input per item in the form (number input, default 0.0, validation ≥ 0)
  - Display format in list: item title + "× {reps}" + " @ {weight}kg" (or just title if both are defaults)
- Routes: `#/sessions`, `#/sessions/new`, `#/sessions/:id`, `#/sessions/:id/edit`
- **Repository changes (`src/db/repositories/sessionRepository.js`):**
  - Update `setItems()` to accept array of objects `{ itemId, itemType, position, repetitions, weight }`
  - Update `getItems()` to return `weight` alongside existing fields, with titles from JOIN queries
- **Import/Export impact:**
  - `useJsonExport.js`: include `weight` in session item export data
  - `useJsonImport.js`: handle this field on import
- **i18n (`en.json` / `fr.json`):**
  - `sessions.form.weight` / `sessions.form.weightLabel`
  - `sessions.item.repsWeight` — display format string
  - Validation message for weight < 0

**Verification:** Playwright tests — create session with exercises/combos and set reps/weight (saved correctly), edit session and change reps/weight (updated), detail view displays reps/weight, validation rejects weight < 0, export/import round-trip preserves reps/weight values

---

### Step 6 — Collection Management & Home Page

**Status:** Done

- `CollectionsView.vue` (at `#/collections`), `CollectionFormView.vue`, `CollectionDetailView.vue`
- Label + ordered list of sessions, reorder with up/down buttons
- Routes: `#/collections`, `#/collections/new`, `#/collections/:id`, `#/collections/:id/edit`
- `HomeView.vue` (at `#/home`): stat cards for sessions in last 7/30 days, last 4 executed sessions with inline exercise summaries and play buttons, history section grouped by day with date filter (default last 30 days)
- `collectionRepository.getAll()` enriched with `sessionCount` via JOIN query
- App.vue `navKeyMap` updated to map collection sub-routes to `collections` nav key
- `useHtmlExport.js`: generates a self-contained HTML export of the home page (embedded icon, stats, recent sessions, filtered history — no play buttons or links); triggered by an action bar button visible when data exists

**Verification:** Playwright tests — CRUD collections, add/reorder/remove sessions, search, validation, duplicate label rejected, navigate to session from collection detail, home empty state, home shows recent 4 sessions, home shows correct 7-day and 30-day counts, history grouped by day, history date filter, play button on recent card, HTML export button present/absent — 23 tests passing on Firefox and Chromium

---

### Step 7 — Import / Export (Full Data)

**Status**: Done

**What was implemented**:

- Extended JSON envelope `data` to include all entity types: `combos`, `sessions`, `collections`, `executionLogs`, `settings`
- Cross-entity reference remapping on import: ID collision resolution updates all referencing entities (`combo_exercise.exercise_id`, `session_item.item_id`, `collection_session.session_id`, `execution_log.session_id`, `execution_log_item.exercise_id`)
- Enabled all entity checkboxes in Settings export filter dialog (combos, sessions, collections, execution logs, settings)
- Added export/import buttons to combo, session, and collection list + detail views
- Generalized suffix collision strategy to all entity types (title/label field)
- Full export: all entities + settings in one JSON file (`trainus-full-YYYY-MM-DD.json`)
- Single entity export from detail views (combo, session, collection)
- "Export All Data" button in Settings for one-click full export
- ImportDialog generalized to handle multiple entity types with collision resolution per entity type
- Repository additions: `createWithId`, `replaceAll`, `getAllWithX` for combo, session, collection, executionLog

**Decisions made**:

- Settings import excludes `user_uuid`, `app_version`, `db_created_at`, `last_backup_at` to preserve local identity
- Execution logs are imported only if the referenced session exists (or was imported in the same batch)
- Suffix is applied once per external user and affects all entity types from that user
- Import dialog detects multi-entity envelopes and uses the new generalized flow; single-entity exercise files use the legacy flow for backward compatibility

**What remains**:

- Manual validation by the user
- Some tests need refinement for Firefox browser compatibility

**Additional tests added (2026-06-02)**:

- `test_execution_logs_same_user_roundtrip`: execution log + item survive export → clear → reimport
- `test_execution_log_id_remapping`: log item `exercise_id` is remapped when exercise gets `new_id`
- `test_session_with_combo_item_remapping`: session `item_type=combo` item_id follows combo remap
- `test_collection_session_remapping`: collection session ref follows session `new_id` remap
- `test_settings_import_roundtrip`: `language` and `theme` applied from settings-only envelope
- `test_single_entity_export_buttons_session_collection`: export button visible on session/collection detail
- Refactored `test_new_id_propagation_to_combo` to load from `tests/fixtures/cross_user_id_collision.json`
- Added `tests/fixtures/cross_user_full.json` for cross-user remapping tests

---

### Step 8 — Execution Mode (Core)

**Status:** Done

#### Decisions

| Decision              | Choice                                                                       | Rationale                                                                                                                                                             |
| --------------------- | ---------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Combo repetitions     | Full sequence × N rounds                                                     | `repetitions` on a session combo item = number of full passes through the combo exercise list                                                                         |
| Schema change         | Add `combo_id` + `round_number` to `execution_log_item` (v2 → v3)            | Enables accurate per-combo statistics in Step 9; nullable columns, no FK constraint                                                                                   |
| Abandon behavior      | Save partial log                                                             | Completed items saved, unfinished omitted; session appears in history as partial                                                                                      |
| Mid-round transition  | Auto-advance silently                                                        | Only the final round of a combo shows a pause screen                                                                                                                  |
| Final-round screen    | Continue + One More                                                          | After the last exercise of the last combo round: "Continue →" advances to the next session item; "+ One More Round" appends one extra round to the queue (repeatable) |
| Prefill source        | Last run of this session                                                     | Reps/weight prefilled from the most recent `execution_log` for this specific session; falls back to item/exercise defaults if no prior log                            |
| Asymmetric side logic | `alternate=true` → flip side per step; `alternate=false` → flip side per set | Matches the exercise definition; side logged in `execution_log_item.side`                                                                                             |

#### Execution Queue Model

When a session starts, the app builds a flat linear **queue** of steps from the session items. A combo with `repetitions = N` unrolls into `N × (number of exercises in combo)` steps:

```
Session items:
  1. Exercise A  ×3 reps, 50kg          → 1 step
  2. Combo "HIIT" ×2 rounds             → 4 steps (2 exercises × 2 rounds)
       Round 1: Push-up ×10, Squat ×15
       Round 2: Push-up ×10, Squat ×15
  3. Exercise B  ×5 reps                → 1 step

Queue (flat):
  [0] Exercise A        (reps=3, weight=50)
  [1] Push-up  — HIIT, Round 1/2
  [2] Squat    — HIIT, Round 1/2
      ↓ auto-advance (not last round)
  [3] Push-up  — HIIT, Round 2/2
  [4] Squat    — HIIT, Round 2/2
      ↓ final-round screen: [Continue →] [+ One More Round]
  [5] Exercise B        (reps=5, weight=0)
```

Each step carries: `exerciseId`, `exerciseTitle`, `reps`, `weight`, `comboId?`, `comboTitle?`, `roundNumber?`, `totalRounds?`, `asymmetric`, `alternate`, `side?`.

#### UI Layout

```
┌─────────────────────────────────────────┐
│  Monday Workout               [✕ exit]  │
│  ████████████░░░░░  3 / 6 steps         │
├─────────────────────────────────────────┤
│  ▶ NOW                                  │
│  ┌───────────────────────────────────┐  │
│  │  Push-up                          │  │
│  │  Combo: HIIT — Round 2 of 2       │  │  ← combo context (hidden for exercises)
│  │                                   │  │
│  │  Reps:   [ 10 ]                   │  │
│  │  Weight: [  0 ] kg                │  │
│  │  Side:   RIGHT ↔                  │  │  ← only for asymmetric exercises
│  │                                   │  │
│  │          [ ✓ Done ]               │  │
│  └───────────────────────────────────┘  │
│                                         │
│  ↓ NEXT                                 │
│  Squat — 15 reps  (HIIT, Round 2)       │
└─────────────────────────────────────────┘
```

Final-round transition screen (replaces the card after the last exercise of the last combo round):

```
│  ✓ HIIT — 2 rounds complete             │
│                                         │
│  [ Continue → ]   [ + One More Round ]  │
```

#### Schema change (v2 → v3)

Two nullable columns added to `execution_log_item`:

```sql
ALTER TABLE execution_log_item ADD COLUMN combo_id TEXT DEFAULT NULL;
ALTER TABLE execution_log_item ADD COLUMN round_number INTEGER DEFAULT NULL;
```

`NULL` for standalone exercises; populated for exercises inside a combo. No FK constraint (combo may have been deleted before statistics are viewed).

#### Files to create / modify

**New:**

- `src/views/SessionExecuteView.vue` — execution UI (stepper, current/next display, reps/weight inputs, side indicator, final-round screen)
- `src/composables/useSessionExecution.js` — queue building, current/next step state, prefill logic, done/advance/exit logic, save to log

**Modified:**

- `src/db/schema.js` — add `combo_id` + `round_number` columns, bump `SCHEMA_VERSION` 2 → 3, add migration script
- `src/db/repositories/executionLogRepository.js` — add `getLastItemsBySession(sessionId)` returning map `exerciseId → { reps_done, weight_used, side }` from most recent log for that session
- `src/router/index.js` — add `{ path: '/sessions/:id/execute', name: 'session-execute', component: SessionExecuteView }`
- `src/views/SessionDetailView.vue` — add play button in action bar
- `src/views/SessionListView.vue` — add play icon per session row
- `src/i18n/locales/en.json`, `fr.json` — new keys under `execution.*`

**Not in this step (deferred):**

- Timers (EMOM/AMRAP/TABATA) → Step 12
- Statistics charts → Step 9

**Verification:** Playwright tests — start session navigates to execute view, steps advance on Done, reps/weight editable and saved, asymmetric side shown and logged, combo rounds auto-advance between non-final rounds, final-round screen shows Continue + One More, One More appends extra round, Continue advances to next session item, exit mid-session saves partial log (done items only), re-execute prefills from last session run, session with no prior log prefills from defaults, progress bar reflects current position

---

### Step 9 — Statistics

**Status:** Done

- `StatsView.vue` at `#/stats`
- Displays database creation date: "You are using TrainUs since [formatted date]"
- Date formatting uses centralized `dateFormatter.js` utility with locale-aware formatting via `Intl.DateTimeFormat` (MM/DD/YYYY English, DD/MM/YYYY French, DD.MM.YYYY Danish)
- `chart.js` + `vue-chartjs` v5
- **Summary cards:** total sessions, total exercises done, current day streak
- **Date range filter:** quick tabs — 7d / 30d / 3m / All (default: 30d)
- **Frequency** (bar): sessions grouped by week or month (toggle)
- **Volume** (bar): total volume (`reps_done × weight_used`) per session execution over time
- **Progression** (line): reps + weight over time for a selected exercise (one at a time)
- `src/composables/useStats.js` — all stat queries

**Deferred to Step 9b:** Statistics export (e.g. CSV export of chart data / execution summary)

**Verification:** Playwright tests — creation date displayed, date format changes with language, summary cards show correct counts, cards respond to date range change, streak count correct, frequency/volume/progression charts render, no-data messages shown when period is empty

---

### Step 10 — PWA Finalization & Polish

**Status:** Done

- Migrated from hand-rolled `public/sw.js` to `vite-plugin-pwa` + Workbox (`registerType: 'autoUpdate'`, 10 MB cache limit to cover `sqlite3.wasm`)
- `manifest.json` updated: `purpose` field on both icons, both icon files now exist (192 × 512 placeholder PNGs)
- `index.html`: removed manual SW registration script (auto-injected by vite-plugin-pwa), added `<link rel="icon">` and `<link rel="apple-touch-icon">`
- Install prompt: `useInstallPrompt.js` composable + `InstallPrompt.vue` component (session-dismissible, teleported into action bar, shows only when `beforeinstallprompt` fires)
- Accessibility (critical flows): `aria-label` on all icon-only actionbar buttons across every view, `role="progressbar"` + `aria-valuenow/min/max` on execution progress bar, `role="alert"` + `aria-describedby` on form error messages, `required`/`aria-required` on required inputs, `role="dialog"` + `aria-modal` + `aria-labelledby` + focus-on-mount + Escape key on `ModalDialog`
- Responsive 400px: wrapping breakpoints added to exercise, combo, session, and collection form views for dense list items and form action rows
- Placeholder icons generated (dark bg + blue "T"); real SVG logo deferred

**Verification:** 10/11 tests pass on Firefox and Chromium (1 skipped: progress bar test requires a pre-existing session in the DB)

---

### Step 11 — Documentation & Final Testing

**Status:** Done

- Full test suite run: **434 passed, 2 skipped** on Firefox + Chromium — no failures
- `docs/technical-documentation.md`: complete rewrite — architecture, full ER diagram (updated for all columns), routing table, all feature sections (Steps 0–10), i18n, versioning, build & deploy
- `docs/user-documentation.md`: updated — added missing Sessions section, generalized Export/Import section to cover all entity types, added PWA install info
- `docs/decisions-log.md`: catch-up entries added for Steps 4 and 7
- `tests/README.md`: new file — setup, run commands, browser targets, file inventory
- `README.md`: updated tech stack (removed step annotations, added vite-plugin-pwa), added tutorial rows for Steps 3, 3b, 5
- `docs/plan.md`: Step 11 marked done

**Verification:** all tests green on Firefox + Chromium, docs complete, manual walkthrough by user

---

### Step 12 — Timers & Combo Execution (Optional — Stretch Goal)

**Status:** Done

#### Timer behavior per combo type

**EMOM (Every Minute On the Minute)** — timer drives advancement:

- `repetitions` on the session item = total duration in **minutes**. The number of rounds is `floor(repetitions / number_of_exercises_in_combo)`. The session form displays the field as **Duration (min)** for EMOM items (same as AMRAP).
- Each exercise occupies one 60-second slot. When the slot timer reaches 0, the app auto-advances to the next step (the log item is saved as completed even if the user never tapped Done). Tapping Done early advances immediately and resets the timer for the next slot.
- Sound pattern per slot: `bip` at 30 s remaining, `bip` at 2 s remaining, `bip` at 1 s remaining, `BIP` at 0 s.

**AMRAP (As Many Rounds As Possible)** — one countdown for the whole combo, user advances manually:

- Duration = `repetitions` minutes (the `repetitions` field label changes to "Duration (min)" in session form, detail, and execute views when the item is an AMRAP combo).
- The user taps Done to advance through exercises and rounds as fast as they can.
- Sound pattern: `BIP` at every elapsed full minute, except the final minute which uses the EMOM pattern — `bip` at 30 s remaining, `bip` at 2 s remaining, `bip` at 1 s remaining, `BIP` at 0 s.
- When the timer reaches 0: show a "Time's up" screen → user taps Continue to advance to the next session item.

**TABATA** — timer drives each phase automatically:

- Work time and rest time are per-combo, stored in `timer_config` JSON (defaults: work = 20 s, rest = 10 s).
- Per exercise slot: work phase starts with `BIP` → countdown → auto-transition to rest phase → rest phase starts with `BIP` → countdown → auto-advance to next exercise.
- After the last exercise of the last round: show the existing final-round screen (Continue / One More Round from Step 8).
- Duration = `(workTime + restTime) × number_of_exercises_in_combo × rounds`.
- `ComboFormView` shows "Work time (s)" and "Rest time (s)" number inputs (min 1) when type = TABATA.

#### Sounds (Web Audio API)

Two sound types synthesised via `AudioContext` — no audio assets required:

| Name  | Frequency | Duration | Gain |
| ----- | --------- | -------- | ---- |
| `bip` | 440 Hz    | 0.1 s    | low  |
| `BIP` | 880 Hz    | 0.3 s    | high |

#### Pause

A pause button is visible whenever a timer is active. Pausing freezes the countdown and suppresses scheduled sounds. Resuming continues from the frozen value. Pause works in both work and rest phases (TABATA).

#### UI additions

**Timer display** — shown inside the exercise card for timed combos:

```
│  Push-up                          │
│  Combo: HIIT — Round 2 of 2       │
│                                   │
│         00:42                     │  ← turns red when < 10 s
│                                   │
│  [⏸ Pause]        [ ✓ Done ]      │
```

**TABATA rest screen** — replaces the exercise card during rest phase:

```
│  REST                             │
│  Next: Squat                      │
│                                   │
│         00:08                     │
│                                   │
│  [⏸ Pause]                        │
```

**AMRAP "Time's up" screen** — shown when the overall AMRAP timer reaches 0:

```
│  ⏱ Time's up — HIIT              │
│                                   │
│  [ Continue → ]                   │
```

#### Schema

No schema version bump needed. The `timer_config` column already exists on `combo` as a JSON text field:

- TABATA: `{ "workTime": 20, "restTime": 10 }`
- EMOM / AMRAP: `{}` or `null` (duration derived from `repetitions` at runtime)

#### Files to create / modify

**New:**

| File                              | Purpose                                                                          |
| --------------------------------- | -------------------------------------------------------------------------------- |
| `src/composables/useTimer.js`     | Core countdown: start, pause, resume, reset, per-second tick with callback hooks |
| `src/composables/useAudioCues.js` | Web Audio API helpers: `playBip()`, `playBIP()`, AudioContext lifecycle          |
| `src/components/TimerDisplay.vue` | Visual countdown (MM:SS), red-when-low styling, pause/resume button              |

**Modified:**

| File                                     | Change                                                                                                          |
| ---------------------------------------- | --------------------------------------------------------------------------------------------------------------- |
| `src/composables/useSessionExecution.js` | Wire timer into step lifecycle: EMOM/TABATA auto-advance on expiry; AMRAP time-up; sound scheduling             |
| `src/views/SessionExecuteView.vue`       | Render `<TimerDisplay>`, TABATA rest screen, AMRAP time-up screen, pause state                                  |
| `src/views/ComboFormView.vue`            | Work/rest time inputs shown only when type = TABATA; saved to `timer_config`                                    |
| `src/views/SessionFormView.vue`          | AMRAP combo item label: "Repetitions" → "Duration (min)"                                                        |
| `src/views/SessionDetailView.vue`        | Same AMRAP label change                                                                                         |
| `src/i18n/locales/en.json` + `fr.json`   | New keys under `execution.timer.*`, `combos.form.workTime`, `combos.form.restTime`, `sessions.form.durationMin` |
| `docs/plan.md`                           | Step marked done                                                                                                |

**Note:** Once implemented, Step 10 (PWA) and Step 11 (documentation) will need a follow-up review pass.

**Verification:** Playwright tests — EMOM timer auto-advances at 0 s, EMOM sounds fire at 30/2/1/0 s, EMOM manual Done advances early and resets timer; AMRAP BIP fires every elapsed minute, AMRAP last-minute bip/bip/BIP pattern fires correctly, AMRAP time-up screen appears at 0 and Continue advances; TABATA work→rest→next-exercise cycle, BIP at start of each phase, pause works in both phases; TABATA ComboForm shows work/rest inputs only for TABATA type and values round-trip correctly; session form shows "Duration (min)" for AMRAP combo items; non-timed combos (NONE) show no timer and behave as in Step 8; mixed session (timed + non-timed items) works end-to-end; pause freezes countdown and suppresses sounds; resume continues from frozen value

---

### Step 13 — Markdown in Description Fields

**Status:** Done

- Enabled `marked` + `DOMPurify` for rendering description fields in exercise, combo, and session detail views (already used as dependencies; this step wired them into the detail views)
- Added "Basic Markdown supported" hint text next to each description label in create and edit forms
- Description is intentionally **not** shown on list view cards (only in detail views)
- `src/utils/markdown.js` centralises the sanitized rendering pipeline; all three detail views call it via `v-html`

**New / modified files:**

- `src/utils/markdown.js` — `renderMarkdown(text)` using `marked.parse` + `DOMPurify.sanitize`
- `src/views/ExerciseDetailView.vue`, `src/views/ComboDetailView.vue`, `src/views/SessionDetailView.vue` — render description via `v-html`
- `src/views/ExerciseFormView.vue`, `src/views/ComboFormView.vue`, `src/views/SessionFormView.vue` — hint text alongside description label
- `src/i18n/locales/en.json`, `fr.json` — `"markdownHint"` translation key

**Verification:** Playwright tests — markdown hint visible on exercise/combo/session forms; bold, italic, and list items render as HTML in exercise detail; bold renders in combo and session details; description absent from all list cards

---

### Step 14 — Form Keyboard Shortcuts

**Status:** Done

- `Ctrl+Enter` saves any create or edit form, even when a text field is focused
- `Escape` cancels the form and navigates back (to list on new forms, to detail on edit forms), even when a text field is focused
- Save button exposes a `title` attribute showing the shortcut hint (visible as a native tooltip on hover)
- Shortcuts are attached to the form element's `keydown` event and skip when the browser is composing (IME)
- Applies to Exercise, Combo, Session, and Collection forms

**Modified files:**

- `src/views/ExerciseFormView.vue`, `src/views/ComboFormView.vue`, `src/views/SessionFormView.vue`, `src/views/CollectionFormView.vue` — keydown handler, save button `title`
- `src/i18n/locales/en.json`, `fr.json` — `"saveShortcutHint"` translation key

**Verification:** Playwright tests — `Ctrl+Enter` saves from body and from within an input; `Ctrl+Enter` with empty title shows validation error (does not save); `Escape` from body and from within an input navigates back; `Escape` on edit form returns to detail view; Save button title contains the shortcut hint; shortcuts confirmed on session, combo, and collection forms

---

### Step 15 — Audio Cues Refinement

**Status:** Done

Iterative refinement of the audio cue timing introduced in Step 12, driven by deterministic fake-clock tests that exposed subtle off-by-one issues in the scheduling logic.

- **TABATA:** `playBip` fires at the exact halfway point of each work and rest interval (not at 30 s absolute like EMOM — relative to the phase duration)
- **TABATA:** `bip, bip, BIP` countdown fires at `r = 2`, `r = 1`, and phase expiry for both work and rest phases
- **AMRAP:** `playBip` (soft) fires at each elapsed full minute tick; the half-time signal uses `playBIP` (loud) — both corrected to fire on the right tick
- Tests use `page.clock.install()` to control fake time, making timer assertions instant and deterministic without real `setTimeout` waits

**Modified files:**

- `src/composables/useSessionExecution.js` — `_tabataCountdown`, `_amrapSounds`, `_maybeStartAmrapTimer` helpers adjusted for correct relative timing
- `tests/test_step15_audio_cues.py` — new test file using `AudioContext` mock + `page.clock`

**Verification:** Playwright tests — TABATA bip at half work/rest interval; bip/bip/BIP countdown at end of work and rest phases; AMRAP per-minute soft bip fires correctly; all assertions driven by fake clock

---

### Step 16 — Single Deployment + Startup DB Migration

**Status:** Done — detailed plan in [`plan-step16-single-deployment-migration.md`](plan-step16-single-deployment-migration.md); manually validated by the user

Replaces the "versioned deployments with isolated data" model: a single deployment (latest version at site root), a stable OPFS database location (no version suffix), and a transactional migration step at app startup driven by the `RELEASES` registry. When a migration is pending, a blocking screen forces the user to download a backup before the update runs. No `SCHEMA_VERSION` bump (stays at 1).

**Key files:**

- `src/db/releases.js` — `adaptScript` renamed to `migrationScript`; new `getMigrationScripts(from, to)` helper shared by startup migration and restore adaptation
- `src/db/worker.js` — stable OPFS directory (`.trainus`); `getSchemaStatus` (fresh / up-to-date / incompatible / migration-pending) and `runMigrations` (one transaction per release, rollback on failure) message handlers
- `src/db/database.js` — init pauses on `migration-pending` (sets `db.migrationPending`); `runPendingMigrations()`; error codes `DB_NEWER_THAN_APP` / `DB_MIGRATION_FAILED`
- `src/App.vue` — blocking migration screen (forced backup download enables the Update button) + error-code → translated message mapping
- `src/composables/useBackupDownload.js` — backup download logic shared by SettingsView and the migration screen
- `tests/test_step16_migration.py` — green + red paths, Firefox + Chromium

**Verification:** Playwright tests — fresh install stamps `schema_version`; data persists across reloads; OPFS dir is unversioned; full migration flow (forced backup → Update → data intact); DB-newer-than-app blocks safely; migration screen cannot be bypassed. Migration-failure rollback test deferred until a real v2 migration script exists.

---

### Improvement Plan (post-v1.0 review) — `docs/improvement-plan.md`

**Status:** Done — manually validated by the user

All items from the improvement plan have been implemented. See `docs/improvement-plan.md` for the original descriptions and DoDs.

| Item | Description                                              | Status  |
| ---- | -------------------------------------------------------- | ------- |
| R1   | Batch transactions for multi-statement repository writes | ✅ Done |
| R2   | Atomic full-DB restore (single transaction)              | ✅ Done |
| R3   | Wall-clock timer (timestamp-based, drift-free)           | ✅ Done |
| R4-B | Execution log status column + route-leave guard          | ✅ Done |
| R5   | DB worker crash → error overlay + immediate reject       | ✅ Done |
| R6   | Remove duplicate `getAllWithItems` key                   | ✅ Done |
| R7   | Deduplicate AMRAP round-adder (`_appendRound`)           | ✅ Done |
| R8   | Add ESLint (flat config, 0 errors/warnings)              | ✅ Done |
| R9   | Fix O(n²) import (Map-based title lookup)                | ✅ Done |
| R10  | Escape LIKE wildcards in all `search()` methods          | ✅ Done |
| R11  | `validateMin` returns `{key, params}` for interpolation  | ✅ Done |
| R12  | 404 catch-all route → redirect to /home                  | ✅ Done |
| R13  | Superseded by Step 16                                    | ✅ N/A  |
| R14  | Cross-user import skips settings                         | ✅ Done |
| R15  | Delete confirms show history count                       | ✅ Done |
| R16  | GitHub Actions CI (lint + build + test)                  | ✅ Done |
| R17  | Documentation refresh (locale claims, COOP/COEP, lint)   | ✅ Done |
| R18  | Test-infra: isolate tmp paths per pytest run             | ✅ Done |

**Key files changed:** `src/db/worker.js`, `src/db/database.js`, `src/db/schema.js`, `src/db/repositories/*.js`, `src/composables/useTimer.js`, `src/composables/useSessionExecution.js`, `src/composables/useJsonImport.js`, `src/views/SessionExecuteView.vue`, `src/views/ExerciseDetailView.vue`, `src/views/SessionDetailView.vue`, `src/router/index.js`, `src/utils/escapeLike.js`, `src/utils/validation.js`, `eslint.config.js`, `.github/workflows/ci.yml`, `tests/conftest.py`, `tests/test_step17_transactions.py`, `tests/test_step18_execution_abandon.py`, `tests/test_step19_db_resilience.py`, `tests/test_step20_search_escaping.py`

---

### Website — Hugo static site (`website/`)

**Status:** Steps 1–5 done (2026-06-14) — manually validated by the user

Hugo 0.123.7 static site under `website/`. FR default at `/`, EN at `/en/`. Deployed to a subpath of the personal domain on Uberspace (manual deploy, no CI). No tutorials section. Blog lives on this project site.

| Step | Description                                                                     | Status  |
| ---- | ------------------------------------------------------------------------------- | ------- |
| W1   | Scaffold: `hugo new site`, `hugo.toml`, npm scripts, `.gitignore`               | ✅ Done |
| W2   | Base theme: `baseof.html`, partials, `main.css`, `single.html`, `list.html`     | ✅ Done |
| W3   | Home page: hero, greyed CTA button, features grid, i18n strings                 | ✅ Done |
| W4   | Docs: port EN docs, FR skeletons, Mermaid hook, stub old docs, update AGENTS.md | ✅ Done |
| W5   | Blog: `_index`, first FR post + EN skeleton, RSS autodiscovery verified         | ✅ Done |
| W6   | Deploy: set `baseURL`, set `params.appUrl`, upload `dist/` to Uberspace         | 🔲 TODO |
| W7   | EN docs: port `user-guide.en.md` to the FR Diataxis split (2026-06-16)          | ✅ Done |

**Key files:** `website/hugo.toml`, `website/assets/css/main.css`, `website/layouts/`, `website/content/docs/`, `website/content/blog/`, `website/i18n/`

**Known quirk:** Mermaid render hook must be at `layouts/_default/_markup/render-codeblock-mermaid.html` (not `layouts/_markup/` — Ubuntu pkg 0.123.7 only picks up the `_default/` path).

---

### Backlog — JSON import: title-conflict resolution UX

**Status:** ✅ Done (2026-06-16)

**Found:** 2026-06-16, while writing the user documentation and tracing `useJsonImport.js` to answer a question about what drives import conflict detection. Conflict detection during import was `id`-only: a title match against a _different_ existing row either silently failed (same-user) or only offered a forced rename (cross-user, after suffixing).

**What changed:**

- `_analyzeEntityList` now detects title conflicts (different id, same title) for same-user imports and folds them into the existing `sameUserCollisions` bucket — the same Replace/Skip screen (with batch Replace All / Skip All) used for id conflicts handles them with zero new UI.
- `_importEntityList` resolves same-user collisions against `collision.existing.id` (not `collision.imported.id`), so Replace updates the existing row in place and Skip leaves it untouched — both redirect any same-batch reference to the imported id (e.g. a combo's `exercise_id`) to the existing row's id via the same `idMap` mechanism already used for cross-user `new_id` remapping.
- Cross-user title conflicts (suffixed title collides with a previously-imported item from the same external user) now also resolve via Replace/Skip instead of a forced rename — `ImportDialog.vue` folds them into the same per-entity-type resolution arrays once detected in `onApply`.
- **Cleanup:** `analyzeExercises`, `applySameUserImport`, `applyDifferentUserImport`, and `checkTitleConflict` in `useJsonImport.js` turned out to be dead code (confirmed via grep — never called for applying; `analyzeAll` already produces an identical shape for exercise-only envelopes). Removed, along with the "legacy exercise-only" branch in `ImportDialog.vue`'s `onMounted` and the now-unreachable forced-rename `titleConflict` step/state. `analyzeAll`/`applyAllImport` now handle every envelope shape.

**Key files changed:** `src/composables/useJsonImport.js`, `src/components/ImportDialog.vue`, `src/i18n/locales/{en,fr,da}.json`, `tests/test_step21_import_title_conflicts.py` (new), `website/content/docs/comprendre.fr.md`, `website/content/docs/user-guide/index.fr.md`, `website/content/docs/user-guide.en.md`, `website/content/developers/technical.en.md`.

**Out of scope (unchanged):** an id-collision where the _new_ title also conflicts with an unrelated third row still surfaces as a raw DB error, same as before.

---

### Backlog — Save filename prompt on every disk save

**Status:** ✅ Done (2026-06-16)

**Found:** 2026-06-16, user request — every file save (JSON exports, home HTML export, SQLite backup) should ask for a filename, prefilled with the previous default, before saving.

**What changed:**

- New singleton composable `useSaveFilePrompt.js` (`promptFilename(defaultFilename)` → `Promise<string|null>`, splits the default into stem + extension) and new component `SaveFileDialog.vue` (editable stem, fixed extension, Enter-to-confirm), mounted once in `App.vue` so it's available app-wide, including the blocking pre-migration backup screen.
- The 3 existing low-level download functions — `downloadJson` (`jsonEnvelope.js`), `downloadBackup` (`useBackupDownload.js`), the local `downloadHtml` (`useHtmlExport.js`) — call the prompt before building the blob/anchor and no-op on cancel. No view-level export call site changed.
- `downloadBackup()` now returns `false` on cancel; `App.vue`'s `onMigrationBackup` only enables the Update button if the save actually completed.
- **Fix found along the way:** `ModalDialog`'s `z-index: 1000` was below the blocking `.db-overlay`'s `9999`, making the dialog unclickable from the migration screen — bumped to `10000`.
- **Fix found along the way:** a few export call sites (`useJsonExport.js`'s `downloadJson` calls, `SettingsView.vue`'s `onExportAll`, `HomeView.vue`'s `onExportHtml`) weren't awaited; added `await` since the call now blocks on user input.

**Key files changed:** `src/composables/useSaveFilePrompt.js` (new), `src/components/SaveFileDialog.vue` (new), `src/utils/jsonEnvelope.js`, `src/composables/useBackupDownload.js`, `src/composables/useHtmlExport.js`, `src/composables/useJsonExport.js`, `src/components/ModalDialog.vue`, `src/App.vue`, `src/views/SettingsView.vue`, `src/views/HomeView.vue`, `src/i18n/locales/{en,fr,da}.json`, `tests/test_step22_save_filename_prompt.py` (new), `tests/test_step2_settings.py`, `tests/test_step3c_backup.py`, `tests/test_step16_migration.py` (updated to confirm the dialog before asserting on the download), `website/content/developers/technical.en.md`, `website/content/docs/user-guide.en.md`, `docs/decisions-log.md`.

---

### Backlog — Clear (×) button on list search fields

**Status:** ✅ Done (2026-06-18)

**Found:** 2026-06-18, user request — the search field on the four list views (Exercises, Combos, Sessions, Collections) had no quick way to clear the text short of selecting/deleting it manually or toggling search off and back on.

**What changed:**

- Each of `ExerciseListView.vue`, `ComboListView.vue`, `SessionListView.vue`, `CollectionsView.vue` gained a `clearSearch()` function and a clear button (`bi-x-lg`) rendered inside the search input, visible only when the field has text. The button uses `@mousedown.prevent` (not `@click`) so it clears focus-safely without triggering the existing blur-to-close-search-bar logic on the old value.
- New shared i18n key `common.clearSearch` (EN/FR/DA) for the button's `title`/`aria-label`.
- New `data-testid="<entity>-search-clear"` per view, following the existing `<entity>-search-*` convention.

**Key files changed:** `src/views/ExerciseListView.vue`, `src/views/ComboListView.vue`, `src/views/SessionListView.vue`, `src/views/CollectionsView.vue`, `src/i18n/locales/{en,fr,da}.json`, `tests/test_step3_exercises.py`, `tests/test_step4_combos.py`, `tests/test_step5_sessions.py`, `tests/test_step6_collections_home.py`, `website/content/docs/user-guide/index.{en,fr}.md`, `website/content/developers/technical.{en,fr}.md`, `docs/decisions-log.md`.

---

### Backlog — First-launch warning modal (single tab / no private browsing)

**Status:** ✅ Done (2026-07-04)

**Requested:** 2026-07-03, user request — on first app startup, show a warning modal telling the user (1) the app can only be open in one browser tab at a time, and (2) the app does not work in private/incognito browsing mode. Both messages in a single modal, one "J'ai compris" (Got it) button to dismiss.

**Why (found during exploration):** the DB layer (`src/db/worker.js`) uses `sqlite3.installOpfsSAHPoolVfs(...)`, the OPFS SyncAccessHandle Pool VFS, which only supports a single active connection per origin — a 2nd tab, or private/incognito mode where OPFS may be unavailable, currently only surfaces as a generic post-failure error screen (`error.browserNotCompatible` in `App.vue`'s `dbErrorBody`). This feature adds a proactive warning instead of only a reactive error.

**What changed:**

- New `settings` table key `first_load_warning_seen` (absent until dismissed, then `"true"`), read/written via `useSettings()`.
- New component `src/components/FirstLoadWarning.vue`, modeled on `src/components/BackupReminder.vue`: on `onMounted`, reads the flag and, if unset, shows a `ModalDialog` with both warnings (single-tab-only + no-private-browsing) and one dismiss button that persists the flag and closes for good (no session-only snooze, unlike the backup reminder).
- Mounted once in `src/App.vue`'s `v-else` app-shell block, alongside `<BackupReminder>` / `<InstallPrompt>`.
- New top-level `firstLoadWarning: { title, singleTab, privateMode, dismiss }` i18n block in `en.json`, `fr.json`, `da.json`.
- New `tests/test_step23_first_load_warning.py`: modal appears on a fresh DB, both messages present, dismiss persists the flag, modal does not reappear on reload. Firefox + Chromium.

**Test-infra fix found along the way:** every Playwright test runs in a fresh browser context — a fresh OPFS database — which is exactly the "first launch" condition, so the modal would have appeared (and blocked interaction) in every one of the ~30 existing test files, not just the new one. Fixed by overriding the `page` fixture in `tests/conftest.py` to inject a `MutationObserver`-based init script that auto-clicks the dismiss button as soon as it appears; the new test file opts out via a `keep_first_load_warning` pytest marker (registered in `tests/pytest.ini`) so it can exercise the real modal. Verified no regressions across the full existing suite plus the trickiest cases (backup startup modal, Step 16 migration flow) on Firefox.

**Key files changed:** `src/components/FirstLoadWarning.vue` (new), `src/App.vue`, `src/i18n/locales/{en,fr,da}.json`, `tests/test_step23_first_load_warning.py` (new), `tests/conftest.py`, `tests/pytest.ini`, `docs/decisions-log.md`, `website/content/developers/technical.en.md`, `website/content/docs/user-guide/index.{en,fr}.md`.

---

### Step 24 — App Update Modal (no more silent freeze)

**Status:** ✅ Done (2026-07-05) — manually validated by the user, including the real SW update flow on a deployed build. Implemented as planned below; 7 Playwright tests passing on Firefox + Chromium.

**Problem:** the service worker is registered with `registerType: 'autoUpdate'` (`vite.config.js`). When a new version is deployed, the new SW downloads the full precache (including `sqlite3.wasm`, ~10 MB), takes control and reloads — the user experiences an unexplained freeze, even when no DB migration is involved. (The DB migration screen from Step 16 only covers schema changes, not app-code updates.)

**Goal:** the user is informed that an update is available, chooses when to apply it, and sees an explicit "updating…" state instead of a freeze.

#### Decisions

| Decision              | Choice                                                                 | Rationale                                                                                          |
| --------------------- | ---------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- |
| Update strategy       | Switch VitePWA to `registerType: 'prompt'`                             | The new SW installs (precaches) in the background but waits; activation becomes a user decision    |
| UI                    | Modal via existing `ModalDialog.vue`: "Update now" / "Later"           | Reuse existing modal component (KISS)                                                              |
| "Update now"          | Show non-dismissible spinner state, call `updateServiceWorker(true)`   | The page reloads when the new SW takes control; the spinner explains the wait                      |
| "Later"               | Dismiss for the current session; modal reappears on next app load      | Same session-dismiss pattern as `InstallPrompt.vue`; no persisted flag needed                      |
| During execution mode | Do not show the modal on `#/sessions/:id/execute`; defer until leaving | Never interrupt a workout with an update prompt                                                    |
| E2E testing           | Dev-only test hook to force `needRefresh`                              | Simulating a real SW update in Playwright requires two builds; the real flow is validated manually |

#### Files to create / modify

**New:**

- `src/composables/useAppUpdate.js` — singleton wrapping `useRegisterSW` from `virtual:pwa-register/vue`: exposes `needRefresh`, `updating`, `applyUpdate()` (sets `updating`, calls `updateServiceWorker(true)`), `dismiss()` (session-only). Guards a dev-only hook `window.__appUpdateTest = { trigger() }` behind `import.meta.env.DEV` so Playwright can force the prompt.
- `src/components/AppUpdateDialog.vue` — modal with two states: (1) choice — title, short body ("A new version of TrainUs is ready"), buttons **Update now** / **Later**; (2) updating — spinner + "Updating…" text, no buttons, not dismissible (no Escape/backdrop close). Hidden while the current route is the execution view; shows automatically once the user navigates away.
- `tests/test_step24_app_update.py`

**Modified:**

- `vite.config.js` — `registerType: 'autoUpdate'` → `'prompt'`
- `src/App.vue` — mount `<AppUpdateDialog>` once in the app-shell block (alongside `<BackupReminder>` / `<InstallPrompt>` / `<FirstLoadWarning>`)
- `src/i18n/locales/en.json`, `fr.json`, `da.json` — new `appUpdate: { title, body, updateNow, later, updating }` block
- `docs/decisions-log.md`, `website/content/developers/technical.en.md`, `website/content/docs/user-guide/index.{en,fr}.md`

**Verification:** Playwright tests (via the dev test hook) — modal appears when an update is pending; "Later" closes it and it does not reappear within the session; "Update now" switches to the spinner state and calls the (mocked) update function; spinner state ignores Escape; modal is suppressed on the execution route and appears after navigating away. Real SW update flow validated manually (deploy old build → deploy new build → prompt → update → reload). Firefox + Chromium.

---

### Step 25 — Headset / Media-Key Control of Session Execution

**Status:** ✅ Done (2026-07-05) — manually validated by the user, including real headset/lock-screen control on a device. Implemented as planned below; 9 Playwright tests passing on Firefox + Chromium.

**Goal:** during session execution, headphone buttons (and OS media keys / lock-screen controls) drive the workout: play/pause, next, previous, stop — via the **Media Session API** (`navigator.mediaSession`).

**Key constraint:** browsers only route hardware media keys to a page that is actively playing audio through a media element — Web Audio beeps do not qualify. The standard workaround is a short **silent looping `<audio>` element** started when execution begins (the user's click on ▶ satisfies the autoplay policy). This is what makes the whole feature work; it must be documented in `technical.en.md`.

#### Decisions

| Action                      | Mapping                                                                                                                                  | Rationale                                                                                |
| --------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- |
| `play`/`pause`              | `togglePause()` when a timer is active; update `mediaSession.playbackState`; no-op otherwise                                             | Matches the on-screen pause button semantics                                             |
| `nexttrack`                 | `markDone()` on a normal step; `Continue` on final-round / AMRAP time-up screens                                                         | "Next" always means "advance", whatever screen is showing                                |
| `previoustrack`             | New `goBack()` in `useSessionExecution.js`: decrement index, reload step state; no-op at index 0                                         | Lets the user redo the previous exercise                                                 |
| On-screen "Previous" button | Also add a visible ← button in `SessionExecuteView.vue` next to the Done button, calling the same `goBack()`; disabled on the first step | Feature usable without a headset; directly testable in the UI (user decision 2026-07-04) |
| `stop`                      | `exit()` directly (partial log saved, status `aborted`), skipping any confirmation                                                       | A headset button cannot answer a confirm dialog                                          |
| `goBack()` log behavior     | Already-saved log items are kept; completing the step again appends a new log item                                                       | KISS — no log rewriting; acceptable duplication, recorded in the decisions log           |
| Timed steps on `goBack()`   | The step timer restarts from the top of its slot/phase                                                                                   | Consistent with `loadStepState()`; simplest correct behavior                             |
| Silent audio                | Tiny silent WAV as data URI, `loop = true`; started on execution start, stopped on done/exit/unmount                                     | No asset file needed; released cleanly                                                   |
| Pause behavior              | Keep the silent audio playing; only set `mediaSession.playbackState = 'paused'`                                                          | Pausing the element would deactivate the media session on some browsers                  |
| Metadata                    | `MediaMetadata { title: exerciseTitle, artist: comboTitle ?? sessionTitle, album: sessionTitle }`, updated on every step change          | Lock screen / headset display shows the current exercise                                 |
| Unsupported browser         | Feature-detect `'mediaSession' in navigator`; silently no-op                                                                             | Progressive enhancement                                                                  |

#### Files to create / modify

**New:**

- `src/composables/useMediaSession.js` — `start({ onPlayPause, onNext, onPrevious, onStop })` (create + play silent audio, register action handlers), `updateMetadata(step, sessionTitle)`, `setPlaybackState(state)`, `stop()` (unregister handlers, stop + release audio). All guarded by feature detection.
- `tests/test_step25_media_session.py`

**Modified:**

- `src/composables/useSessionExecution.js` — add `goBack()`; expose it
- `src/views/SessionExecuteView.vue` — wire `useMediaSession`: start on mount (after `init()`), update metadata on `currentStep` change (watch), map actions per the table above, stop on unmount / `sessionDone`; add the on-screen "Previous" button (`data-testid="execute-previous-btn"`, `bi-skip-start-fill`, aria-label, disabled on the first step) next to the Done button
- `src/i18n/locales/{en,fr,da}.json` — `execution.previous` (button title/aria-label); other strings use existing titles
- `docs/decisions-log.md`, `website/content/developers/technical.en.md`, `website/content/docs/user-guide/index.{en,fr}.md` (mention headset control + browser support caveats)

**Verification:** Playwright tests — mock `navigator.mediaSession` via `page.add_init_script` (capture `setActionHandler` registrations and `metadata` assignments into `window.__mediaSessionMock`), then invoke the captured handlers from the test: `nexttrack` advances a step and saves the log item; `nexttrack` on the final-round screen continues to the next item; `previoustrack` returns to the previous step and is a no-op on the first step; the on-screen Previous button does the same and is disabled on the first step; `play`/`pause` toggles the timer on a timed combo and playbackState updates; `stop` exits and saves a partial log with status `aborted`; metadata title equals the current exercise title and updates on advance; handlers are unregistered after exit; app works with `mediaSession` absent (red path). Firefox + Chromium.

---

### Step 26 — Voice Announcements (Web Speech API)

**Status:** ✅ Done (2026-07-06) — manually validated by the user. Implemented as planned below; 9 Playwright tests passing on Firefox + Chromium. Full regression suite (374 passed, 1 skipped) green on Firefox 2026-07-06.

**Goal:** in execution mode, a synthesized voice (SpeechSynthesis — `window.speechSynthesis`, no audio assets, supported by Firefox and Chromium) announces the exercise title at each step change. In TABATA and EMOM the _next_ exercise is pre-announced. The voice can be disabled in Settings → Sound.

#### Announcement rules

| Moment                         | Spoken text                                       | Notes                                                                              |
| ------------------------------ | ------------------------------------------------- | ---------------------------------------------------------------------------------- |
| Step becomes current           | the exercise title                                | Includes the very first step; skipped if this exact step was just pre-announced    |
| EMOM: 10 s left in a slot      | "Next: {next exercise title}" (translated prefix) | Hooked into the existing `onTick` (like `_emomSounds`); only if a next step exists |
| TABATA: rest phase opens       | "Next: {next exercise title}"                     | Fires in `_startRestPhase`, mirroring the on-screen "Next:" label                  |
| AMRAP / NONE / plain exercises | step-change announcement only                     | No pre-announcement                                                                |

#### Decisions

| Decision            | Choice                                                                                                                           | Rationale                                                         |
| ------------------- | -------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------- |
| API                 | `speechSynthesis` + `SpeechSynthesisUtterance`                                                                                   | Native, offline-capable, no dependency — fits the PWA             |
| Utterance language  | From current i18next language: `en` → `en-US`, `fr` → `fr-FR`, `da` → `da-DK`                                                    | Browser picks a matching installed voice; titles are spoken as-is |
| Volume              | `utterance.volume = audioVolume` (existing `audio_volume` setting, already loaded in `useSessionExecution`)                      | One volume knob for beeps and voice                               |
| Overlap             | `speechSynthesis.cancel()` before each `speak()`                                                                                 | A new announcement always preempts a stale one                    |
| Double announcement | Track the last pre-announced queue index; skip the step-change announcement for that index                                       | Avoids "Squat" being spoken twice within seconds                  |
| Setting             | New `settings` key `voice_enabled`, default enabled (`"true"`); checkbox in the existing **Sound** section of `SettingsView.vue` | User asked for an opt-out, not an opt-in                          |
| Unsupported browser | Feature-detect `'speechSynthesis' in window`; silently no-op (checkbox still shown)                                              | Progressive enhancement                                           |
| Session end/exit    | `speechSynthesis.cancel()` on exit/done/unmount                                                                                  | No announcement after leaving the execution view                  |

#### Files to create / modify

**New:**

- `src/composables/useSpeech.js` — `speak(text, { volume })` (cancel + utter with mapped `lang`), `cancel()`, `isSupported`; reads `voice_enabled` once per execution via `settingsRepository`
- `tests/test_step26_voice_announcements.py`

**Modified:**

- `src/composables/useSessionExecution.js` — load `voice_enabled` in `init()`; announce in `loadStepState()`; pre-announce in the EMOM tick (at `remaining === 10`) and in `_startRestPhase()`; cancel on `exit()` / `_finish()`
- `src/views/SettingsView.vue` — "Voice announcements" checkbox in the Sound section, persisted like `audio_volume`
- `src/i18n/locales/{en,fr,da}.json` — `settings.voiceAnnouncements`, `execution.voice.next` ("Next: {{title}}" / "Suivant : {{title}}" / da)
- `docs/decisions-log.md`, `website/content/developers/technical.en.md`, `website/content/docs/user-guide/index.{en,fr}.md`

**Verification:** Playwright tests — mock `speechSynthesis` / `SpeechSynthesisUtterance` via `page.add_init_script` (record `{ text, lang, volume }` into `window.__spokenTexts`); use `page.clock` (as in Step 15) for timer-driven cases. Green paths: first step title spoken on execution start; title spoken on advance; EMOM speaks "Next: X" at 10 s remaining; TABATA speaks "Next: X" when rest opens; no duplicate announcement right after a pre-announcement; utterance `lang` follows the app language; utterance volume follows `audio_volume`. Red paths: checkbox off → nothing spoken (and persists across reload); `speechSynthesis` absent → execution works without errors. Firefox + Chromium.

---

### Step 27 — Manual "Check for updates" button in Settings

**Status:** ✅ Done (2026-07-22) — manually validated by the user. 5 Playwright tests passing on Firefox + Chromium. Follow-up (2026-07-24): section renamed to "Check Updates" and moved just above Credits; now also shows the last manual check date. 7 Playwright tests passing on Firefox + Chromium.

**Requested:** 2026-07-21, user request — the PWA doesn't seem to update automatically; add a manual update button in Settings.

**Why:** Step 24 (`useAppUpdate.js`, `AppUpdateDialog.vue`) already shows an update prompt when the browser detects a new service worker, but that detection only runs on the browser's own schedule (roughly on navigation/reload) — if the PWA is left open, an update can go unnoticed for a long time. A manual trigger in Settings lets the user force a check on demand.

#### Decisions

| Decision            | Choice                                                                                                                         | Rationale                                                                                        |
| ------------------- | ------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------ |
| Detection mechanism | `registration.update()` via the SW registration captured through `useRegisterSW`'s `onRegisteredSW` callback                   | Forces the browser to re-fetch the SW script now instead of waiting for its own schedule         |
| Found-update path   | Reuses the existing `needRefresh` flow and `AppUpdateDialog` unchanged                                                         | Same "Update now" / "Later" choice regardless of how the update was detected — no auto-apply     |
| No-update feedback  | Timeout heuristic in `SettingsView.vue`: if `needRefresh` hasn't flipped ~2s after the check, show "You're up to date" for ~3s | There is no direct "no update found" event from the Service Worker API                           |
| Scope               | No periodic/background auto-check — checking stays a deliberate user action                                                    | User decision 2026-07-21: updating stays optional even when available, checking stays opt-in too |

#### Files created / modified

- `src/composables/useAppUpdate.js` — capture `registration` via `onRegisteredSW`; add `checkForUpdate()` (calls `registration.update()`, guarded by a dev-only `mockCheckFn` test hook) and `checkingForUpdate` state
- `src/views/SettingsView.vue` — "Check Updates" section (moved just above Credits, 2026-07-24 follow-up): "Check for updates" button (disabled while checking), transient "up to date" message, and the last manual check date read/written via the `last_update_check_at` settings key (in-place addition, no schema bump)
- `src/i18n/locales/{en,fr,da}.json` — `settings.checkUpdates`, `settings.checkForUpdate`, `settings.upToDate`, `settings.lastManualCheck`, `settings.neverCheckedManually`
- `tests/test_step27_check_for_updates.py`
- `docs/decisions-log.md`, `website/content/developers/technical.en.md`, `website/content/docs/user-guide/index.{en,fr}.md`

**Verification:** Playwright tests (via the dev test hook `window.__appUpdateTest.mockCheckFn`) — button calls through to `registration.update()`; no update found shows the transient "up to date" message which then disappears; an update found via manual check still shows the "Update now" / "Later" modal (choice preserved, no auto-apply); the "up to date" message never appears when an update was found; last-check date shows "never checked manually" on a fresh DB and updates + persists across reload after a click. Firefox + Chromium.

---

### Step 28 — First-load network failure: clear error + in-app retry

**Status:** ✅ Done (2026-07-26) — 4 Playwright tests passing (1 on Firefox + Chromium, 3 Chromium-only: Playwright cannot intercept dedicated-worker requests on Firefox).

**Requested:** 2026-07-26, user report — an exception on the very first load (bad network, likely a timeout downloading `sqlite3.wasm`); once it failed, it was never attempted again.

**Why:** On a first load nothing is cached yet, so the DB worker downloads `sqlite3.wasm` over the network. Three compounding problems made a transient network failure final: `Database.init()` memoized its promise even when rejected (no retry possible), the error overlay had no retry button, and a network failure fell through to the misleading "browser not compatible" message. There is no timeout value to extend — the wasm download is a plain browser `fetch()` with no application-level timeout.

#### Decisions

| Decision              | Choice                                                                                                                            | Rationale                                                                                                            |
| --------------------- | --------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- |
| Retry mechanism       | `init()` resets itself (terminate worker, clear state) when the worker never became usable, so a later call starts fresh          | The OPFS SAH pool allows one connection per origin — the dead worker must be terminated before a new one can attach  |
| Reset condition       | Only when `db.info` is still null (worker never initialized)                                                                      | `DB_NEWER_THAN_APP` fails _after_ the DB opened; its worker stays alive for export/diagnostics, and retry won't help |
| Error tagging         | Worker wraps `sqlite3InitModule` failures as `DB_LOAD_FAILED: …`                                                                  | That call is the only network-touching step, so it cleanly separates connectivity from storage/compat failures       |
| Retry UX              | "Try again" button on the error overlay reruns `bootstrapDatabase()` in place (extracted from `main.js` to `src/db/bootstrap.js`) | Recovers without a page reload; hidden for `DB_NEWER_THAN_APP` / `DB_MIGRATION_FAILED` where retrying is pointless   |
| Connection-lost event | `_handleError` only dispatches `db-connection-lost` after a successful init                                                       | During startup the failure surfaces through `init()`'s rejection (with retry); the overlay is for post-init crashes  |

#### Files created / modified

- `src/db/bootstrap.js` — new: full startup sequence (init + persisted language/theme + DOM attributes), re-callable
- `src/db/database.js` — `init()` resets on pre-usable failure; guarded `db-connection-lost` dispatch
- `src/db/worker.js` — tag `sqlite3InitModule` failures as `DB_LOAD_FAILED`
- `src/main.js` — startup sequence moved to `bootstrapDatabase()`
- `src/App.vue` — `Try again` button (`data-testid="db-retry"`), `DB_LOAD_FAILED` message mapping, `onRetryInit()`
- `src/i18n/locales/{en,fr,da}.json` — `error.dbLoadFailed`, `error.retry`
- `tests/test_step28_init_retry.py`
- `docs/decisions-log.md`, `website/content/developers/technical.en.md`, `website/content/docs/user-guide/index.{en,fr}.md`

**Verification:** Playwright tests — blocking `**/sqlite3.wasm` (route abort, service workers blocked) shows the `db-error` overlay tagged `DB_LOAD_FAILED` with the retry button; unblocking + "Try again" reaches `data-db-ready` in place (marker proves no reload); retrying while still blocked shows the overlay again. Cross-browser (Firefox + Chromium): breaking worker creation via a wrapped `window.Worker`, then fixing it and retrying, recovers in place. Existing step 16/19 suites still green.

---

### Step 29 — Request persistent storage on startup

**Status:** ✅ Done (2026-07-26) — 3 Playwright tests passing on Firefox + Chromium.

**Requested:** 2026-07-26, user report — Bootstrap Icons occasionally render as placeholder boxes after not using the app for a while.

**Why:** Without `navigator.storage.persist()`, all site storage is "best-effort": the browser may evict Cache Storage (the Workbox precache, including the icon font) and the OPFS SQLite database after long inactivity or under disk pressure. Evicted precache + a stale HTTP-cached page referencing an old content-hashed font file that no longer exists on the server → 404 → placeholder glyphs. The user-data risk (OPFS in the same evictable bucket) is the bigger reason to fix it.

#### Decisions

| Decision  | Choice                                                                      | Rationale                                                                                       |
| --------- | --------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- |
| Placement | `main.js`, not the re-callable `bootstrapDatabase()`                        | Runs exactly once per page load; a retry after a failed first load must not re-prompt (Firefox) |
| Behaviour | Fire-and-forget, guarded (`navigator.storage?.persist`), result only logged | Grant/denial must never block startup; no UI or i18n strings needed                             |
| Testing   | Stub `StorageManager.prototype.persist` via init script                     | The real outcome depends on browser heuristics / permission UI — not reproducible in CI         |

#### Files created / modified

- `src/main.js` — `navigator.storage.persist()` request at startup
- `tests/test_step29_persistent_storage.py`
- `docs/decisions-log.md`, `website/content/developers/technical.{en,fr}.md`, `website/content/docs/user-guide/index.{en,fr}.md`

**Verification:** Playwright tests (Firefox + Chromium) — startup calls `persist()` exactly once and the app boots when granted; a denied request doesn't block startup; a browser without the API still boots with no call attempted.

---

## Data Model

```mermaid
erDiagram
    SETTINGS {
        text key PK
        text value
    }
    EXERCISE {
        text id PK
        text title UK
        text description
        boolean asymmetric
        boolean alternate
        text image_urls
        text video_urls
        integer default_reps
        real default_weight
        text created_by
    }
    COMBO {
        text id PK
        text title UK
        text description
        text type
        text timer_config
        text created_by
    }
    COMBO_EXERCISE {
        text combo_id FK
        text exercise_id FK
        integer position
        integer default_reps
        real default_weight
    }
    SESSION {
        text id PK
        text title UK
        text description
        text created_by
    }
    SESSION_ITEM {
        text session_id FK
        text item_id
        text item_type
        integer position
        integer repetitions
        real weight
    }
    COLLECTION {
        text id PK
        text label UK
        text created_by
    }
    COLLECTION_SESSION {
        text collection_id FK
        text session_id FK
        integer position
    }
    EXECUTION_LOG {
        text id PK
        text session_id FK
        datetime started_at
        datetime finished_at
    }
    EXECUTION_LOG_ITEM {
        text id PK
        text log_id FK
        text exercise_id FK
        text combo_id
        integer round_number
        integer reps_done
        real weight_used
        text side
        boolean completed
        datetime timestamp
    }
    SUFFIX {
        text user_uuid PK
        text user_name
        text suffix UK
    }

    COMBO ||--|{ COMBO_EXERCISE : contains
    EXERCISE ||--o{ COMBO_EXERCISE : "used in"
    SESSION ||--|{ SESSION_ITEM : contains
    COLLECTION ||--|{ COLLECTION_SESSION : contains
    SESSION ||--o{ COLLECTION_SESSION : "used in"
    SESSION ||--o{ EXECUTION_LOG : "executed as"
    EXECUTION_LOG ||--|{ EXECUTION_LOG_ITEM : records
    EXERCISE ||--o{ EXECUTION_LOG_ITEM : tracks
```

`SETTINGS` stores: `user_uuid`, `user_name`, `app_version`, `language`, `theme`, `db_created_at`, `last_backup_at`, `backup_reminder_days` as key/value rows. `created_by` on entities = UUID string for export provenance. `SUFFIX` maps external user UUIDs to short suffix strings used to disambiguate imported titles (e.g., `"Push-ups [JD]"`).

---

## Decisions

| Decision         | Choice                                                   | Rationale                                                                                                                                                                       |
| ---------------- | -------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Framework        | Vue 3 + Vite                                             | Reactive framework needed for form-heavy CRUD, timers, i18n                                                                                                                     |
| i18n             | i18next + vue-i18next                                    | Proven open-source, MIT licensed, rich features                                                                                                                                 |
| App version      | package.json → Vite inject → DB settings                 | Single source of truth, available at build + runtime                                                                                                                            |
| User model       | Single user per device, identity in settings             | No multi-user complexity needed                                                                                                                                                 |
| Settings order   | Step 2 (early)                                           | Language/theme switching before entity CRUD                                                                                                                                     |
| SQLite           | Official WASM with OPFS VFS                              | Best maintained, native OPFS                                                                                                                                                    |
| Charts           | Chart.js via vue-chartjs                                 | Good balance of features and size                                                                                                                                               |
| Routing          | Vue Router, hash mode                                    | No server config needed                                                                                                                                                         |
| ID collisions    | Suffix on title + new UUID                               | Preserves alphabetical sort; "Title [SUFFIX]" is user-readable; suffix pre-filled with exporter name; batch Replace All / Skip All for same-user conflicts                      |
| OPFS namespacing | Stable directory `.trainus`, no version suffix (Step 16) | OPFS is origin-scoped, so versioned directories never truly isolated data; a stable location preserves data across app updates, with startup migrations handling schema changes |
| Timer config     | EMOM/AMRAP/TABATA params as JSON                         | Flexible, stored in `timer_config` column                                                                                                                                       |
