---
title: 'Reference'
description: 'Architecture, database schema, features, routing, i18n, versioning, and build commands — the full technical reference.'
weight: 3
date: 2026-06-16
---

> This page describes every part of TrainUs in detail: tech stack, schema, routes, composables. For a guided first run, see [Getting Started]({{< relref "getting-started.en.md" >}}); for concrete tasks, see [How-to Guides]({{< relref "how-to-guides.en.md" >}}); for the "why" behind the choices, see [Understanding the Architecture]({{< relref "understanding-the-architecture.en.md" >}}).

> **AI authorship.** The vast majority of the source code in this project was written by AI assistants (primarily Claude). The human author directed the architecture, reviewed every change, guided the implementation, and made all design and product decisions — but in terms of who typed the lines, that credit belongs mostly to the AI. This is stated here for transparency, not as a caveat: the code was reviewed and validated at every step.

## 1. Architecture

TrainUs is a **Progressive Web Application (PWA)** that runs entirely in the browser — there is no backend server, and all data is stored locally via SQLite WASM with OPFS (Origin Private File System) persistence. The app works offline after the first load and can be installed on the home screen. See [Understanding the Architecture]({{< relref "understanding-the-architecture.en.md" >}}) for why it's built this way.

### Tech Stack

| Layer        | Technology                 |
| ------------ | -------------------------- |
| UI Framework | Vue 3 (Composition API)    |
| Build Tool   | Vite                       |
| Routing      | Vue Router (hash mode)     |
| i18n         | i18next + i18next-vue      |
| Database     | SQLite WASM + OPFS         |
| Charts       | Chart.js 4 + vue-chartjs 5 |
| Icons        | Bootstrap Icons            |
| Markdown     | marked + DOMPurify         |
| PWA          | vite-plugin-pwa + Workbox  |
| Formatting   | Prettier                   |
| Testing      | Playwright (Python)        |
| License      | GNU AGPL v3                |

### Project Structure

```
trainUs/
├── index.html                      # Entry point
├── package.json                    # Dependencies & npm scripts
├── vite.config.js                  # Vite + PWA + CORS configuration
├── public/
│   ├── manifest.json               # PWA manifest (hand-authored)
│   ├── icon-192.png                # PWA icon 192×192
│   ├── icon-512.png                # PWA icon 512×512
│   └── sqlite3-opfs-async-proxy.js # OPFS async proxy for SQLite worker
├── src/
│   ├── main.js                     # App bootstrap (DB init, settings restore)
│   ├── App.vue                     # Root component (shell layout, nav)
│   ├── router/
│   │   └── index.js                # Vue Router — all 21 routes
│   ├── views/                      # Page-level components (one per route)
│   ├── components/                 # Reusable UI components
│   ├── composables/                # Vue composables (reactive data & logic)
│   ├── db/
│   │   ├── database.js             # DB manager (worker bridge)
│   │   ├── worker.js               # Web Worker (SQLite WASM + OPFS)
│   │   ├── schema.js                # DDL + SCHEMA_VERSION + MIGRATIONS
│   │   ├── releases.js              # Release registry for restore compatibility
│   │   └── repositories/            # Per-entity data access layer
│   ├── i18n/
│   │   ├── index.js                # i18next initialization
│   │   └── locales/
│   │       ├── en.json             # English translations
│   │       └── fr.json             # French translations
│   ├── styles/
│   │   ├── variables.css           # CSS custom properties (light/dark themes)
│   │   └── layout.css              # Global layout, nav, action bar
│   └── utils/                      # Pure helper functions (validation, dateFormatter, markdown, …)
├── docs/                           # All documentation
└── tests/                          # Playwright Python test suite
```

### Code Statistics

