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