trainUs/website/content/developers/technical.en.md
David 1424029eae
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
Step 26 to announce exercise
2026-07-06 15:33:33 +02:00

73 KiB
Raw Blame History

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

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 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).

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.

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'

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

{
  ;(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:

  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

Headset / Media-Key Control

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

useMediaSession() returns:

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

SessionExecuteView.vue wires it in:

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

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

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

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

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

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

Voice Announcements

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

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

useSessionExecution.js calls speak() at two moments:

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

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

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

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

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

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


3.8 Statistics

View: StatsView#/stats

Composable: useStats

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

Date range filter

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

Summary cards

Card Query
Sessions Count of execution_log rows in the period
Exercises done Count of execution_log_item (completed) rows in the period
Day streak Consecutive calendar days (ending today) with at least one 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

{
  "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

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:

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

App manifest

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

Install prompt

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

Accessibility

ARIA additions applied across the app:

  • aria-label on all icon-only action bar buttons across all views
  • role="progressbar" + aria-valuenow/min/max on the execution progress bar
  • role="alert" + aria-describedby on form error messages
  • required / aria-required on required inputs
  • role="dialog" + aria-modal + aria-labelledby + focus-on-mount + Escape key on ModalDialog

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


3.12 Save Filename Prompt

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

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

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

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

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


3.13 First-Load Warning

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

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

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

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

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


3.14 App Update Prompt

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

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

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

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

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

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

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

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

Key files: vite.config.js, src/composables/useAppUpdate.js, src/components/AppUpdateDialog.vue, 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

  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.