trainUs/website/content/developers/technical.en.md
David 352a3d5e88
Some checks are pending
CI / Lint & Format (push) Waiting to run
CI / Build (push) Waiting to run
CI / Tests (${{ matrix.browser }}) (chromium) (push) Waiting to run
CI / Tests (${{ matrix.browser }}) (firefox) (push) Waiting to run
Initial commit
2026-06-18 13:06:57 +02:00

1123 lines
62 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

---
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
```
### 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 (0100%) + 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 (1365, 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` (0100) and converted to a 0.01.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`
---
### 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 log; 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:
| Function | English | French |
| ----------------------------- | ------------------ | ---------------------- |
| `formatDate(date, lang?)` | `MM/DD/YYYY` | `DD/MM/YYYY` |
| `formatDateTime(date, lang?)` | `toLocaleString()` | `DD/MM/YYYY, 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` | `autoUpdate` | Silently activates new SW on next navigation |
| `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.
#### 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`
---
## 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.