This page describes every part of Kaivalya Trainer in detail: tech stack, schema, routes, composables. For a guided first run, see Getting Started; for concrete tasks, see How-to Guides; for the “why” behind the choices, see Understanding the Architecture.

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

Kaivalya Trainer 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 for why it’s built this way.

Tech Stack

LayerTechnology
UI FrameworkVue 3 (Composition API)
Build ToolVite
RoutingVue Router (hash mode)
i18ni18next + i18next-vue
DatabaseSQLite WASM + OPFS
ChartsChart.js 4 + vue-chartjs 5
MapsLeaflet 1.9 (OSM / IGN)
IconsBootstrap Icons
Markdownmarked + DOMPurify
PWAvite-plugin-pwa + Workbox
FormattingPrettier
TestingPlaywright (Python)
LicenseGNU AGPL v3

Project Structure

kaivalya-trainer/
β”œβ”€β”€ 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):

AreaFilesCode linesMain languages
src/ (application)8113,973Vue SFC, JavaScript, CSS
tests/ (Playwright)377,371Python
website/ (docs site)494,732Markdown, CSS, HTML
docs/41,500Markdown

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 Kaivalya Trainer for server configuration snippets, and Understanding the Architecture for why the VFS used here doesn’t strictly require them.

Responsive Design

BreakpointLayout
> 700 pxFixed sidebar navigation (labeled links)
≀ 700 pxBottom navigation bar (icons + labels)
≀ 400 pxIcon-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 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 2.

TablePurpose
schema_versionMigration tracking (version + applied_at)
settingsKey-value configuration (user_uuid, name, theme, …)
exerciseExercise definitions (title UNIQUE); category (strength/cardio) + target
comboCombo definitions (title UNIQUE)
combo_exerciseCombo ↔ exercise junction (position, reps, weight, cardio duration)
sessionSession definitions (title UNIQUE); free-style flag
session_itemSession ↔ item junction (type, position, reps, weight, cardio overrides)
collectionCollections (label UNIQUE)
collection_sessionCollection ↔ session junction (position)
execution_logExecution records (started_at, finished_at)
execution_log_itemPer-exercise execution results (reps, weight, side, cardio/GPS results…)
suffixExternal 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 category text target_unit integer default_duration_sec real default_distance_m 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 integer duration_sec } SESSION { text id PK text title UK text description boolean free_style text created_by } SESSION_ITEM { text session_id FK text item_id text item_type integer position integer repetitions real weight integer duration_sec real distance_m } 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 integer duration_sec real distance_m real elevation_gain_m real elevation_loss_m text gps_track } 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)
  • exercise.category: 'strength' (default) or 'cardio'; target_unit ('time'/'distance') plus default_duration_sec/default_distance_m (0 = no target) only apply to cardio exercises. session_item.duration_sec/distance_m and combo_exercise.duration_sec are per-use overrides (NULL = inherit the exercise default); combo_exercise.duration_sec only applies to cardio elements in NONE-type combos (time-only, auto-advance).
  • execution_log_item.duration_sec/distance_m/elevation_gain_m/elevation_loss_m/gps_track: cardio results for a step (gps_track is a JSON blob of {points, source, elevationSource}, one row per step); all NULL for strength items.
  • 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).

Migration scripts are applied statement-by-statement by _runMigrationScript() (src/db/worker.js), which is idempotent for ALTER TABLE ... ADD COLUMN: before running such a statement it checks PRAGMA table_info(table) and skips the statement if the column already exists. This makes re-running a migration against a target that is already at (or partway through) the destination schema a no-op instead of a “duplicate column name” failure β€” used by both runMigrations (startup path) and the restore adapt step below (uploaded-backup path).