Lines of code by area, measured with [cloc](https://github.com/AlDanial/cloc) (generated files, dependencies, and the Hugo theme excluded):

| Area                   | Files | Code lines | Main languages           |
| ---------------------- | ----: | ---------: | ------------------------ |
| `src/` (application)   |    81 |     13,973 | Vue SFC, JavaScript, CSS |
| `tests/` (Playwright)  |    37 |      7,371 | Python                   |
| `website/` (docs site) |    49 |      4,732 | Markdown, CSS, HTML      |
| `docs/`                |     4 |      1,500 | Markdown                 |

Within `src/`, Vue single-file components make up the bulk of application code (9,045 lines across 32 components), with 4,065 lines of plain JavaScript (composables, DB layer, utilities, router).

### Main Thread / Worker Architecture

SQLite runs in a **Web Worker** to access OPFS APIs, which are unavailable on the main thread.

```
Main Thread                           Worker Thread
┌──────────────────────┐              ┌──────────────────┐
│ database.js          │──postMsg()──▶│ worker.js        │
│  exec / run /        │◀──postMsg()──│ sqlite3 WASM     │
│  selectAll /         │              │ OPFS storage     │
│  exportDatabase /    │              └──────────────────┘
│  importDatabase      │
├──────────────────────┤
│ repositories/        │  Pure async CRUD per entity
├──────────────────────┤
│ composables/         │  Reactive ref wrappers for Vue
└──────────────────────┘
```

Communication is fully Promise-based: `database.js` wraps every `postMessage()` in a Promise resolved by a matching reply ID.

### COOP / COEP Headers

```
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
```

See [How-to Guides → Deploy TrainUs]({{< relref "how-to-guides.en.md" >}}#deploy-trainus) for server configuration snippets, and [Understanding the Architecture]({{< relref "understanding-the-architecture.en.md" >}}) for why the VFS used here doesn't strictly require them.

### Responsive Design

| Breakpoint | Layout                                   |
| ---------- | ---------------------------------------- |
| > 700 px   | Fixed sidebar navigation (labeled links) |
| ≤ 700 px   | Bottom navigation bar (icons + labels)   |
| ≤ 400 px   | Icon-only bottom navigation              |

### Theming

Light and dark themes are implemented via CSS custom properties on `:root` / `[data-theme="dark"]`. The theme is persisted in the `settings` table and applied before the first render (no flash).

---

## 2. Database Layer

### VFS: OPFS SyncAccessHandle Pool

The app uses the `opfs-sahpool` VFS:

- Synchronous I/O through `FileSystemSyncAccessHandle`
- Works on all major browsers since March 2023 (Firefox 111+, Chrome 102+, Safari 16.4+)
- Does not require COOP/COEP headers (though we have them)
- Single-connection only (fine for a single-user PWA)

If OPFS is unavailable (private browsing mode, outdated browser), the app displays a full-screen error. There is no in-memory fallback. See [Understanding the Architecture]({{< relref "understanding-the-architecture.en.md" >}}) for why this VFS was chosen and why there's no fallback.

### Schema

12 tables, all defined with `CREATE TABLE IF NOT EXISTS` (idempotent). The current `SCHEMA_VERSION` is **1**.

| Table                | Purpose                                                |
| -------------------- | ------------------------------------------------------ |
| `schema_version`     | Migration tracking (`version` + `applied_at`)          |
| `settings`           | Key-value configuration (user_uuid, name, theme, …)    |
| `exercise`           | Exercise definitions (title UNIQUE)                    |
| `combo`              | Combo definitions (title UNIQUE)                       |
| `combo_exercise`     | Combo ↔ exercise junction (position, reps, weight)     |
| `session`            | Session definitions (title UNIQUE)                     |
| `session_item`       | Session ↔ item junction (type, position, reps, weight) |
| `collection`         | Collections (label UNIQUE)                             |
| `collection_session` | Collection ↔ session junction (position)               |
| `execution_log`      | Execution records (started_at, finished_at)            |
| `execution_log_item` | Per-exercise execution results (reps, weight, side, …) |
| `suffix`             | External user registry for import disambiguation       |

#### Entity-Relationship Diagram

```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
```

**Notable column details:**

- `session_item.item_type`: `"exercise"` or `"combo"` — used to JOIN with the correct table for titles
- `execution_log_item.combo_id` / `round_number`: NULL for standalone exercises; populated for exercises executed inside a combo (no FK constraint — combo may be deleted after execution)
- `settings` keys: `user_uuid`, `user_name`, `app_version`, `language`, `theme`, `db_created_at`, `last_backup_at`, `backup_reminder_days`

#### UNIQUE Constraints

Title/label uniqueness is enforced at the DB level on `exercise.title`, `combo.title`, `session.title`, `collection.label`. Violations surface as `UNIQUE constraint failed` errors handled by the repositories.

#### Suffix Table

Stores the mapping between external user UUIDs and their assigned suffix tags. Used during import to disambiguate titles from other users (see §3.9).

### Migration System

At startup, `database.js` sends a `getSchemaStatus` message to the worker, which detects the DB state without modifying existing data:

- **Fresh DB** (no `schema_version` rows): run `SCHEMA_SQL` and stamp `SCHEMA_VERSION`.
- **Up to date** (`dbVersion === SCHEMA_VERSION`): continue normally.
- **Newer than app** (`dbVersion > SCHEMA_VERSION`): blocking error (`DB_NEWER_THAN_APP`), data untouched.
- **Older than app** (`dbVersion < SCHEMA_VERSION`): the app pauses on a blocking migration screen. The user must download a backup first; only then is the **Update** button enabled, which sends `runMigrations` to the worker.

`runMigrations` applies the pending migration scripts from `getMigrationScripts(dbVersion, SCHEMA_VERSION)` in `src/db/releases.js`. Each release's script runs inside a transaction together with its `INSERT INTO schema_version (version) VALUES (N)`; on error the transaction is rolled back, the DB stays at the previous version, and a blocking `DB_MIGRATION_FAILED` error is shown (the user holds the backup downloaded just before).

```mermaid
flowchart TD
    A[App start: db.init] --> B[Worker: open OPFS pool dir .trainus, open trainus.db]
    B --> C{schema_version table\nexists with rows?}
    C -- no: fresh DB --> D[Run SCHEMA_SQL\nINSERT schema_version = SCHEMA_VERSION]
    C -- yes --> E{dbVersion vs SCHEMA_VERSION}
    E -- equal --> F[Up to date — continue]
    E -- "dbVersion > app" --> G[Blocking error overlay\nDB untouched]
    E -- "dbVersion < app" --> H[Blocking migration screen\nDB untouched, app paused]
    H --> H2[User clicks Download backup\nexportDatabase + formatBackupFilename\nupdate last_backup_at]
    H2 --> H3[Update button enabled\nuser clicks Update]
    H3 --> I[For each release N in dbVersion+1..SCHEMA_VERSION:\nBEGIN; migrationScript; INSERT schema_version N; COMMIT]
    I -- error --> J[ROLLBACK\nBlocking error overlay\nDB still at previous version + user has backup]
    I -- ok --> F
    D --> K[_initDefaults: settings rows]
    F --> K
    K --> L[App ready]
```

In-place schema changes (adding columns directly in `SCHEMA_SQL`) are acceptable before any release to users. A `SCHEMA_VERSION` bump is reserved for schema changes made after a version has been deployed — at that point the release ships a `migrationScript` in `RELEASES` (see §6). See [How-to Guides → Add a Database Migration]({{< relref "how-to-guides.en.md" >}}#add-a-database-migration) for the step-by-step recipe.

### Repository Pattern

Each entity has a dedicated repository in `src/db/repositories/` providing standard methods: `create`, `getById`, `getAll`, `update`, `delete`, `search`. Junction table management is included in the parent repository (e.g., `setExercises()` in `comboRepository`).

Repositories use only `database.exec()`, `database.run()`, and `database.selectAll()` — no SQLite-specific constructs outside this layer.

### Vue Composables

Reactive wrappers in `src/composables/` expose `ref`-based state (`items`, `loading`, `error`) and async methods for use in Vue components. Components never import repositories directly; they use composables.

| Composable            | Wraps                                        |
| --------------------- | -------------------------------------------- |
| `useExercises`        | `exerciseRepository`                         |
| `useCombos`           | `comboRepository`                            |
| `useSessions`         | `sessionRepository`                          |
| `useCollections`      | `collectionRepository`                       |
| `useExecutionLogs`    | `executionLogRepository`                     |
| `useSettings`         | `settingsRepository`                         |
| `useStats`            | Multiple repositories (read-only aggregates) |
| `useSessionExecution` | `executionLogRepository` + queue logic       |
| `useJsonExport`       | All repositories + `jsonEnvelope.js`         |
| `useJsonImport`       | All repositories + `suffixRepository`        |
| `useBackupStatus`     | `settingsRepository`                         |
| `useInstallPrompt`    | `beforeinstallprompt` event (browser API)    |
| `useHtmlExport`       | Pure data rendering — no repository calls    |

---

## 3. Features

### 3.1 Settings

**View:** `src/views/SettingsView.vue` — `#/settings`

**Startup restoration:** On app startup (`src/main.js`), after DB initialization and before the first render, `language` and `theme` are read from `settings` and applied. This prevents any flash of default values.

#### Available settings

| Setting                  | Control                                    | Persistence key                 |
| ------------------------ | ------------------------------------------ | ------------------------------- |
| Language                 | `<select>` (English / Français / Dansk)    | `settings.language`             |
| User Name                | Text input (required, non-empty)           | `settings.user_name`            |
| User ID                  | Read-only UUID display                     | `settings.user_uuid`            |
| Theme                    | `<select>` (Light / Dark)                  | `settings.theme`                |
| Sound Volume             | Slider (0–100%) + Test button              | `settings.audio_volume`         |
| Download DB              | Button → downloads `.sqlite3`              | —                               |
| Restore DB               | File picker → validation pipeline          | —                               |
| Initialize DB            | Button + mandatory checkbox                | —                               |
| Backup reminder interval | Number input (1–365, default 7)            | `settings.backup_reminder_days` |
| Export Data              | Button → entity filter dialog              | —                               |
| Import Data              | Button → file picker                       | —                               |
| Suffix management        | List of registered suffixes                | `suffix` table                  |
| Credits                  | Button → modal (icon/library attributions) | —                               |
| About                    | Button → modal (version, author)           | —                               |

Sound Volume is detailed in [§ 3.7 Audio Cues](#audio-cues).

#### Download Database

Calls `sqlite3_js_db_export()` in the worker. The raw SQLite file is downloaded as `trainus-{version}-backup-YYYYMMDD-HHhMMmSSs.sqlite3` and `last_backup_at` is set on success.

#### Restore Database

Multi-step pipeline executed in the worker:

```mermaid
flowchart TD
    A[User clicks Restore] --> B[File picker]
    B --> C[Confirm dialog]
    C -->|Cancel| Z[Abort]
    C -->|OK| D[Show spinner]
    D --> E[Deserialize into :memory: DB]
    E -->|Invalid file| F[Error: INVALID_FILE]
    E -->|Valid| G[Check required tables]
    G -->|Missing| H[Error: INCOMPLETE_DB]
    G -->|Present| I[Read schema versions]
    I --> J[Check compatibility]
    J -->|Incompatible| K[Error: INCOMPATIBLE_VERSION]
    J -->|Compatible| L{Needs adaptation?}
    L -->|Yes| M[Run adapt scripts]
    M -->|Failed| N[Error: ADAPT_FAILED]
    M -->|OK| O[Table-by-table copy in transaction]
    L -->|No| O
    O -->|Failed| P[Error: RESTORE_FAILED]
    O -->|OK| Q[Success report card]
```

The table-by-table copy only transfers columns present in both schemas. The entire operation runs in a transaction; on failure it rolls back. After success, settings (language, theme) are re-applied in-place without a page reload.

Version compatibility is managed by `src/db/releases.js`: a `RELEASES` array where each entry declares `schemaVersion`, `appVersion`, `dbBreak`, and optional `migrationScript`. `checkCompatibility()` walks this history to determine if a migration path exists. The same `migrationScript` artifacts drive both restore adaptation and the startup migration (§ Migration System).

#### Initialize Database

Deletes all data from all tables in FK-safe order (children first), then calls `_initDefaults()` to regenerate `user_uuid`, `user_name`, `app_version`, `db_created_at`. The page reloads to reflect the fresh state. A mandatory confirmation checkbox prevents accidental use.

**Key files:**
`src/views/SettingsView.vue`, `src/db/database.js`, `src/db/worker.js`, `src/db/releases.js`, `src/db/repositories/settingsRepository.js`

#### Credits Dialog

A modal opened from the **Credits** card on the Settings page, attributing third-party assets and libraries:

| Item                    | License   | Source                                                                                                                                                                                                                                                                                                                                                                                                                                                                                 |
| ----------------------- | --------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| App icon (Mighty Force) | CC BY 3.0 | [game-icons.net](https://game-icons.net), by Delapouite                                                                                                                                                                                                                                                                                                                                                                                                                                |
| Bootstrap Icons         | MIT       | [icons.getbootstrap.com](https://icons.getbootstrap.com)                                                                                                                                                                                                                                                                                                                                                                                                                               |
| Third-party libraries   | —         | [Vue](https://vuejs.org), [Vue Router](https://router.vuejs.org), [Vite](https://vitejs.dev), [SQLite WASM](https://sqlite.org/wasm/), [Chart.js](https://www.chartjs.org), [vue-chartjs](https://vue-chartjs.org), [i18next](https://www.i18next.com), [i18next-vue](https://i18next.github.io/i18next-vue/), [Prettier](https://prettier.io), [vite-plugin-pwa](https://vite-pwa-org.netlify.app), [marked](https://marked.js.org), [DOMPurify](https://github.com/cure53/DOMPurify) |
| Testing Tools           | —         | [Playwright](https://playwright.dev/python/), [pytest](https://pytest.org)                                                                                                                                                                                                                                                                                                                                                                                                             |

The icon and library names/links are hardcoded in the component; attribution text comes from the `credits.*` i18n namespace.

**Component:** `src/components/CreditsDialog.vue`

#### About Dialog

A modal opened from the **About TrainUs** card at the bottom of the Settings page. It displays:

| Field               | Source                                                |
| ------------------- | ----------------------------------------------------- |
| Application Version | `__APP_VERSION__` (Vite global from `package.json`)   |
| Database Version    | `SCHEMA_VERSION` (from `src/db/schema.js`)            |
| Created by          | Static string: David Florance                         |
| Repository          | Placeholder link (`href="#"`)                         |
| Website             | Placeholder link (`href="#"`)                         |
| View License        | Disabled button with "License to be determined." note |

**Component:** `src/components/AboutDialog.vue`

i18n keys are under the `about.*` namespace in all locale files.

---

### 3.2 Exercise Management

**Views:** `ExerciseListView`, `ExerciseFormView`, `ExerciseDetailView`

**Routes:** `#/exercises`, `#/exercises/new`, `#/exercises/:id`, `#/exercises/:id/edit`

#### Form fields

| Field          | Validation                                                                 |
| -------------- | -------------------------------------------------------------------------- |
| Title          | Required, UNIQUE (DB-level)                                                |
| Description    | Optional; rendered as Markdown in the detail view (`marked` + `DOMPurify`) |
| Asymmetric     | Boolean; reveals Alternate sub-option                                      |
| Alternate      | Radio: alternate per rep vs per set                                        |
| Default Reps   | ≥ 0                                                                        |
| Default Weight | ≥ 0                                                                        |
| Image URLs     | Each must be valid HTTP(S) URL                                             |
| Video URLs     | Each must be valid HTTP(S) URL                                             |

`ExerciseFormView` is shared for create and edit modes, distinguished by `route.name` (`exercise-new` vs `exercise-edit`).

#### Action bar (Teleport pattern)

Views inject buttons into the shared action bar using `<Teleport to="#actionbar-actions" defer>`. The `defer` attribute (Vue 3.5+) resolves the target in the next render cycle, avoiding a race condition with the `v-else`-gated shell.

#### Validation utility

`src/utils/validation.js` provides reusable validators returning i18n keys:

- `validateRequired(value)` → `'validation.required'`
- `validateMin(value, min)` → `'validation.minValue'`
- `validateUrl(value)` → `'validation.invalidUrl'`

#### Search

Toggleable search bar with 250ms debounce → `exerciseRepository.search()` (SQL `LIKE %query%`). A clear (×) button is shown inside the input whenever it has a value; it empties the field and refocuses it. Clearing the input (via the button, manually, or toggling search off) calls `fetchAll()`. The same pattern (search bar, debounce, clear button) is duplicated across `ExerciseListView`, `ComboListView`, `SessionListView`, and `CollectionsView` rather than factored into a shared composable.

#### Media embedding

- **Images**: Displayed in a responsive grid with lazy loading. `@error` handler hides broken images.
- **YouTube**: Video IDs extracted from `youtube.com/watch?v=` and `youtu.be/` formats, embedded via `youtube-nocookie.com`.
- **Other URLs**: Rendered as clickable links.

**Key files:** `src/views/ExerciseListView.vue`, `src/views/ExerciseFormView.vue`, `src/views/ExerciseDetailView.vue`, `src/utils/validation.js`, `src/utils/markdown.js`, `src/utils/youtube.js`

---

### 3.3 Combo Management

**Views:** `ComboListView`, `CombosView`, `ComboFormView`, `ComboDetailView`

**Routes:** `#/combos`, `#/combos/new`, `#/combos/:id`, `#/combos/:id/edit`

A combo is an ordered group of exercises with an optional timed format (EMOM, AMRAP, TABATA, or NONE for plain supersets).

#### Form fields

| Field        | Notes                                                            |
| ------------ | ---------------------------------------------------------------- |
| Title        | Required, UNIQUE                                                 |
| Description  | Optional; rendered as Markdown in the detail view                |
| Type         | NONE / EMOM / AMRAP / TABATA                                     |
| Timer config | Duration (min) for EMOM/AMRAP; rounds for TABATA; stored as JSON |
| Exercises    | Ordered list with per-exercise reps (≥ 1) and weight (≥ 0)       |

Each exercise in the combo has its own `default_reps` and `default_weight` stored in the `combo_exercise` junction table. These override the exercise's own defaults when the combo is used in a session.

#### Exercise ordering

Move up / move down buttons adjust position. `comboRepository.setExercises()` replaces the entire junction table for a combo in one operation (delete + re-insert).

#### `getExercises()` return shape

```js
{
  ;(exerciseId, exerciseTitle, position, defaultReps, defaultWeight, asymmetric, alternate)
}
```

`asymmetric` and `alternate` are included so execution mode can determine side handling without an extra query.

**Key files:** `src/views/ComboListView.vue`, `src/views/ComboFormView.vue`, `src/views/ComboDetailView.vue`, `src/db/repositories/comboRepository.js`

---

### 3.4 Session Management

**Views:** `SessionListView`, `SessionsView`, `SessionFormView`, `SessionDetailView`, `SessionExecuteView`

**Routes:** `#/sessions`, `#/sessions/new`, `#/sessions/:id`, `#/sessions/:id/edit`, `#/sessions/:id/execute`

A session is an ordered list of items — each item is either an **exercise** or a **combo**, with per-item repetitions and weight.

#### Session items

`session_item` uses a polymorphic `item_id` + `item_type` pair:

```sql
item_type IN ('exercise', 'combo')
```

`sessionRepository.getItems()` executes two separate queries (one joining with `exercise`, one joining with `combo`) and merges results sorted by `position`. SQLite doesn't support conditional JOINs across different tables in a single query.

#### Form fields

| Field       | Notes                                                          |
| ----------- | -------------------------------------------------------------- |
| Title       | Required, UNIQUE                                               |
| Description | Optional; rendered as Markdown in the detail view              |
| Items       | Ordered list; each item has repetitions (≥ 1) and weight (≥ 0) |

The item type selector (exercise vs combo) controls which dropdown is populated. Items can be moved up/down and removed.

#### Repetitions semantics

For a session item that is a combo, `repetitions` = number of full passes through the combo's exercise list (not individual exercise repetitions).

**Key files:** `src/views/SessionListView.vue`, `src/views/SessionFormView.vue`, `src/views/SessionDetailView.vue`, `src/db/repositories/sessionRepository.js`

---

### 3.5 Collection Management

**Views:** `CollectionsView`, `CollectionFormView`, `CollectionDetailView`

**Routes:** `#/collections`, `#/collections/new`, `#/collections/:id`, `#/collections/:id/edit`

A collection is an ordered group of sessions — useful for organizing weekly plans or training phases.

#### Form fields

| Field    | Notes                                              |
| -------- | -------------------------------------------------- |
| Label    | Required, UNIQUE                                   |
| Sessions | Ordered list; each session can appear at most once |

`collectionRepository.getAll()` includes a `sessionCount` field via a COUNT query on `collection_session` — avoiding loading full session data for the list view.

**Key files:** `src/views/CollectionsView.vue`, `src/views/CollectionFormView.vue`, `src/views/CollectionDetailView.vue`, `src/db/repositories/collectionRepository.js`

---

### 3.6 Home Page

**View:** `HomeView` — `#/home`

Landing page with a quick overview of recent training activity.

| Widget                    | Data source                                                                     |
| ------------------------- | ------------------------------------------------------------------------------- |
| Sessions last 7 days      | `executionLogRepository.getCountSince(isoDate)`                                 |
| Sessions last 30 days     | `executionLogRepository.getCountSince(isoDate)`                                 |
| 4 most recent sessions    | `executionLogRepository.getRecentWithItems(4)` (JOIN with `session` and items)  |
| History by day (filtered) | `executionLogRepository.getAllWithItems()` — filtered client-side by date range |

#### Recent sessions

The 4 most recently executed sessions are shown as cards. Each card displays the session title, execution date (locale-formatted via `dateFormatter.js`), total duration (when `finished_at` is set), a **play button (▶)** to re-execute, and a compact inline summary of every exercise/combo performed with actual reps, weight, and per-item duration.

The inline summary groups items using `groupItems()` / `enrichItemsWithTiming()` / `comboRounds()` helpers defined in `HomeView`. Item timing is derived from consecutive `execution_log_item.timestamp` deltas relative to `execution_log.started_at`.

#### History section

All execution logs are loaded once at mount via `getAllWithItems()` and then filtered client-side by the active date range (default: last 30 days). The result is grouped by calendar day (descending) using a Vue `computed`. Each day group has a heading and one compact card per session, also with duration, play button, and inline item summary.

Two `<input type="date">` pickers (from / to) let the user adjust the range interactively without a network round-trip.

#### HTML export

An action bar button (`bi-filetype-html`) renders only when at least one execution log exists. Clicking it calls `useHtmlExport.exportHomeAsHtml()`, which:

1. Fetches `/icon.svg` and encodes it as a base64 `data:` URI (`TextEncoder` + `btoa`).
2. Builds a fully self-contained HTML document from the current reactive state — stats cards, recent sessions, and history filtered by the active date range.
3. Strips play buttons, navigation links, and date pickers from the output (read-only export).
4. Downloads as `trainus-home-YYYY-MM-DD.html`.

**Key files:** `src/views/HomeView.vue`, `src/composables/useHtmlExport.js`, `src/db/repositories/executionLogRepository.js`, `src/utils/dateFormatter.js`

---

### 3.7 Execution Mode

**View:** `SessionExecuteView` — `#/sessions/:id/execute`

**Composable:** `useSessionExecution`

Execution Mode steps through a session one exercise at a time, logging actual reps, weight, and side.

#### Execution Queue Model

At execution start, `useSessionExecution` builds a **flat linear queue** of step objects from the session items:

- An **exercise** item → 1 step
- A **combo** item → `totalRounds × M` steps, where `totalRounds` depends on combo type:

| Combo type  | `repetitions` meaning | `totalRounds` formula                                 |
| ----------- | --------------------- | ----------------------------------------------------- |
| NONE/TABATA | Number of full rounds | `repetitions`                                         |
| EMOM        | Total duration (min)  | `floor(repetitions / M)` — each exercise = 1 min slot |
| AMRAP       | Total duration (min)  | `1` — only one pass is queued; user repeats freely    |

```
Session items:
  1. Exercise A  ×3 reps                     → 1 step
  2. Combo "HIIT" ×2 rounds (2 exercises)    → 4 steps (NONE/TABATA)
       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
  [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
```

For an **EMOM** combo with 2 exercises and `repetitions = 6` (= 6-minute duration): `totalRounds = floor(6/2) = 3`, producing 6 steps of 1 minute each.

Each step carries: `exerciseId`, `exerciseTitle`, `prefillReps`, `prefillWeight`, `prefillSide`, `comboId?`, `comboTitle?`, `roundNumber?`, `totalRounds?`, `asymmetric`, `alternate`, `isFinalComboStep`, `comboExercises`.

#### `isFinalComboStep` flag

Set to `true` only on the last exercise of the last round of a combo. When the user presses **Done** on that step, the composable shows the final-round screen instead of auto-advancing.

#### Prefill logic

On init, `executionLogRepository.getLastItemsBySession(sessionId)` fetches the most recent `execution_log` for the session and returns a map `exerciseId → {reps_done, weight_used, side}`. Steps use this map for prefill, falling back to session item defaults when no prior log exists.

#### `+ One More Round`

When the user presses **+ One More Round**, the composable:

1. Looks up the completed steps for the current final round (actual reps/weight used)
2. Inserts a new batch of steps at `currentIndex + 1` in the queue
3. Marks `isFinalComboStep = true` on the last new step
4. Advances `currentIndex` to the first new step

#### Asymmetric exercise side handling

If `asymmetric = true` and `alternate = true`, the side flips after each step of the same exercise. If `alternate = false`, it flips after each full set. The final side value is logged in `execution_log_item.side`.

#### Partial log on exit

Pressing **✕** (exit) saves all **completed** steps to `execution_log` and omits unfinished steps. The log is marked with `finished_at = null` to indicate a partial run. Completed items are still included in statistics.

#### Exercise Detail Toggle

The exercise title in the current step card is a toggle button. Clicking it:

1. Expands an inline detail panel (description rendered as Markdown, images, video links).
2. **Pauses the active timer** — but only if it was running at click time (`detailPausedTimer` flag tracks this).

Collapsing the panel resumes the timer only when `detailPausedTimer` is true, leaving manually-paused timers untouched.

The detail panel is loaded lazily on first expand via `exerciseRepository.getById(step.exerciseId)`. Subsequent toggles within the same step reuse the cached `detailExercise` ref. A `watch(currentStep, …)` resets `showDetail`, `detailExercise`, and `detailPausedTimer` whenever the step advances.

For online state detection (to conditionally show video links), `SessionExecuteView` uses `useOnlineStatus()`. YouTube ID extraction uses the shared `src/utils/youtube.js` utility (same function used in `ExerciseDetailView`).

#### UI Layout

```
┌─────────────────────────────────────────┐
│  Session Title                [✕ exit]  │
│  ████████████░░░░░  3 / 6 steps         │
├─────────────────────────────────────────┤
│  ▶ NOW                                  │
│  ┌───────────────────────────────────┐  │
│  │  Push-up ˅                        │  │  ← title is a toggle button
│  │  ┌─────────────────────────────┐  │  │  ← detail panel (when expanded)
│  │  │  Keep your body straight…   │  │  │
│  │  │  [image] [image]            │  │  │
│  │  └─────────────────────────────┘  │  │
│  │  Combo: HIIT — Round 2 of 2       │  │
│  │  Reps:   [ 10 ]                   │  │
│  │  Weight: [  0 ] kg                │  │
│  │  Side:   RIGHT ↔                  │  │  ← asymmetric only
│  │          [ ✓ Done ]               │  │
│  └───────────────────────────────────┘  │
│  ↓ NEXT                                 │
│  Squat — 15 reps  (HIIT, Round 2)       │
└─────────────────────────────────────────┘
```

#### Audio Cues

`src/composables/useAudioCues.js` provides two functions built on the Web Audio API:

| Function          | Frequency | Duration | Role                                |
| ----------------- | --------- | -------- | ----------------------------------- |
| `playBip(volume)` | 440 Hz    | 0.1 s    | Soft tick — countdown, midpoint     |
| `playBIP(volume)` | 880 Hz    | 0.3 s    | Loud signal — phase change, time-up |

`AudioContext` is created lazily on first use (singleton). Volume is loaded from `settings.audio_volume` (0–100) and converted to a 0.0–1.0 multiplier.

Sound logic lives in `useSessionExecution` as three private helpers:

- **`_emomSounds(remaining)`** — fires `playBip` at r = 30, 2, 1 (EMOM only).
- **`_amrapLastMinuteSounds(remaining)`** — fires `playBip` at r = 30, 2, 1 (last minute of AMRAP).
- **`_tabataCountdown(remaining, totalTime)`** — fires `playBip` at `floor(totalTime / 2)`, r = 2, and r = 1.

AMRAP timer logic in `_maybeStartAmrapTimer`:

```
r > 60:
  elapsed minute changes → playBip        (per-minute tick)
  r === round(duration / 2) → playBIP     (half-time signal; takes priority over minute tick)
r ≤ 60:
  _amrapLastMinuteSounds(r)
r = 0 (expire):
  playBIP                                 (time-up signal)
```

The **bip, bip, BIP** sequence (r = 2, r = 1, r = 0/expire) is evenly spaced at 1-second intervals because `onTick` fires at r = 2 and r = 1, and `onExpire` fires at r = 0 — each exactly one timer tick apart.

**Key files:** `src/views/SessionExecuteView.vue`, `src/composables/useSessionExecution.js`, `src/composables/useAudioCues.js`, `src/db/repositories/executionLogRepository.js`, `src/utils/youtube.js`

#### Headset / Media-Key Control

Hardware media keys (headphone buttons, OS media keys, lock-screen controls) are only routed by the browser to a page **actively playing audio through a media element** — the Web Audio beeps from §"Audio Cues" above do not qualify, since they don't use `<audio>`/`<video>`. `useMediaSession.js` works around this with a silent, looping `<audio>` element (a 592-byte base64-encoded WAV data URI, no asset file) started when execution begins; the user's ▶ tap to start the session is the user gesture that satisfies the browser's autoplay policy.

`useMediaSession()` returns:

- `isSupported` — `'mediaSession' in navigator`; every other method is a no-op when `false`.
- `start({ onPlayPause, onNext, onPrevious, onStop })` — creates (once) and plays the silent audio element, registers `navigator.mediaSession` action handlers for `play`, `pause`, `nexttrack`, `previoustrack`, `stop`.
- `updateMetadata(step, sessionTitle)` — sets `navigator.mediaSession.metadata = new MediaMetadata({ title, artist, album })`, so the lock screen / headset display shows the current exercise. `title` = exercise title, `artist` = combo title (falls back to the session title outside a combo), `album` = session title.
- `setPlaybackState(state)` — sets `navigator.mediaSession.playbackState`.
- `stop()` — unregisters all action handlers, pauses and rewinds the silent audio.

`SessionExecuteView.vue` wires it in:

- `start()` is called in `onMounted`, right after `init()` succeeds.
- A `watch(currentStep, …)` — the same watcher that resets the exercise-detail panel — calls `updateMetadata(step, sessionTitle)`, so metadata refreshes on every step change, including the very first step.
- `stop()` is called in `onUnmounted` and whenever `sessionDone` becomes `true`.

Action mapping deliberately reuses the on-screen handlers rather than inventing media-specific logic:

| Media action     | Maps to                                                                                                    | Notes                                                 |
| ---------------- | ---------------------------------------------------------------------------------------------------------- | ----------------------------------------------------- |
| `play` / `pause` | `togglePause()` if a timer is active, then `setPlaybackState('paused'\|'playing')`                         | No-op if no timer is running (nothing to pause)       |
| `nexttrack`      | `onDone()` on a normal step; `onContinue()`/`onContinueAmrap()` on the final-round / AMRAP-time-up screens | Mirrors whichever button is visible                   |
| `previoustrack`  | `goBack()` (new in `useSessionExecution.js`)                                                               | No-op at the first step                               |
| `stop`           | `onExit()` directly, no confirmation                                                                       | A headset button can't answer a JS `confirm()` dialog |

**`goBack()`** decrements `currentIndex`, clears any final-round/AMRAP-time-up/rest screen state, stops the running timer, and calls the existing `loadStepState()` — so a timed step restarts its timer from the top of its slot/phase, consistent with how every other step transition works. It intentionally does not rewrite execution history: a step's previously logged item (if any) is left in place, and completing the step again after going back simply appends a new log item. A matching on-screen **Previous** button (`bi-skip-start-fill`, `data-testid="execute-previous-btn"`, disabled on the first step) sits next to **Done**, calling `goBack()` directly — so the feature doesn't require a headset to use or test.

**Testing:** `navigator.mediaSession` is replaced by a mock in `page.add_init_script` that records `setActionHandler` registrations and `metadata`/`playbackState` assignments, then invokes the captured handlers directly to simulate a headset button press. A dedicated red-path test deletes `Navigator.prototype.mediaSession` to confirm execution still works with the API entirely absent.

**Key files:** `src/composables/useMediaSession.js`, `src/composables/useSessionExecution.js` (`goBack()`), `src/views/SessionExecuteView.vue`

#### Voice Announcements

`useSpeech.js` wraps `window.speechSynthesis` / `SpeechSynthesisUtterance` (native, no dependency, works offline):

- `isSupported` — `'speechSynthesis' in window`; `speak()`/`cancel()` no-op when `false`.
- `init()` — reads the `voice_enabled` settings key once (default enabled) and caches it for the rest of the execution.
- `speak(text, { volume })` — calls `speechSynthesis.cancel()` first (a new announcement always preempts a stale one), then builds a `SpeechSynthesisUtterance` with `utterance.lang` mapped from the current i18next language (`en` → `en-US`, `fr` → `fr-FR`, `da` → `da-DK`) and `utterance.volume` set from the same `audio_volume` setting used for the Web Audio beeps.
- `cancel()` — stops any in-progress or queued utterance.

`useSessionExecution.js` calls `speak()` at two moments:

| Moment                              | Spoken text                    | Hook                                                                              |
| ----------------------------------- | ------------------------------ | --------------------------------------------------------------------------------- |
| A step becomes current              | The exercise title             | `loadStepState()` — every step, including the first                               |
| EMOM: 10 s left in the current slot | `"Next: {title}"` (translated) | `_emomSounds(remaining)`, alongside the existing beep thresholds                  |
| TABATA: rest phase opens            | `"Next: {title}"`              | `_startRestPhase(step)`, right where the on-screen "Next:" label is already shown |

AMRAP and NONE combos only get the step-change announcement — matching the on-screen UI, which likewise shows no "next" preview until the step actually advances for those types.

To avoid the same exercise being announced twice within seconds (once as a pre-announcement, once when it becomes current), `useSessionExecution` tracks the queue index that was just pre-announced in a closure variable (`preAnnouncedIndex`); `loadStepState()` skips the step-change announcement exactly once when `currentIndex` reaches that index, then clears it.

`cancel()` is called on `exit()` and `_finish()` so nothing keeps talking after the session ends or the user leaves the view. The **Voice announcements** checkbox in Settings → Sound persists `voice_enabled` the same way `audio_volume` is persisted.

**Testing:** `speechSynthesis` is replaced by a mock in `page.add_init_script`, recording each `speak()` call's `{ text, lang, volume }`. Firefox exposes `window.speechSynthesis` as a getter-only property, so the mock must install it via `Object.defineProperty(window, 'speechSynthesis', { configurable: true, value: … })` — a plain assignment silently no-ops and the real API keeps running underneath. `page.clock` drives the EMOM/TABATA timer-based pre-announcement tests deterministically, as in §"Audio Cues" above. A red-path test deletes `Window.prototype.speechSynthesis` to confirm execution still works with the API entirely absent.

**Key files:** `src/composables/useSpeech.js`, `src/composables/useSessionExecution.js`, `src/views/SettingsView.vue`

---

### 3.8 Statistics

**View:** `StatsView` — `#/stats`

**Composable:** `useStats`

**Libraries:** Chart.js 4 + vue-chartjs 5, registered locally in `StatsView.vue` (not globally, to avoid side effects on other routes).

#### Date range filter

Four tabs control which period is shown: **7d**, **30d** (default), **3m**, **All**.

#### Summary cards

| Card           | Query                                                                                                                              |
| -------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
| Sessions       | Count of `execution_log` rows in the period                                                                                        |
| Exercises done | Count of `execution_log_item` (completed) rows in the period                                                                       |
| Day streak     | Consecutive calendar days (ending today) with at least one **completed** log (aborted runs don't count); always across all history |

Streak uses `setUTCHours(0,0,0,0)` for date comparison to match SQLite's `DATE(started_at)` UTC extraction.

#### Charts

| Chart       | Type | Metric                                                                       |
| ----------- | ---- | ---------------------------------------------------------------------------- |
| Frequency   | Bar  | Sessions per week or per month (toggle)                                      |
| Volume      | Bar  | `SUM(reps_done × weight_used)` per session execution                         |
| Progression | Line | Reps (left axis) + max weight (right axis) over time for a selected exercise |

Volume only counts weighted exercises (weight > 0). The Progression chart only lists exercises with at least one execution log entry.

#### Database creation date

Displayed at the top as "You are using TrainUs since [date]", read from `settings.db_created_at`. Formatted via `dateFormatter.js` and reactive to language changes.

**Key files:** `src/views/StatsView.vue`, `src/composables/useStats.js`, `src/utils/dateFormatter.js`

---

### 3.9 Import / Export (JSON)

**Composables:** `useJsonExport`, `useJsonImport`

**Components:** `ImportDialog`, `ExportFilterDialog`, `ModalDialog`

**Utilities:** `src/utils/jsonEnvelope.js`

#### JSON envelope format

```json
{
  "metadata": {
    "appName": "trainus",
    "appVersion": "1.0.0",
    "schemaVersion": 1,
    "exportDate": "2026-03-30T12:00:00.000Z",
    "exportedBy": { "name": "User Name", "uuid": "uuid-string" }
  },
  "data": {
    "exercises": [...],
    "combos": [...],
    "sessions": [...],
    "collections": [...],
    "executionLogs": [...],
    "settings": [...]
  }
}
```

Only the keys present in `data` are exported/imported. Single-entity exports (e.g., export one exercise) include only that entity's array.

#### Export entry points

| Entry point                                         | Scope                                                                     |
| --------------------------------------------------- | ------------------------------------------------------------------------- |
| Exercise / Combo / Session / Collection list view   | All items of that entity type, or only the active search filter's matches |
| Exercise / Combo / Session / Collection detail view | Single item                                                               |
| Settings → Export Data                              | Any combination of entity types (filter dialog)                           |

The filter dialog shows checkboxes per entity type. The full export from Settings produces `trainus-full-YYYY-MM-DD.json`.

List-view exports pass the view's trimmed search query (if any) into `exportAllExercises/Combos/Sessions/Collections(query)` in `useJsonExport.js`, which use `search()`/`searchWithExercises()`/`searchWithItems()`/`searchWithSessions()` instead of `getAll()`/`getAllWithX()` when a query is present.

Combo/session/collection exports (single, list, or filter-dialog) are never just the requested entity's own table row — `collectComboDependencies`/`collectSessionDependencies`/`collectCollectionDependencies` in `useJsonExport.js` walk the nested FK references (a session's items, a combo's exercises, a collection's sessions) to fetch the full dependent entities and add them as top-level `exercises`/`combos`/`sessions` arrays in the envelope. `exportByFilter` cascades this collections → sessions → combos → exercises so a partial checkbox selection still produces a file that imports standalone, without dangling references on a device that doesn't already have those dependencies.

#### Import entry points

| Entry point                                       | Notes                            |
| ------------------------------------------------- | -------------------------------- |
| Exercise / Combo / Session / Collection list view | Per-entity import via action bar |
| Settings → Import Data                            | Multi-entity envelope supported  |

All import entry points use the same generalized `analyzeAll` / `applyAllImport` flow, including single-entity exercise files (a separate legacy flow existed historically but turned out to produce an identical analysis shape, so it was removed).

#### Collision strategy

| Scenario                                                        | Behavior                                                                                                                                 |
| --------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
| Same user (`created_by` = local `user_uuid`), no ID/title match | Insert with original ID                                                                                                                  |
| Same user, same ID exists                                       | Prompt: **Replace** / **Skip** (with batch **Replace All** / **Skip All**)                                                               |
| Same user, different ID but same title as an existing row       | Same Replace/Skip prompt, resolved against the existing row's ID; Skip redirects same-batch references to it                             |
| Different user, UUID unknown                                    | Prompt for suffix → register in `suffix` table                                                                                           |
| Different user, same ID + same `created_by`                     | Update in place (no new ID)                                                                                                              |
| Different user, same ID + different `created_by`                | Generate new UUID                                                                                                                        |
| Different user, suffixed title collides with an existing row    | Same Replace/Skip prompt (no separate rename step) — almost always a re-import of a previously-imported item from the same external user |

**Title format for external imports:** `"Original Title [SUFFIX]"`.

#### Cross-entity reference remapping

When an entity's ID changes during import (new UUID generated), all referencing entities are updated:

- `combo_exercise.exercise_id`
- `session_item.item_id` (for exercises and combos)
- `collection_session.session_id`
- `execution_log.session_id`
- `execution_log_item.exercise_id`

#### Settings import restrictions

Settings are imported with exclusions to preserve local identity:

| Key                                                      | Imported?             |
| -------------------------------------------------------- | --------------------- |
| `user_uuid`                                              | No — always preserved |
| `app_version`                                            | No — always preserved |
| `db_created_at`                                          | No — always preserved |
| `last_backup_at`                                         | No — always preserved |
| `user_name`, `language`, `theme`, `backup_reminder_days` | Yes                   |

#### Execution log import

Execution logs are only imported if the referenced session exists locally or was imported in the same batch.

#### Import dialog flow

```mermaid
stateDiagram-v2
    [*] --> Loading: File selected
    Loading --> SuffixPrompt: External user detected
    Loading --> Preview: Same user
    SuffixPrompt --> Preview: Suffix entered
    Preview --> Collisions: Conflicts found
    Preview --> Applying: No conflicts
    Collisions --> Applying: All resolved
    Applying --> Report: Done
    Report --> [*]: Dismissed
```

#### Suffix management

The Settings page lists all registered suffixes (from the `suffix` table). Renaming a suffix cascades to all entity title/label columns via SQL `REPLACE()`:

```sql
UPDATE exercise SET title = REPLACE(title, '[OLD]', '[NEW]') WHERE title LIKE '%[OLD]%'
```

Applied across `exercise`, `combo`, `session`, and `collection` tables.

**Key files:** `src/composables/useJsonExport.js`, `src/composables/useJsonImport.js`, `src/utils/jsonEnvelope.js`, `src/components/ImportDialog.vue`, `src/components/ExportFilterDialog.vue`, `src/db/repositories/suffixRepository.js`

---

### 3.10 Backup

**Composable:** `useBackupStatus` (module-level singleton refs)

**Component:** `BackupReminder` (teleported into `#actionbar-actions`)

**Utility:** `src/utils/backupFilename.js`

#### Filename format

```
trainus-{version}-backup-YYYYMMDD-HHhMMmSSs.sqlite3
```

Local time, zero-padded. Letters `h`, `m`, `s` replace colons for Windows filesystem compatibility.

#### Settings keys

| Key                    | Default             | Written by                        |
| ---------------------- | ------------------- | --------------------------------- |
| `db_created_at`        | Set on first launch | `database._initDefaults()`        |
| `last_backup_at`       | Absent              | Download success, Restore success |
| `backup_reminder_days` | `"7"`               | Settings number input             |

#### Stale detection

```
effectiveDate = last_backup_at OR db_created_at (fallback)
isStale = (effectiveDate absent) OR (now_utc − effectiveDate) > backup_reminder_days × 86400s
```

#### Reminder UI

`BackupReminder.vue` is mounted once in `App.vue` (inside the DB-ready branch) and teleports into `#actionbar-actions`. It shows a warning-styled button with `bi-exclamation-triangle-fill`. Clicking navigates to `#/settings` and scrolls to the `#settings-backup-section` anchor. The reminder is not dismissible.

`useBackupStatus` uses module-level refs so the same state is shared across all component instances (singleton pattern).

#### Date formatting

`src/utils/dateFormatter.js` provides two locale-aware pure functions used throughout the app. Both delegate to `Intl.DateTimeFormat` with a fixed app-language → BCP-47 map (`en` → `en-US`, `fr` → `fr-FR`, `da` → `da-DK`, same mapping as `useSpeech.js`) and 2-digit day/month, so output is deterministic per app language regardless of the OS locale:

| Function                      | English                 | French            | Danish            |
| ----------------------------- | ----------------------- | ----------------- | ----------------- |
| `formatDate(date, lang?)`     | `MM/DD/YYYY`            | `DD/MM/YYYY`      | `DD.MM.YYYY`      |
| `formatDateTime(date, lang?)` | date + `hh:mm:ss AM/PM` | date + `HH:MM:SS` | date + `HH.MM.SS` |

The optional `lang` parameter allows Vue computed properties to pass the reactive language ref, ensuring date displays update immediately on language switch.

**Key files:** `src/composables/useBackupStatus.js`, `src/components/BackupReminder.vue`, `src/utils/backupFilename.js`, `src/utils/dateFormatter.js`

---

### 3.11 PWA

**Plugin:** `vite-plugin-pwa` + Workbox

#### Service worker

| Config                          | Value    | Rationale                                 |
| ------------------------------- | -------- | ----------------------------------------- |
| `registerType`                  | `prompt` | New SW waits for user consent — see §3.14 |
| `maximumFileSizeToCacheInBytes` | 10 MB    | Covers `sqlite3.wasm` (~8 MB)             |
| `manifest`                      | `false`  | Hand-authored `public/manifest.json` kept |

Workbox handles content-hash-based precaching of the entire app shell at install time, enabling full offline support. The WASM binary is included in the precache manifest via the 10 MB limit.

#### Persistent storage

At startup, `main.js` calls `navigator.storage.persist()` once, fire-and-forget. Without it, 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 periods of inactivity or under disk pressure. A granted request exempts the origin from automatic eviction. Chromium decides silently based on engagement heuristics; Firefox may show a permission prompt; a denial (or a browser without the API) never blocks startup — the result is only logged.

#### App manifest

`public/manifest.json` declares `name`, `short_name`, `display: standalone`, `theme_color`, `background_color`, and two icons (192×192 and 512×512) with both `maskable` and `any` purposes.

#### Install prompt

`useInstallPrompt.js` captures the `beforeinstallprompt` event at module load time (singleton ref `canInstall`). `InstallPrompt.vue` teleports a download icon button into the action bar when `canInstall` is true. The prompt is session-dismissible (no persistence needed).

#### Accessibility

ARIA additions applied across the app:

- `aria-label` on all icon-only action bar buttons across all views
- `role="progressbar"` + `aria-valuenow/min/max` on the 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`

**Key files:** `vite.config.js`, `public/manifest.json`, `src/composables/useInstallPrompt.js`, `src/components/InstallPrompt.vue`

---

### 3.12 Save Filename Prompt

Every file saved to disk (JSON exports, the home HTML export, the SQLite backup — including the forced pre-migration backup) prompts for a filename instead of downloading silently under an auto-generated name.

**Composable:** `useSaveFilePrompt` (module-level singleton, same pattern as `useBackupStatus`). `promptFilename(defaultFilename)` splits the default name into stem + extension at the last dot, stores it in the singleton `pendingSave` ref, and returns a `Promise<string|null>` that resolves once the dialog is dismissed (`null` if cancelled).

**Component:** `SaveFileDialog.vue`, mounted once at the root of `App.vue` so it is available regardless of which view (or the blocking migration screen) triggers a save. Renders an editable stem input (prefilled, text selected) next to a fixed, read-only extension. Enter confirms; an empty stem falls back to the default.

The three low-level download helpers (`downloadJson` in `jsonEnvelope.js`, `downloadBackup` in `useBackupDownload.js`, the local `downloadHtml` in `useHtmlExport.js`) call `promptFilename()` before building the blob/anchor, and no-op if the user cancels — no other call site changed. For backups specifically, `markBackedUp()` (and, on the migration screen, enabling the Update button) only runs if the save wasn't cancelled.

**Key files:** `src/composables/useSaveFilePrompt.js`, `src/components/SaveFileDialog.vue`, `src/utils/jsonEnvelope.js`, `src/composables/useBackupDownload.js`, `src/composables/useHtmlExport.js`, `src/App.vue`

---

### 3.13 First-Load Warning

The `opfs-sahpool` VFS used by the DB worker (`src/db/worker.js`, see §2) only supports one active connection per origin, and OPFS may be unavailable entirely in private/incognito browsing. Both conditions used to only surface reactively, as the generic `error.browserNotCompatible` message once a second tab or a private window failed to open the database (see `App.vue`'s `dbErrorBody`). This feature adds a proactive, one-time warning instead.

**Settings key:** `first_load_warning_seen` — absent until dismissed, then `"true"`, never reset.

**Component:** `FirstLoadWarning.vue`, mounted once in `App.vue`'s DB-ready branch, modeled on `BackupReminder`'s conditional-modal pattern. On mount it reads the settings flag; if absent, it shows a `ModalDialog` with both warnings (single tab only, no private browsing) and a single dismiss button (`$t('firstLoadWarning.dismiss')`) that persists the flag and closes. Unlike `BackupReminder`, it has no snooze/session-dismiss state — once dismissed it is gone for good.

**Test infrastructure note:** since every Playwright test runs in a fresh browser context (a fresh OPFS database), the modal's "first launch" condition is true for every single test, not just a real user's actual first run. `tests/conftest.py`'s `page` fixture injects a `MutationObserver`-based init script that auto-clicks the dismiss button as soon as it appears, for every test except the ones in `tests/test_step23_first_load_warning.py` that exercise the modal directly (opted out via the `keep_first_load_warning` pytest marker).

**Key files:** `src/components/FirstLoadWarning.vue`, `src/App.vue`, `tests/conftest.py`

---

### 3.14 App Update Prompt

The service worker was originally registered with `registerType: 'autoUpdate'`: when a new version was deployed, the new SW downloaded the full precache (including the ~8 MB `sqlite3.wasm`), took control, and reloaded the page — the user experienced an unexplained freeze. (The migration screen from §2 only covers database schema changes, not app-code updates.)

`registerType` is now `'prompt'`: the new SW still installs (precaches) in the background, but waits. Activation becomes a user decision, surfaced through a modal.

**Composable:** `useAppUpdate.js` — a module-level singleton wrapping `useRegisterSW` from `virtual:pwa-register/vue` (importing the virtual module also replaces the plugin's auto-injected registration script). It exposes:

- `needRefresh` — ref from `useRegisterSW`, true when a new SW is installed and waiting
- `applyUpdate()` — sets `updating`, then calls `updateServiceWorker(true)`; the page reloads when the new SW takes control, so `updating` is never reset
- `dismiss()` — session-only dismissal (plain ref, same pattern as `InstallPrompt`); the modal reappears on the next app load if the update is still pending
- `checkForUpdate()` / `checkingForUpdate` — see "Manual check" below

**Component:** `AppUpdateDialog.vue`, mounted once in `App.vue`'s DB-ready branch. Two states:

1. **Choice** — title, short body, **Update now** / **Later** buttons.
2. **Updating** — spinner + "Updating…" text, not dismissible: the `ModalDialog` gets no title (which removes its header close button) and the `close` event is ignored while `updating` is true (swallows Escape and backdrop clicks).

The modal is suppressed while the current route is the execution view (`route.name === 'session-execute'`) so an update prompt never interrupts a workout; it shows automatically once the user navigates away.

**Testing:** simulating a real SW update in Playwright would require two production builds, so `useAppUpdate.js` exposes a dev-only hook (`window.__appUpdateTest`, guarded by `import.meta.env.DEV`) that lets tests force `needRefresh` and mock the update call. The real update flow (deploy old build → deploy new build → prompt → update → reload) is validated manually.

**Manual check (Step 27):** the browser only re-fetches the SW script on its own schedule (roughly on navigation/reload), so a PWA left open for a long time can miss an update notification for a while. `useRegisterSW`'s `onRegisteredSW(swScriptUrl, registration)` callback captures the live `registration` into a module-level variable; `checkForUpdate()` calls `registration.update()` to force that re-fetch on demand, setting `checkingForUpdate` around the call. If the script changed, Workbox flips `needRefresh` exactly as it would from a passive check, so `AppUpdateDialog` appears with its usual Update now / Later choice — there is no separate "found via manual check" code path, so a found update is never applied without asking. Settings has a **Check Updates** section with a "Check for updates" button wired to `checkForUpdate()`. Since the Service Worker API has no "no update found" event, `SettingsView.vue` uses a timeout heuristic: if `needRefresh` hasn't flipped ~2s after the check, a transient "You're up to date" message is shown for ~3s. Testing reuses the same dev-only hook, extended with `mockCheckFn` to stand in for `registration.update()`.

**Last-checked date:** the section also shows when the button was last clicked ("Never checked manually" until then), independent of the update outcome. `SettingsView.vue` stores the timestamp under the `last_update_check_at` settings key (ISO-8601 UTC, in-place addition — no schema bump) immediately after `checkForUpdate()` resolves, and formats it with `formatDateTime`. This only reflects manual clicks: the Service Worker API exposes no event for the browser's own background checks, so there is nothing to hook for those.

**Key files:** `vite.config.js`, `src/composables/useAppUpdate.js`, `src/components/AppUpdateDialog.vue`, `src/App.vue`, `src/views/SettingsView.vue`

### 3.15 First-Load Failure Recovery (Retry)

On a very first load nothing is precached yet, so the DB worker downloads `sqlite3.wasm` (~8 MB) over the network. Three compounding problems used to make a transient network failure permanent: `Database.init()` memoized its promise even when rejected, the `db-error` overlay had no action button, and any unrecognized error fell through to the "browser not compatible" message — telling a user with a flaky connection that their browser was the problem. There is no timeout to tune: the wasm download is a plain browser `fetch()` with no application-level timeout, so the failure comes from the network stack and the only sound fix is recoverability.

**Error classification:** `worker.js` wraps the `sqlite3InitModule()` call — the only network-touching step of startup — and rethrows failures as `DB_LOAD_FAILED: <original message>`. `App.vue` maps that prefix to a "check your internet connection and try again" message; storage/compat failures keep their existing messages.

**Re-callable init:** `Database.init()` attaches a reset handler to its memoized promise. If init fails while `db.info` is still null (the worker never became usable), the worker is terminated, pending calls are cleared, and `_initPromise` is nulled so the next `init()` call starts from scratch — terminating the dead worker first matters because the OPFS SAH pool VFS (§2) allows only one connection per origin. Failures _after_ the DB opened (e.g. `DB_NEWER_THAN_APP`) deliberately skip the reset: that worker stays alive for export/diagnostics, and a retry could not change the outcome. For the same reason, `_handleError` only dispatches `db-connection-lost` once init has succeeded — startup failures always land on the retryable error overlay, never on the reload-only connection-lost overlay.

**Retry UX:** the startup sequence (DB init, persisted language/theme, `data-db-*` attributes) moved from `main.js` into `bootstrapDatabase()` (`src/db/bootstrap.js`). `main.js` calls it once at startup; the **Try again** button on the error overlay calls it again in place — no page reload, so recovery does not depend on cache behavior across navigations. The button is hidden for `DB_NEWER_THAN_APP` and `DB_MIGRATION_FAILED`, where retrying is pointless. After one successful load the service worker precaches the wasm, so this path only concerns first loads (or cleared site data).

**Testing:** the wasm-download failure is simulated with a Playwright route abort on `**/sqlite3.wasm` in a context with service workers blocked — Chromium-only, because Playwright cannot intercept requests issued inside a dedicated worker on Firefox. A cross-browser test covers the retry machinery instead by wrapping `window.Worker` to break (then fix) the worker URL.

**Key files:** `src/db/database.js`, `src/db/worker.js`, `src/db/bootstrap.js`, `src/main.js`, `src/App.vue`

---

## 4. Routing

All routes use hash mode (`createWebHashHistory`). The root path `/` redirects to `/home`.

| Route                    | View                   | Notes               |
| ------------------------ | ---------------------- | ------------------- |
| `#/home`                 | `HomeView`             | Activity overview   |
| `#/exercises`            | `ExerciseListView`     | List + search       |
| `#/exercises/new`        | `ExerciseFormView`     | Create mode         |
| `#/exercises/:id`        | `ExerciseDetailView`   | Read-only           |
| `#/exercises/:id/edit`   | `ExerciseFormView`     | Edit mode           |
| `#/combos`               | `ComboListView`        | List + search       |
| `#/combos/new`           | `ComboFormView`        | Create mode         |
| `#/combos/:id`           | `ComboDetailView`      | Read-only           |
| `#/combos/:id/edit`      | `ComboFormView`        | Edit mode           |
| `#/sessions`             | `SessionListView`      | List + search       |
| `#/sessions/new`         | `SessionFormView`      | Create mode         |
| `#/sessions/:id`         | `SessionDetailView`    | Read-only           |
| `#/sessions/:id/edit`    | `SessionFormView`      | Edit mode           |
| `#/sessions/:id/execute` | `SessionExecuteView`   | Execution mode      |
| `#/collections`          | `CollectionsView`      | List + search       |
| `#/collections/new`      | `CollectionFormView`   | Create mode         |
| `#/collections/:id`      | `CollectionDetailView` | Read-only           |
| `#/collections/:id/edit` | `CollectionFormView`   | Edit mode           |
| `#/stats`                | `StatsView`            | Statistics + charts |
| `#/settings`             | `SettingsView`         | User settings       |

The `navKeyMap` in `App.vue` maps sub-routes (e.g., `exercise-detail`, `exercise-edit`) to their top-level nav key (`exercises`), so the nav item stays highlighted when the user navigates into detail/edit views.

---

## 5. Internationalization

**Libraries:** `i18next` + `i18next-vue`

**Locale files:** `src/i18n/locales/en.json` (English, default), `src/i18n/locales/fr.json` (French), `src/i18n/locales/da.json` (Danish)

**Initialization:** `src/i18n/index.js` registers all three locales and sets the default language to English. The composable `useTranslation()` from `i18next-vue` is used in all components — no direct i18next calls in templates.

**Startup:** `src/main.js` calls `i18next.changeLanguage(stored_language)` before the first render, so the UI always opens in the user's persisted language.

**Language switching:** The Settings language selector calls `i18next.changeLanguage()` and persists the new value to `settings.language`. All reactive `t()` calls update immediately.

**Date formatting:** `dateFormatter.js` reads `i18next.language` as fallback but accepts an explicit `lang` parameter to enable reactive date display in Vue computed properties.

See [How-to Guides → Add a New Language]({{< relref "how-to-guides.en.md" >}}#add-a-new-language) to add support for an additional language.

---

## 6. Versioning

### App version

Sourced from `package.json`, injected by Vite as `__APP_VERSION__` (a global string constant). Available at build time (SW cache name) and runtime (export metadata, settings table, backup filenames). It is informational only — it plays no role in data location or migration decisions.

### Database file naming

The OPFS database lives at a stable, version-independent location: directory `.trainus`, file `trainus.db`. OPFS is _origin_-scoped, so the data automatically survives app updates; schema changes are handled by the startup migration system (§2 Migration System), not by relocating the data.

> Historical note: before Step 16 the directory was named `.trainus-{appVersion}`, orphaning the data on every release. Old versioned directories from dev machines are simply abandoned (clear them via browser devtools if needed).

### Schema version

`SCHEMA_VERSION` in `src/db/schema.js` is an integer stored in the `schema_version` table. It drives both the startup migration check and the Restore Database compatibility check.

**Rule:** `SCHEMA_VERSION` is only incremented when a schema change is deployed to users. In-place changes before first release (adding columns directly in `SCHEMA_SQL`) do not require a bump.

### Release registry

`src/db/releases.js` exports a `RELEASES` array — the single source of truth for schema history. Each entry declares `{ schemaVersion, appVersion, dbBreak, migrationScript }`, where `migrationScript` is the SQL bringing a live DB from `schemaVersion - 1` to `schemaVersion` (or `null` for a no-op migration when `dbBreak` is false). Every `SCHEMA_VERSION` bump must ship exactly one `RELEASES` entry.

Two consumers share this registry:

- `getMigrationScripts(from, to)` — ordered scripts for the startup migration.
- `checkCompatibility(imported, current)` — Restore Database flow.

---

## 7. Build Commands

```bash
npm install
npm run dev             # Vite dev server at http://localhost:5173
npm run build            # Production build to dist/
npm run preview          # Preview production build locally
npm run format           # Format all files with Prettier
npm run format:check     # CI check (non-zero exit on diff)
```

COOP/COEP headers are injected automatically by `vite.config.js` for the dev server. See [How-to Guides → Deploy TrainUs]({{< relref "how-to-guides.en.md" >}}#deploy-trainus) for production deployment.

See `tests/README.md` for the full test setup and run instructions.

---

## 8. Annexes

### Annex A — Content Width Cap on List/Detail Views

On wide desktop viewports (e.g., 1920×1080), content pages do not span the full available width. Cards stop at ~800px.

| View                                                  | Cap    |
| ----------------------------------------------------- | ------ |
| List views (exercises, combos, sessions, collections) | 800 px |
| Detail views                                          | 800 px |
| Form views                                            | 600 px |
| Settings view                                         | 600 px |

The cap is implemented per-view (not globally) so each screen can opt into a different readable width.

#### To add multi-column layout on wide screens

1. Raise the cap on list views to `1200px` (or remove it) and replace the vertical stack with a CSS grid using `grid-template-columns: repeat(auto-fill, minmax(320px, 1fr))`.
2. Keep detail and settings views at their current caps.
3. Wrap only the cards in an inner `<div class="grid-wrapper">` to avoid restyling the action bar and empty-state messages.
4. Apply the same pattern to all list views for consistency.
