trainUs/docs/plan.md
David 63f2f2ba24
Some checks are pending
CI / Lint & Format (push) Waiting to run
CI / Build (push) Waiting to run
CI / Tests (${{ matrix.browser }}) (chromium) (push) Waiting to run
CI / Tests (${{ matrix.browser }}) (firefox) (push) Waiting to run
Add last manual check update date
2026-07-24 09:35:48 +02:00

82 KiB
Raw Blame History

TrainUs — Implementation Plan (v3)

Last updated: 2026-07-24 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 F1F12 (see docs/code-review-remediation-plan.md) manually validated by the user.

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-Policy headers, build output to dist/, inject app version via define from package.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-icons npm 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, create docs/technical-documentation.md, docs/decisions-log.md
  • Set up Playwright Python project in tests/ with conftest.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_at in settings table
  • Repository layer (src/db/repositories/) — one per entity with create, 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.vue at #/settings
  • Language selector: dropdown (English / French) — calls i18next changeLanguage(), persisted in settings table, 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 in settings
  • 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 UNIQUE constraint on exercise.title, combo.title, session.title, collection.label
    • Add suffix table: user_uuid TEXT PRIMARY KEY, user_name TEXT NOT NULL, suffix TEXT NOT NULL UNIQUE — registry of known external users for import disambiguation
  • Documentation restructuring:
    • Rename docs/technical-overview.mddocs/technical-documentation.md; enrich with full DB schema (ER diagram), architecture details, data flows
    • Rename docs/user-guide.mddocs/user-documentation.md
    • Update all references across project files: README.md, AGENTS.md, docs/plan.md, .opencode/ config files
    • Rewrite README.md Documentation section: link all docs and all tutorials/codelabs with short descriptions; bilingual links use (en / fr) format

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) — uses v-model bindings
  • ExerciseDetailView.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, validates appName + 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
  • ID collision strategy:
    • No collision: insert directly
    • Same user (created_by = local user_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 ≠ local user_uuid):
      • If user UUID exists in suffix table: inform user the existing suffix will be used
      • If user UUID not in suffix table: prompt for a new suffix (pre-filled with exporter's name), validate uniqueness against existing suffixes, save (user_uuid, user_name, suffix) to suffix table
      • Same created_by as existing object AND same ID: treat as update (no new ID generated)
      • Different created_by from 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)
    • 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 suffix table (user name, UUID, suffix)
    • Edit suffix: changing a suffix updates all titles containing [OLD_SUFFIX] to [NEW_SUFFIX] across exercise, combo, session, and collection tables
  • 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:

  1. Renames the downloaded file so each manual backup is unique, sortable, and carries the app version.
  2. Tracks when the user last downloaded (or restored) a backup.
  3. 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}.sqlite3 to trainus-{version}-backup-YYYYMMDD-HHhMMmSSs.sqlite3 using local time (example for 1.0.0 at 2026-03-23 15:24:06 local → trainus-1.0.0-backup-20260323-15h24m06s.sqlite3). Letters h, m, s replace the Windows-invalid colon.
  • New settings keys (no schema change; uses existing settings key/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 1365).
    • db_created_at — ISO-8601 UTC timestamp set on first launch. Used as fallback for backup reminder when last_backup_at is 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 when now - last_backup_at > backup_reminder_days OR when last_backup_at is 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 #/settings and 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.
  • Settings UI additions (Database section):
    • Display "Last backup: {{localDateTime}}" or "Never backed up".
    • Number input "Remind me every N days" (1365; default 7) with inline validation.
  • i18n: new EN + FR keys under settings.backup.* and backup.reminder.*.
  • New files:
    • src/utils/backupFilename.js — pure formatBackupFilename(version, date).
    • src/composables/useBackupStatus.js — reactive lastBackupAt, 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, set last_backup_at on 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
  • comboRepository updated with createWithId and replaceAll for 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 1 and default_weight REAL NOT NULL DEFAULT 0.0 columns to combo_exercise table
    • Increment SCHEMA_VERSION from 1 to 2
    • Add migration script for existing databases (ALTER TABLE combo_exercise ADD COLUMN ...)
  • 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 return default_reps and default_weight columns alongside existing fields
  • 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.repsLabel
    • combos.form.weight / combos.form.weightLabel
    • combos.exercise.repsWeight — display format string
    • Validation messages for reps < 1 and weight < 0
  • Import/Export impact:
    • useJsonExport.js: include default_reps and default_weight in combo exercise export data
    • useJsonImport.js: handle these fields on import (no special collision logic needed)
  • Modified files:
    • src/db/schema.js — add columns, bump version
    • src/db/repositories/comboRepository.js — update setExercises and getExercises
    • src/views/ComboFormView.vue — add reps/weight inputs per exercise
    • src/views/ComboDetailView.vue — display reps/weight
    • src/composables/useJsonExport.js — include new fields
    • src/composables/useJsonImport.js — handle new fields
    • src/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 new weight field, similar to combo exercises. These override the exercise's defaults when the session is executed.
    • Add weight REAL NOT NULL DEFAULT 0.0 to session_item table (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)
  • 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 return weight alongside existing fields, with titles from JOIN queries
  • Import/Export impact:
    • useJsonExport.js: include weight in session item export data
    • useJsonImport.js: handle this field on import
  • i18n (en.json / fr.json):
    • sessions.form.weight / sessions.form.weightLabel
    • sessions.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 with sessionCount via JOIN query
  • App.vue navKeyMap updated to map collection sub-routes to collections nav 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 data to 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, getAllWithX for combo, session, collection, executionLog

