79 KiB
| title | description | weight | date |
|---|---|---|---|
| Reference | Architecture, database schema, features, routing, i18n, versioning, and build commands — the full technical reference. | 3 | 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 (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
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 titlesexecution_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)settingskeys: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_versionrows): runSCHEMA_SQLand stampSCHEMA_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 sendsrunMigrationsto 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).
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.
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:
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, by Delapouite |
| Bootstrap Icons | MIT | icons.getbootstrap.com |
| Third-party libraries | — | Vue, Vue Router, Vite, SQLite WASM, Chart.js, vue-chartjs, i18next, i18next-vue, Prettier, vite-plugin-pwa, marked, DOMPurify |
| Testing Tools | — | Playwright, pytest |
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.
@errorhandler hides broken images. - YouTube: Video IDs extracted from
youtube.com/watch?v=andyoutu.be/formats, embedded viayoutube-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
{
;(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:
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:
- Fetches
/icon.svgand encodes it as a base64data:URI (TextEncoder+btoa). - Builds a fully self-contained HTML document from the current reactive state — stats cards, recent sessions, and history filtered by the active date range.
- Strips play buttons, navigation links, and date pickers from the output (read-only export).
- 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 × Msteps, wheretotalRoundsdepends 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:
- Looks up the completed steps for the current final round (actual reps/weight used)
- Inserts a new batch of steps at
currentIndex + 1in the queue - Marks
isFinalComboStep = trueon the last new step - Advances
currentIndexto 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:
- Expands an inline detail panel (description rendered as Markdown, images, video links).
- Pauses the active timer — but only if it was running at click time (
detailPausedTimerflag 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)— firesplayBipat r = 30, 2, 1 (EMOM only)._amrapLastMinuteSounds(remaining)— firesplayBipat r = 30, 2, 1 (last minute of AMRAP)._tabataCountdown(remaining, totalTime)— firesplayBipatfloor(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 whenfalse.start({ onPlayPause, onNext, onPrevious, onStop })— creates (once) and plays the silent audio element, registersnavigator.mediaSessionaction handlers forplay,pause,nexttrack,previoustrack,stop.updateMetadata(step, sessionTitle)— setsnavigator.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)— setsnavigator.mediaSession.playbackState.stop()— unregisters all action handlers, pauses and rewinds the silent audio.
SessionExecuteView.vue wires it in:
start()is called inonMounted, right afterinit()succeeds.- A
watch(currentStep, …)— the same watcher that resets the exercise-detail panel — callsupdateMetadata(step, sessionTitle), so metadata refreshes on every step change, including the very first step. stop()is called inonUnmountedand wheneversessionDonebecomestrue.
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 whenfalse.init()— reads thevoice_enabledsettings key once (default enabled) and caches it for the rest of the execution.speak(text, { volume })— callsspeechSynthesis.cancel()first (a new announcement always preempts a stale one), then builds aSpeechSynthesisUtterancewithutterance.langmapped from the current i18next language (en→en-US,fr→fr-FR,da→da-DK) andutterance.volumeset from the sameaudio_volumesetting 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
{
"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_idsession_item.item_id(for exercises and combos)collection_session.session_idexecution_log.session_idexecution_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
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():
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-labelon all icon-only action bar buttons across all viewsrole="progressbar"+aria-valuenow/min/maxon the execution progress barrole="alert"+aria-describedbyon form error messagesrequired/aria-requiredon required inputsrole="dialog"+aria-modal+aria-labelledby+ focus-on-mount + Escape key onModalDialog
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 fromuseRegisterSW, true when a new SW is installed and waitingapplyUpdate()— setsupdating, then callsupdateServiceWorker(true); the page reloads when the new SW takes control, soupdatingis never resetdismiss()— session-only dismissal (plain ref, same pattern asInstallPrompt); the modal reappears on the next app load if the update is still pendingcheckForUpdate()/checkingForUpdate— see "Manual check" below
Component: AppUpdateDialog.vue, mounted once in App.vue's DB-ready branch. Two states:
- Choice — title, short body, Update now / Later buttons.
- Updating — spinner + "Updating…" text, not dismissible: the
ModalDialoggets no title (which removes its header close button) and thecloseevent is ignored whileupdatingis 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
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
- Raise the cap on list views to
1200px(or remove it) and replace the vertical stack with a CSS grid usinggrid-template-columns: repeat(auto-fill, minmax(320px, 1fr)). - Keep detail and settings views at their current caps.
- Wrap only the cards in an inner
<div class="grid-wrapper">to avoid restyling the action bar and empty-state messages. - Apply the same pattern to all list views for consistency.