flowchart TD A[App start: db.init] --> B[Worker: open OPFS pool dir .kaivalya-trainer, open kaivalya-trainer.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 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.

ComposableWraps
useExercisesexerciseRepository
useComboscomboRepository
useSessionssessionRepository
useCollectionscollectionRepository
useExecutionLogsexecutionLogRepository
useSettingssettingsRepository
useStatsMultiple repositories (read-only aggregates)
useSessionExecutionexecutionLogRepository + queue logic
useJsonExportAll repositories + jsonEnvelope.js
useJsonImportAll repositories + suffixRepository
useBackupStatussettingsRepository
useInstallPromptbeforeinstallprompt event (browser API)
useHtmlExportPure 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

SettingControlPersistence key
Language<select> (English / FranΓ§ais / Dansk)settings.language
User NameText input (required, non-empty)settings.user_name
User IDRead-only UUID displaysettings.user_uuid
Theme<select> (Light / Dark)settings.theme
Sound VolumeSlider (0–100%) + Test buttonsettings.audio_volume
Download DBButton β†’ downloads .sqlite3β€”
Restore DBFile picker β†’ validation pipelineβ€”
Initialize DBButton + mandatory checkboxβ€”
Backup reminder intervalNumber input (1–365, default 7)settings.backup_reminder_days
Export DataButton β†’ entity filter dialogβ€”
Import DataButton β†’ file pickerβ€”
Suffix managementList of registered suffixessuffix table
CreditsButton β†’ modal (icon/library attributions)β€”
AboutButton β†’ 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 kaivalya-trainer-{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, map data and libraries.

Libraries, map sources and testing tools are three arrays of { name, url, license } in the component, each rendered by one v-for; only the icons are laid out by hand. Before this the list was a run of hand-written anchors with no licence on any of them, which is how Leaflet β€” BSD-2-Clause, and what draws every map in the app β€” came to be named without being acknowledged.

GroupContent
App icon (Mighty Force)CC BY 3.0 β€” game-icons.net, by Delapouite
Bootstrap IconsMIT β€” icons.getbootstrap.com
Maps & elevation dataOpenStreetMap (ODbL, Β© OpenStreetMap contributors), IGN GΓ©oplateforme (Β© IGN-F/GΓ©oportail), Open Topo Data (SRTM β€” NASA / USGS)
Third-party libraries13 entries, each with the SPDX identifier its own manifest declares
Testing toolsPlaywright (Apache-2.0), pytest (MIT)

Two licences are easy to get wrong from memory and are therefore read off the package rather than recalled: SQLite WASM is Apache-2.0 (public domain is SQLite itself, not the npm wrapper), and DOMPurify is dual MPL-2.0 / Apache-2.0.

The map providers get an attribution string rather than an SPDX tag β€” that is what they ask for, and inventing an identifier for the IGN GΓ©oplateforme terms would be a claim, not a credit. Leaflet’s attributionControl already prints all three on the map itself, but only while a map is on screen; the dialog is where they are readable at any time. The same list is published on the project site at /credits/, linked from the footer.

Descriptive text comes from the credits.* i18n namespace; names, URLs and licence identifiers are not translated.

Component: src/components/CreditsDialog.vue

About Dialog

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

FieldSource
Application Version__APP_VERSION__ (Vite global from package.json)
Database VersionSCHEMA_VERSION (from src/db/schema.js)
Created byStatic string: David Florance
RepositoryPlaceholder link (href="#")
WebsitePlaceholder link (href="#")
View LicenseDisabled 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

FieldValidation
TitleRequired, UNIQUE (DB-level)
DescriptionOptional; rendered as Markdown in the detail view (marked + DOMPurify)
Categorystrength (default) or cardio β€” toggles the rest of the form
AsymmetricBoolean; reveals Alternate sub-option. Strength only: hidden and forced to false when the category is cardio
AlternateRadio: alternate per rep vs per set
Default Repsβ‰₯ 0 (strength only)
Default Weightβ‰₯ 0 (strength only)
TargetCardio only: radio (time/distance) + one value, β‰₯ 0 (0 = no target)
Image URLsEach must be valid HTTP(S) URL
Video URLsEach must be valid HTTP(S) URL

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

Category and target (cardio)

Every exercise has a category: strength (default, today’s reps Γ— weight behavior, unchanged) or cardio. A cardio exercise has one target_unit β€” time (minutes in the form, stored as default_duration_sec) or distance (km in the form, stored as default_distance_m) β€” with 0 meaning no target. Choosing cardio hides the reps/weight fields and shows the Target section instead; on save, the fields for the unselected category are forced to their zero/default values so switching category never leaves stale data behind. The list and detail views show a bi-heart-pulse badge for cardio exercises, plus the formatted target (src/utils/cardioFormat.js) on the detail view. session_item/combo_exercise can override the target per use (see Β§ 3.4/3.3); this is wired up starting with Step 31b.

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

FieldNotes
TitleRequired, UNIQUE
DescriptionOptional; rendered as Markdown in the detail view
TypeNONE / EMOM / AMRAP / TABATA
Timer configDuration (min) for EMOM/AMRAP; rounds for TABATA; stored as JSON
ExercisesOrdered 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.

Cardio elements

A cardio exercise inside a combo is time-only: it stores combo_exercise.duration_sec and shows no reps/weight inputs. The duration field appears only for NONE combos, where each element runs its own countdown; in EMOM / AMRAP / TABATA the element follows the combo’s own timer and the field is replaced by a hint. Prefilled from the exercise’s default_duration_sec when its target unit is time, minimum 5 s (validateMin(value, 5)).

This is what makes the jeffing pattern a plain NONE combo of cardio elements (e.g. run 60 s / walk 30 s) looped by the session item’s repetitions β€” no dedicated combo type.

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 (or, for cardio exercises, a target).

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

FieldNotes
TitleRequired, UNIQUE
DescriptionOptional; rendered as Markdown in the detail view
ItemsOrdered list; each item has repetitions (β‰₯ 1) and weight (β‰₯ 0)
Free styleCheckbox (session.free_style); targets are ignored during execution

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

Cardio items and inherited targets

A session item pointing at a cardio exercise replaces the reps/weight steppers with a single target input, expressed in the exercise’s own unit (minutes or km β€” the unit itself is not overridable per item).

The override columns session_item.duration_sec / .distance_m stay NULL while the item follows the exercise’s target. SessionFormView prefills the input from the exercise default and writes NULL back whenever the value still equals that default; any other value is stored as an override. So raising an exercise’s target later also raises every item that never overrode it, while explicit per-item values are preserved.

SessionDetailView displays the effective target β€” duration_sec ?? default_duration_sec (resp. distance_m ?? default_distance_m) β€” formatted through src/utils/cardioFormat.js.

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

FieldNotes
LabelRequired, UNIQUE
SessionsOrdered 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.

WidgetData source
Sessions last 7 daysexecutionLogRepository.getCountSince(isoDate)
Sessions last 30 daysexecutionLogRepository.getCountSince(isoDate)
4 most recent sessionsexecutionLogRepository.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.

Past tracks (cardio)

Recent cards and history rows carry a map icon β€” only when the execution has cardio data (logHasCardioData() in src/utils/cardioSummary.js: any item with a gps_track, a distance_m or a duration_sec). It opens TrackMapDialog.vue, which is also reachable from the session-complete screen at the end of a cardio session.

The dialog:

  • draws each logged item in its own color, matched by a swatch in the breakdown list, and fits the map to the whole session (layer chosen from the first track point, as during execution). An item is drawn as one polyline per continuous stretch (splitTrackSegments()), so a pause or a frozen page leaves a gap instead of a straight line across it, and a stretch of a single point is drawn as a marker β€” the execution card starts its map from the very first fix, so a run that only ever got one used to be saved and then declared trackless here;
  • totals distance, time, average speed and D+/Dβˆ’ (totals(); speed is speedKmh() β€” total distance Γ· total time, never a mean of per-rep speeds β€” formatted by formatSpeed() in km/h; Step 40 replaced the former min/km pace everywhere, the user found pace unreadable);
  • lists standalone exercises flat, merging every occurrence of the same exercise in the log into one row (a “run” logged 4 separate times outside a combo becomes one row with the summed distance/time and a computed speed, and an “Γ—4” count), and collapses each combo into one row whose <details> expands to the per-element split (groupCardioItems(), keyed by exercise_id for standalone items, by combo_id for combo rounds). The <details> summary reads “Detailed view” (the combo title, usually identical to the session name, was redundant with the dialog header), with the combo title appended only when the log holds several combos;
  • adds a folded “Aggregated view” <details> (aggregateByExercise()): one row per exercise_id across the whole log, standalone items and combo rounds merged β€” count, distance, time, average speed;
  • skips the map entirely, keeping the totals, when nothing was tracked β€” a treadmill run entered by hand.

parseTrack() swallows malformed JSON deliberately: a corrupt gps_track must never break the screen that displays it.

Both the Home “Recent sessions” cards and the “History” rows also carry a delete button (bi-trash), guarded by the same window.confirm() pattern used for deleting an exercise or a session. It calls executionLogRepository.delete() (cascading to execution_log_item) and reloads the Home view’s data β€” recent sessions, history, and the 7/30-day counts together, via a shared _reloadAll().

Editing cardio values after the fact

The dialog’s pencil turns every cardio item into a Duration (min) + Distance (km) pair, written back with executionLogRepository.updateItemCardio(). The item’s existing elevation gain/loss are passed straight back in: a hand-corrected distance says nothing about the climb, and recomputing it would need a track the execution may not have. Values can be added to an execution that recorded none, and negative input is rejected with the shared validateMin message.

Everything on Home, in the history and in the statistics reads live from the database, so a correction shows up everywhere with no extra wiring; the dialog emits updated so the open Home view re-reads immediately.

GPX export

TrackMapDialog.vue’s “Export GPX” button (shown whenever the log has a track) serializes the recorded track as a GPX 1.1 document via buildGpx() in src/utils/gpx.js β€” plain string building, no library. One <trk> per logged cardio item (named after the exercise), one <trkseg> per continuous stretch (splitTrackSegments(), the same function the map already draws gaps with).

gps_track stores each point’s time as tSec, relative to that item’s own tracking start (see useGeoTracking.js), not as a wall-clock time. execution_log_item.timestamp (the item’s end time) is the only absolute clock stored per item, so the export anchors the last recorded point there and walks earlier points back by their real tSec deltas β€” correct across a pause too, since a pause already shows up as a gap in tSec. A point with no recorded altitude gets no <ele> element rather than a fabricated one.

The file is downloaded through the same useSaveFilePrompt() singleton the HTML export below uses (SaveFileDialog.vue), defaulting to track-YYYY-MM-DD.gpx from the log’s started_at.

This is the read side of GPS tracks β€” exporting what was recorded. GPX import (a route to follow during execution) is a separate, later feature (see docs/plan-step38-guided-route.md).

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). Unlike the screen, it keeps the full {…} split of cardio combos, and appends under each log with cardio data the same per-exercise aggregate as the track dialog (renderCardioAggregateHtml(), reusing aggregateByExercise()) β€” the export is the archival copy of what Home summarizes.
  4. Downloads as kaivalya-trainer-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 typerepetitions meaningtotalRounds formula
NONE/TABATANumber of full roundsrepetitions
EMOMTotal duration (min)floor(repetitions / M) β€” each exercise = 1 min slot
AMRAPTotal 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, plus the cardio fields category, targetUnit, targetDurationSec, targetDistanceM and freeStyle (session-level).

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

Cardio steps (GPS, map, elevation)

A step whose exercise category is cardio renders CardioExecuteCard.vue β€” map, stopwatch or countdown, GPS toggle β€” instead of the reps/weight inputs. The map’s .map-wrapper height is clamp(140px, 26vh, 220px) rather than a fixed 220px, so it scales down on a short phone viewport instead of pushing the Previous/Done buttons below the fold; desktop and tablet still get the full 220px.

Who owns the clock. A cardio step drives its own timer only when it is standalone or inside a NONE combo. In EMOM / AMRAP / TABATA the combo timer keeps driving it exactly as for a strength element, and the card only contributes the stopwatch and the map.

Cardio stepClockEnds on
Time target (standalone or NONE combo)Countdown, EMOM tick sounds + pre-announceAuto-advance at zero
Distance targetCount-up, live distance + progress barFinish (never auto)
No target, or free-style sessionCount-up, no distance shownFinish
Inside EMOM / AMRAP / TABATAThe combo’s timer (unchanged)As per the combo type

Live distance is deliberately shown only for a standalone distance-target step: inside a combo the tracking spans several elements, so an on-screen running total would not answer the question the athlete is asking. Everything is recorded either way and shown after the session (31d).

Audio cues. A cardio step that counts down bips at half time and at 2 s and 1 s, with a BIP at expiry (_cardioSounds, see Β§ 3.7 Audio Cues). A step that does not β€” free style, no target, or a distance target β€” starts no timer at all, and until the 2026-08-31 field fixes that made it silent from end to end, because every cue in the app hangs off a timer tick or expiry. Three cues now come from elsewhere: a BIP when the step is finished by hand (markDone), a BIP on reaching a distance target, and a soft bip per full kilometre, the last two driven by a watcher on the tracked distance rather than a clock. The per-step distance is measured from the step’s own start, so a second cardio step in the same session does not inherit the first one’s kilometres.

Live GPS quality. The card shows the accuracy the receiver is reporting and how stale the last accepted fix is (execution.cardio.gpsQuality / gpsAccuracy). Before the fixes, a phone reporting 40 m accuracy in power-saving mode produced a card that said “Waiting for a GPS fix…” indefinitely with no hint as to why.

Tracking (useGeoTracking.js) wraps navigator.geolocation.watchPosition (enableHighAccuracy, maximumAge: 0, 15 s timeout) and gates the noise: fixes with accuracy > 50 m are dropped, moves smaller than max(3 m, accuracy / 2) are ignored, and a pause forgets the last point so resuming does not draw a straight line across it. Distance accumulates with haversineM. Tracking runs continuously while the queue stays on cardio work or on a combo that contains a cardio element β€” a strength element inside a run/walk combo must not cut the track in two β€” and markSegment() at each step save hands that step exactly the points recorded since the previous one. One gps_track per execution_log_item; the session track is the ordered concatenation of its items.

The accuracy gate started at a flat 30 m with a flat 3 m jitter floor. The 2026-08-31 field test showed that to be self-defeating: with the screen locked, Android drops to a power-saving location mode that commonly reports 30-50 m, so every fix was silently discarded while the card still promised one was coming. Accepting coarse fixes without scaling the jitter floor would simply have turned their jitter into distance, hence the accuracy-proportional floor.

Holes in the stream. More than 30 s between two accepted fixes is not a move, it is a hole β€” a frozen page, or a receiver that lost the sky. The tracker drops its anchor point, so the gap contributes no distance, and splitTrackSegments() (src/utils/geo.js) lets both map components draw each stretch as its own polyline instead of one straight line across the hole. The split is derived from the points’ own tSec timestamps, so it needs no change to the stored format and applies to tracks recorded before it existed.

Whose clock dates a fix. The receiver’s, not ours. Android answers the first callback of every watch with its cached last-known location β€” a wifi/cell estimate that arrives fresh however old it is, which maximumAge: 0 does not prevent. The 2026-09-01 field test joined one to the next real fix and booked 1186 m in three seconds (1423 km/h), ringing the kilometre cue 2.9 s after geoStart; the hole guard never saw it, because in arrival time the two fixes were 3 s apart. _onPosition therefore reads position.timestamp: a fix stamped more than 30 s in the past is refused outright (geoStaleFix), and the hole check and the point’s own tSec are measured from that stamp too, so a burst of buffered fixes delivered after an unfreeze is not read as continuous movement.

A speed ceiling backs that up for platforms that restamp the cached fix with the current time, which slips past the staleness gate: a move implying more than 30 m/s (108 km/h) drops the anchor rather than bridging it, and logs geoTeleport. This is not a model of how fast an athlete runs β€” at 108 km/h it sits far above anything human-powered, and the observed artefacts ran at 1400 km/h β€” so it only ever rejects the receiver. The position itself is still added to the track: whichever of the two fixes is the artefact, the newest one is where the athlete is. One consequence is cosmetic and accepted: the polyline still joins the two points, because a track can only be broken on a time gap and a teleport happens in no time.

Re-arming. Android can leave a watch registered but silent after the page is unfrozen, and codes 2 (POSITION_UNAVAILABLE) / 3 (TIMEOUT) can do the same. clearWatch() + watchPosition() runs on visibilitychange β†’ visible, on a 2 s β†’ 30 s backoff after those two error codes, and β€” since the 2026-09-01 field test β€” on a staleness watchdog: a 30 s interval that re-arms whenever the last fix is older than 45 s, whatever the page’s visibility, throttled to one re-arm a minute. Code 1 (denied) is still final for the run.

That watchdog exists because the other two triggers were not enough. In the 78-minute 2026-09-01 session, 34 of the 40 log items hold no gps_track at all: with the screen off the watch stayed registered and mute for up to 1749 s while the heartbeat, the timers and the keep-alive audio all kept running, and every single recovery came from the athlete looking at the phone. Whether a hidden page can be served on that device at all is no longer open: it cannot, and the reason is an Android permission rule rather than anything in this code β€” see Β§3.18, which documents the rule and the pocket mode built around it. The watchdog stays because it is correct and cheap, and because geoRearm reason=stale in a field log is the marker of exactly that limit.

Map (Leaflet 1.9.4, BSD-2). The tile layer is decided once, from the first fix: IGN Plan IGN v2 in France (isInFrance bbox), OSM elsewhere. Tiles are requested with crossOrigin: 'anonymous' β€” production runs under COEP require-corp, where a tile without CORS is blocked. Before the first fix no tile is requested at all; the card shows a gray placeholder.

GPS off / manual mode. The toggle defaults to on when navigator.geolocation exists and is forced off with a hint when the permission is denied. With GPS off the card shows a distance field in km, and that typed value is what gets saved.

Elevation (src/utils/elevation.js) is resolved after the log is written β€” _resolveElevationsForLog() is fired, not awaited, from _finish()/exit(), because finishing a workout must never wait on the network. The track is downsampled to ~1 point per 30 m, then:

  1. IGN altimetry (data.geopf.fr/altimetrie/…&zonly=true&resource=ign_rge_alti_wld, batches of 150) when the first point is in France;
  2. Open Topo Data SRTM 30 m (batches of 100, throttled to 1 request/s) elsewhere;
  3. on any failure, a moving average (window 5) over the raw GPS altitudes, flagged elevationSource: 'gps'.

The resource parameter is not optional and its absence is not diagnosable from the response: the GΓ©oplateforme gateway answers 405 Unsupported Request β€” not a 400 naming what is missing β€” so for as long as the request went out without it, every elevation in France silently took the GPS fallback. The 2026-09-01 field session logged elevationLookupFailed message="IGN elevation HTTP 405" inFrance=true on all 40 of its steps. ign_rge_alti_wld is the RGE ALTI resource covering metropolitan France, which is the only place this endpoint is reached from. The suite’s stub answers 405 to a request naming no resource, so the guard is on every elevation test rather than one.

Gain and loss accumulate only past a Β±3 m hysteresis: the reference altitude moves only once the profile has deviated by more than 3 m, so DEM sampling noise along a flat road does not invent hundreds of meters of climb. Results go back through executionLogRepository.updateItemElevation(), which also rewrites gps_track with the DEM elevations.

Resilience. Cardio steps use the same keep-alive audio, wake lock and media session as timed steps. The Step 33 snapshot additionally carries the track points, accumulated distance, elapsed time and the GPS toggle, written at most once every 5 s from the stopwatch tick (a fix lands about every second; serializing an hour-long track each time would be pointless churn). A kill mid-run resumes with its kilometers intact.

The row exists before the step ends. A snapshot only helps a kill the user then resumes from. Since the 2026-08-31 field fixes the execution_log_item of a cardio step is opened when the step starts, rewritten by _flushCardioProgress() on that same 5 s throttle (executionLogRepository.updateItemProgress() writes duration_sec, distance_m, gps_track and completed in one statement), and finalised in place when the step is saved. A kill therefore costs at most one flush window instead of the whole run.

Three consequences worth stating explicitly:

  • Rows are written completed = 1 from the first flush. The alternative β€” an in-progress marker filtered out of isCardioItem() and the statistics queries β€” would hide precisely the run this mechanism exists to protect, because a hard kill never comes back to finalise it. An unfinished run is real data.
  • exit() saves before aborting. Pressing X used to discard the running cardio step outright; a 34-minute walk vanished that way in the field test because Finish was never pressed. It now finalises the step when it recorded anything (elapsed time, distance or points) and only then marks the log aborted. A step that recorded nothing has its row deleted, as does a step stepped away from with Previous or cut short by an AMRAP’s expiry.
  • Flushes cannot clobber the final values. A flush is a full rewrite of the row, so one that resolves after the step was finalised would undo it. _flushCardioProgress() keeps at most one write in flight and gives up if the step started being finalised while it was waiting, or if the row it was writing to is no longer the open one.

Offline maps (runtime cache + map packs)

Two independent mechanisms, deliberately not wired together.

1 β€” Runtime tile cache (passive). Two runtimeCaching entries in vite.config.js (kaivalya-trainer-osm-tiles, kaivalya-trainer-ign-tiles), both CacheFirst, 2000 entries, 90 days. They are declared before the generic request.destination === 'image' rule, because Workbox takes the first matching route and tiles would otherwise land in the 300-entry kaivalya-trainer-exercise-images cache and evict the exercise pictures. Nothing else is needed: an area already looked at renders offline.

2 β€” Map packs (explicit pre-download). A pack is { id, name, bounds, minZoom: 10, maxZoom, createdAt, tileCount, sizeBytes }, persisted as JSON in settings.map_packs β€” no schema change. The tile list is never stored: it is re-derived from the bounds by enumerateTiles() whenever it is needed, which is what makes “which tiles does this pack own?” answerable at delete time without having written thousands of URLs to the database.

PieceRole
src/utils/mapPacks.jsPure geometry/sizing: enumerateTiles, estimatePack, formatBytes
useMapPacks.jsCache Storage writes, metadata persistence, progress, delete
MapPackDialog.vueFraming map, zoom select, estimate, progress bar
mapLayers.jscreateTileLayer() β€” the pack-aware Leaflet layer used by every map

Serving packed tiles. generateSW owns the service worker’s routing and knows nothing about packs, so the lookup lives in the tile layer instead: PackAwareTileLayer.createTile() tries caches.match(url) in the kaivalya-trainer-map-packs bucket first and serves the hit as a blob URL (revoked in _removeTile), falling back to a normal network request otherwise. Because createTileLayer() is now the single place a tile layer is built, packs work identically on the live execution map and on past-track playback.

Choosing the area. The dialog’s viewport is the selection β€” pan and zoom to frame it, and what is on screen is what gets downloaded. No rectangle to drag, and the zoom already used to look at the area is the one that gets saved. The tile count and a size estimate (count Γ— ~15 KB) are shown before anything is fetched, and a pack over 500 MB raises a warning.

Packs are IGN-only: OpenStreetMap’s tile usage policy forbids bulk downloading, so an area whose center falls outside isInFrance() is blocked with an explanation rather than silently downloading from OSM.

Concurrency and failure. Tiles are fetched four at a time, abortable mid-download; individual failures are counted rather than fatal (a pack missing a few tiles still renders β€” it just goes to the network there). Deleting a pack removes only the tiles no other pack still needs, since overlapping areas are the normal case.

One instance per screen. The Settings card and the download dialog each call useMapPacks(), so every mutation reads the stored list back from settings before writing it. Mutating packs.value directly would have let the dialog persist a list that never contained the packs the card was showing β€” which is exactly what the first version did.

A pack download also warms the kaivalya-trainer-ign-tiles runtime cache, since the fetch() goes through the service worker like any other request. That bucket is LRU-capped at 2000 entries, so the duplication is bounded and self-correcting.

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:

FunctionFrequencyDurationRole
playBip(volume)440 Hz0.1 sSoft tick β€” countdown, midpoint
playBIP(volume)880 Hz0.3 sLoud 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 four 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.
  • _cardioSounds(remaining, totalTime) β€” fires playBip at floor(totalTime / 2), r = 2, and r = 1, and pre-announces the next step at r = 10 (cardio countdown only).

A cardio countdown used to borrow _emomSounds, and its 30 s cue landed almost on the finish: EMOM’s window is 60 s, where 30 s is half time, but a cardio step runs in whole minutes. _cardioSounds moves that cue to half time β€” “turn round now” β€” and leaves EMOM, AMRAP and TABATA exactly as they were.

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 and notification 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>. That element is the Step 34 keep-alive audio (Β§3.17), started by useSessionExecution.js when execution begins; the user’s β–Ά tap to start the session is the user gesture that satisfies the browser’s autoplay policy. useMediaSession.js originally played a second, private silent loop (a 592-byte data-URI WAV) as its anchor β€” removed in Step 35: it was only 50 ms long, below Gecko’s 3 s controllable-media minimum, so Firefox anchored the Android media notification on the keep-alive element while the metadata was being declared around the ineligible one, leaving the notification with a stale generic label. One page, one anchor element.

useMediaSession() returns:

  • isSupported β€” 'mediaSession' in navigator; every other method is a no-op when false.
  • start({ onPlayPause, onNext, onPrevious, onStop }) β€” registers navigator.mediaSession action handlers for play, pause, nexttrack, previoustrack, stop.
  • updateMetadata({ title, artist, album }) β€” sets navigator.mediaSession.metadata = new MediaMetadata(…), so the lock screen / notification / headset display follows the execution. Since Step 35 the caller passes plain strings, letting the view express phase-aware content (below).
  • setPlaybackState(state) β€” sets navigator.mediaSession.playbackState.
  • stop() β€” unregisters all action handlers, clears the metadata, declares playbackState = 'none'.

SessionExecuteView.vue wires it in:

  • start() is called in onMounted, right after init() succeeds; the declared playback state is set to 'playing' at that point (or from the Resume/Restart actions when a resume prompt was shown first β€” while the prompt is up, nothing is running and the state stays 'none').
  • _syncMediaMetadata(), watched on [currentStep, timerPhase] (Step 35, extended same day after user feedback): artist is “Next: X”, where X is normally the queue’s nextStep exercise β€” except during a TABATA work phase, where rest (not nextStep) is what actually comes next, since rest is a phase of the current queue entry rather than its own entry, so X is the localized REST label there instead. Falls back to the combo title (or session title outside a combo) only when there’s genuinely nothing to announce (last step, non-TABATA). During a TABATA rest phase title is the localized REST label; otherwise title = exercise title. album = session title throughout.
  • A watch(timerPaused, …) declares 'paused'/'playing' whatever caused the change β€” notification tap, on-screen pause button, or the exercise-detail panel auto-pausing (Step 35; previously only the media handler itself set it, so the notification button drifted out of sync with on-screen pauses and showed β–Ά while the session ran).
  • Declaring the state is not enough on its own: per the spec (and Gecko’s MediaStatusManager), the displayed state is declared === 'playing' ? 'playing' : guessed, where guessed comes from the actual media elements β€” a declared 'paused' loses to still-playing audio. So togglePause() in useSessionExecution.js also really pauses/resumes the keep-alive element. Trade-off, accepted deliberately: while paused, background freeze protection is off β€” but no timer is running during a pause, and the snapshot/resume flow covers a kill; Gecko keeps paused media controllable (notification + handlers) for 24h (media.mediacontrol.stopcontrol.timer.ms).
  • Next/previous buttons in the Android media controls: Fenix only advertises ACTION_SKIP_TO_NEXT/_PREVIOUS to Android when its mediaNotificationImprovements feature flag (a Mozilla-side Nimbus rollout) is enabled β€” a web page cannot influence this. Our nexttrack/previoustrack handlers are registered and already work from headset/Bluetooth media keys; the buttons will appear in the notification by themselves once Mozilla enables the flag.
  • 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 actionMaps toNotes
play / pausetogglePause() if a timer is active; the timerPaused watcher declares the resulting stateNo-op if no timer is running (nothing to pause)
nexttrackonDone() on a normal step; onSkipRest() during a TABATA rest (Step 35); onContinue()/onContinueAmrap() on the final-round / AMRAP-time-up screensMirrors whichever button is visible
previoustrackgoBack() (new in useSessionExecution.js)No-op at the first step
stoponExit() directly, no confirmationA headset button can’t answer a JS confirm() dialog

Skippable rest (Step 35): skipRest() in useSessionExecution.js stops the rest timer and runs the exact outcome of a natural rest expiry β€” final-round screen if the step was the combo’s last, otherwise advance to the next step. It’s exposed both as the nexttrack mapping above and as an on-screen Skip rest button (bi-skip-end-fill, data-testid="execute-skip-rest-btn") on the rest card, styled like the Done button β€” so the feature doesn’t require a headset or the notification to use or test.

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

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

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

Voice Announcements

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

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

useSessionExecution.js calls speak() at two moments:

MomentSpoken textHook
A step becomes currentThe exercise titleloadStepState() β€” 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

CardQuery
SessionsCount of execution_log rows in the period
Exercises doneCount of execution_log_item (completed) rows in the period
Day streakConsecutive 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

ChartTypeMetric
FrequencyBarSessions per week or per month (toggle)
VolumeBarSUM(reps_done Γ— weight_used) per session execution
ProgressionLineReps (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.

Strength / cardio switch

Endurance work and strength work answer different questions β€” kilometers and elevation on one side, reps and load on the other β€” so mixing them in one column of charts would leave both halves diluted. A two-button switch sits directly under the summary cards and swaps the whole analysis area between a Strength panel (frequency, volume, progression β€” unchanged) and a Cardio panel. The summary cards and the date-range filter stay above the switch: they describe the training history as a whole and apply to both panels.

The switch is always shown, even with no cardio data at all, so the feature stays discoverable; the cardio panel then renders a single empty-state line instead of a wall of zeros.

Cardio panel

BlockContent
Totals (4 cards)Distance, time, elevation gain (D+), elevation loss (Dβˆ’)
Distance over timeLine chart, one point per day that recorded cardio work
By dayOne row per calendar day (UTC, like every other date grouping)
By sessionOne row per execution, labelled by session title + date
By comboOne row per combo, with the number of executions it appeared in
By exerciseOne row per cardio exercise β€” the per-element splits recorded in 3.7

The four aggregations share one SQL builder (_cardioSource() in executionLogRepository) and one SELECT list (CARDIO_SUMS): they read exactly the same rows β€” items that are completed = 1, whose exercise category = 'cardio', and that recorded a distance_m or a duration_sec β€” and differ only by their GROUP BY and their label join. A hand-entered treadmill run with no GPS track is therefore counted like any other.

The totals are derived from the by-day rows rather than a fifth SUM query: same rows, same filter, so a separate query could only ever disagree with the table printed under it.

The “By combo” section is hidden when no cardio work happened inside a combo β€” unlike the other three, an empty combo table says nothing useful.

Rendering is factored into CardioStatsTable.vue: the four levels differ only by their label column, so StatsView normalizes each result set to { key, label, sublabel, distanceM, durationSec, gainM, lossM, paceSecPerKm } and the component draws the table, including a Pace column (formatPace(), same formula totals() uses β€” durationSec / (distanceM / 1000) β€” an empty cell renders “β€”” rather than NaN/Infinity when either figure is zero). “By exercise” already summed every occurrence of an exercise across a log via its plain GROUP BY e.id β€” several standalone items of the same exercise in one session were already totalled together here β€” so the pace column is what was actually missing to answer “what was my average speed for this exercise”. Step 40 turned that column into Speed (km/h, speedKmh() shared with totals()).

Home cards shorten cardio combos to Title Γ—rounds (isCardioCombo() in HomeView.vue, a per-group check so a mixed strength + cardio session keeps its strength detail); a 23-round interval combo otherwise filled the card.

Cardio progress chart

CardioProgressChart.vue sits at the top of the cardio panel, outside the hasCardioData gate and independent of the page’s date-range buttons, because it takes arbitrary dates or a single execution. State lives in useCardioProgress.js; the component only turns points into Chart.js datasets.

flowchart LR EX[exercise select] --> M{period mode} M -- Dates --> R["getCardioSeriesByExecution(ex, from, to)<br/>one row per execution, summed"] M -- Session --> S["getCardioRepsForExecution(ex, logId)<br/>one row per item"] R --> P[points] S --> P P --> D["one dataset + y-axis per ticked measure<br/>distance km Β· time min Β· speed km/h"]
  • Range bounds are local midnights converted to ISO, upper bound exclusive (to + 1 day), since started_at is stored in UTC.
  • Units differ, so every ticked measure gets its own y-axis (first on the left with grid lines, others on the right). Measures are kept in a fixed CARDIO_MEASURES order whatever the click order; the last ticked checkbox is disabled rather than silently refusing the click (a refused change on an unchanged ref left the DOM checkbox unticked).
  • Category x-axis (short date, or #n per repetition) β€” Chart.js’ time scale would need a date adapter the app does not ship.
  • The chart container exposes data-point-count and data-measures: tests cannot read a canvas.
  • The four queries reuse _cardioSource(), so the chart counts exactly the rows the tables count.

Database creation date

Displayed at the top as “You are using Kaivalya Trainer since [date]”, read from settings.db_created_at. Formatted via dateFormatter.js and reactive to language changes.

Key files: src/views/StatsView.vue, src/components/CardioStatsTable.vue, src/components/CardioProgressChart.vue, src/composables/useCardioProgress.js, src/composables/useStats.js, src/db/repositories/executionLogRepository.js, src/utils/cardioFormat.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": "kaivalya-trainer",
    "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 pointScope
Exercise / Combo / Session / Collection list viewAll items of that entity type, or only the active search filter’s matches
Exercise / Combo / Session / Collection detail viewSingle item
Settings β†’ Export DataAny combination of entity types (filter dialog)

The filter dialog shows checkboxes per entity type. The full export from Settings produces kaivalya-trainer-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 pointNotes
Exercise / Combo / Session / Collection list viewPer-entity import via action bar
Settings β†’ Import DataMulti-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

ScenarioBehavior
Same user (created_by = local user_uuid), no ID/title matchInsert with original ID
Same user, same ID existsPrompt: Replace / Skip (with batch Replace All / Skip All)
Same user, different ID but same title as an existing rowSame Replace/Skip prompt, resolved against the existing row’s ID; Skip redirects same-batch references to it
Different user, UUID unknownPrompt for suffix β†’ register in suffix table
Different user, same ID + same created_byUpdate in place (no new ID)
Different user, same ID + different created_byGenerate new UUID
Different user, suffixed title collides with an existing rowSame 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:

KeyImported?
user_uuidNo β€” always preserved
app_versionNo β€” always preserved
db_created_atNo β€” always preserved
last_backup_atNo β€” always preserved
user_name, language, theme, backup_reminder_daysYes

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

kaivalya-trainer-{version}-backup-YYYYMMDD-HHhMMmSSs.sqlite3

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

Settings keys

KeyDefaultWritten by
db_created_atSet on first launchdatabase._initDefaults()
last_backup_atAbsentDownload 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.

In addition to the persistent button, a blocking modal appears once per page load when the backup is stale (tracked by the module-level sessionReminderShown ref). The modal is suppressed on routes flagged meta: { noInterruptions: true } (currently only session execution): the component returns early without consuming sessionReminderShown, and because App.vue keys it on route.fullPath (remount on every navigation), the modal then shows on the first route visited after the workout β€” deferred, never dropped.

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:

FunctionEnglishFrenchDanish
formatDate(date, lang?)MM/DD/YYYYDD/MM/YYYYDD.MM.YYYY
formatDateTime(date, lang?)date + hh:mm:ss AM/PMdate + HH:MM:SSdate + 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

ConfigValueRationale
registerTypepromptNew SW waits for user consent β€” see Β§3.14
maximumFileSizeToCacheInBytes10 MBCovers sqlite3.wasm (~8 MB)
manifestfalseHand-authored public/manifest.json kept

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

Persistent storage

At startup, main.js calls navigator.storage.persist() once, fire-and-forget. Without it, all site storage is “best-effort”: the browser may evict Cache Storage (the Workbox precache, including the icon font) and the OPFS SQLite database after long periods of inactivity or under disk pressure. A granted request exempts the origin from automatic eviction. Chromium decides silently based on engagement heuristics; Firefox may show a permission prompt; a denial (or a browser without the API) never blocks startup β€” the result is only logged.

App manifest

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

Install prompt

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

Accessibility

ARIA additions applied across the app:

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

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


3.12 Save Filename Prompt

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

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

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

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

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


3.13 First-Load Warning

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

Settings key: first_load_warning_seen β€” absent until dismissed, then "true", never reset.

Component: FirstLoadWarning.vue, mounted once in App.vue’s DB-ready branch, modeled on BackupReminder’s conditional-modal pattern. On mount it reads the settings flag; if absent, it shows a ModalDialog with both warnings (single tab only, no private browsing) and a single dismiss button ($t('firstLoadWarning.dismiss')) that persists the flag and closes. Unlike BackupReminder, it has no snooze/session-dismiss state β€” once dismissed it is gone for good. On a noInterruptions route (session execution) the modal is parked in a pending ref instead of shown; a watch on route.meta.noInterruptions displays it as soon as the user navigates away (this component is mounted once, not remounted per route).

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

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


3.14 App Update Prompt

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

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

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

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

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

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

The modal is suppressed while the current route is flagged meta: { noInterruptions: true } (the execution view) so an update prompt never interrupts a workout; it shows automatically once the user navigates away. The same route flag defers the backup-reminder and first-load-warning modals.

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


3.16 Diagnostic Logging

Training session executions sometimes stopped silently (no more sounds), reloading from step 0 on unlock. The user often locks the phone deliberately and it sometimes keeps working, so screen lock alone isn’t the bug β€” intermittently the locked/backgrounded tab gets frozen then discarded by the OS/browser, and on unlock the tab reloads from scratch, losing all in-memory state. There is no remote access to a user’s phone to debug this, so a downloadable, client-side log is the primary diagnostic tool.

Logger: src/utils/logger.js is a plain ES module singleton (not a composable), so it is callable from repositories, event handlers, and before Vue mounts. Four levels β€” NONE < ERROR < INFO < DEBUG β€” gate calls synchronously via a module variable; the active level is cached from settings.log_level (no schema change) so a disabled logger is a no-op with no OPFS access at all.

Storage & rotation: log files live in an OPFS directory logs/ at the OPFS root, a sibling of the SQLite VFS directory .kaivalya-trainer/ (Β§2.1) β€” not inside it, so a DB export/restore never touches logs and logging still works if the DB layer itself is failing. Lines are buffered in memory and flushed every 2s, on visibilitychangeβ†’hidden, and on pagehide; logger.error() flushes immediately. A file rotates to a new one when its creation date is no longer today, or it exceeds 10 MB β€” whichever comes first β€” and at most 14 files are kept, oldest deleted first. File names carry the rotation timestamp for readability and uniqueness: log-YYYYMMdd-HHmmss.txt. Because names are second-resolution, two rotations within the same wall-clock second (only reachable by stress-testing rotation with a tiny forced max size) would otherwise collide and silently merge into one file; the logger tracks the last rotation’s timestamp and bumps it forward by a second whenever needed to keep names distinct.

What gets logged: logger.info(action, data) defaults data.button to 'Automatic' when omitted, so INFO lines distinguish a user’s button press (button=Done, button=Continue, …) from an automatic transition (timer expiry, backup check, app-lifecycle events). SessionExecuteView.vue logs INFO for every button with the action handler name, current step, and exercise; useSessionExecution.js logs DEBUG on entry to its state-transition functions and INFO on automatic transitions (EMOM/TABATA/AMRAP expiry, _finish); useTimer.js logs a timerCatchUp INFO event when a hiddenβ†’visible recompute jumps the countdown by more than 2 seconds β€” direct evidence of the tab having been frozen. Global window.addEventListener('error'/'unhandledrejection') handlers and visibilitychange/pagehide/pageshow (with the persisted flag β€” false after a tab discard proves the reload) are the main evidence for diagnosing the freeze/discard pattern itself.

Settings UI: a “Diagnostic Logs” card (SettingsView.vue) exposes the level selector, a file-count/size summary, a download dialog (LogDownloadDialog.vue β€” lists files with checkboxes and a select-all), and a clear button. Downloading a single selected file yields it as-is; downloading more than one bundles them into a single kaivalya-trainer-logs-<date>.zip via src/utils/zip.js (a minimal dependency-free STORE-method ZIP writer) rather than firing one <a download> click per file, since browsers throttle or silently block several auto-triggered downloads from one click.

Testing: a DEV-only hook window.__loggerTest = { setMaxSize, setMaxFiles, flushNow } (same pattern as useAppUpdate.js’s test hooks) lets Playwright force rotation deterministically instead of waiting on real timers or writing 10 MB of log data.

Key files: src/utils/logger.js, src/main.js, src/db/bootstrap.js, src/components/LogDownloadDialog.vue, src/utils/zip.js, src/views/SettingsView.vue


3.17 Session Resilience (Wake Lock, Keep-Alive Audio + Resume)

Companion feature to Β§3.16, addressing the same reported symptom. Be honest about what is and is not guaranteed: a web app cannot forbid the OS from discarding a background tab under memory pressure. The wake lock and keep-alive audio remove or mitigate the most common triggers; the resume snapshot is what actually makes a kill recoverable, whatever its cause.

Field-test findings (Step 34, 2026-08-26 log analysis): a real-phone test showed audio firing correctly on schedule for 26+ minutes with the screen locked β€” silence is a symptom of the process being killed, not an audio-suspension issue. Android killed the app repeatedly within 2–4 minutes of a manual lock while an execution was actively running (no pagehide/freeze/beforeunloadPrompt at the end of the previous log β€” the process died with no lifecycle event at all), but the same hidden page survived over an hour once idle. The Step 33 sessionStorage snapshot never survived those kills: sessionStorage is wiped when Android kills an installed-PWA process. Also, the Screen Wake Lock only prevents the screen’s own auto-lock timeout β€” it does not cover this user, who always locks the phone manually with the power button; the sentinel is released the instant the page hides and cannot be re-acquired while locked. wake_lock_enabled is kept as-is (still useful for auto-lock users) but is not part of the Step 34 fix.

Field-test findings (Step 34 revision, second 2026-08-26 log analysis): the first keep-alive implementation (1s, 8-bit, all-zero-samples WAV) failed in the field β€” heartbeats stopped for ~23 minutes with the screen locked despite the keep-alive element “playing”. Two independent causes, one per browser family: Firefox (the user’s browser) only treats media longer than 3s (media.mediacontrol.eligible.media.duration.s in Gecko) as controllable media, and that eligibility is what makes Firefox on Android post the media notification whose foreground service protects the process β€” a 1s loop never qualifies, so Android froze the app as usual. Chrome classifies tab audibility from decoded signal power (silence β‰ˆ below βˆ’72 dBFS), so a silent file earns no background-throttling exemption there either. Worse, 8-bit WAV samples are unsigned (silence = 0x80); the all-zero buffer decoded to a full-scale DC offset played at volume 1 β€” inaudible after hardware DC filtering but able to trip Android speaker-protection limiters, the prime suspect for a reported regression where the Web Audio beeps went silent everywhere (session and Settings sound test) while speech announcements (system TTS engine, no AudioContext) kept working.

Wake lock: src/composables/useWakeLock.js wraps navigator.wakeLock.request('screen'), opt-in via a wake_lock_enabled settings flag that defaults to off — the user locks the phone deliberately during some workouts and it must keep working, so the app must never force the screen to stay on. The browser releases the wake lock sentinel whenever the page is hidden; the composable tracks a wanted flag separately from the ephemeral active grant and re-requests on visibilitychange→visible if the caller still wants it.

Keep-alive audio (Step 34, revised): src/composables/useKeepAliveAudio.js builds a 10s, mono, 8kHz, 16-bit near-silent WAV at runtime (no committed asset) β€” a 40 Hz sine at β‰ˆ βˆ’54 dBFS (amplitude 64/32767), an integer number of cycles so the loop is seamless β€” wraps it in a Blob URL, and plays it in a loop through an <audio> element for the whole duration of an execution β€” volume = 1, never muted. Each parameter is load-bearing: the duration must exceed Gecko’s 3s controllable-media minimum (Firefox/Android media notification + foreground service β€” the mechanism that actually keeps the process alive; Chrome’s notification wants β‰₯ 5s); the signal must not be digital silence (Chrome’s audibility check measures decoded signal power, threshold β‰ˆ βˆ’72 dBFS) yet stays far below human hearing on a phone speaker; and 16-bit signed samples centered on 0 avoid the unsigned-8-bit DC pitfall described in the field-test findings above. Verified target is Firefox for Android (the user’s device β€” the media notification appearing during a session is the observable success signal); on Chrome/Android the same file should satisfy the audibility check, but this is best-effort and unverified on a real device. The element is owned by useSessionExecution.js (not the view) and is always on during execution with no setting β€” it’s inaudible and only runs while timers/audio run anyway, so a toggle would add a failure mode for no benefit. start() is idempotent (a no-op while already playing); a play() rejection (e.g. autoplay blocked outside a user-gesture context) is caught and logged as an ERROR (keepAliveFailed) rather than thrown, so a blocked autoplay never breaks the execution. This is a best-effort mitigation, not a guarantee β€” a web page still cannot forbid the OS from killing it under low memory, an incoming call, or camera use.

Keep-alive recovery (2026-08-31 field fixes): the composable now separates intent from playback, exactly as useWakeLock separates wanted from active. isPlaying used to be assigned by start() and stop() and nothing else, so it was not an observation but a claim: when Android paused the element behind the app’s back the flag still read true, and a whole field log’s worth of heartbeat audioPlaying=true lines proved nothing. It is now driven by the element’s own play / pause / ended events.

An unexpected pause β€” one that arrives while wanted is true β€” is logged (keepAlivePaused) and fought: play() is retried on a 500 ms β†’ 8 s backoff, again on visibilitychange β†’ visible, and once more from a one-shot pointerdown listener when a programmatic retry is refused (the user-activation rule is the only thing that can refuse one, and the next tap is the one moment it may succeed). A successful recovery logs keepAliveRecovered.

The likely source of those pauses is the app’s own speech: _startFresh() called keepAlive.start() but only logged after await el.play(), so loadStepState() β€” the geolocation permission prompt and the first speechSynthesis.speak() β€” ran inside the pending promise. In all three field-test starts geoStart preceded keepAliveStarted by ~270 ms. On Android TTS takes audio focus and the browser pauses other media, and the foreground service behind the media notification goes with it. keepAlive.start() is now awaited before loadStepState() in _startFresh() and before _resumeStepState() in resumeExecution(), which closes that window.

Beep diagnostics (Step 34 revision): _beep() in useAudioCues.js now awaits AudioContext.resume() when the context isn’t running and logs beepCtxNotRunning (context still not running after resume β€” the beep is queued, not heard) and beepFailed (exception) as ERROR, instead of swallowing every failure silently β€” the beep regression above was invisible in the field logs precisely because nothing was logged.

Pull-to-refresh: SessionExecuteView.vue adds a no-overscroll class to <html> on mount (removed on unmount) that sets overscroll-behavior-y: none (src/styles/layout.css), scoped to the execute route only.

Reload/close guard: a beforeunload listener, active only while !sessionDone && !loading, complements the existing in-app onBeforeRouteLeave confirmation for in-app navigation.

Resume snapshot: useSessionExecution.js writes a JSON snapshot to localStorage (moved from sessionStorage in Step 34 β€” sessionStorage does not survive an Android process kill, which is exactly the failure the field test reproduced repeatedly; localStorage does) under kaivalya-trainer.execution.<sessionId>, on every state-changing action (queue/index mutation, reps/weight/side load, final-round and AMRAP-time-up transitions, and β€” since Step 34 β€” every TABATA work/rest phase transition). The key, shape validation, and the existing 12h RESUME_MAX_AGE_MS staleness guard are unchanged. Timer phase is now preserved for TABATA (timerPhase: 'work' | 'rest' | 'amrap' | null): a kill during a TABATA rest phase resumes back into the rest screen instead of replaying the work phase already done. EMOM/AMRAP behaviour is unchanged (a documented simplification from Step 33): EMOM restarts its current minute at full duration, and AMRAP restarts from the last known remaining time (persisted on every tick) rounded up to a full minute. The “final round” and “AMRAP time-up” waiting screens (no timer running) are snapshotted explicitly so resuming into them does not spuriously start one.

On mounting the execute view, a snapshot for the same session younger than 12h is offered via an explicit “Resume at step N / Start over” prompt rather than silently auto-resuming β€” a silent resume could trap a user who intentionally meant to restart. Resume reuses the existing execution_log row (no duplicate) and restarts the keep-alive audio and heartbeat; Restart clears the snapshot and runs the normal fresh-start path (new log row). A snapshot that fails to parse or fails basic shape validation is treated the same as no snapshot β€” logged as an error, falling back to a fresh start rather than blocking or crashing.

Heartbeat diagnostics (Step 34, extended 2026-08-31): while an execution is active, useSessionExecution.js logs an INFO heartbeat line every 60s: step index, timer phase, remaining time, document.visibilityState, whether the keep-alive audio is playing, the element’s real paused state, navigator.mediaSession.playbackState, the recorded point count and the age of the last GPS fix. The last four were added after the first field test, where the only audio evidence in the log was a flag that could not be wrong: a heartbeat now says objectively whether the media notification (and the foreground service behind it) was still up, and whether fixes were still landing. At ~60 lines/hour this is negligible against the 10 MB log rotation (Β§3.16); it exists purely so the next field test’s logs show throttling or a kill directly β€” a gap between heartbeats over ~65s means throttling, an abrupt end with no lifecycle event means a kill. logger.js also records the Page Lifecycle freeze/resume and pagehide/pageshow events, so a freeze appears in the log directly rather than being inferred from missing heartbeats.

flowchart TD A[Mount SessionExecuteView] --> B["useSessionExecution.init()"] B --> C{Valid snapshot in localStorage?} C -- No / corrupt / older than 12h --> D[Fresh start: build queue, new execution_log row, start keep-alive audio + heartbeat] C -- Yes --> E["Resume / Start over" card] E -- Start over --> D E -- Resume --> F[Restore queue, index, reps/weight/side; reuse existing logId; start keep-alive audio + heartbeat] F --> G{What was active?} G -- TABATA step, phase = rest --> H2[Restart directly in the rest phase] G -- EMOM step, or TABATA phase = work --> H[Restart current step timer at full duration] G -- AMRAP step --> I[Restart timer at last known remaining time, rounded up to a minute] G -- Final-round / AMRAP time-up screen --> J[No timer β€” show the waiting screen]

Testing: the Wake Lock API is stubbed via Object.defineProperty(navigator, 'wakeLock', …) in an init script rather than relying on real grants, since headless browsers may not actually grant a lock β€” the stub reliably counts request/release calls regardless. Audio.play() rejection is exercised the same way, by overriding HTMLMediaElement.prototype.play in an init script to return a rejected promise.

Key files: src/composables/useWakeLock.js, src/composables/useKeepAliveAudio.js, src/composables/useSessionExecution.js, src/views/SessionExecuteView.vue, src/styles/layout.css, src/views/SettingsView.vue


3.18 Pocket Mode (cardio tracking with the screen covered)

The problem, stated plainly: cardio tracking with the phone locked does not work on Android browsers, and cannot be made to work from a web page. Since Android 10 the system serves location to an app holding the “while using” permission only while that app has a visible activity, or a foreground service declared with foregroundServiceType="location". Firefox’s foreground service is the media one behind the keep-alive audio of Β§3.17, typed mediaPlayback: it keeps the process alive β€” heartbeats, timers and audio all survive a locked screen β€” but carries no location capability, so the OS silently stops delivering fixes to a registered watch. No error reaches the page.

That is exactly what the 2026-09-01 field session recorded: the watch stayed registered and mute for up to 1749 s while everything else kept running, and 34 of the session’s 40 log items hold no gps_track at all. Mozilla bug 1482733 ("watchPosition() doesn’t work in background on Android O and above") has been open at P2 since 2018; Fenix does not request ACCESS_BACKGROUND_LOCATION, so Android never even offers “Allow all the time”. Chromium stops geolocation for hidden documents by design, so switching browsers changes nothing, and /e/OS is LineageOS underneath and applies the same AOSP rule. The watchdog re-arm of Β§3.7 (Step 31h) stays because it is correct and harmless, and geoRearm reason=stale in a field log is the objective marker of this limit β€” but it cannot conjure a fix the OS refuses to send.

The design: the only lever a web page has is to stay visible, so the page becomes its own lock screen. Pocket mode holds the Screen Wake Lock and covers everything with a black, touch-swallowing overlay showing dim, large text: the time of day (as a lock screen would, re-read every 5 s by the overlay itself via formatClockTime() in dateFormatter.js), the step timer, the distance and the GPS state. The document never becomes hidden, so the GPS watch, the timers, the audio cues, the media notification and the headset keys all keep full service.

  • Composable: src/composables/usePocketMode.js owns the state machine and exports POCKET_IDLE_MS (20 s) and POCKET_HOLD_MS (3 s) so the component and the tests share one source of truth.
  • Entry is both manual (a button on the cardio card) and automatic (after 20 s with no pointerdown/keydown anywhere on the document), governed by a pocket_mode setting whose values are off, manual and auto. It defaults to auto: locking the phone loses the run, so the safe default is the one that records it.
  • Exit is a 3 s press-and-hold with a progress ring, never a tap. Pocket friction produces short, moving touches; a stationary 3 s press does not happen by accident often, and when it does the idle timer covers the screen again 20 s later.
  • Eligibility is cardio steps only, and only while no screen needs a decision: the resume prompt, the final-round card, the AMRAP time-up card, the exercise detail and the completion summary all uncover the screen automatically (leave('step')).
  • Wake lock ownership. Pocket mode always requests the lock, independently of wake_lock_enabled, and releases it on exit only if that setting did not want it β€” releasing otherwise would clear the intent the setting owns and let the screen start auto-locking mid-workout. There is exactly one useWakeLock instance per view, passed into the composable: a second instance would keep its own wanted flag and release the sentinel the first still wants. Entering also skips the request when a sentinel is already held, because useWakeLock stores only the latest and a second request would leak the first.
  • Availability is gated on navigator.wakeLock. Without it the overlay cannot keep the page visible, so the button and the automatic entry are hidden and the setting is disabled, the same way the wake-lock toggle already behaves.
  • Interrupted by an SMS. The overlay is plain in-memory reactive state, so it survives visibilitychange and is still there when the app returns; the wake lock is re-acquired by useWakeLock’s existing visibility handler. Fixes missed while the app was away are a hole in the track, which the existing 30 s gap rule already refuses to bridge.
  • Covering everything, literally. Leaflet stacks its panes and controls from z-index 200 to 1000 and its container starts no stacking context of its own, so on the first phone test the live map painted straight through the black screen β€” and in portrait the map band sits exactly where the overlay centres its clock, which is why the time was readable in landscape only. The map wrapper now carries isolation: isolate, keeping that ladder local to the card, and the overlay sits at z-index: 9000 β€” above every in-page widget, below the modal dialogs at 10000, which must stay reachable.
  • The map freezes underneath. A map that keeps panning behind an opaque black screen still fetches tiles and repaints, spending the battery this whole mode exists to save. CardioExecuteCard takes a mapFrozen prop bound to pocketActive and skips _updateMap() while it is set β€” recording, distance, cues and the overlay’s own readout are untouched, only the drawing waits. Leaving runs one update that redraws the whole track (_syncPolylines() sets each segment’s full list of points rather than appending), moves the marker and pans back to the athlete. The map object and its tiles stay in place, so the restore costs nothing and needs no network.
  • What is deliberately absent: buttons. Pause, next, previous and stop are all reachable from the media notification and the headset keys, which keep working underneath.

The cardio card also carries a one-line hint under the GPS controls stating that locking the screen pauses GPS on Android, so the limit is named where the choice is made rather than only in this document.

stateDiagram-v2 [*] --> Visible Visible --> Pocket: button tap Visible --> Pocket: 20 s idle (setting = auto, cardio step) Pocket --> Visible: hold 3 s Pocket --> Visible: non-cardio step / final round / AMRAP time-up / session done / exit Pocket --> Pocket: cardio to cardio transition, pause/resume, page hidden then visible (SMS)

Testing: tests/test_step37_pocket_mode.py stubs the Wake Lock API as Β§3.17 does, plus two things this feature needs: sentinels that can be released the way a browser releases them when the page is hidden (which is what makes the re-acquire assertion meaningful), and a switchable document.visibilityState. page.clock drives both the idle countdown and the hold. Removing wake-lock support means deleting the accessor from Navigator.prototype, not shadowing it on the instance, because support is tested with 'wakeLock' in navigator.

Key files: src/composables/usePocketMode.js, src/components/PocketOverlay.vue, src/components/CardioExecuteCard.vue, src/views/SessionExecuteView.vue, src/views/SettingsView.vue, src/utils/cardioFormat.js


4. Routing

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

RouteViewNotes
#/homeHomeViewActivity overview
#/exercisesExerciseListViewList + search
#/exercises/newExerciseFormViewCreate mode
#/exercises/:idExerciseDetailViewRead-only
#/exercises/:id/editExerciseFormViewEdit mode
#/combosComboListViewList + search
#/combos/newComboFormViewCreate mode
#/combos/:idComboDetailViewRead-only
#/combos/:id/editComboFormViewEdit mode
#/sessionsSessionListViewList + search
#/sessions/newSessionFormViewCreate mode
#/sessions/:idSessionDetailViewRead-only
#/sessions/:id/editSessionFormViewEdit mode
#/sessions/:id/executeSessionExecuteViewExecution mode
#/collectionsCollectionsViewList + search
#/collections/newCollectionFormViewCreate mode
#/collections/:idCollectionDetailViewRead-only
#/collections/:id/editCollectionFormViewEdit mode
#/statsStatsViewStatistics + charts
#/settingsSettingsViewUser 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 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 .kaivalya-trainer, file kaivalya-trainer.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} (under the app’s former name, TrainUs), 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 Kaivalya Trainer 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.

ViewCap
List views (exercises, combos, sessions, collections)800 px
Detail views800 px
Form views600 px
Settings view600 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.