Decisions made:

  • Settings import excludes user_uuid, app_version, db_created_at, last_backup_at to 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 → reimport
  • test_execution_log_id_remapping: log item exercise_id is remapped when exercise gets new_id
  • test_session_with_combo_item_remapping: session item_type=combo item_id follows combo remap
  • test_collection_session_remapping: collection session ref follows session new_id remap
  • test_settings_import_roundtrip: language and theme applied from settings-only envelope
  • test_single_entity_export_buttons_session_collection: export button visible on session/collection detail
  • Refactored test_new_id_propagation_to_combo to load from tests/fixtures/cross_user_id_collision.json
  • Added tests/fixtures/cross_user_full.json for 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 — add combo_id + round_number columns, bump SCHEMA_VERSION 2 → 3, add migration script
  • src/db/repositories/executionLogRepository.js — add getLastItemsBySession(sessionId) returning map exerciseId → { reps_done, weight_used, side } from most recent log for that session
  • src/router/index.js — add { path: '/sessions/:id/execute', name: 'session-execute', component: SessionExecuteView }
  • src/views/SessionDetailView.vue — add play button in action bar
  • src/views/SessionListView.vue — add play icon per session row
  • src/i18n/locales/en.json, fr.json — new keys under execution.*

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.vue at #/stats
  • Displays database creation date: "You are using TrainUs since [formatted date]"
  • Date formatting uses centralized dateFormatter.js utility with locale-aware formatting via Intl.DateTimeFormat (MM/DD/YYYY English, DD/MM/YYYY French, DD.MM.YYYY Danish)
  • chart.js + vue-chartjs v5
  • 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.js to vite-plugin-pwa + Workbox (registerType: 'autoUpdate', 10 MB cache limit to cover sqlite3.wasm)
  • manifest.json updated: purpose field 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.js composable + InstallPrompt.vue component (session-dismissible, teleported into action bar, shows only when beforeinstallprompt fires)
  • Accessibility (critical flows): aria-label on all icon-only actionbar buttons across every view, role="progressbar" + aria-valuenow/min/max on execution progress bar, role="alert" + aria-describedby on form error messages, required/aria-required on required inputs, role="dialog" + aria-modal + aria-labelledby + focus-on-mount + Escape key on ModalDialog
  • 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 010), i18n, versioning, build & deploy
  • docs/user-documentation.md: updated — added missing Sessions section, generalized Export/Import section to cover all entity types, added PWA install info
  • docs/decisions-log.md: catch-up entries added for Steps 4 and 7
  • tests/README.md: new file — setup, run commands, browser targets, file inventory
  • README.md: updated tech stack (removed step annotations, added vite-plugin-pwa), added tutorial rows for Steps 3, 3b, 5
  • docs/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:

  • repetitions on the session item = total duration in minutes. The number of rounds is floor(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: bip at 30 s remaining, bip at 2 s remaining, bip at 1 s remaining, BIP at 0 s.

AMRAP (As Many Rounds As Possible) — one countdown for the whole combo, user advances manually:

  • Duration = repetitions minutes (the repetitions field 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: BIP at every elapsed full minute, except the final minute which uses the EMOM pattern — bip at 30 s remaining, bip at 2 s remaining, bip at 1 s remaining, BIP at 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_config JSON (defaults: work = 20 s, rest = 10 s).
  • Per exercise slot: work phase starts with BIP → countdown → auto-transition to rest phase → rest phase starts with BIP → 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.
  • ComboFormView shows "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: {} or null (duration derived from repetitions at 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 + DOMPurify for 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.js centralises the sanitized rendering pipeline; all three detail views call it via v-html

New / modified files:

  • src/utils/markdown.jsrenderMarkdown(text) using marked.parse + DOMPurify.sanitize
  • src/views/ExerciseDetailView.vue, src/views/ComboDetailView.vue, src/views/SessionDetailView.vue — render description via v-html
  • src/views/ExerciseFormView.vue, src/views/ComboFormView.vue, src/views/SessionFormView.vue — hint text alongside description label
  • src/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+Enter saves any create or edit form, even when a text field is focused
  • Escape cancels 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 title attribute showing the shortcut hint (visible as a native tooltip on hover)
  • Shortcuts are attached to the form element's keydown event 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 button title
  • src/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: playBip fires 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, BIP countdown fires at r = 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 uses playBIP (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 real setTimeout waits

Modified files:

  • src/composables/useSessionExecution.js_tabataCountdown, _amrapSounds, _maybeStartAmrapTimer helpers adjusted for correct relative timing
  • tests/test_step15_audio_cues.py — new test file using AudioContext mock + 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.jsadaptScript renamed to migrationScript; new getMigrationScripts(from, to) helper shared by startup migration and restore adaptation
  • src/db/worker.js — stable OPFS directory (.trainus); getSchemaStatus (fresh / up-to-date / incompatible / migration-pending) and runMigrations (one transaction per release, rollback on failure) message handlers
  • src/db/database.js — init pauses on migration-pending (sets db.migrationPending); runPendingMigrations(); error codes DB_NEWER_THAN_APP / DB_MIGRATION_FAILED
  • src/App.vue — blocking migration screen (forced backup download enables the Update button) + error-code → translated message mapping
  • src/composables/useBackupDownload.js — backup download logic shared by SettingsView and the migration screen
  • tests/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 15 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:

  • _analyzeEntityList now detects title conflicts (different id, same title) for same-user imports and folds them into the existing sameUserCollisions bucket — the same Replace/Skip screen (with batch Replace All / Skip All) used for id conflicts handles them with zero new UI.
  • _importEntityList resolves same-user collisions against collision.existing.id (not collision.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's exercise_id) to the existing row's id via the same idMap mechanism already used for cross-user new_id remapping.
  • 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.vue folds them into the same per-entity-type resolution arrays once detected in onApply.
  • Cleanup: analyzeExercises, applySameUserImport, applyDifferentUserImport, and checkTitleConflict in useJsonImport.js turned out to be dead code (confirmed via grep — never called for applying; analyzeAll already produces an identical shape for exercise-only envelopes). Removed, along with the "legacy exercise-only" branch in ImportDialog.vue's onMounted and the now-unreachable forced-rename titleConflict step/state. analyzeAll/applyAllImport now 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 component SaveFileDialog.vue (editable stem, fixed extension, Enter-to-confirm), mounted once in App.vue so 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 local downloadHtml (useHtmlExport.js) — call the prompt before building the blob/anchor and no-op on cancel. No view-level export call site changed.
  • downloadBackup() now returns false on cancel; App.vue's onMigrationBackup only enables the Update button if the save actually completed.
  • Fix found along the way: ModalDialog's z-index: 1000 was below the blocking .db-overlay's 9999, making the dialog unclickable from the migration screen — bumped to 10000.
  • Fix found along the way: a few export call sites (useJsonExport.js's downloadJson calls, SettingsView.vue's onExportAll, HomeView.vue's onExportHtml) weren't awaited; added await since 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.vue gained a clearSearch() 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's title/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 settings table key first_load_warning_seen (absent until dismissed, then "true"), read/written via useSettings().
  • New component src/components/FirstLoadWarning.vue, modeled on src/components/BackupReminder.vue: on onMounted, reads the flag and, if unset, shows a ModalDialog with 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's v-else app-shell block, alongside <BackupReminder> / <InstallPrompt>.
  • New top-level firstLoadWarning: { title, singleTab, privateMode, dismiss } i18n block in en.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 wrapping useRegisterSW from virtual:pwa-register/vue: exposes needRefresh, updating, applyUpdate() (sets updating, calls updateServiceWorker(true)), dismiss() (session-only). Guards a dev-only hook window.__appUpdateTest = { trigger() } behind import.meta.env.DEV so 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.jsregisterType: '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 — new appUpdate: { title, body, updateNow, later, updating } block
  • docs/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.jsstart({ 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 — add goBack(); expose it
  • src/views/SessionExecuteView.vue — wire useMediaSession: start on mount (after init()), update metadata on currentStep change (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 button
  • src/i18n/locales/{en,fr,da}.jsonexecution.previous (button title/aria-label); other strings use existing titles
  • docs/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: enen-US, frfr-FR, dada-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.jsspeak(text, { volume }) (cancel + utter with mapped lang), cancel(), isSupported; reads voice_enabled once per execution via settingsRepository
  • tests/test_step26_voice_announcements.py

Modified:

  • src/composables/useSessionExecution.js — load voice_enabled in init(); announce in loadStepState(); pre-announce in the EMOM tick (at remaining === 10) and in _startRestPhase(); cancel on exit() / _finish()
  • src/views/SettingsView.vue — "Voice announcements" checkbox in the Sound section, persisted like audio_volume
  • src/i18n/locales/{en,fr,da}.jsonsettings.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 — capture registration via onRegisteredSW; add checkForUpdate() (calls registration.update(), guarded by a dev-only mockCheckFn test hook) and checkingForUpdate state
  • src/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 the last_update_check_at settings key (in-place addition, no schema bump)
  • src/i18n/locales/{en,fr,da}.jsonsettings.checkUpdates, settings.checkForUpdate, settings.upToDate, settings.lastManualCheck, settings.neverCheckedManually
  • tests/test_step27_check_for_updates.py
  • docs/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.


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