89 KiB
TrainUs — Implementation Plan (v3)
Last updated: 2026-07-26 Status: Step 24 (app-update modal), Step 25 (headset media controls), Step 26 (voice announcements), and Step 27 (manual "Check for updates" button) all manually validated by the user. Code-review remediation F1–F12 (see
docs/code-review-remediation-plan.md) manually validated by the user. Step 28 (first-load network failure: clear error + in-app retry) and Step 29 (request persistent storage) implemented, awaiting manual validation.
Overview
Greenfield PWA fitness app using Vue 3 + Vite, SQLite WASM (official) on OPFS, i18next (+ vue-i18next), Chart.js. Single user per device. English (default), French & Danish. Version sourced from package.json, injected by Vite at build time, stored in DB. Two modes: Edit (CRUD) and Execution (timers, logging). Single deployment (latest version at site root) with a stable database location and automatic startup migration.
Steps
Step 0 — Project Scaffolding & Infrastructure
Status: Done
- Initialize Vue 3 + Vite project in
src/ - Configure Vite: dev server with
Cross-Origin-Opener-Policy/Cross-Origin-Embedder-Policyheaders, build output todist/, inject app version viadefinefrompackage.json - Set up folder structure:
src/components/,src/composables/,src/db/,src/i18n/,src/styles/,src/views/,src/router/,src/utils/ - Port CSS from sample into
src/styles/— CSS variable system, add dark mode variable set - Integrate Bootstrap Icons (via
bootstrap-iconsnpm package) - Set up Vue Router (hash mode):
#/home,#/exercises,#/combos,#/sessions,#/stats,#/settings - Create app shell layout component: sidebar nav (desktop ≥ 700px) / bottom nav (mobile), action bar,
<router-view>content area — faithful to sample - Set up i18next + vue-i18next:
src/i18n/locales/en.json,src/i18n/locales/fr.json, reactive language switching - Create
manifest.json(PWA) with app name, icons placeholder, versioned start URL - Set up basic service worker (precache shell)
- Write initial
README.md, createdocs/technical-documentation.md,docs/decisions-log.md - Set up Playwright Python project in
tests/withconftest.py, targeting Firefox
Verification: npm run dev serves the app, responsive shell renders, routes switch views, i18n renders English keys, Playwright can launch in Firefox
Step 1 — Database Layer (SQLite WASM + OPFS)
Status: Done
- Integrate official SQLite WASM build
- Create
src/db/database.js— initializes SQLite in a Web Worker with OPFS VFS, namespaces DB file by app version (e.g.,trainus-1.0.0.sqlite3) - SQL schema:
exercise,combo,combo_exercise,session,session_item,collection,collection_session,settings(key/value),execution_log,execution_log_item,schema_version - On first launch: generate UUID, store
user_uuid,user_name,app_version,db_created_atinsettingstable - Repository layer (
src/db/repositories/) — one per entity withcreate,getById,getAll,update,delete,search - Vue composables (
src/composables/) wrapping repositories for reactive data in components
Verification: Playwright tests — DB initializes, UUID + app version persisted, CRUD records, data survives reload
Step 2 — Settings
Status: Done
- Create
SettingsView.vueat#/settings - Language selector: dropdown (English / French) — calls i18next
changeLanguage(), persisted insettingstable, loaded at startup - User name editor: text input, persisted in
settings - UUID display: read-only info field
- Dark/light mode toggle: switches CSS variable set on
<html>, persisted insettings - Download Database: export raw SQLite file via
sqlite3_js_db_export() - Restore Database: file picker → confirm dialog → validation pipeline (deserialize into
:memory:→ check required tables → version compatibility → adapt scripts if needed → table-by-table copy in transaction) → spinner during process → detailed report card (success with table/row counts, or translated error codes) - Release registry (
src/db/releases.js): tracks schema versions and breaking changes for restore compatibility - Placeholder sections for Export/Import (wired in Step 7)
Verification: Playwright tests — change language (all labels update reactively), change name (persists on reload), toggle theme (colors switch), red path: empty name rejected, download DB, restore flow (round-trip), restore with invalid file, restore with incomplete DB, cancel dialog aborts restore
Step 2b — Schema Hardening & Documentation Restructuring
Status: Done
- Schema changes (in-place, no version bump — not yet released):
- Add
UNIQUEconstraint onexercise.title,combo.title,session.title,collection.label - Add
suffixtable:user_uuid TEXT PRIMARY KEY,user_name TEXT NOT NULL,suffix TEXT NOT NULL UNIQUE— registry of known external users for import disambiguation
- Add
- Documentation restructuring:
- Rename
docs/technical-overview.md→docs/technical-documentation.md; enrich with full DB schema (ER diagram), architecture details, data flows - Rename
docs/user-guide.md→docs/user-documentation.md - Update all references across project files:
README.md,AGENTS.md,docs/plan.md,.opencode/config files - Rewrite
README.mdDocumentation section: link all docs and all tutorials/codelabs with short descriptions; bilingual links use(en / fr)format
- Rename
Verification: Playwright tests — UNIQUE constraints enforced, suffix table exists, all doc links resolve correctly
Step 3 — Exercise Management (Edit Mode)
Status: Done
ExerciseListView.vue: card/list display, search bar (reactive filter by title)ExerciseFormView.vue: form with all fields (title, description, asymmetric toggle + sub-option, image URLs, video URLs, default reps, default weight) — usesv-modelbindingsExerciseDetailView.vue: read-only, embedded images/videos (iframe for YouTube)- Routes:
#/exercises,#/exercises/new,#/exercises/:id,#/exercises/:id/edit - Input validation (title required, reps ≥ 0, weight ≥ 0, URL format)
- Action bar: New, Search buttons (via Vue Teleport with
defer) - Validation utility module (
src/utils/validation.js) - i18n keys for all labels, validation messages, and form elements (EN + FR)
Verification: Playwright tests — create, view, edit, delete, search, validation rejects bad input — 17 tests passing on Firefox and Chromium
Step 3b — Exercise JSON Export/Import
Status: Done
- JSON envelope format:
{ metadata: { appName, appVersion, schemaVersion, exportDate, exportedBy: { name, uuid } }, data: { exercises: [...] } }— extensible for all entities in Step 7 - Export service (
src/composables/useJsonExport.js): builds envelope, serializes selected entity arrays - Import service (
src/composables/useJsonImport.js): parses envelope, validatesappName+schemaVersion, detects ID collisions, applies import - Export features:
- Exercise list view: "Export" action bar button — exports all exercises
- Exercise detail view: "Export" button — exports single exercise
- Settings page: enable "Export Data" button with entity filter dialog (checkboxes per entity type; only "Exercises" active, others disabled with "Coming soon")
- Downloaded file:
trainus-exercises-YYYY-MM-DD.json
- Import features:
- Exercise list view: "Import" action bar button — file picker for
.json - Settings page: enable "Import Data" button — same file picker
- Import flow: parse → validate envelope → preview summary ("X exercises from NAME on DATE") → collision detection → result report
- Exercise list view: "Import" action bar button — file picker for
- ID collision strategy:
- No collision: insert directly
- Same user (
created_by= localuser_uuid):- Different ID: import as new exercise
- Same ID: show confirmation dialog with export date ("Overwrite 'Exercise X'? Exported on DATE") — options: Replace / Skip / Replace All / Skip All (batch)
- If an update causes a title conflict with a different existing exercise: prompt user to enter a new title
- Different user (
created_by≠ localuser_uuid):- If user UUID exists in
suffixtable: inform user the existing suffix will be used - If user UUID not in
suffixtable: prompt for a new suffix (pre-filled with exporter's name), validate uniqueness against existing suffixes, save(user_uuid, user_name, suffix)tosuffixtable - Same
created_byas existing object AND same ID: treat as update (no new ID generated) - Different
created_byfrom existing object AND same ID: generate a new UUID for import - Title format:
"Original Title [SUFFIX]"— if this title is already taken, prompt user to enter a new base title (suffix is always appended)
- If user UUID exists in
- Single dialog handles all collisions: grouped by type, batch actions (Replace All / Skip All), one suffix input per external user
- Settings — Suffix management:
- New section in Settings: list all entries from
suffixtable (user name, UUID, suffix) - Edit suffix: changing a suffix updates all titles containing
[OLD_SUFFIX]to[NEW_SUFFIX]acrossexercise,combo,session, andcollectiontables
- New section in Settings: list all entries from
- i18n: all new labels in
en.json/fr.json
Verification: Playwright tests — export/import round-trip, same-user collision (replace + skip + batch Replace All / Skip All), different-user collision (suffix prompt, title with [SUFFIX], new ID generated), update from same external user (no new ID), title conflict prompts, suffix rename cascades to titles, invalid file rejection, Settings suffix management, Settings page export filter, single exercise export from detail view
Step 3c — Backup Filename & Reminder
Status: Done
Scope is deliberately minimal: no automatic backups, no OPFS-stored backups, no schema change. The manual Download / Restore buttons from Step 2 remain the only backup mechanism. This step only:
- Renames the downloaded file so each manual backup is unique, sortable, and carries the app version.
- Tracks when the user last downloaded (or restored) a backup.
- Adds a non-intrusive warning indicator reminding the user to back up after a configurable number of days.
- Filename convention: manual DB download renamed from
trainus-{version}.sqlite3totrainus-{version}-backup-YYYYMMDD-HHhMMmSSs.sqlite3using local time (example for1.0.0at 2026-03-23 15:24:06 local →trainus-1.0.0-backup-20260323-15h24m06s.sqlite3). Lettersh,m,sreplace the Windows-invalid colon. - New settings keys (no schema change; uses existing
settingskey/value table):last_backup_at— ISO-8601 UTC timestamp (...Z). Set on successful Download and on successful Restore. Absent = never backed up.backup_reminder_days— integer stored as string, default"7". User-configurable in Settings (valid range 1–365).db_created_at— ISO-8601 UTC timestamp set on first launch. Used as fallback for backup reminder whenlast_backup_atis absent, so fresh databases don't immediately show a stale warning. Also displayed on the Statistics page.
- Backup reminder indicator:
- Warning-styled button, leftmost in the action bar (
#actionbar-actions), visible on every page whennow - last_backup_at > backup_reminder_daysOR whenlast_backup_atis absent. - Icon:
bi-exclamation-triangle-fill. Tooltip: "Last backup {{days}} day(s) ago — click to back up" or "No backup yet — click to create one" (translated). - Click: navigate to
#/settingsand focus/scroll the Database section. - A secondary
×dismisses the reminder for the current session only (not persisted). Reloads re-show it if still stale. - Hidden entirely when backup is fresh.
- Warning-styled button, leftmost in the action bar (
- Settings UI additions (Database section):
- Display "Last backup: {{localDateTime}}" or "Never backed up".
- Number input "Remind me every N days" (1–365; default 7) with inline validation.
- i18n: new EN + FR keys under
settings.backup.*andbackup.reminder.*. - New files:
src/utils/backupFilename.js— pureformatBackupFilename(version, date).src/composables/useBackupStatus.js— reactivelastBackupAt,daysSinceBackup,isStale,dismissed,markBackedUp(),dismiss().src/components/BackupReminder.vue— teleported warning button.tests/test_step3c_backup.py— Playwright tests (Firefox + Chromium).
- Modified files:
src/views/SettingsView.vue— use new filename, setlast_backup_aton Download/Restore success, render Database section additions.src/App.vue— mount<BackupReminder>once at app level so it teleports into each page's action bar.src/i18n/locales/en.json,fr.json— new translation keys.
Verification: Playwright tests — download uses new filename pattern, last_backup_at set on download, reminder appears after threshold, reminder hidden when fresh, reminder hidden after dismiss within same session, reminder reappears on reload if still stale, clicking reminder navigates to Settings, Settings shows last backup date, changing backup_reminder_days re-evaluates reminder visibility, restore updates last_backup_at to now, invalid threshold rejected, filename is filesystem-safe (no colons, zero-padded), download failure does not update last_backup_at.
Step 4 — Combo Management (Edit Mode)
Status: Done
ComboListView.vue,ComboFormView.vue,ComboDetailView.vue- Type picker (EMOM / AMRAP / TABATA / NONE), exercise selector (pick from existing, reorder with up/down buttons), timer config (varies by type: duration for EMOM/AMRAP, rounds for TABATA)
- Routes:
#/combos,#/combos/new,#/combos/:id,#/combos/:id/edit comboRepositoryupdated withcreateWithIdandreplaceAllfor import support
Verification: Playwright tests — create combo with exercises, reorder, type change, validation, edit, delete, search
Step 4b — Combo Exercise Reps & Weight
Status: Done
Add default_reps and default_weight fields to exercises within combos. Each exercise in a combo has its own reps count and weight, shown in edit mode and used as defaults during execution.
- Schema migration:
- Add
default_reps INTEGER NOT NULL DEFAULT 1anddefault_weight REAL NOT NULL DEFAULT 0.0columns tocombo_exercisetable - Increment
SCHEMA_VERSIONfrom 1 to 2 - Add migration script for existing databases (
ALTER TABLE combo_exercise ADD COLUMN ...)
- Add
- Repository changes (
src/db/repositories/comboRepository.js):- Update
setExercises()to accept array of objects{ exerciseId, position, defaultReps, defaultWeight }instead of plain IDs - Update
getExercises()to returndefault_repsanddefault_weightcolumns alongside existing fields
- Update
- ComboFormView.vue:
- Add inline reps and weight inputs per exercise in the ordered list
- Reps: number input, default 1, validation ≥ 1
- Weight: number input, default 0.0, validation ≥ 0
- Display format in list: exercise title + "× {reps}" + " @ {weight}kg" (or just title if both are defaults)
- ComboDetailView.vue:
- Display reps and weight for each exercise in the list (e.g., "3 × 50 kg" format next to exercise name)
- i18n (
en.json/fr.json):combos.form.reps/combos.form.repsLabelcombos.form.weight/combos.form.weightLabelcombos.exercise.repsWeight— display format string- Validation messages for reps < 1 and weight < 0
- Import/Export impact:
useJsonExport.js: includedefault_repsanddefault_weightin combo exercise export datauseJsonImport.js: handle these fields on import (no special collision logic needed)
- Modified files:
src/db/schema.js— add columns, bump versionsrc/db/repositories/comboRepository.js— updatesetExercisesandgetExercisessrc/views/ComboFormView.vue— add reps/weight inputs per exercisesrc/views/ComboDetailView.vue— display reps/weightsrc/composables/useJsonExport.js— include new fieldssrc/composables/useJsonImport.js— handle new fieldssrc/i18n/locales/en.json,fr.json— new translation keys
Verification: Playwright tests — create combo with exercises and set reps/weight (saved correctly), edit combo and change reps/weight (updated), detail view displays reps/weight, validation rejects reps < 1 and weight < 0, export/import round-trip preserves reps/weight values
Step 5 — Session Management (Edit Mode)
Status: Done
SessionListView.vue,SessionFormView.vue,SessionDetailView.vue- Ordered list of items (add exercise OR combo), reorder with up/down buttons
- Per-item reps and weight: each session item has its own
repetitions(already in schema) and a newweightfield, similar to combo exercises. These override the exercise's defaults when the session is executed.- Add
weight REAL NOT NULL DEFAULT 0.0tosession_itemtable (in-place, no version bump — not yet released) - Inline weight input per item in the form (number input, default 0.0, validation ≥ 0)
- Display format in list: item title + "× {reps}" + " @ {weight}kg" (or just title if both are defaults)
- Add
- Routes:
#/sessions,#/sessions/new,#/sessions/:id,#/sessions/:id/edit - Repository changes (
src/db/repositories/sessionRepository.js):- Update
setItems()to accept array of objects{ itemId, itemType, position, repetitions, weight } - Update
getItems()to returnweightalongside existing fields, with titles from JOIN queries
- Update
- Import/Export impact:
useJsonExport.js: includeweightin session item export datauseJsonImport.js: handle this field on import
- i18n (
en.json/fr.json):sessions.form.weight/sessions.form.weightLabelsessions.item.repsWeight— display format string- Validation message for weight < 0
Verification: Playwright tests — create session with exercises/combos and set reps/weight (saved correctly), edit session and change reps/weight (updated), detail view displays reps/weight, validation rejects weight < 0, export/import round-trip preserves reps/weight values
Step 6 — Collection Management & Home Page
Status: Done
CollectionsView.vue(at#/collections),CollectionFormView.vue,CollectionDetailView.vue- Label + ordered list of sessions, reorder with up/down buttons
- Routes:
#/collections,#/collections/new,#/collections/:id,#/collections/:id/edit HomeView.vue(at#/home): stat cards for sessions in last 7/30 days, last 4 executed sessions with inline exercise summaries and play buttons, history section grouped by day with date filter (default last 30 days)collectionRepository.getAll()enriched withsessionCountvia JOIN query- App.vue
navKeyMapupdated to map collection sub-routes tocollectionsnav key useHtmlExport.js: generates a self-contained HTML export of the home page (embedded icon, stats, recent sessions, filtered history — no play buttons or links); triggered by an action bar button visible when data exists
Verification: Playwright tests — CRUD collections, add/reorder/remove sessions, search, validation, duplicate label rejected, navigate to session from collection detail, home empty state, home shows recent 4 sessions, home shows correct 7-day and 30-day counts, history grouped by day, history date filter, play button on recent card, HTML export button present/absent — 23 tests passing on Firefox and Chromium
Step 7 — Import / Export (Full Data)
Status: Done
What was implemented:
- Extended JSON envelope
datato include all entity types:combos,sessions,collections,executionLogs,settings - Cross-entity reference remapping on import: ID collision resolution updates all referencing entities (
combo_exercise.exercise_id,session_item.item_id,collection_session.session_id,execution_log.session_id,execution_log_item.exercise_id) - Enabled all entity checkboxes in Settings export filter dialog (combos, sessions, collections, execution logs, settings)
- Added export/import buttons to combo, session, and collection list + detail views
- Generalized suffix collision strategy to all entity types (title/label field)
- Full export: all entities + settings in one JSON file (
trainus-full-YYYY-MM-DD.json) - Single entity export from detail views (combo, session, collection)
- "Export All Data" button in Settings for one-click full export
- ImportDialog generalized to handle multiple entity types with collision resolution per entity type
- Repository additions:
createWithId,replaceAll,getAllWithXfor combo, session, collection, executionLog
Decisions made:
- Settings import excludes
user_uuid,app_version,db_created_at,last_backup_atto preserve local identity - Execution logs are imported only if the referenced session exists (or was imported in the same batch)
- Suffix is applied once per external user and affects all entity types from that user
- Import dialog detects multi-entity envelopes and uses the new generalized flow; single-entity exercise files use the legacy flow for backward compatibility
What remains:
- Manual validation by the user
- Some tests need refinement for Firefox browser compatibility
Additional tests added (2026-06-02):
test_execution_logs_same_user_roundtrip: execution log + item survive export → clear → reimporttest_execution_log_id_remapping: log itemexercise_idis remapped when exercise getsnew_idtest_session_with_combo_item_remapping: sessionitem_type=comboitem_id follows combo remaptest_collection_session_remapping: collection session ref follows sessionnew_idremaptest_settings_import_roundtrip:languageandthemeapplied from settings-only envelopetest_single_entity_export_buttons_session_collection: export button visible on session/collection detail- Refactored
test_new_id_propagation_to_comboto load fromtests/fixtures/cross_user_id_collision.json - Added
tests/fixtures/cross_user_full.jsonfor cross-user remapping tests
Step 8 — Execution Mode (Core)
Status: Done
Decisions
| Decision | Choice | Rationale |
|---|---|---|
| Combo repetitions | Full sequence × N rounds | repetitions on a session combo item = number of full passes through the combo exercise list |
| Schema change | Add combo_id + round_number to execution_log_item (v2 → v3) |
Enables accurate per-combo statistics in Step 9; nullable columns, no FK constraint |
| Abandon behavior | Save partial log | Completed items saved, unfinished omitted; session appears in history as partial |
| Mid-round transition | Auto-advance silently | Only the final round of a combo shows a pause screen |
| Final-round screen | Continue + One More | After the last exercise of the last combo round: "Continue →" advances to the next session item; "+ One More Round" appends one extra round to the queue (repeatable) |
| Prefill source | Last run of this session | Reps/weight prefilled from the most recent execution_log for this specific session; falls back to item/exercise defaults if no prior log |
| Asymmetric side logic | alternate=true → flip side per step; alternate=false → flip side per set |
Matches the exercise definition; side logged in execution_log_item.side |
Execution Queue Model
When a session starts, the app builds a flat linear queue of steps from the session items. A combo with repetitions = N unrolls into N × (number of exercises in combo) steps:
Session items:
1. Exercise A ×3 reps, 50kg → 1 step
2. Combo "HIIT" ×2 rounds → 4 steps (2 exercises × 2 rounds)
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 (reps=3, weight=50)
[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 (reps=5, weight=0)
Each step carries: exerciseId, exerciseTitle, reps, weight, comboId?, comboTitle?, roundNumber?, totalRounds?, asymmetric, alternate, side?.
UI Layout
┌─────────────────────────────────────────┐
│ Monday Workout [✕ exit] │
│ ████████████░░░░░ 3 / 6 steps │
├─────────────────────────────────────────┤
│ ▶ NOW │
│ ┌───────────────────────────────────┐ │
│ │ Push-up │ │
│ │ Combo: HIIT — Round 2 of 2 │ │ ← combo context (hidden for exercises)
│ │ │ │
│ │ Reps: [ 10 ] │ │
│ │ Weight: [ 0 ] kg │ │
│ │ Side: RIGHT ↔ │ │ ← only for asymmetric exercises
│ │ │ │
│ │ [ ✓ Done ] │ │
│ └───────────────────────────────────┘ │
│ │
│ ↓ NEXT │
│ Squat — 15 reps (HIIT, Round 2) │
└─────────────────────────────────────────┘
Final-round transition screen (replaces the card after the last exercise of the last combo round):
│ ✓ HIIT — 2 rounds complete │
│ │
│ [ Continue → ] [ + One More Round ] │
Schema change (v2 → v3)
Two nullable columns added to execution_log_item:
ALTER TABLE execution_log_item ADD COLUMN combo_id TEXT DEFAULT NULL;
ALTER TABLE execution_log_item ADD COLUMN round_number INTEGER DEFAULT NULL;
NULL for standalone exercises; populated for exercises inside a combo. No FK constraint (combo may have been deleted before statistics are viewed).
Files to create / modify
New:
src/views/SessionExecuteView.vue— execution UI (stepper, current/next display, reps/weight inputs, side indicator, final-round screen)src/composables/useSessionExecution.js— queue building, current/next step state, prefill logic, done/advance/exit logic, save to log
Modified:
src/db/schema.js— addcombo_id+round_numbercolumns, bumpSCHEMA_VERSION2 → 3, add migration scriptsrc/db/repositories/executionLogRepository.js— addgetLastItemsBySession(sessionId)returning mapexerciseId → { reps_done, weight_used, side }from most recent log for that sessionsrc/router/index.js— add{ path: '/sessions/:id/execute', name: 'session-execute', component: SessionExecuteView }src/views/SessionDetailView.vue— add play button in action barsrc/views/SessionListView.vue— add play icon per session rowsrc/i18n/locales/en.json,fr.json— new keys underexecution.*
Not in this step (deferred):
- Timers (EMOM/AMRAP/TABATA) → Step 12
- Statistics charts → Step 9
Verification: Playwright tests — start session navigates to execute view, steps advance on Done, reps/weight editable and saved, asymmetric side shown and logged, combo rounds auto-advance between non-final rounds, final-round screen shows Continue + One More, One More appends extra round, Continue advances to next session item, exit mid-session saves partial log (done items only), re-execute prefills from last session run, session with no prior log prefills from defaults, progress bar reflects current position
Step 9 — Statistics
Status: Done
StatsView.vueat#/stats- Displays database creation date: "You are using TrainUs since [formatted date]"
- Date formatting uses centralized
dateFormatter.jsutility with locale-aware formatting viaIntl.DateTimeFormat(MM/DD/YYYY English, DD/MM/YYYY French, DD.MM.YYYY Danish) chart.js+vue-chartjsv5- Summary cards: total sessions, total exercises done, current day streak
- Date range filter: quick tabs — 7d / 30d / 3m / All (default: 30d)
- Frequency (bar): sessions grouped by week or month (toggle)
- Volume (bar): total volume (
reps_done × weight_used) per session execution over time - Progression (line): reps + weight over time for a selected exercise (one at a time)
src/composables/useStats.js— all stat queries
Deferred to Step 9b: Statistics export (e.g. CSV export of chart data / execution summary)
Verification: Playwright tests — creation date displayed, date format changes with language, summary cards show correct counts, cards respond to date range change, streak count correct, frequency/volume/progression charts render, no-data messages shown when period is empty
Step 10 — PWA Finalization & Polish
Status: Done
- Migrated from hand-rolled
public/sw.jstovite-plugin-pwa+ Workbox (registerType: 'autoUpdate', 10 MB cache limit to coversqlite3.wasm) manifest.jsonupdated:purposefield on both icons, both icon files now exist (192 × 512 placeholder PNGs)index.html: removed manual SW registration script (auto-injected by vite-plugin-pwa), added<link rel="icon">and<link rel="apple-touch-icon">- Install prompt:
useInstallPrompt.jscomposable +InstallPrompt.vuecomponent (session-dismissible, teleported into action bar, shows only whenbeforeinstallpromptfires) - Accessibility (critical flows):
aria-labelon all icon-only actionbar buttons across every view,role="progressbar"+aria-valuenow/min/maxon execution progress bar,role="alert"+aria-describedbyon form error messages,required/aria-requiredon required inputs,role="dialog"+aria-modal+aria-labelledby+ focus-on-mount + Escape key onModalDialog - Responsive 400px: wrapping breakpoints added to exercise, combo, session, and collection form views for dense list items and form action rows
- Placeholder icons generated (dark bg + blue "T"); real SVG logo deferred
Verification: 10/11 tests pass on Firefox and Chromium (1 skipped: progress bar test requires a pre-existing session in the DB)
Step 11 — Documentation & Final Testing
Status: Done
- Full test suite run: 434 passed, 2 skipped on Firefox + Chromium — no failures
docs/technical-documentation.md: complete rewrite — architecture, full ER diagram (updated for all columns), routing table, all feature sections (Steps 0–10), i18n, versioning, build & deploydocs/user-documentation.md: updated — added missing Sessions section, generalized Export/Import section to cover all entity types, added PWA install infodocs/decisions-log.md: catch-up entries added for Steps 4 and 7tests/README.md: new file — setup, run commands, browser targets, file inventoryREADME.md: updated tech stack (removed step annotations, added vite-plugin-pwa), added tutorial rows for Steps 3, 3b, 5docs/plan.md: Step 11 marked done
Verification: all tests green on Firefox + Chromium, docs complete, manual walkthrough by user
Step 12 — Timers & Combo Execution (Optional — Stretch Goal)
Status: Done
Timer behavior per combo type
EMOM (Every Minute On the Minute) — timer drives advancement:
repetitionson the session item = total duration in minutes. The number of rounds isfloor(repetitions / number_of_exercises_in_combo). The session form displays the field as Duration (min) for EMOM items (same as AMRAP).- Each exercise occupies one 60-second slot. When the slot timer reaches 0, the app auto-advances to the next step (the log item is saved as completed even if the user never tapped Done). Tapping Done early advances immediately and resets the timer for the next slot.
- Sound pattern per slot:
bipat 30 s remaining,bipat 2 s remaining,bipat 1 s remaining,BIPat 0 s.
AMRAP (As Many Rounds As Possible) — one countdown for the whole combo, user advances manually:
- Duration =
repetitionsminutes (therepetitionsfield label changes to "Duration (min)" in session form, detail, and execute views when the item is an AMRAP combo). - The user taps Done to advance through exercises and rounds as fast as they can.
- Sound pattern:
BIPat every elapsed full minute, except the final minute which uses the EMOM pattern —bipat 30 s remaining,bipat 2 s remaining,bipat 1 s remaining,BIPat 0 s. - When the timer reaches 0: show a "Time's up" screen → user taps Continue to advance to the next session item.
TABATA — timer drives each phase automatically:
- Work time and rest time are per-combo, stored in
timer_configJSON (defaults: work = 20 s, rest = 10 s). - Per exercise slot: work phase starts with
BIP→ countdown → auto-transition to rest phase → rest phase starts withBIP→ countdown → auto-advance to next exercise. - After the last exercise of the last round: show the existing final-round screen (Continue / One More Round from Step 8).
- Duration =
(workTime + restTime) × number_of_exercises_in_combo × rounds. ComboFormViewshows "Work time (s)" and "Rest time (s)" number inputs (min 1) when type = TABATA.
Sounds (Web Audio API)
Two sound types synthesised via AudioContext — no audio assets required:
| Name | Frequency | Duration | Gain |
|---|---|---|---|
bip |
440 Hz | 0.1 s | low |
BIP |
880 Hz | 0.3 s | high |
Pause
A pause button is visible whenever a timer is active. Pausing freezes the countdown and suppresses scheduled sounds. Resuming continues from the frozen value. Pause works in both work and rest phases (TABATA).
UI additions
Timer display — shown inside the exercise card for timed combos:
│ Push-up │
│ Combo: HIIT — Round 2 of 2 │
│ │
│ 00:42 │ ← turns red when < 10 s
│ │
│ [⏸ Pause] [ ✓ Done ] │
TABATA rest screen — replaces the exercise card during rest phase:
│ REST │
│ Next: Squat │
│ │
│ 00:08 │
│ │
│ [⏸ Pause] │
AMRAP "Time's up" screen — shown when the overall AMRAP timer reaches 0:
│ ⏱ Time's up — HIIT │
│ │
│ [ Continue → ] │
Schema
No schema version bump needed. The timer_config column already exists on combo as a JSON text field:
- TABATA:
{ "workTime": 20, "restTime": 10 } - EMOM / AMRAP:
{}ornull(duration derived fromrepetitionsat runtime)
Files to create / modify
New:
| File | Purpose |
|---|---|
src/composables/useTimer.js |
Core countdown: start, pause, resume, reset, per-second tick with callback hooks |
src/composables/useAudioCues.js |
Web Audio API helpers: playBip(), playBIP(), AudioContext lifecycle |
src/components/TimerDisplay.vue |
Visual countdown (MM:SS), red-when-low styling, pause/resume button |
Modified:
| File | Change |
|---|---|
src/composables/useSessionExecution.js |
Wire timer into step lifecycle: EMOM/TABATA auto-advance on expiry; AMRAP time-up; sound scheduling |
src/views/SessionExecuteView.vue |
Render <TimerDisplay>, TABATA rest screen, AMRAP time-up screen, pause state |
src/views/ComboFormView.vue |
Work/rest time inputs shown only when type = TABATA; saved to timer_config |
src/views/SessionFormView.vue |
AMRAP combo item label: "Repetitions" → "Duration (min)" |
src/views/SessionDetailView.vue |
Same AMRAP label change |
src/i18n/locales/en.json + fr.json |
New keys under execution.timer.*, combos.form.workTime, combos.form.restTime, sessions.form.durationMin |
docs/plan.md |
Step marked done |
Note: Once implemented, Step 10 (PWA) and Step 11 (documentation) will need a follow-up review pass.
Verification: Playwright tests — EMOM timer auto-advances at 0 s, EMOM sounds fire at 30/2/1/0 s, EMOM manual Done advances early and resets timer; AMRAP BIP fires every elapsed minute, AMRAP last-minute bip/bip/BIP pattern fires correctly, AMRAP time-up screen appears at 0 and Continue advances; TABATA work→rest→next-exercise cycle, BIP at start of each phase, pause works in both phases; TABATA ComboForm shows work/rest inputs only for TABATA type and values round-trip correctly; session form shows "Duration (min)" for AMRAP combo items; non-timed combos (NONE) show no timer and behave as in Step 8; mixed session (timed + non-timed items) works end-to-end; pause freezes countdown and suppresses sounds; resume continues from frozen value
Step 13 — Markdown in Description Fields
Status: Done
- Enabled
marked+DOMPurifyfor rendering description fields in exercise, combo, and session detail views (already used as dependencies; this step wired them into the detail views) - Added "Basic Markdown supported" hint text next to each description label in create and edit forms
- Description is intentionally not shown on list view cards (only in detail views)
src/utils/markdown.jscentralises the sanitized rendering pipeline; all three detail views call it viav-html
New / modified files:
src/utils/markdown.js—renderMarkdown(text)usingmarked.parse+DOMPurify.sanitizesrc/views/ExerciseDetailView.vue,src/views/ComboDetailView.vue,src/views/SessionDetailView.vue— render description viav-htmlsrc/views/ExerciseFormView.vue,src/views/ComboFormView.vue,src/views/SessionFormView.vue— hint text alongside description labelsrc/i18n/locales/en.json,fr.json—"markdownHint"translation key
Verification: Playwright tests — markdown hint visible on exercise/combo/session forms; bold, italic, and list items render as HTML in exercise detail; bold renders in combo and session details; description absent from all list cards
Step 14 — Form Keyboard Shortcuts
Status: Done
Ctrl+Entersaves any create or edit form, even when a text field is focusedEscapecancels the form and navigates back (to list on new forms, to detail on edit forms), even when a text field is focused- Save button exposes a
titleattribute showing the shortcut hint (visible as a native tooltip on hover) - Shortcuts are attached to the form element's
keydownevent and skip when the browser is composing (IME) - Applies to Exercise, Combo, Session, and Collection forms
Modified files:
src/views/ExerciseFormView.vue,src/views/ComboFormView.vue,src/views/SessionFormView.vue,src/views/CollectionFormView.vue— keydown handler, save buttontitlesrc/i18n/locales/en.json,fr.json—"saveShortcutHint"translation key
Verification: Playwright tests — Ctrl+Enter saves from body and from within an input; Ctrl+Enter with empty title shows validation error (does not save); Escape from body and from within an input navigates back; Escape on edit form returns to detail view; Save button title contains the shortcut hint; shortcuts confirmed on session, combo, and collection forms
Step 15 — Audio Cues Refinement
Status: Done
Iterative refinement of the audio cue timing introduced in Step 12, driven by deterministic fake-clock tests that exposed subtle off-by-one issues in the scheduling logic.
- TABATA:
playBipfires at the exact halfway point of each work and rest interval (not at 30 s absolute like EMOM — relative to the phase duration) - TABATA:
bip, bip, BIPcountdown fires atr = 2,r = 1, and phase expiry for both work and rest phases - AMRAP:
playBip(soft) fires at each elapsed full minute tick; the half-time signal usesplayBIP(loud) — both corrected to fire on the right tick - Tests use
page.clock.install()to control fake time, making timer assertions instant and deterministic without realsetTimeoutwaits
Modified files:
src/composables/useSessionExecution.js—_tabataCountdown,_amrapSounds,_maybeStartAmrapTimerhelpers adjusted for correct relative timingtests/test_step15_audio_cues.py— new test file usingAudioContextmock +page.clock
Verification: Playwright tests — TABATA bip at half work/rest interval; bip/bip/BIP countdown at end of work and rest phases; AMRAP per-minute soft bip fires correctly; all assertions driven by fake clock
Step 16 — Single Deployment + Startup DB Migration
Status: Done — detailed plan in plan-step16-single-deployment-migration.md; manually validated by the user
Replaces the "versioned deployments with isolated data" model: a single deployment (latest version at site root), a stable OPFS database location (no version suffix), and a transactional migration step at app startup driven by the RELEASES registry. When a migration is pending, a blocking screen forces the user to download a backup before the update runs. No SCHEMA_VERSION bump (stays at 1).
Key files:
src/db/releases.js—adaptScriptrenamed tomigrationScript; newgetMigrationScripts(from, to)helper shared by startup migration and restore adaptationsrc/db/worker.js— stable OPFS directory (.trainus);getSchemaStatus(fresh / up-to-date / incompatible / migration-pending) andrunMigrations(one transaction per release, rollback on failure) message handlerssrc/db/database.js— init pauses onmigration-pending(setsdb.migrationPending);runPendingMigrations(); error codesDB_NEWER_THAN_APP/DB_MIGRATION_FAILEDsrc/App.vue— blocking migration screen (forced backup download enables the Update button) + error-code → translated message mappingsrc/composables/useBackupDownload.js— backup download logic shared by SettingsView and the migration screentests/test_step16_migration.py— green + red paths, Firefox + Chromium
Verification: Playwright tests — fresh install stamps schema_version; data persists across reloads; OPFS dir is unversioned; full migration flow (forced backup → Update → data intact); DB-newer-than-app blocks safely; migration screen cannot be bypassed. Migration-failure rollback test deferred until a real v2 migration script exists.
Improvement Plan (post-v1.0 review) — docs/improvement-plan.md
Status: Done — manually validated by the user
All items from the improvement plan have been implemented. See docs/improvement-plan.md for the original descriptions and DoDs.
| Item | Description | Status |
|---|---|---|
| R1 | Batch transactions for multi-statement repository writes | ✅ Done |
| R2 | Atomic full-DB restore (single transaction) | ✅ Done |
| R3 | Wall-clock timer (timestamp-based, drift-free) | ✅ Done |
| R4-B | Execution log status column + route-leave guard | ✅ Done |
| R5 | DB worker crash → error overlay + immediate reject | ✅ Done |
| R6 | Remove duplicate getAllWithItems key |
✅ Done |
| R7 | Deduplicate AMRAP round-adder (_appendRound) |
✅ Done |
| R8 | Add ESLint (flat config, 0 errors/warnings) | ✅ Done |
| R9 | Fix O(n²) import (Map-based title lookup) | ✅ Done |
| R10 | Escape LIKE wildcards in all search() methods |
✅ Done |
| R11 | validateMin returns {key, params} for interpolation |
✅ Done |
| R12 | 404 catch-all route → redirect to /home | ✅ Done |
| R13 | Superseded by Step 16 | ✅ N/A |
| R14 | Cross-user import skips settings | ✅ Done |
| R15 | Delete confirms show history count | ✅ Done |
| R16 | GitHub Actions CI (lint + build + test) | ✅ Done |
| R17 | Documentation refresh (locale claims, COOP/COEP, lint) | ✅ Done |
| R18 | Test-infra: isolate tmp paths per pytest run | ✅ Done |
Key files changed: src/db/worker.js, src/db/database.js, src/db/schema.js, src/db/repositories/*.js, src/composables/useTimer.js, src/composables/useSessionExecution.js, src/composables/useJsonImport.js, src/views/SessionExecuteView.vue, src/views/ExerciseDetailView.vue, src/views/SessionDetailView.vue, src/router/index.js, src/utils/escapeLike.js, src/utils/validation.js, eslint.config.js, .github/workflows/ci.yml, tests/conftest.py, tests/test_step17_transactions.py, tests/test_step18_execution_abandon.py, tests/test_step19_db_resilience.py, tests/test_step20_search_escaping.py
Website — Hugo static site (website/)
Status: Steps 1–5 done (2026-06-14) — manually validated by the user
Hugo 0.123.7 static site under website/. FR default at /, EN at /en/. Deployed to a subpath of the personal domain on Uberspace (manual deploy, no CI). No tutorials section. Blog lives on this project site.
| Step | Description | Status |
|---|---|---|
| W1 | Scaffold: hugo new site, hugo.toml, npm scripts, .gitignore |
✅ Done |
| W2 | Base theme: baseof.html, partials, main.css, single.html, list.html |
✅ Done |
| W3 | Home page: hero, greyed CTA button, features grid, i18n strings | ✅ Done |
| W4 | Docs: port EN docs, FR skeletons, Mermaid hook, stub old docs, update AGENTS.md | ✅ Done |
| W5 | Blog: _index, first FR post + EN skeleton, RSS autodiscovery verified |
✅ Done |
| W6 | Deploy: set baseURL, set params.appUrl, upload dist/ to Uberspace |
🔲 TODO |
| W7 | EN docs: port user-guide.en.md to the FR Diataxis split (2026-06-16) |
✅ Done |
Key files: website/hugo.toml, website/assets/css/main.css, website/layouts/, website/content/docs/, website/content/blog/, website/i18n/
Known quirk: Mermaid render hook must be at layouts/_default/_markup/render-codeblock-mermaid.html (not layouts/_markup/ — Ubuntu pkg 0.123.7 only picks up the _default/ path).
Backlog — JSON import: title-conflict resolution UX
Status: ✅ Done (2026-06-16)
Found: 2026-06-16, while writing the user documentation and tracing useJsonImport.js to answer a question about what drives import conflict detection. Conflict detection during import was id-only: a title match against a different existing row either silently failed (same-user) or only offered a forced rename (cross-user, after suffixing).
What changed:
_analyzeEntityListnow detects title conflicts (different id, same title) for same-user imports and folds them into the existingsameUserCollisionsbucket — the same Replace/Skip screen (with batch Replace All / Skip All) used for id conflicts handles them with zero new UI._importEntityListresolves same-user collisions againstcollision.existing.id(notcollision.imported.id), so Replace updates the existing row in place and Skip leaves it untouched — both redirect any same-batch reference to the imported id (e.g. a combo'sexercise_id) to the existing row's id via the sameidMapmechanism already used for cross-usernew_idremapping.- Cross-user title conflicts (suffixed title collides with a previously-imported item from the same external user) now also resolve via Replace/Skip instead of a forced rename —
ImportDialog.vuefolds them into the same per-entity-type resolution arrays once detected inonApply. - Cleanup:
analyzeExercises,applySameUserImport,applyDifferentUserImport, andcheckTitleConflictinuseJsonImport.jsturned out to be dead code (confirmed via grep — never called for applying;analyzeAllalready produces an identical shape for exercise-only envelopes). Removed, along with the "legacy exercise-only" branch inImportDialog.vue'sonMountedand the now-unreachable forced-renametitleConflictstep/state.analyzeAll/applyAllImportnow handle every envelope shape.
Key files changed: src/composables/useJsonImport.js, src/components/ImportDialog.vue, src/i18n/locales/{en,fr,da}.json, tests/test_step21_import_title_conflicts.py (new), website/content/docs/comprendre.fr.md, website/content/docs/user-guide/index.fr.md, website/content/docs/user-guide.en.md, website/content/developers/technical.en.md.
Out of scope (unchanged): an id-collision where the new title also conflicts with an unrelated third row still surfaces as a raw DB error, same as before.
Backlog — Save filename prompt on every disk save
Status: ✅ Done (2026-06-16)
Found: 2026-06-16, user request — every file save (JSON exports, home HTML export, SQLite backup) should ask for a filename, prefilled with the previous default, before saving.
What changed:
- New singleton composable
useSaveFilePrompt.js(promptFilename(defaultFilename)→Promise<string|null>, splits the default into stem + extension) and new componentSaveFileDialog.vue(editable stem, fixed extension, Enter-to-confirm), mounted once inApp.vueso it's available app-wide, including the blocking pre-migration backup screen. - The 3 existing low-level download functions —
downloadJson(jsonEnvelope.js),downloadBackup(useBackupDownload.js), the localdownloadHtml(useHtmlExport.js) — call the prompt before building the blob/anchor and no-op on cancel. No view-level export call site changed. downloadBackup()now returnsfalseon cancel;App.vue'sonMigrationBackuponly enables the Update button if the save actually completed.- Fix found along the way:
ModalDialog'sz-index: 1000was below the blocking.db-overlay's9999, making the dialog unclickable from the migration screen — bumped to10000. - Fix found along the way: a few export call sites (
useJsonExport.js'sdownloadJsoncalls,SettingsView.vue'sonExportAll,HomeView.vue'sonExportHtml) weren't awaited; addedawaitsince the call now blocks on user input.
Key files changed: src/composables/useSaveFilePrompt.js (new), src/components/SaveFileDialog.vue (new), src/utils/jsonEnvelope.js, src/composables/useBackupDownload.js, src/composables/useHtmlExport.js, src/composables/useJsonExport.js, src/components/ModalDialog.vue, src/App.vue, src/views/SettingsView.vue, src/views/HomeView.vue, src/i18n/locales/{en,fr,da}.json, tests/test_step22_save_filename_prompt.py (new), tests/test_step2_settings.py, tests/test_step3c_backup.py, tests/test_step16_migration.py (updated to confirm the dialog before asserting on the download), website/content/developers/technical.en.md, website/content/docs/user-guide.en.md, docs/decisions-log.md.
Backlog — Clear (×) button on list search fields
Status: ✅ Done (2026-06-18)
Found: 2026-06-18, user request — the search field on the four list views (Exercises, Combos, Sessions, Collections) had no quick way to clear the text short of selecting/deleting it manually or toggling search off and back on.
What changed:
- Each of
ExerciseListView.vue,ComboListView.vue,SessionListView.vue,CollectionsView.vuegained aclearSearch()function and a clear button (bi-x-lg) rendered inside the search input, visible only when the field has text. The button uses@mousedown.prevent(not@click) so it clears focus-safely without triggering the existing blur-to-close-search-bar logic on the old value. - New shared i18n key
common.clearSearch(EN/FR/DA) for the button'stitle/aria-label. - New
data-testid="<entity>-search-clear"per view, following the existing<entity>-search-*convention.
Key files changed: src/views/ExerciseListView.vue, src/views/ComboListView.vue, src/views/SessionListView.vue, src/views/CollectionsView.vue, src/i18n/locales/{en,fr,da}.json, tests/test_step3_exercises.py, tests/test_step4_combos.py, tests/test_step5_sessions.py, tests/test_step6_collections_home.py, website/content/docs/user-guide/index.{en,fr}.md, website/content/developers/technical.{en,fr}.md, docs/decisions-log.md.
Backlog — First-launch warning modal (single tab / no private browsing)
Status: ✅ Done (2026-07-04)
Requested: 2026-07-03, user request — on first app startup, show a warning modal telling the user (1) the app can only be open in one browser tab at a time, and (2) the app does not work in private/incognito browsing mode. Both messages in a single modal, one "J'ai compris" (Got it) button to dismiss.
Why (found during exploration): the DB layer (src/db/worker.js) uses sqlite3.installOpfsSAHPoolVfs(...), the OPFS SyncAccessHandle Pool VFS, which only supports a single active connection per origin — a 2nd tab, or private/incognito mode where OPFS may be unavailable, currently only surfaces as a generic post-failure error screen (error.browserNotCompatible in App.vue's dbErrorBody). This feature adds a proactive warning instead of only a reactive error.
What changed:
- New
settingstable keyfirst_load_warning_seen(absent until dismissed, then"true"), read/written viauseSettings(). - New component
src/components/FirstLoadWarning.vue, modeled onsrc/components/BackupReminder.vue: ononMounted, reads the flag and, if unset, shows aModalDialogwith both warnings (single-tab-only + no-private-browsing) and one dismiss button that persists the flag and closes for good (no session-only snooze, unlike the backup reminder). - Mounted once in
src/App.vue'sv-elseapp-shell block, alongside<BackupReminder>/<InstallPrompt>. - New top-level
firstLoadWarning: { title, singleTab, privateMode, dismiss }i18n block inen.json,fr.json,da.json. - New
tests/test_step23_first_load_warning.py: modal appears on a fresh DB, both messages present, dismiss persists the flag, modal does not reappear on reload. Firefox + Chromium.
Test-infra fix found along the way: every Playwright test runs in a fresh browser context — a fresh OPFS database — which is exactly the "first launch" condition, so the modal would have appeared (and blocked interaction) in every one of the ~30 existing test files, not just the new one. Fixed by overriding the page fixture in tests/conftest.py to inject a MutationObserver-based init script that auto-clicks the dismiss button as soon as it appears; the new test file opts out via a keep_first_load_warning pytest marker (registered in tests/pytest.ini) so it can exercise the real modal. Verified no regressions across the full existing suite plus the trickiest cases (backup startup modal, Step 16 migration flow) on Firefox.
Key files changed: src/components/FirstLoadWarning.vue (new), src/App.vue, src/i18n/locales/{en,fr,da}.json, tests/test_step23_first_load_warning.py (new), tests/conftest.py, tests/pytest.ini, docs/decisions-log.md, website/content/developers/technical.en.md, website/content/docs/user-guide/index.{en,fr}.md.
Step 24 — App Update Modal (no more silent freeze)
Status: ✅ Done (2026-07-05) — manually validated by the user, including the real SW update flow on a deployed build. Implemented as planned below; 7 Playwright tests passing on Firefox + Chromium.
Problem: the service worker is registered with registerType: 'autoUpdate' (vite.config.js). When a new version is deployed, the new SW downloads the full precache (including sqlite3.wasm, ~10 MB), takes control and reloads — the user experiences an unexplained freeze, even when no DB migration is involved. (The DB migration screen from Step 16 only covers schema changes, not app-code updates.)
Goal: the user is informed that an update is available, chooses when to apply it, and sees an explicit "updating…" state instead of a freeze.
Decisions
| Decision | Choice | Rationale |
|---|---|---|
| Update strategy | Switch VitePWA to registerType: 'prompt' |
The new SW installs (precaches) in the background but waits; activation becomes a user decision |
| UI | Modal via existing ModalDialog.vue: "Update now" / "Later" |
Reuse existing modal component (KISS) |
| "Update now" | Show non-dismissible spinner state, call updateServiceWorker(true) |
The page reloads when the new SW takes control; the spinner explains the wait |
| "Later" | Dismiss for the current session; modal reappears on next app load | Same session-dismiss pattern as InstallPrompt.vue; no persisted flag needed |
| During execution mode | Do not show the modal on #/sessions/:id/execute; defer until leaving |
Never interrupt a workout with an update prompt |
| E2E testing | Dev-only test hook to force needRefresh |
Simulating a real SW update in Playwright requires two builds; the real flow is validated manually |
Files to create / modify
New:
src/composables/useAppUpdate.js— singleton wrappinguseRegisterSWfromvirtual:pwa-register/vue: exposesneedRefresh,updating,applyUpdate()(setsupdating, callsupdateServiceWorker(true)),dismiss()(session-only). Guards a dev-only hookwindow.__appUpdateTest = { trigger() }behindimport.meta.env.DEVso Playwright can force the prompt.src/components/AppUpdateDialog.vue— modal with two states: (1) choice — title, short body ("A new version of TrainUs is ready"), buttons Update now / Later; (2) updating — spinner + "Updating…" text, no buttons, not dismissible (no Escape/backdrop close). Hidden while the current route is the execution view; shows automatically once the user navigates away.tests/test_step24_app_update.py
Modified:
vite.config.js—registerType: 'autoUpdate'→'prompt'src/App.vue— mount<AppUpdateDialog>once in the app-shell block (alongside<BackupReminder>/<InstallPrompt>/<FirstLoadWarning>)src/i18n/locales/en.json,fr.json,da.json— newappUpdate: { title, body, updateNow, later, updating }blockdocs/decisions-log.md,website/content/developers/technical.en.md,website/content/docs/user-guide/index.{en,fr}.md
Verification: Playwright tests (via the dev test hook) — modal appears when an update is pending; "Later" closes it and it does not reappear within the session; "Update now" switches to the spinner state and calls the (mocked) update function; spinner state ignores Escape; modal is suppressed on the execution route and appears after navigating away. Real SW update flow validated manually (deploy old build → deploy new build → prompt → update → reload). Firefox + Chromium.
Step 25 — Headset / Media-Key Control of Session Execution
Status: ✅ Done (2026-07-05) — manually validated by the user, including real headset/lock-screen control on a device. Implemented as planned below; 9 Playwright tests passing on Firefox + Chromium.
Goal: during session execution, headphone buttons (and OS media keys / lock-screen controls) drive the workout: play/pause, next, previous, stop — via the Media Session API (navigator.mediaSession).
Key constraint: browsers only route hardware media keys to a page that is actively playing audio through a media element — Web Audio beeps do not qualify. The standard workaround is a short silent looping <audio> element started when execution begins (the user's click on ▶ satisfies the autoplay policy). This is what makes the whole feature work; it must be documented in technical.en.md.
Decisions
| Action | Mapping | Rationale |
|---|---|---|
play/pause |
togglePause() when a timer is active; update mediaSession.playbackState; no-op otherwise |
Matches the on-screen pause button semantics |
nexttrack |
markDone() on a normal step; Continue on final-round / AMRAP time-up screens |
"Next" always means "advance", whatever screen is showing |
previoustrack |
New goBack() in useSessionExecution.js: decrement index, reload step state; no-op at index 0 |
Lets the user redo the previous exercise |
| On-screen "Previous" button | Also add a visible ← button in SessionExecuteView.vue next to the Done button, calling the same goBack(); disabled on the first step |
Feature usable without a headset; directly testable in the UI (user decision 2026-07-04) |
stop |
exit() directly (partial log saved, status aborted), skipping any confirmation |
A headset button cannot answer a confirm dialog |
goBack() log behavior |
Already-saved log items are kept; completing the step again appends a new log item | KISS — no log rewriting; acceptable duplication, recorded in the decisions log |
Timed steps on goBack() |
The step timer restarts from the top of its slot/phase | Consistent with loadStepState(); simplest correct behavior |
| Silent audio | Tiny silent WAV as data URI, loop = true; started on execution start, stopped on done/exit/unmount |
No asset file needed; released cleanly |
| Pause behavior | Keep the silent audio playing; only set mediaSession.playbackState = 'paused' |
Pausing the element would deactivate the media session on some browsers |
| Metadata | MediaMetadata { title: exerciseTitle, artist: comboTitle ?? sessionTitle, album: sessionTitle }, updated on every step change |
Lock screen / headset display shows the current exercise |
| Unsupported browser | Feature-detect 'mediaSession' in navigator; silently no-op |
Progressive enhancement |
Files to create / modify
New:
src/composables/useMediaSession.js—start({ onPlayPause, onNext, onPrevious, onStop })(create + play silent audio, register action handlers),updateMetadata(step, sessionTitle),setPlaybackState(state),stop()(unregister handlers, stop + release audio). All guarded by feature detection.tests/test_step25_media_session.py
Modified:
src/composables/useSessionExecution.js— addgoBack(); expose itsrc/views/SessionExecuteView.vue— wireuseMediaSession: start on mount (afterinit()), update metadata oncurrentStepchange (watch), map actions per the table above, stop on unmount /sessionDone; add the on-screen "Previous" button (data-testid="execute-previous-btn",bi-skip-start-fill, aria-label, disabled on the first step) next to the Done buttonsrc/i18n/locales/{en,fr,da}.json—execution.previous(button title/aria-label); other strings use existing titlesdocs/decisions-log.md,website/content/developers/technical.en.md,website/content/docs/user-guide/index.{en,fr}.md(mention headset control + browser support caveats)
Verification: Playwright tests — mock navigator.mediaSession via page.add_init_script (capture setActionHandler registrations and metadata assignments into window.__mediaSessionMock), then invoke the captured handlers from the test: nexttrack advances a step and saves the log item; nexttrack on the final-round screen continues to the next item; previoustrack returns to the previous step and is a no-op on the first step; the on-screen Previous button does the same and is disabled on the first step; play/pause toggles the timer on a timed combo and playbackState updates; stop exits and saves a partial log with status aborted; metadata title equals the current exercise title and updates on advance; handlers are unregistered after exit; app works with mediaSession absent (red path). Firefox + Chromium.
Step 26 — Voice Announcements (Web Speech API)
Status: ✅ Done (2026-07-06) — manually validated by the user. Implemented as planned below; 9 Playwright tests passing on Firefox + Chromium. Full regression suite (374 passed, 1 skipped) green on Firefox 2026-07-06.
Goal: in execution mode, a synthesized voice (SpeechSynthesis — window.speechSynthesis, no audio assets, supported by Firefox and Chromium) announces the exercise title at each step change. In TABATA and EMOM the next exercise is pre-announced. The voice can be disabled in Settings → Sound.
Announcement rules
| Moment | Spoken text | Notes |
|---|---|---|
| Step becomes current | the exercise title | Includes the very first step; skipped if this exact step was just pre-announced |
| EMOM: 10 s left in a slot | "Next: {next exercise title}" (translated prefix) | Hooked into the existing onTick (like _emomSounds); only if a next step exists |
| TABATA: rest phase opens | "Next: {next exercise title}" | Fires in _startRestPhase, mirroring the on-screen "Next:" label |
| AMRAP / NONE / plain exercises | step-change announcement only | No pre-announcement |
Decisions
| Decision | Choice | Rationale |
|---|---|---|
| API | speechSynthesis + SpeechSynthesisUtterance |
Native, offline-capable, no dependency — fits the PWA |
| Utterance language | From current i18next language: en → en-US, fr → fr-FR, da → da-DK |
Browser picks a matching installed voice; titles are spoken as-is |
| Volume | utterance.volume = audioVolume (existing audio_volume setting, already loaded in useSessionExecution) |
One volume knob for beeps and voice |
| Overlap | speechSynthesis.cancel() before each speak() |
A new announcement always preempts a stale one |
| Double announcement | Track the last pre-announced queue index; skip the step-change announcement for that index | Avoids "Squat" being spoken twice within seconds |
| Setting | New settings key voice_enabled, default enabled ("true"); checkbox in the existing Sound section of SettingsView.vue |
User asked for an opt-out, not an opt-in |
| Unsupported browser | Feature-detect 'speechSynthesis' in window; silently no-op (checkbox still shown) |
Progressive enhancement |
| Session end/exit | speechSynthesis.cancel() on exit/done/unmount |
No announcement after leaving the execution view |
Files to create / modify
New:
src/composables/useSpeech.js—speak(text, { volume })(cancel + utter with mappedlang),cancel(),isSupported; readsvoice_enabledonce per execution viasettingsRepositorytests/test_step26_voice_announcements.py
Modified:
src/composables/useSessionExecution.js— loadvoice_enabledininit(); announce inloadStepState(); pre-announce in the EMOM tick (atremaining === 10) and in_startRestPhase(); cancel onexit()/_finish()src/views/SettingsView.vue— "Voice announcements" checkbox in the Sound section, persisted likeaudio_volumesrc/i18n/locales/{en,fr,da}.json—settings.voiceAnnouncements,execution.voice.next("Next: {{title}}" / "Suivant : {{title}}" / da)docs/decisions-log.md,website/content/developers/technical.en.md,website/content/docs/user-guide/index.{en,fr}.md
Verification: Playwright tests — mock speechSynthesis / SpeechSynthesisUtterance via page.add_init_script (record { text, lang, volume } into window.__spokenTexts); use page.clock (as in Step 15) for timer-driven cases. Green paths: first step title spoken on execution start; title spoken on advance; EMOM speaks "Next: X" at 10 s remaining; TABATA speaks "Next: X" when rest opens; no duplicate announcement right after a pre-announcement; utterance lang follows the app language; utterance volume follows audio_volume. Red paths: checkbox off → nothing spoken (and persists across reload); speechSynthesis absent → execution works without errors. Firefox + Chromium.
Step 27 — Manual "Check for updates" button in Settings
Status: ✅ Done (2026-07-22) — manually validated by the user. 5 Playwright tests passing on Firefox + Chromium. Follow-up (2026-07-24): section renamed to "Check Updates" and moved just above Credits; now also shows the last manual check date. 7 Playwright tests passing on Firefox + Chromium.
Requested: 2026-07-21, user request — the PWA doesn't seem to update automatically; add a manual update button in Settings.
Why: Step 24 (useAppUpdate.js, AppUpdateDialog.vue) already shows an update prompt when the browser detects a new service worker, but that detection only runs on the browser's own schedule (roughly on navigation/reload) — if the PWA is left open, an update can go unnoticed for a long time. A manual trigger in Settings lets the user force a check on demand.
Decisions
| Decision | Choice | Rationale |
|---|---|---|
| Detection mechanism | registration.update() via the SW registration captured through useRegisterSW's onRegisteredSW callback |
Forces the browser to re-fetch the SW script now instead of waiting for its own schedule |
| Found-update path | Reuses the existing needRefresh flow and AppUpdateDialog unchanged |
Same "Update now" / "Later" choice regardless of how the update was detected — no auto-apply |
| No-update feedback | Timeout heuristic in SettingsView.vue: if needRefresh hasn't flipped ~2s after the check, show "You're up to date" for ~3s |
There is no direct "no update found" event from the Service Worker API |
| Scope | No periodic/background auto-check — checking stays a deliberate user action | User decision 2026-07-21: updating stays optional even when available, checking stays opt-in too |
Files created / modified
src/composables/useAppUpdate.js— captureregistrationviaonRegisteredSW; addcheckForUpdate()(callsregistration.update(), guarded by a dev-onlymockCheckFntest hook) andcheckingForUpdatestatesrc/views/SettingsView.vue— "Check Updates" section (moved just above Credits, 2026-07-24 follow-up): "Check for updates" button (disabled while checking), transient "up to date" message, and the last manual check date read/written via thelast_update_check_atsettings key (in-place addition, no schema bump)src/i18n/locales/{en,fr,da}.json—settings.checkUpdates,settings.checkForUpdate,settings.upToDate,settings.lastManualCheck,settings.neverCheckedManuallytests/test_step27_check_for_updates.pydocs/decisions-log.md,website/content/developers/technical.en.md,website/content/docs/user-guide/index.{en,fr}.md
Verification: Playwright tests (via the dev test hook window.__appUpdateTest.mockCheckFn) — button calls through to registration.update(); no update found shows the transient "up to date" message which then disappears; an update found via manual check still shows the "Update now" / "Later" modal (choice preserved, no auto-apply); the "up to date" message never appears when an update was found; last-check date shows "never checked manually" on a fresh DB and updates + persists across reload after a click. Firefox + Chromium.
Step 28 — First-load network failure: clear error + in-app retry
Status: ✅ Done (2026-07-26) — 4 Playwright tests passing (1 on Firefox + Chromium, 3 Chromium-only: Playwright cannot intercept dedicated-worker requests on Firefox).
Requested: 2026-07-26, user report — an exception on the very first load (bad network, likely a timeout downloading sqlite3.wasm); once it failed, it was never attempted again.
Why: On a first load nothing is cached yet, so the DB worker downloads sqlite3.wasm over the network. Three compounding problems made a transient network failure final: Database.init() memoized its promise even when rejected (no retry possible), the error overlay had no retry button, and a network failure fell through to the misleading "browser not compatible" message. There is no timeout value to extend — the wasm download is a plain browser fetch() with no application-level timeout.
Decisions
| Decision | Choice | Rationale |
|---|---|---|
| Retry mechanism | init() resets itself (terminate worker, clear state) when the worker never became usable, so a later call starts fresh |
The OPFS SAH pool allows one connection per origin — the dead worker must be terminated before a new one can attach |
| Reset condition | Only when db.info is still null (worker never initialized) |
DB_NEWER_THAN_APP fails after the DB opened; its worker stays alive for export/diagnostics, and retry won't help |
| Error tagging | Worker wraps sqlite3InitModule failures as DB_LOAD_FAILED: … |
That call is the only network-touching step, so it cleanly separates connectivity from storage/compat failures |
| Retry UX | "Try again" button on the error overlay reruns bootstrapDatabase() in place (extracted from main.js to src/db/bootstrap.js) |
Recovers without a page reload; hidden for DB_NEWER_THAN_APP / DB_MIGRATION_FAILED where retrying is pointless |
| Connection-lost event | _handleError only dispatches db-connection-lost after a successful init |
During startup the failure surfaces through init()'s rejection (with retry); the overlay is for post-init crashes |
Files created / modified
src/db/bootstrap.js— new: full startup sequence (init + persisted language/theme + DOM attributes), re-callablesrc/db/database.js—init()resets on pre-usable failure; guardeddb-connection-lostdispatchsrc/db/worker.js— tagsqlite3InitModulefailures asDB_LOAD_FAILEDsrc/main.js— startup sequence moved tobootstrapDatabase()src/App.vue—Try againbutton (data-testid="db-retry"),DB_LOAD_FAILEDmessage mapping,onRetryInit()src/i18n/locales/{en,fr,da}.json—error.dbLoadFailed,error.retrytests/test_step28_init_retry.pydocs/decisions-log.md,website/content/developers/technical.en.md,website/content/docs/user-guide/index.{en,fr}.md
Verification: Playwright tests — blocking **/sqlite3.wasm (route abort, service workers blocked) shows the db-error overlay tagged DB_LOAD_FAILED with the retry button; unblocking + "Try again" reaches data-db-ready in place (marker proves no reload); retrying while still blocked shows the overlay again. Cross-browser (Firefox + Chromium): breaking worker creation via a wrapped window.Worker, then fixing it and retrying, recovers in place. Existing step 16/19 suites still green.
Step 29 — Request persistent storage on startup
Status: ✅ Done (2026-07-26) — 3 Playwright tests passing on Firefox + Chromium.
Requested: 2026-07-26, user report — Bootstrap Icons occasionally render as placeholder boxes after not using the app for a while.
Why: Without navigator.storage.persist(), 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 inactivity or under disk pressure. Evicted precache + a stale HTTP-cached page referencing an old content-hashed font file that no longer exists on the server → 404 → placeholder glyphs. The user-data risk (OPFS in the same evictable bucket) is the bigger reason to fix it.
Decisions
| Decision | Choice | Rationale |
|---|---|---|
| Placement | main.js, not the re-callable bootstrapDatabase() |
Runs exactly once per page load; a retry after a failed first load must not re-prompt (Firefox) |
| Behaviour | Fire-and-forget, guarded (navigator.storage?.persist), result only logged |
Grant/denial must never block startup; no UI or i18n strings needed |
| Testing | Stub StorageManager.prototype.persist via init script |
The real outcome depends on browser heuristics / permission UI — not reproducible in CI |
Files created / modified
src/main.js—navigator.storage.persist()request at startuptests/test_step29_persistent_storage.pydocs/decisions-log.md,website/content/developers/technical.{en,fr}.md,website/content/docs/user-guide/index.{en,fr}.md
Verification: Playwright tests (Firefox + Chromium) — startup calls persist() exactly once and the app boots when granted; a denied request doesn't block startup; a browser without the API still boots with no call attempted.
Data Model
erDiagram
SETTINGS {
text key PK
text value
}
EXERCISE {
text id PK
text title UK
text description
boolean asymmetric
boolean alternate
text image_urls
text video_urls
integer default_reps
real default_weight
text created_by
}
COMBO {
text id PK
text title UK
text description
text type
text timer_config
text created_by
}
COMBO_EXERCISE {
text combo_id FK
text exercise_id FK
integer position
integer default_reps
real default_weight
}
SESSION {
text id PK
text title UK
text description
text created_by
}
SESSION_ITEM {
text session_id FK
text item_id
text item_type
integer position
integer repetitions
real weight
}
COLLECTION {
text id PK
text label UK
text created_by
}
COLLECTION_SESSION {
text collection_id FK
text session_id FK
integer position
}
EXECUTION_LOG {
text id PK
text session_id FK
datetime started_at
datetime finished_at
}
EXECUTION_LOG_ITEM {
text id PK
text log_id FK
text exercise_id FK
text combo_id
integer round_number
integer reps_done
real weight_used
text side
boolean completed
datetime timestamp
}
SUFFIX {
text user_uuid PK
text user_name
text suffix UK
}
COMBO ||--|{ COMBO_EXERCISE : contains
EXERCISE ||--o{ COMBO_EXERCISE : "used in"
SESSION ||--|{ SESSION_ITEM : contains
COLLECTION ||--|{ COLLECTION_SESSION : contains
SESSION ||--o{ COLLECTION_SESSION : "used in"
SESSION ||--o{ EXECUTION_LOG : "executed as"
EXECUTION_LOG ||--|{ EXECUTION_LOG_ITEM : records
EXERCISE ||--o{ EXECUTION_LOG_ITEM : tracks
SETTINGS stores: user_uuid, user_name, app_version, language, theme, db_created_at, last_backup_at, backup_reminder_days as key/value rows. created_by on entities = UUID string for export provenance. SUFFIX maps external user UUIDs to short suffix strings used to disambiguate imported titles (e.g., "Push-ups [JD]").
Decisions
| Decision | Choice | Rationale |
|---|---|---|
| Framework | Vue 3 + Vite | Reactive framework needed for form-heavy CRUD, timers, i18n |
| i18n | i18next + vue-i18next | Proven open-source, MIT licensed, rich features |
| App version | package.json → Vite inject → DB settings | Single source of truth, available at build + runtime |
| User model | Single user per device, identity in settings | No multi-user complexity needed |
| Settings order | Step 2 (early) | Language/theme switching before entity CRUD |
| SQLite | Official WASM with OPFS VFS | Best maintained, native OPFS |
| Charts | Chart.js via vue-chartjs | Good balance of features and size |
| Routing | Vue Router, hash mode | No server config needed |
| ID collisions | Suffix on title + new UUID | Preserves alphabetical sort; "Title [SUFFIX]" is user-readable; suffix pre-filled with exporter name; batch Replace All / Skip All for same-user conflicts |
| OPFS namespacing | Stable directory .trainus, no version suffix (Step 16) |
OPFS is origin-scoped, so versioned directories never truly isolated data; a stable location preserves data across app updates, with startup migrations handling schema changes |
| Timer config | EMOM/AMRAP/TABATA params as JSON | Flexible, stored in timer_config column |