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
| Layer | Technology |
|---|---|
| UI Framework | Vue 3 (Composition API) |
| Build Tool | Vite |
| Routing | Vue Router (hash mode) |
| i18n | i18next + i18next-vue |
| Database | SQLite WASM + OPFS |
| Charts | Chart.js 4 + vue-chartjs 5 |
| Maps | Leaflet 1.9 (OSM / IGN) |
| Icons | Bootstrap Icons |
| Markdown | marked + DOMPurify |
| PWA | vite-plugin-pwa + Workbox |
| Formatting | Prettier |
| Testing | Playwright (Python) |
| License | GNU 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):
| Area | Files | Code lines | Main languages |
|---|---|---|---|
src/ (application) | 81 | 13,973 | Vue SFC, JavaScript, CSS |
tests/ (Playwright) | 37 | 7,371 | Python |
website/ (docs site) | 49 | 4,732 | Markdown, CSS, HTML |
docs/ | 4 | 1,500 | Markdown |
Within src/, Vue single-file components make up the bulk of application code (9,045 lines across 32 components), with 4,065 lines of plain JavaScript (composables, DB layer, utilities, router).
Main Thread / Worker Architecture
SQLite runs in a Web Worker to access OPFS APIs, which are unavailable on the main thread.
Main Thread Worker Thread
ββββββββββββββββββββββββ ββββββββββββββββββββ
β database.js βββpostMsg()βββΆβ worker.js β
β exec / run / ββββpostMsg()βββ sqlite3 WASM β
β selectAll / β β OPFS storage β
β exportDatabase / β ββββββββββββββββββββ
β importDatabase β
ββββββββββββββββββββββββ€
β repositories/ β Pure async CRUD per entity
ββββββββββββββββββββββββ€
β composables/ β Reactive ref wrappers for Vue
ββββββββββββββββββββββββ
Communication is fully Promise-based: database.js wraps every postMessage() in a Promise resolved by a matching reply ID.
COOP / COEP Headers
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
See How-to Guides β Deploy Kaivalya Trainer for server configuration snippets, and Understanding the Architecture for why the VFS used here doesn’t strictly require them.
Responsive Design
| Breakpoint | Layout |
|---|---|
| > 700 px | Fixed sidebar navigation (labeled links) |
| β€ 700 px | Bottom navigation bar (icons + labels) |
| β€ 400 px | Icon-only bottom navigation |
Theming
Light and dark themes are implemented via CSS custom properties on :root / [data-theme="dark"]. The theme is persisted in the settings table and applied before the first render (no flash).
2. Database Layer
VFS: OPFS SyncAccessHandle Pool
The app uses the opfs-sahpool VFS:
- Synchronous I/O through
FileSystemSyncAccessHandle - Works on all major browsers since March 2023 (Firefox 111+, Chrome 102+, Safari 16.4+)
- Does not require COOP/COEP headers (though we have them)
- Single-connection only (fine for a single-user PWA)
If OPFS is unavailable (private browsing mode, outdated browser), the app displays a full-screen error. There is no in-memory fallback. See Understanding the Architecture 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.
| Table | Purpose |
|---|---|
schema_version | Migration tracking (version + applied_at) |
settings | Key-value configuration (user_uuid, name, theme, β¦) |
exercise | Exercise definitions (title UNIQUE); category (strength/cardio) + target |
combo | Combo definitions (title UNIQUE) |
combo_exercise | Combo β exercise junction (position, reps, weight, cardio duration) |
session | Session definitions (title UNIQUE); free-style flag |
session_item | Session β item junction (type, position, reps, weight, cardio overrides) |
collection | Collections (label UNIQUE) |
collection_session | Collection β session junction (position) |
execution_log | Execution records (started_at, finished_at) |
execution_log_item | Per-exercise execution results (reps, weight, side, cardio/GPS resultsβ¦) |
suffix | External user registry for import disambiguation |
Entity-Relationship Diagram
Notable column details:
session_item.item_type:"exercise"or"combo"β used to JOIN with the correct table for titlesexecution_log_item.combo_id/round_number: NULL for standalone exercises; populated for exercises executed inside a combo (no FK constraint β combo may be deleted after execution)exercise.category:'strength'(default) or'cardio';target_unit('time'/'distance') plusdefault_duration_sec/default_distance_m(0= no target) only apply to cardio exercises.session_item.duration_sec/distance_mandcombo_exercise.duration_secare per-use overrides (NULL= inherit the exercise default);combo_exercise.duration_seconly applies to cardio elements inNONE-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_trackis a JSON blob of{points, source, elevationSource}, one row per step); allNULLfor strength items.settingskeys:user_uuid,user_name,app_version,language,theme,db_created_at,last_backup_at,backup_reminder_days
UNIQUE Constraints
Title/label uniqueness is enforced at the DB level on exercise.title, combo.title, session.title, collection.label. Violations surface as UNIQUE constraint failed errors handled by the repositories.
Suffix Table
Stores the mapping between external user UUIDs and their assigned suffix tags. Used during import to disambiguate titles from other users (see Β§3.9).
Migration System
At startup, database.js sends a getSchemaStatus message to the worker, which detects the DB state without modifying existing data:
- Fresh DB (no
schema_versionrows): runSCHEMA_SQLand stampSCHEMA_VERSION. - Up to date (
dbVersion === SCHEMA_VERSION): continue normally. - Newer than app (
dbVersion > SCHEMA_VERSION): blocking error (DB_NEWER_THAN_APP), data untouched. - Older than app (
dbVersion < SCHEMA_VERSION): the app pauses on a blocking migration screen. The user must download a backup first; only then is the Update button enabled, which sendsrunMigrationsto the worker.
runMigrations applies the pending migration scripts from getMigrationScripts(dbVersion, SCHEMA_VERSION) in src/db/releases.js. Each release’s script runs inside a transaction together with its INSERT INTO schema_version (version) VALUES (N); on error the transaction is rolled back, the DB stays at the previous version, and a blocking DB_MIGRATION_FAILED error is shown (the user holds the backup downloaded just before).
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).
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.
| Composable | Wraps |
|---|---|
useExercises | exerciseRepository |
useCombos | comboRepository |
useSessions | sessionRepository |
useCollections | collectionRepository |
useExecutionLogs | executionLogRepository |
useSettings | settingsRepository |
useStats | Multiple repositories (read-only aggregates) |
useSessionExecution | executionLogRepository + queue logic |
useJsonExport | All repositories + jsonEnvelope.js |
useJsonImport | All repositories + suffixRepository |
useBackupStatus | settingsRepository |
useInstallPrompt | beforeinstallprompt event (browser API) |
useHtmlExport | Pure data rendering β no repository calls |
3. Features
3.1 Settings
View: src/views/SettingsView.vue β #/settings
Startup restoration: On app startup (src/main.js), after DB initialization and before the first render, language and theme are read from settings and applied. This prevents any flash of default values.
Available settings
| Setting | Control | Persistence key |
|---|---|---|
| Language | <select> (English / FranΓ§ais / Dansk) | settings.language |
| User Name | Text input (required, non-empty) | settings.user_name |
| User ID | Read-only UUID display | settings.user_uuid |
| Theme | <select> (Light / Dark) | settings.theme |
| Sound Volume | Slider (0β100%) + Test button | settings.audio_volume |
| Download DB | Button β downloads .sqlite3 | β |
| Restore DB | File picker β validation pipeline | β |
| Initialize DB | Button + mandatory checkbox | β |
| Backup reminder interval | Number input (1β365, default 7) | settings.backup_reminder_days |
| Export Data | Button β entity filter dialog | β |
| Import Data | Button β file picker | β |
| Suffix management | List of registered suffixes | suffix table |
| Credits | Button β modal (icon/library attributions) | β |
| About | Button β modal (version, author) | β |
Sound Volume is detailed in Β§ 3.7 Audio Cues.
Download Database
Calls sqlite3_js_db_export() in the worker. The raw SQLite file is downloaded as kaivalya-trainer-{version}-backup-YYYYMMDD-HHhMMmSSs.sqlite3 and last_backup_at is set on success.
Restore Database
Multi-step pipeline executed in the worker:
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.
| Group | Content |
|---|---|
| App icon (Mighty Force) | CC BY 3.0 β game-icons.net, by Delapouite |
| Bootstrap Icons | MIT β icons.getbootstrap.com |
| Maps & elevation data | OpenStreetMap (ODbL, Β© OpenStreetMap contributors), IGN GΓ©oplateforme (Β© IGN-F/GΓ©oportail), Open Topo Data (SRTM β NASA / USGS) |
| Third-party libraries | 13 entries, each with the SPDX identifier its own manifest declares |
| Testing tools | Playwright (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:
| Field | Source |
|---|---|
| Application Version | __APP_VERSION__ (Vite global from package.json) |
| Database Version | SCHEMA_VERSION (from src/db/schema.js) |
| Created by | Static string: David Florance |
| Repository | Placeholder link (href="#") |
| Website | Placeholder link (href="#") |
| View License | Disabled button with “License to be determined.” note |
Component: src/components/AboutDialog.vue
i18n keys are under the about.* namespace in all locale files.
3.2 Exercise Management
Views: ExerciseListView, ExerciseFormView, ExerciseDetailView
Routes: #/exercises, #/exercises/new, #/exercises/:id, #/exercises/:id/edit
Form fields
| Field | Validation |
|---|---|
| Title | Required, UNIQUE (DB-level) |
| Description | Optional; rendered as Markdown in the detail view (marked + DOMPurify) |
| Category | strength (default) or cardio β toggles the rest of the form |
| Asymmetric | Boolean; reveals Alternate sub-option. Strength only: hidden and forced to false when the category is cardio |
| Alternate | Radio: alternate per rep vs per set |
| Default Reps | β₯ 0 (strength only) |
| Default Weight | β₯ 0 (strength only) |
| Target | Cardio only: radio (time/distance) + one value, β₯ 0 (0 = no target) |
| Image URLs | Each must be valid HTTP(S) URL |
| Video URLs | Each must be valid HTTP(S) URL |
ExerciseFormView is shared for create and edit modes, distinguished by route.name (exercise-new vs exercise-edit).
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'
Search
Toggleable search bar with 250ms debounce β exerciseRepository.search() (SQL LIKE %query%). A clear (Γ) button is shown inside the input whenever it has a value; it empties the field and refocuses it. Clearing the input (via the button, manually, or toggling search off) calls fetchAll(). The same pattern (search bar, debounce, clear button) is duplicated across ExerciseListView, ComboListView, SessionListView, and CollectionsView rather than factored into a shared composable.
Media embedding
- Images: Displayed in a responsive grid with lazy loading.
@errorhandler hides broken images. - YouTube: Video IDs extracted from
youtube.com/watch?v=andyoutu.be/formats, embedded viayoutube-nocookie.com. - Other URLs: Rendered as clickable links.
Key files: src/views/ExerciseListView.vue, src/views/ExerciseFormView.vue, src/views/ExerciseDetailView.vue, src/utils/validation.js, src/utils/markdown.js, src/utils/youtube.js
3.3 Combo Management
Views: ComboListView, CombosView, ComboFormView, ComboDetailView
Routes: #/combos, #/combos/new, #/combos/:id, #/combos/:id/edit
A combo is an ordered group of exercises with an optional timed format (EMOM, AMRAP, TABATA, or NONE for plain supersets).
Form fields
| Field | Notes |
|---|---|
| Title | Required, UNIQUE |
| Description | Optional; rendered as Markdown in the detail view |
| Type | NONE / EMOM / AMRAP / TABATA |
| Timer config | Duration (min) for EMOM/AMRAP; rounds for TABATA; stored as JSON |
| Exercises | Ordered list with per-exercise reps (β₯ 1) and weight (β₯ 0) |
Each exercise in the combo has its own default_reps and default_weight stored in the combo_exercise junction table. These override the exercise’s own defaults when the combo is used in a session.
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
| Field | Notes |
|---|---|
| Title | Required, UNIQUE |
| Description | Optional; rendered as Markdown in the detail view |
| Items | Ordered list; each item has repetitions (β₯ 1) and weight (β₯ 0) |
| Free style | Checkbox (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
| Field | Notes |
|---|---|
| Label | Required, UNIQUE |
| Sessions | Ordered list; each session can appear at most once |
collectionRepository.getAll() includes a sessionCount field via a COUNT query on collection_session β avoiding loading full session data for the list view.
Key files: src/views/CollectionsView.vue, src/views/CollectionFormView.vue, src/views/CollectionDetailView.vue, src/db/repositories/collectionRepository.js
3.6 Home Page
View: HomeView β #/home
Landing page with a quick overview of recent training activity.
| Widget | Data source |
|---|---|
| Sessions last 7 days | executionLogRepository.getCountSince(isoDate) |
| Sessions last 30 days | executionLogRepository.getCountSince(isoDate) |
| 4 most recent sessions | executionLogRepository.getRecentWithItems(4) (JOIN with session and items) |
| History by day (filtered) | executionLogRepository.getAllWithItems() β filtered client-side by date range |
Recent sessions
The 4 most recently executed sessions are shown as cards. Each card displays the session title, execution date (locale-formatted via dateFormatter.js), total duration (when finished_at is set), a play button (βΆ) to re-execute, and a compact inline summary of every exercise/combo performed with actual reps, weight, and per-item duration.
The inline summary groups items using groupItems() / enrichItemsWithTiming() / comboRounds() helpers defined in HomeView. Item timing is derived from consecutive execution_log_item.timestamp deltas relative to execution_log.started_at.
History section
All execution logs are loaded once at mount via getAllWithItems() and then filtered client-side by the active date range (default: last 30 days). The result is grouped by calendar day (descending) using a Vue computed. Each day group has a heading and one compact card per session, also with duration, play button, and inline item summary.
Two <input type="date"> pickers (from / to) let the user adjust the range interactively without a network round-trip.
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 isspeedKmh()β total distance Γ· total time, never a mean of per-rep speeds β formatted byformatSpeed()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 byexercise_idfor standalone items, bycombo_idfor 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 perexercise_idacross 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:
- Fetches
/icon.svgand encodes it as a base64data:URI (TextEncoder+btoa). - Builds a fully self-contained HTML document from the current reactive state β stats cards, recent sessions, and history filtered by the active date range.
- Strips play buttons, navigation links, and date pickers from the output (read-only export).
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(), reusingaggregateByExercise()) β the export is the archival copy of what Home summarizes. - 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 Γ Msteps, wheretotalRoundsdepends on combo type:
| Combo type | repetitions meaning | totalRounds formula |
|---|---|---|
| NONE/TABATA | Number of full rounds | repetitions |
| EMOM | Total duration (min) | floor(repetitions / M) β each exercise = 1 min slot |
| AMRAP | Total duration (min) | 1 β only one pass is queued; user repeats freely |
Session items:
1. Exercise A Γ3 reps β 1 step
2. Combo "HIIT" Γ2 rounds (2 exercises) β 4 steps (NONE/TABATA)
Round 1: Push-up Γ10, Squat Γ15
Round 2: Push-up Γ10, Squat Γ15
3. Exercise B Γ5 reps β 1 step
Queue (flat):
[0] Exercise A
[1] Push-up β HIIT, Round 1/2
[2] Squat β HIIT, Round 1/2
β auto-advance (not last round)
[3] Push-up β HIIT, Round 2/2
[4] Squat β HIIT, Round 2/2
β final-round screen: [Continue β] [+ One More Round]
[5] Exercise B
For an EMOM combo with 2 exercises and repetitions = 6 (= 6-minute duration): totalRounds = floor(6/2) = 3, producing 6 steps of 1 minute each.
Each step carries: exerciseId, exerciseTitle, prefillReps, prefillWeight, prefillSide, comboId?, comboTitle?, roundNumber?, totalRounds?, asymmetric, alternate, isFinalComboStep, comboExercises, 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:
- Looks up the completed steps for the current final round (actual reps/weight used)
- Inserts a new batch of steps at
currentIndex + 1in the queue - Marks
isFinalComboStep = trueon the last new step - Advances
currentIndexto the first new step
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 step | Clock | Ends on |
|---|---|---|
| Time target (standalone or NONE combo) | Countdown, EMOM tick sounds + pre-announce | Auto-advance at zero |
| Distance target | Count-up, live distance + progress bar | Finish (never auto) |
| No target, or free-style session | Count-up, no distance shown | Finish |
| Inside EMOM / AMRAP / TABATA | The 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:
- IGN altimetry (
data.geopf.fr/altimetrie/β¦&zonly=true&resource=ign_rge_alti_wld, batches of 150) when the first point is in France; - Open Topo Data SRTM 30 m (batches of 100, throttled to 1 request/s) elsewhere;
- 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 = 1from the first flush. The alternative β an in-progress marker filtered out ofisCardioItem()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 logaborted. 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.
| Piece | Role |
|---|---|
src/utils/mapPacks.js | Pure geometry/sizing: enumerateTiles, estimatePack, formatBytes |
useMapPacks.js | Cache Storage writes, metadata persistence, progress, delete |
MapPackDialog.vue | Framing map, zoom select, estimate, progress bar |
mapLayers.js | createTileLayer() β 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:
- Expands an inline detail panel (description rendered as Markdown, images, video links).
- Pauses the active timer β but only if it was running at click time (
detailPausedTimerflag tracks this).
Collapsing the panel resumes the timer only when detailPausedTimer is true, leaving manually-paused timers untouched.
The detail panel is loaded lazily on first expand via exerciseRepository.getById(step.exerciseId). Subsequent toggles within the same step reuse the cached detailExercise ref. A watch(currentStep, β¦) resets showDetail, detailExercise, and detailPausedTimer whenever the step advances.
For online state detection (to conditionally show video links), SessionExecuteView uses useOnlineStatus(). YouTube ID extraction uses the shared src/utils/youtube.js utility (same function used in ExerciseDetailView).
UI Layout
βββββββββββββββββββββββββββββββββββββββββββ
β Session Title [β exit] β
β βββββββββββββββββ 3 / 6 steps β
βββββββββββββββββββββββββββββββββββββββββββ€
β βΆ NOW β
β βββββββββββββββββββββββββββββββββββββ β
β β Push-up Λ
β β β title is a toggle button
β β βββββββββββββββββββββββββββββββ β β β detail panel (when expanded)
β β β Keep your body straightβ¦ β β β
β β β [image] [image] β β β
β β βββββββββββββββββββββββββββββββ β β
β β Combo: HIIT β Round 2 of 2 β β
β β Reps: [ 10 ] β β
β β Weight: [ 0 ] kg β β
β β Side: RIGHT β β β β asymmetric only
β β [ β Done ] β β
β βββββββββββββββββββββββββββββββββββββ β
β β NEXT β
β Squat β 15 reps (HIIT, Round 2) β
βββββββββββββββββββββββββββββββββββββββββββ
Audio Cues
src/composables/useAudioCues.js provides two functions built on the Web Audio API:
| Function | Frequency | Duration | Role |
|---|---|---|---|
playBip(volume) | 440 Hz | 0.1 s | Soft tick β countdown, midpoint |
playBIP(volume) | 880 Hz | 0.3 s | Loud signal β phase change, time-up |
AudioContext is created lazily on first use (singleton). Volume is loaded from settings.audio_volume (0β100) and converted to a 0.0β1.0 multiplier.
Sound logic lives in useSessionExecution as four private helpers:
_emomSounds(remaining)β firesplayBipat r = 30, 2, 1 (EMOM only)._amrapLastMinuteSounds(remaining)β firesplayBipat r = 30, 2, 1 (last minute of AMRAP)._tabataCountdown(remaining, totalTime)β firesplayBipatfloor(totalTime / 2), r = 2, and r = 1._cardioSounds(remaining, totalTime)β firesplayBipatfloor(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 whenfalse.start({ onPlayPause, onNext, onPrevious, onStop })β registersnavigator.mediaSessionaction handlers forplay,pause,nexttrack,previoustrack,stop.updateMetadata({ title, artist, album })β setsnavigator.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)β setsnavigator.mediaSession.playbackState.stop()β unregisters all action handlers, clears the metadata, declaresplaybackState = 'none'.
SessionExecuteView.vue wires it in:
start()is called inonMounted, right afterinit()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):artistis “Next: X”, where X is normally the queue’snextStepexercise β except during a TABATA work phase, where rest (notnextStep) 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 phasetitleis the localized REST label; otherwisetitle= 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 isdeclared === 'playing' ? 'playing' : guessed, where guessed comes from the actual media elements β a declared'paused'loses to still-playing audio. SotogglePause()inuseSessionExecution.jsalso 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/_PREVIOUSto Android when itsmediaNotificationImprovementsfeature flag (a Mozilla-side Nimbus rollout) is enabled β a web page cannot influence this. Ournexttrack/previoustrackhandlers 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 inonUnmountedand wheneversessionDonebecomestrue.
Action mapping deliberately reuses the on-screen handlers rather than inventing media-specific logic:
| Media action | Maps to | Notes |
|---|---|---|
play / pause | togglePause() if a timer is active; the timerPaused watcher declares the resulting state | No-op if no timer is running (nothing to pause) |
nexttrack | onDone() on a normal step; onSkipRest() during a TABATA rest (Step 35); onContinue()/onContinueAmrap() on the final-round / AMRAP-time-up screens | Mirrors whichever button is visible |
previoustrack | goBack() (new in useSessionExecution.js) | No-op at the first step |
stop | onExit() directly, no confirmation | A headset button can’t answer a JS confirm() dialog |
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 whenfalse.init()β reads thevoice_enabledsettings key once (default enabled) and caches it for the rest of the execution.speak(text, { volume })β callsspeechSynthesis.cancel()first (a new announcement always preempts a stale one), then builds aSpeechSynthesisUtterancewithutterance.langmapped from the current i18next language (enβen-US,frβfr-FR,daβda-DK) andutterance.volumeset from the sameaudio_volumesetting used for the Web Audio beeps.cancel()β stops any in-progress or queued utterance.
useSessionExecution.js calls speak() at two moments:
| Moment | Spoken text | Hook |
|---|---|---|
| A step becomes current | The exercise title | loadStepState() β every step, including the first |
| EMOM: 10 s left in the current slot | "Next: {title}" (translated) | _emomSounds(remaining), alongside the existing beep thresholds |
| TABATA: rest phase opens | "Next: {title}" | _startRestPhase(step), right where the on-screen “Next:” label is already shown |
AMRAP and NONE combos only get the step-change announcement β matching the on-screen UI, which likewise shows no “next” preview until the step actually advances for those types.
To avoid the same exercise being announced twice within seconds (once as a pre-announcement, once when it becomes current), useSessionExecution tracks the queue index that was just pre-announced in a closure variable (preAnnouncedIndex); loadStepState() skips the step-change announcement exactly once when currentIndex reaches that index, then clears it.
cancel() is called on exit() and _finish() so nothing keeps talking after the session ends or the user leaves the view. The Voice announcements checkbox in Settings β Sound persists voice_enabled the same way audio_volume is persisted.
Testing: speechSynthesis is replaced by a mock in page.add_init_script, recording each speak() call’s { text, lang, volume }. Firefox exposes window.speechSynthesis as a getter-only property, so the mock must install it via Object.defineProperty(window, 'speechSynthesis', { configurable: true, value: β¦ }) β a plain assignment silently no-ops and the real API keeps running underneath. page.clock drives the EMOM/TABATA timer-based pre-announcement tests deterministically, as in Β§“Audio Cues” above. A red-path test deletes Window.prototype.speechSynthesis to confirm execution still works with the API entirely absent.
Key files: src/composables/useSpeech.js, src/composables/useSessionExecution.js, src/views/SettingsView.vue
3.8 Statistics
View: StatsView β #/stats
Composable: useStats
Libraries: Chart.js 4 + vue-chartjs 5, registered locally in StatsView.vue (not globally, to avoid side effects on other routes).
Date range filter
Four tabs control which period is shown: 7d, 30d (default), 3m, All.
Summary cards
| Card | Query |
|---|---|
| Sessions | Count of execution_log rows in the period |
| Exercises done | Count of execution_log_item (completed) rows in the period |
| Day streak | Consecutive calendar days (ending today) with at least one completed log (aborted runs don’t count); always across all history |
Streak uses setUTCHours(0,0,0,0) for date comparison to match SQLite’s DATE(started_at) UTC extraction.
Charts
| Chart | Type | Metric |
|---|---|---|
| Frequency | Bar | Sessions per week or per month (toggle) |
| Volume | Bar | SUM(reps_done Γ weight_used) per session execution |
| Progression | Line | Reps (left axis) + max weight (right axis) over time for a selected exercise |
Volume only counts weighted exercises (weight > 0). The Progression chart only lists exercises with at least one execution log entry.
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
| Block | Content |
|---|---|
| Totals (4 cards) | Distance, time, elevation gain (D+), elevation loss (Dβ) |
| Distance over time | Line chart, one point per day that recorded cardio work |
| By day | One row per calendar day (UTC, like every other date grouping) |
| By session | One row per execution, labelled by session title + date |
| By combo | One row per combo, with the number of executions it appeared in |
| By exercise | One 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.
- Range bounds are local midnights converted to ISO, upper bound exclusive (
to + 1 day), sincestarted_atis 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_MEASURESorder 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
#nper repetition) β Chart.js’ time scale would need a date adapter the app does not ship. - The chart container exposes
data-point-countanddata-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 point | Scope |
|---|---|
| Exercise / Combo / Session / Collection list view | All items of that entity type, or only the active search filter’s matches |
| Exercise / Combo / Session / Collection detail view | Single item |
| Settings β Export Data | Any combination of entity types (filter dialog) |
The filter dialog shows checkboxes per entity type. The full export from Settings produces 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 point | Notes |
|---|---|
| Exercise / Combo / Session / Collection list view | Per-entity import via action bar |
| Settings β Import Data | Multi-entity envelope supported |
All import entry points use the same generalized analyzeAll / applyAllImport flow, including single-entity exercise files (a separate legacy flow existed historically but turned out to produce an identical analysis shape, so it was removed).
Collision strategy
| Scenario | Behavior |
|---|---|
Same user (created_by = local user_uuid), no ID/title match | Insert with original ID |
| Same user, same ID exists | Prompt: Replace / Skip (with batch Replace All / Skip All) |
| Same user, different ID but same title as an existing row | Same Replace/Skip prompt, resolved against the existing row’s ID; Skip redirects same-batch references to it |
| Different user, UUID unknown | Prompt for suffix β register in suffix table |
Different user, same ID + same created_by | Update in place (no new ID) |
Different user, same ID + different created_by | Generate new UUID |
| Different user, suffixed title collides with an existing row | Same Replace/Skip prompt (no separate rename step) β almost always a re-import of a previously-imported item from the same external user |
Title format for external imports: "Original Title [SUFFIX]".
Cross-entity reference remapping
When an entity’s ID changes during import (new UUID generated), all referencing entities are updated:
combo_exercise.exercise_idsession_item.item_id(for exercises and combos)collection_session.session_idexecution_log.session_idexecution_log_item.exercise_id
Settings import restrictions
Settings are imported with exclusions to preserve local identity:
| Key | Imported? |
|---|---|
user_uuid | No β always preserved |
app_version | No β always preserved |
db_created_at | No β always preserved |
last_backup_at | No β always preserved |
user_name, language, theme, backup_reminder_days | Yes |
Execution log import
Execution logs are only imported if the referenced session exists locally or was imported in the same batch.
Import dialog flow
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
| Key | Default | Written by |
|---|---|---|
db_created_at | Set on first launch | database._initDefaults() |
last_backup_at | Absent | Download success, Restore success |
backup_reminder_days | "7" | Settings number input |
Stale detection
effectiveDate = last_backup_at OR db_created_at (fallback)
isStale = (effectiveDate absent) OR (now_utc β effectiveDate) > backup_reminder_days Γ 86400s
Reminder UI
BackupReminder.vue is mounted once in App.vue (inside the DB-ready branch) and teleports into #actionbar-actions. It shows a warning-styled button with bi-exclamation-triangle-fill. Clicking navigates to #/settings and scrolls to the #settings-backup-section anchor. The reminder is not dismissible.
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:
| Function | English | French | Danish |
|---|---|---|---|
formatDate(date, lang?) | MM/DD/YYYY | DD/MM/YYYY | DD.MM.YYYY |
formatDateTime(date, lang?) | date + hh:mm:ss AM/PM | date + HH:MM:SS | date + HH.MM.SS |
The optional lang parameter allows Vue computed properties to pass the reactive language ref, ensuring date displays update immediately on language switch.
Key files: src/composables/useBackupStatus.js, src/components/BackupReminder.vue, src/utils/backupFilename.js, src/utils/dateFormatter.js
3.11 PWA
Plugin: vite-plugin-pwa + Workbox
Service worker
| Config | Value | Rationale |
|---|---|---|
registerType | prompt | New SW waits for user consent β see Β§3.14 |
maximumFileSizeToCacheInBytes | 10 MB | Covers sqlite3.wasm (~8 MB) |
manifest | false | Hand-authored public/manifest.json kept |
Workbox handles content-hash-based precaching of the entire app shell at install time, enabling full offline support. The WASM binary is included in the precache manifest via the 10 MB limit.
Persistent storage
At startup, main.js calls navigator.storage.persist() once, fire-and-forget. Without it, all site storage is “best-effort”: the browser may evict Cache Storage (the Workbox precache, including the icon font) and the OPFS SQLite database after long periods of inactivity or under disk pressure. A granted request exempts the origin from automatic eviction. Chromium decides silently based on engagement heuristics; Firefox may show a permission prompt; a denial (or a browser without the API) never blocks startup β the result is only logged.
App manifest
public/manifest.json declares name, short_name, display: standalone, theme_color, background_color, and two icons (192Γ192 and 512Γ512) with both maskable and any purposes.
Install prompt
useInstallPrompt.js captures the beforeinstallprompt event at module load time (singleton ref canInstall). InstallPrompt.vue teleports a download icon button into the action bar when canInstall is true. The prompt is session-dismissible (no persistence needed).
Accessibility
ARIA additions applied across the app:
aria-labelon all icon-only action bar buttons across all viewsrole="progressbar"+aria-valuenow/min/maxon the execution progress barrole="alert"+aria-describedbyon form error messagesrequired/aria-requiredon required inputsrole="dialog"+aria-modal+aria-labelledby+ focus-on-mount + Escape key onModalDialog
Key files: vite.config.js, public/manifest.json, src/composables/useInstallPrompt.js, src/components/InstallPrompt.vue
3.12 Save Filename Prompt
Every file saved to disk (JSON exports, the home HTML export, the SQLite backup β including the forced pre-migration backup) prompts for a filename instead of downloading silently under an auto-generated name.
Composable: useSaveFilePrompt (module-level singleton, same pattern as useBackupStatus). promptFilename(defaultFilename) splits the default name into stem + extension at the last dot, stores it in the singleton pendingSave ref, and returns a Promise<string|null> that resolves once the dialog is dismissed (null if cancelled).
Component: SaveFileDialog.vue, mounted once at the root of App.vue so it is available regardless of which view (or the blocking migration screen) triggers a save. Renders an editable stem input (prefilled, text selected) next to a fixed, read-only extension. Enter confirms; an empty stem falls back to the default.
The three low-level download helpers (downloadJson in jsonEnvelope.js, downloadBackup in useBackupDownload.js, the local downloadHtml in useHtmlExport.js) call promptFilename() before building the blob/anchor, and no-op if the user cancels β no other call site changed. For backups specifically, markBackedUp() (and, on the migration screen, enabling the Update button) only runs if the save wasn’t cancelled.
Key files: src/composables/useSaveFilePrompt.js, src/components/SaveFileDialog.vue, src/utils/jsonEnvelope.js, src/composables/useBackupDownload.js, src/composables/useHtmlExport.js, src/App.vue
3.13 First-Load Warning
The opfs-sahpool VFS used by the DB worker (src/db/worker.js, see Β§2) only supports one active connection per origin, and OPFS may be unavailable entirely in private/incognito browsing. Both conditions used to only surface reactively, as the generic error.browserNotCompatible message once a second tab or a private window failed to open the database (see App.vue’s dbErrorBody). This feature adds a proactive, one-time warning instead.
Settings key: first_load_warning_seen β absent until dismissed, then "true", never reset.
Component: FirstLoadWarning.vue, mounted once in App.vue’s DB-ready branch, modeled on BackupReminder’s conditional-modal pattern. On mount it reads the settings flag; if absent, it shows a ModalDialog with both warnings (single tab only, no private browsing) and a single dismiss button ($t('firstLoadWarning.dismiss')) that persists the flag and closes. Unlike BackupReminder, it has no snooze/session-dismiss state β once dismissed it is gone for good. 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 fromuseRegisterSW, true when a new SW is installed and waitingapplyUpdate()β setsupdating, then callsupdateServiceWorker(true); the page reloads when the new SW takes control, soupdatingis never resetdismiss()β session-only dismissal (plain ref, same pattern asInstallPrompt); the modal reappears on the next app load if the update is still pendingcheckForUpdate()/checkingForUpdateβ see “Manual check” below
Component: AppUpdateDialog.vue, mounted once in App.vue’s DB-ready branch. Two states:
- Choice β title, short body, Update now / Later buttons.
- Updating β spinner + “Updating⦔ text, not dismissible: the
ModalDialoggets no title (which removes its header close button) and thecloseevent is ignored whileupdatingis true (swallows Escape and backdrop clicks).
The modal is suppressed while the current route is 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.
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.jsowns the state machine and exportsPOCKET_IDLE_MS(20 s) andPOCKET_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/keydownanywhere on the document), governed by apocket_modesetting whose values areoff,manualandauto. It defaults toauto: 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 oneuseWakeLockinstance per view, passed into the composable: a second instance would keep its ownwantedflag and release the sentinel the first still wants. Entering also skips the request when a sentinel is already held, becauseuseWakeLockstores 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
visibilitychangeand is still there when the app returns; the wake lock is re-acquired byuseWakeLock’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-index200 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 carriesisolation: isolate, keeping that ladder local to the card, and the overlay sits atz-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.
CardioExecuteCardtakes amapFrozenprop bound topocketActiveand 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.
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.
| Route | View | Notes |
|---|---|---|
#/home | HomeView | Activity overview |
#/exercises | ExerciseListView | List + search |
#/exercises/new | ExerciseFormView | Create mode |
#/exercises/:id | ExerciseDetailView | Read-only |
#/exercises/:id/edit | ExerciseFormView | Edit mode |
#/combos | ComboListView | List + search |
#/combos/new | ComboFormView | Create mode |
#/combos/:id | ComboDetailView | Read-only |
#/combos/:id/edit | ComboFormView | Edit mode |
#/sessions | SessionListView | List + search |
#/sessions/new | SessionFormView | Create mode |
#/sessions/:id | SessionDetailView | Read-only |
#/sessions/:id/edit | SessionFormView | Edit mode |
#/sessions/:id/execute | SessionExecuteView | Execution mode |
#/collections | CollectionsView | List + search |
#/collections/new | CollectionFormView | Create mode |
#/collections/:id | CollectionDetailView | Read-only |
#/collections/:id/edit | CollectionFormView | Edit mode |
#/stats | StatsView | Statistics + charts |
#/settings | SettingsView | User settings |
The navKeyMap in App.vue maps sub-routes (e.g., exercise-detail, exercise-edit) to their top-level nav key (exercises), so the nav item stays highlighted when the user navigates into detail/edit views.
5. Internationalization
Libraries: i18next + i18next-vue
Locale files: src/i18n/locales/en.json (English, default), src/i18n/locales/fr.json (French), src/i18n/locales/da.json (Danish)
Initialization: src/i18n/index.js registers all three locales and sets the default language to English. The composable useTranslation() from i18next-vue is used in all components β no direct i18next calls in templates.
Startup: src/main.js calls i18next.changeLanguage(stored_language) before the first render, so the UI always opens in the user’s persisted language.
Language switching: The Settings language selector calls i18next.changeLanguage() and persists the new value to settings.language. All reactive t() calls update immediately.
Date formatting: dateFormatter.js reads i18next.language as fallback but accepts an explicit lang parameter to enable reactive date display in Vue computed properties.
See How-to Guides β Add a New Language 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.
| View | Cap |
|---|---|
| List views (exercises, combos, sessions, collections) | 800 px |
| Detail views | 800 px |
| Form views | 600 px |
| Settings view | 600 px |
The cap is implemented per-view (not globally) so each screen can opt into a different readable width.
To add multi-column layout on wide screens
- Raise the cap on list views to
1200px(or remove it) and replace the vertical stack with a CSS grid usinggrid-template-columns: repeat(auto-fill, minmax(320px, 1fr)). - Keep detail and settings views at their current caps.
- Wrap only the cards in an inner
<div class="grid-wrapper">to avoid restyling the action bar and empty-state messages. - Apply the same pattern to all list views for consistency.