Compare commits

..

No commits in common. "efeeb164b877de8b10c0ce21b96f86e73ae86d31" and "13f3b305bd6fbbae38f2e8a028954135e330d4db" have entirely different histories.

39 changed files with 341 additions and 722 deletions

View file

@ -54,14 +54,6 @@ website/ # Hugo project site (docs + blog), two separate sections
- `npm run preview` -- Preview production build
- `npm run format` -- Format code with Prettier
- `npm run format:check` -- Check formatting
- `npm run lint` -- Lint `src/` with ESLint (CI runs this)
- `npm run lint:fix` -- Lint and auto-fix
- `npm run test` -- Playwright suite on Firefox (`tests/.venv` must exist)
- `npm run test:chromium` -- Playwright suite on Chromium
- `npm run test:parallel` -- Both browsers in parallel (xdist, per-file distribution)
- `npm run test:parallel:firefox` / `test:parallel:chromium` -- One browser in parallel
- `npm run test:parallel:n` -- Both browsers, worker count from `WORKERS` (default 4)
- `npm run site:dev` / `site:build` -- Hugo website dev server / minified build
## Plan

View file

@ -98,7 +98,7 @@ are behaviour fixes; Phase 3 is code quality; Phase 4 is documentation drift.
2. Extend delete confirmations to include usage counts (new i18n keys ×3
languages).
3. On exercise delete, also `DELETE FROM session_item WHERE item_id = ? AND
item_type = 'exercise'` (and same for combo delete) so no orphans remain.
item_type = 'exercise'` (and same for combo delete) so no orphans remain.
- **Decision to validate first:** blocking vs. informative confirm — proposal:
keep it a confirm (informative), consistent with current UX.
- **Tests:** Extend `tests/test_step3_exercises.py` / `test_step4_combos.py`:
@ -201,7 +201,7 @@ As Phase 13 fixes land, record the notable ones in `docs/decisions-log.md`
## Suggested execution order
| Order | Fix | Why this order |
| ----- | ------- | ------------------------------------------------------------- |
| ----- | --- | -------------- |
| 1 | F2 | Trivial, immediately user-visible stat fix |
| 2 | F1 | High-value, small, adds the missing re-import regression test |
| 3 | F3 | Small, defuses the migration time bomb before it's forgotten |

View file

@ -2,27 +2,9 @@
Record of architectural and technical decisions made during development.
## 2026-07-09 — Deleting an exercise/combo cleans session usage and warns about it (code review F4)
**Decision:** `session_item.item_id` is polymorphic (exercise or combo) and carries no FK, so deleting an exercise or combo used in a session silently left orphaned `session_item` rows that the INNER JOIN in `sessionRepository.getItems` simply hid — the entry vanished from the session with no trace. Deletes in `exerciseRepository`/`comboRepository` now also remove the matching `session_item` rows (in one `db.batch` transaction), and the delete confirmations on the exercise/combo/session detail views show usage counts before the user confirms: sessions+combos using the exercise, sessions using the combo, collections containing the session (new repository count helpers, new `deleteConfirmUsage` i18n keys in EN/FR/DA).
**Rationale:** The confirmation stays **informative, not blocking** — consistent with the existing history-cascade warning UX; the user can still delete, they just see the blast radius first. Collection/session junctions already have FK CASCADE so they need no cleanup, only the usage warning. (Chosen from the remediation plan's proposal without live validation — flagged for review.)
## 2026-07-09 — DB restore no longer copies the backup's schema_version stamp (code review F3)
**Decision:** `handleImportDb` in `src/db/worker.js` now excludes `schema_version` from the tables copied out of a restored backup. The live DB keeps its own version stamp, which is correct by construction: any adapt/migration scripts run against the temp DB _before_ the copy, so the restored data always matches the current schema.
**Rationale:** This was a latent time bomb, dormant only because `SCHEMA_VERSION` is still 1. Once a v2 migration ships, restoring a v1 backup would have copied the old stamp into the live DB; the next launch would read migration-pending and re-run the migration against an already-adapted schema (e.g. a duplicate `ALTER TABLE ADD COLUMN` fails and strands the user on the migration-failed screen). Excluding the table was chosen over re-stamping after restore because it keeps the restore loop generic and adds no write. A regression test simulates an older stamp (version 0) in a backup and asserts the live stamp survives the restore; a comment in `src/db/releases.js` requires the first real v2 migration to ship a full end-to-end older-version restore test.
## 2026-07-09 — Same-user re-import skips existing execution logs (code review F1)
**Decision:** In `useJsonImport.js`, execution logs whose id already exists locally (`sameUserCollisions`) are now counted as skipped instead of being re-inserted with their original id. Previously each one hit a PRIMARY KEY violation whose message ended up in the import report's error list, making a routine "re-import my own full export" look like a partially failed import.
**Rationale:** Execution logs are immutable historical records — unlike exercises/combos/sessions there is nothing to Replace or merge, so a skip is always the correct resolution and no Replace/Skip UI is warranted. A regression test covers the exact scenario the existing round-trip test missed: re-importing a full export _without_ deleting anything first must produce zero errors and no duplicate logs.
## 2026-07-06 — Voice announcements via the Web Speech API (Step 26)
**Decision:** Added `useSpeech.js`, a thin wrapper around `window.speechSynthesis` exposing `init()` (reads the new `voice_enabled` settings key, default enabled), `speak(text, { volume })` (cancels any pending utterance first, so a new announcement always preempts a stale one, and maps the current i18next language to a BCP-47 tag: `en``en-US`, `fr``fr-FR`, `da``da-DK`), `cancel()`, and `isSupported`. `useSessionExecution.js` calls `speak()` with the exercise title whenever a step becomes current (`loadStepState()`), and pre-announces the _next_ exercise ("Next: X") in the two places the on-screen UI already does the same thing silently: 10 s before an EMOM slot ends (hooked into the existing `_emomSounds` tick handler) and the instant a TABATA rest phase opens (`_startRestPhase`). AMRAP and plain (NONE) combos only get the step-change announcement, matching their on-screen behavior of not showing a "next" preview until the step actually changes. To avoid the same exercise being spoken twice in quick succession, the queue index that was just pre-announced is tracked in a closure variable; `loadStepState()` skips the step-change announcement exactly once when it reaches that index. `speechSynthesis.cancel()` is also called on `exit()` and `_finish()` so nothing keeps talking after the user leaves the execution view.
**Decision:** Added `useSpeech.js`, a thin wrapper around `window.speechSynthesis` exposing `init()` (reads the new `voice_enabled` settings key, default enabled), `speak(text, { volume })` (cancels any pending utterance first, so a new announcement always preempts a stale one, and maps the current i18next language to a BCP-47 tag: `en``en-US`, `fr``fr-FR`, `da``da-DK`), `cancel()`, and `isSupported`. `useSessionExecution.js` calls `speak()` with the exercise title whenever a step becomes current (`loadStepState()`), and pre-announces the *next* exercise ("Next: X") in the two places the on-screen UI already does the same thing silently: 10 s before an EMOM slot ends (hooked into the existing `_emomSounds` tick handler) and the instant a TABATA rest phase opens (`_startRestPhase`). AMRAP and plain (NONE) combos only get the step-change announcement, matching their on-screen behavior of not showing a "next" preview until the step actually changes. To avoid the same exercise being spoken twice in quick succession, the queue index that was just pre-announced is tracked in a closure variable; `loadStepState()` skips the step-change announcement exactly once when it reaches that index. `speechSynthesis.cancel()` is also called on `exit()` and `_finish()` so nothing keeps talking after the user leaves the execution view.
The Settings → Sound section gained a "Voice announcements" checkbox next to the existing volume slider, persisted the same way as `audio_volume`.

View file

@ -1,11 +1,11 @@
# TrainUs — Implementation Plan (v3)
> **Last updated:** 2026-07-09
> **Status:** Step 24 implemented (app-update modal) — awaiting manual validation; Step 25 implemented (headset media controls) — awaiting manual validation; Step 26 implemented (voice announcements) — awaiting manual validation. Code-review remediation F1F12 (see `docs/code-review-remediation-plan.md`) implemented — awaiting manual validation.
> **Last updated:** 2026-07-06
> **Status:** Step 24 implemented (app-update modal) — awaiting manual validation; Step 25 implemented (headset media controls) — awaiting manual validation; Step 26 implemented (voice announcements) — awaiting manual validation. Full suite green on Firefox 2026-07-06 (374 passed, 1 skipped).
## 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.
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. 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
@ -427,7 +427,7 @@ ALTER TABLE execution_log_item ADD COLUMN round_number INTEGER DEFAULT NULL;
- `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)
- Date formatting uses centralized `dateFormatter.js` utility with locale-aware formatting (DD/MM/YYYY for French, MM/DD/YYYY for English)
- `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)

View file

@ -1,71 +0,0 @@
import { ref } from 'vue'
/**
* Factory for the four identical CRUD composables (exercises, combos,
* sessions, collections). Returns a composable exposing the reactive item
* list under `itemsKey` plus the shared loading/error/CRUD API, so
* useExercises & co. stay one-liners with unchanged call sites.
*/
export function createCrudComposable(repository, itemsKey) {
return function useCrud() {
const items = ref([])
const loading = ref(false)
const error = ref(null)
async function fetchAll() {
loading.value = true
error.value = null
try {
items.value = await repository.getAll()
} catch (e) {
error.value = e.message
} finally {
loading.value = false
}
}
async function create(data) {
const id = await repository.create(data)
await fetchAll()
return id
}
async function update(id, data) {
await repository.update(id, data)
await fetchAll()
}
async function remove(id) {
await repository.delete(id)
await fetchAll()
}
async function search(query) {
loading.value = true
error.value = null
try {
items.value = await repository.search(query)
} catch (e) {
error.value = e.message
} finally {
loading.value = false
}
}
async function getById(id) {
return repository.getById(id)
}
return {
[itemsKey]: items,
loading,
error,
fetchAll,
create,
update,
remove,
search,
getById,
}
}
}

View file

@ -1,4 +1,54 @@
import { ref } from 'vue'
import { collectionRepository } from '../db/repositories/collectionRepository.js'
import { createCrudComposable } from './createCrudComposable.js'
export const useCollections = createCrudComposable(collectionRepository, 'collections')
export function useCollections() {
const collections = ref([])
const loading = ref(false)
const error = ref(null)
async function fetchAll() {
loading.value = true
error.value = null
try {
collections.value = await collectionRepository.getAll()
} catch (e) {
error.value = e.message
} finally {
loading.value = false
}
}
async function create(data) {
const id = await collectionRepository.create(data)
await fetchAll()
return id
}
async function update(id, data) {
await collectionRepository.update(id, data)
await fetchAll()
}
async function remove(id) {
await collectionRepository.delete(id)
await fetchAll()
}
async function search(query) {
loading.value = true
error.value = null
try {
collections.value = await collectionRepository.search(query)
} catch (e) {
error.value = e.message
} finally {
loading.value = false
}
}
async function getById(id) {
return collectionRepository.getById(id)
}
return { collections, loading, error, fetchAll, create, update, remove, search, getById }
}

View file

@ -1,4 +1,54 @@
import { ref } from 'vue'
import { comboRepository } from '../db/repositories/comboRepository.js'
import { createCrudComposable } from './createCrudComposable.js'
export const useCombos = createCrudComposable(comboRepository, 'combos')
export function useCombos() {
const combos = ref([])
const loading = ref(false)
const error = ref(null)
async function fetchAll() {
loading.value = true
error.value = null
try {
combos.value = await comboRepository.getAll()
} catch (e) {
error.value = e.message
} finally {
loading.value = false
}
}
async function create(data) {
const id = await comboRepository.create(data)
await fetchAll()
return id
}
async function update(id, data) {
await comboRepository.update(id, data)
await fetchAll()
}
async function remove(id) {
await comboRepository.delete(id)
await fetchAll()
}
async function search(query) {
loading.value = true
error.value = null
try {
combos.value = await comboRepository.search(query)
} catch (e) {
error.value = e.message
} finally {
loading.value = false
}
}
async function getById(id) {
return comboRepository.getById(id)
}
return { combos, loading, error, fetchAll, create, update, remove, search, getById }
}

View file

@ -1,4 +1,54 @@
import { ref } from 'vue'
import { exerciseRepository } from '../db/repositories/exerciseRepository.js'
import { createCrudComposable } from './createCrudComposable.js'
export const useExercises = createCrudComposable(exerciseRepository, 'exercises')
export function useExercises() {
const exercises = ref([])
const loading = ref(false)
const error = ref(null)
async function fetchAll() {
loading.value = true
error.value = null
try {
exercises.value = await exerciseRepository.getAll()
} catch (e) {
error.value = e.message
} finally {
loading.value = false
}
}
async function create(data) {
const id = await exerciseRepository.create(data)
await fetchAll()
return id
}
async function update(id, data) {
await exerciseRepository.update(id, data)
await fetchAll()
}
async function remove(id) {
await exerciseRepository.delete(id)
await fetchAll()
}
async function search(query) {
loading.value = true
error.value = null
try {
exercises.value = await exerciseRepository.search(query)
} catch (e) {
error.value = e.message
} finally {
loading.value = false
}
}
async function getById(id) {
return exerciseRepository.getById(id)
}
return { exercises, loading, error, fetchAll, create, update, remove, search, getById }
}

View file

@ -98,8 +98,7 @@ function renderSessionItemsHtml(log) {
async function fetchIconAsBase64() {
try {
// Resolve against BASE_URL so subdirectory deploys (base: './') still find the icon
const res = await fetch(import.meta.env.BASE_URL + 'icon.svg')
const res = await fetch('/icon.svg')
if (!res.ok) return null
const text = await res.text()
const bytes = new TextEncoder().encode(text)

View file

@ -538,13 +538,10 @@ export function useJsonImport() {
report,
errors,
) {
// Same-user collisions mean the log already exists locally. Execution logs
// are immutable historical records, so re-importing them is always a skip
// (re-inserting would only produce PRIMARY KEY violations).
report.skipped += (analysis.sameUserCollisions || []).length
// Collect all logs to import from the three analysis buckets
const logsToImport = [
...(analysis.noCollision || []).map((log) => ({ log, action: 'insert' })),
...(analysis.sameUserCollisions || []).map((e) => ({ log: e.imported, action: 'insert' })),
...(analysis.differentUser || []).map((e) => ({ log: e.imported, action: e.action })),
]

View file

@ -3,9 +3,9 @@ import { ref, onMounted, onBeforeUnmount, nextTick } from 'vue'
/**
* Provides keyboard navigation for a list of items using arrow keys.
*
* @param {import('vue').Ref<number>} itemCount - Ref holding the number of items in the list
* @param {number} itemCount - The number of items in the list
* @param {Function} onSelect - Callback when Enter is pressed on a focused item (receives index)
* @returns {{ focusedIndex: import('vue').Ref<number>, onKeydown: Function, focusItem: Function }}
* @returns {{ focusedIndex: Ref<number>, onKeydown: Function, focusItem: Function }}
*/
export function useListNavigation(itemCount, onSelect) {
const focusedIndex = ref(-1)

View file

@ -1,4 +1,54 @@
import { ref } from 'vue'
import { sessionRepository } from '../db/repositories/sessionRepository.js'
import { createCrudComposable } from './createCrudComposable.js'
export const useSessions = createCrudComposable(sessionRepository, 'sessions')
export function useSessions() {
const sessions = ref([])
const loading = ref(false)
const error = ref(null)
async function fetchAll() {
loading.value = true
error.value = null
try {
sessions.value = await sessionRepository.getAll()
} catch (e) {
error.value = e.message
} finally {
loading.value = false
}
}
async function create(data) {
const id = await sessionRepository.create(data)
await fetchAll()
return id
}
async function update(id, data) {
await sessionRepository.update(id, data)
await fetchAll()
}
async function remove(id) {
await sessionRepository.delete(id)
await fetchAll()
}
async function search(query) {
loading.value = true
error.value = null
try {
sessions.value = await sessionRepository.search(query)
} catch (e) {
error.value = e.message
} finally {
loading.value = false
}
}
async function getById(id) {
return sessionRepository.getById(id)
}
return { sessions, loading, error, fetchAll, create, update, remove, search, getById }
}

View file

@ -1,12 +1,3 @@
// Interaction with DB restore (src/db/worker.js, handleImportDb): when a backup
// is restored, migration scripts in range run against the *temp* DB before its
// tables are copied into the live DB, and the schema_version table is NOT
// copied — the live DB keeps its current-version stamp. If it were copied, the
// next launch would see the backup's old stamp and re-run migrations against an
// already-adapted schema (e.g. a duplicate ALTER TABLE ADD COLUMN would fail
// and block the app behind the migration-failed screen). The first release that
// bumps schemaVersion past 1 must ship an end-to-end test restoring an
// older-version backup.
export const RELEASES = [
{
schemaVersion: 1,

View file

@ -20,13 +20,15 @@ export const collectionRepository = {
},
async getAll() {
return db.selectAll(
`SELECT c.*, COUNT(cs.session_id) AS sessionCount
FROM collection c
LEFT JOIN collection_session cs ON cs.collection_id = c.id
GROUP BY c.id
ORDER BY c.label`,
const collections = await db.selectAll('SELECT * FROM collection ORDER BY label')
for (const c of collections) {
const row = await db.selectOne(
'SELECT COUNT(*) AS sessionCount FROM collection_session WHERE collection_id = ?',
[c.id],
)
c.sessionCount = row ? row.sessionCount : 0
}
return collections
},
async getAllWithSessions() {
@ -86,14 +88,6 @@ export const collectionRepository = {
])
},
async countCollectionsUsingSession(sessionId) {
const row = await db.selectOne(
'SELECT COUNT(DISTINCT collection_id) AS count FROM collection_session WHERE session_id = ?',
[sessionId],
)
return row ? row.count : 0
},
async getSessions(collectionId) {
return db.selectAll(
`SELECT cs.position, cs.session_id, s.title

View file

@ -64,23 +64,7 @@ export const comboRepository = {
},
async delete(id) {
// session_item.item_id is polymorphic (no FK), so combo entries in
// sessions must be removed explicitly or they'd linger as orphans
return db.batch([
{
sql: "DELETE FROM session_item WHERE item_id = ? AND item_type = 'combo'",
params: [id],
},
{ sql: 'DELETE FROM combo WHERE id = ?', params: [id] },
])
},
async countCombosUsingExercise(exerciseId) {
const row = await db.selectOne(
'SELECT COUNT(DISTINCT combo_id) AS count FROM combo_exercise WHERE exercise_id = ?',
[exerciseId],
)
return row ? row.count : 0
return db.run('DELETE FROM combo WHERE id = ?', [id])
},
async createWithId(id, combo) {

View file

@ -158,7 +158,7 @@ export const executionLogRepository = {
async getDistinctSessionDays() {
return db.selectAll(
`SELECT DATE(started_at) AS day FROM execution_log WHERE status = 'completed' GROUP BY DATE(started_at) ORDER BY day DESC`,
`SELECT DATE(started_at) AS day FROM execution_log GROUP BY DATE(started_at) ORDER BY day DESC`,
)
},

View file

@ -113,15 +113,7 @@ export const exerciseRepository = {
},
async delete(id) {
// session_item.item_id is polymorphic (no FK), so exercise entries in
// sessions must be removed explicitly or they'd linger as orphans
return db.batch([
{
sql: "DELETE FROM session_item WHERE item_id = ? AND item_type = 'exercise'",
params: [id],
},
{ sql: 'DELETE FROM exercise WHERE id = ?', params: [id] },
])
return db.run('DELETE FROM exercise WHERE id = ?', [id])
},
async search(query) {

View file

@ -74,14 +74,6 @@ export const sessionRepository = {
])
},
async countSessionsUsingItem(itemId, itemType) {
const row = await db.selectOne(
'SELECT COUNT(DISTINCT session_id) AS count FROM session_item WHERE item_id = ? AND item_type = ?',
[itemId, itemType],
)
return row ? row.count : 0
},
async getItems(sessionId) {
const exerciseRows = await db.selectAll(
`SELECT si.position, si.item_id, si.item_type, si.repetitions, si.weight, e.title

View file

@ -36,8 +36,6 @@ export const suffixRepository = {
/**
* Renames a suffix across all entity title/label columns.
* Updates suffix table entry + all titles containing [OLD_SUFFIX] to [NEW_SUFFIX].
* Runs as a single transaction: a failure (e.g. a UNIQUE-title collision on
* a renamed title) rolls everything back instead of leaving a half-rename.
*/
async renameSuffix(userUuid, newSuffix) {
const existing = await this.getByUuid(userUuid)
@ -45,29 +43,27 @@ export const suffixRepository = {
const oldPattern = `[${existing.suffix}]`
const newPattern = `[${newSuffix}]`
const params = [oldPattern, newPattern, oldPattern]
await db.batch([
{
sql: 'UPDATE suffix SET suffix = ? WHERE user_uuid = ?',
params: [newSuffix, userUuid],
},
{
sql: "UPDATE exercise SET title = REPLACE(title, ?, ?) WHERE title LIKE '%' || ? || '%'",
params,
},
{
sql: "UPDATE combo SET title = REPLACE(title, ?, ?) WHERE title LIKE '%' || ? || '%'",
params,
},
{
sql: "UPDATE session SET title = REPLACE(title, ?, ?) WHERE title LIKE '%' || ? || '%'",
params,
},
{
sql: "UPDATE collection SET label = REPLACE(label, ?, ?) WHERE label LIKE '%' || ? || '%'",
params,
},
// Update suffix table
await db.run('UPDATE suffix SET suffix = ? WHERE user_uuid = ?', [newSuffix, userUuid])
// Update titles across entity tables
await db.run(
"UPDATE exercise SET title = REPLACE(title, ?, ?) WHERE title LIKE '%' || ? || '%'",
[oldPattern, newPattern, oldPattern],
)
await db.run("UPDATE combo SET title = REPLACE(title, ?, ?) WHERE title LIKE '%' || ? || '%'", [
oldPattern,
newPattern,
oldPattern,
])
await db.run(
"UPDATE session SET title = REPLACE(title, ?, ?) WHERE title LIKE '%' || ? || '%'",
[oldPattern, newPattern, oldPattern],
)
await db.run(
"UPDATE collection SET label = REPLACE(label, ?, ?) WHERE label LIKE '%' || ? || '%'",
[oldPattern, newPattern, oldPattern],
)
},
}

View file

@ -246,15 +246,8 @@ function handleImportDb(bytes) {
)
const currentTableNames = currentTableRows.map((r) => r[0])
// Only restore tables that exist in both databases.
// schema_version is deliberately excluded: after the adapt scripts ran on the
// temp DB (step 5), its data matches the *current* schema, so the live DB's
// own version stamp is already correct. Copying the backup's (older) stamp
// would make the next launch re-run migrations against an already-adapted
// schema (see src/db/releases.js).
const tablesToRestore = importedTableNames.filter(
(t) => currentTableNames.includes(t) && t !== 'schema_version',
)
// Only restore tables that exist in both databases
const tablesToRestore = importedTableNames.filter((t) => currentTableNames.includes(t))
// 7. Table-by-table copy inside a single transaction so a mid-restore
// failure cannot leave the live DB partially wiped.

View file

@ -54,7 +54,6 @@
"detail": "Øvelsesdetaljer",
"deleteConfirm": "Er du sikker på, at du vil slette \"{{title}}\"? Dette kan ikke fortrydes.",
"deleteConfirmWithHistory": "Er du sikker på, at du vil slette \"{{title}}\"? Dette sletter også {{count}} journalpost(er). Dette kan ikke fortrydes.",
"deleteConfirmUsage": "Den bruges i {{sessions}} session(er) og {{combos}} kombo(er) og vil blive fjernet derfra.",
"deleted": "Øvelse slettet.",
"form": {
"title": "Titel",
@ -91,7 +90,6 @@
"edit": "Rediger kombo",
"detail": "Kombodetailer",
"deleteConfirm": "Er du sikker på, at du vil slette \"{{title}}\"? Dette kan ikke fortrydes.",
"deleteConfirmUsage": "Den bruges i {{sessions}} session(er) og vil blive fjernet derfra.",
"deleted": "Kombo slettet.",
"exercises": "øvelser",
"timerConfig": "Timer-konfiguration",
@ -160,7 +158,6 @@
"detail": "Sessionsdetaljer",
"deleteConfirm": "Er du sikker på, at du vil slette \"{{title}}\"? Dette kan ikke fortrydes.",
"deleteConfirmWithHistory": "Er du sikker på, at du vil slette \"{{title}}\"? Dette sletter også {{count}} udførelseslog(ge). Dette kan ikke fortrydes.",
"deleteConfirmUsage": "Den er en del af {{collections}} samling(er) og vil blive fjernet derfra.",
"deleted": "Session slettet.",
"items": "elementer",
"delete": "Slet session",
@ -350,7 +347,6 @@
"suffixPreview": "Titler vil se sådan ud: \"{{title}} [{{suffix}}]\"",
"suffixRequired": "Suffiks er påkrævet.",
"suffixTaken": "Dette suffiks bruges allerede af en anden bruger.",
"suffixRenameFailed": "Omdøbning mislykkedes: den ville skabe duplikerede titler. Intet blev omdøbt.",
"collisionExplanation": "{{count}} element(er) er i konflikt med eksisterende data. Vælg hvad der skal gøres for hvert:",
"replace": "Erstat",
"skip": "Spring over",

View file

@ -54,7 +54,6 @@
"detail": "Exercise Details",
"deleteConfirm": "Are you sure you want to delete \"{{title}}\"? This cannot be undone.",
"deleteConfirmWithHistory": "Are you sure you want to delete \"{{title}}\"? This will also delete {{count}} journal entry/entries. This cannot be undone.",
"deleteConfirmUsage": "It is used in {{sessions}} session(s) and {{combos}} combo(s) and will be removed from them.",
"deleted": "Exercise deleted.",
"form": {
"title": "Title",
@ -91,7 +90,6 @@
"edit": "Edit Combo",
"detail": "Combo Details",
"deleteConfirm": "Are you sure you want to delete \"{{title}}\"? This cannot be undone.",
"deleteConfirmUsage": "It is used in {{sessions}} session(s) and will be removed from them.",
"deleted": "Combo deleted.",
"exercises": "exercises",
"timerConfig": "Timer Configuration",
@ -160,7 +158,6 @@
"detail": "Session Details",
"deleteConfirm": "Are you sure you want to delete \"{{title}}\"? This cannot be undone.",
"deleteConfirmWithHistory": "Are you sure you want to delete \"{{title}}\"? This will also delete {{count}} journal log(s). This cannot be undone.",
"deleteConfirmUsage": "It is part of {{collections}} collection(s) and will be removed from them.",
"deleted": "Session deleted.",
"items": "items",
"delete": "Delete session",
@ -350,7 +347,6 @@
"suffixPreview": "Titles will look like: \"{{title}} [{{suffix}}]\"",
"suffixRequired": "Suffix is required.",
"suffixTaken": "This suffix is already used by another user.",
"suffixRenameFailed": "Rename failed: it would create duplicate titles. Nothing was renamed.",
"collisionExplanation": "{{count}} item(s) conflict with existing data. Choose what to do for each:",
"replace": "Replace",
"skip": "Skip",

View file

@ -54,7 +54,6 @@
"detail": "Détails de l'exercice",
"deleteConfirm": "Êtes-vous sûr de vouloir supprimer « {{title}} » ? Cette action est irréversible.",
"deleteConfirmWithHistory": "Êtes-vous sûr de vouloir supprimer « {{title}} » ? Cela supprimera également {{count}} entrée(s) du journal. Cette action est irréversible.",
"deleteConfirmUsage": "Il est utilisé dans {{sessions}} séance(s) et {{combos}} combo(s), et en sera retiré.",
"deleted": "Exercice supprimé.",
"form": {
"title": "Titre",
@ -91,7 +90,6 @@
"edit": "Modifier le Combo",
"detail": "Détails du Combo",
"deleteConfirm": "Êtes-vous sûr de vouloir supprimer « {{title}} » ? Cette action est irréversible.",
"deleteConfirmUsage": "Il est utilisé dans {{sessions}} séance(s) et en sera retiré.",
"deleted": "Combo supprimé.",
"exercises": "exercices",
"timerConfig": "Configuration du minuteur",
@ -160,7 +158,6 @@
"detail": "Détails de la séance",
"deleteConfirm": "Êtes-vous sûr de vouloir supprimer « {{title}} » ? Cette action est irréversible.",
"deleteConfirmWithHistory": "Êtes-vous sûr de vouloir supprimer « {{title}} » ? Cela supprimera également {{count}} journal(aux) d'exécution. Cette action est irréversible.",
"deleteConfirmUsage": "Elle fait partie de {{collections}} collection(s) et en sera retirée.",
"deleted": "Séance supprimée.",
"items": "éléments",
"delete": "Supprimer la séance",
@ -350,7 +347,6 @@
"suffixPreview": "Les titres ressembleront à : « {{title}} [{{suffix}}] »",
"suffixRequired": "Le suffixe est requis.",
"suffixTaken": "Ce suffixe est déjà utilisé par un autre utilisateur.",
"suffixRenameFailed": "Le renommage a échoué : il créerait des titres en double. Rien n'a été renommé.",
"collisionExplanation": "{{count}} élément(s) sont en conflit avec des données existantes. Choisissez quoi faire pour chacun :",
"replace": "Remplacer",
"skip": "Ignorer",

View file

@ -1,24 +1,20 @@
import i18next from 'i18next'
// App language → BCP-47 tag (same mapping as useSpeech.js). Fixed tags keep
// the output deterministic per app language regardless of the OS locale, so
// tests can assert it (en: 03/15/2026, fr: 15/03/2026, da: 15.03.2026).
const LANG_MAP = { en: 'en-US', fr: 'fr-FR', da: 'da-DK' }
function toLangTag(lang) {
return LANG_MAP[lang || i18next.language] || LANG_MAP.en
}
export function formatDate(date, lang) {
if (!date) return ''
const d = date instanceof Date ? date : new Date(date)
if (isNaN(d.getTime())) return ''
return new Intl.DateTimeFormat(toLangTag(lang), {
day: '2-digit',
month: '2-digit',
year: 'numeric',
}).format(d)
lang = lang || i18next.language || 'en'
const pad = (n) => String(n).padStart(2, '0')
const dd = pad(d.getDate())
const mm = pad(d.getMonth() + 1)
const yyyy = d.getFullYear()
if (lang === 'fr') {
return `${dd}/${mm}/${yyyy}`
}
return `${mm}/${dd}/${yyyy}`
}
export function formatItemDuration(ms) {
@ -50,12 +46,17 @@ export function formatDateTime(date, lang) {
const d = date instanceof Date ? date : new Date(date)
if (isNaN(d.getTime())) return ''
return new Intl.DateTimeFormat(toLangTag(lang), {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
}).format(d)
lang = lang || i18next.language || 'en'
const pad = (n) => String(n).padStart(2, '0')
const dd = pad(d.getDate())
const mm = pad(d.getMonth() + 1)
const yyyy = d.getFullYear()
const HH = pad(d.getHours())
const min = pad(d.getMinutes())
const ss = pad(d.getSeconds())
if (lang === 'fr') {
return `${dd}/${mm}/${yyyy}, ${HH}:${min}:${ss}`
}
return d.toLocaleString()
}

View file

@ -5,8 +5,6 @@ export function getYouTubeId(url) {
return u.pathname.slice(1)
}
if (u.hostname.includes('youtube.com')) {
const pathMatch = u.pathname.match(/^\/(?:embed|shorts)\/([^/?]+)/)
if (pathMatch) return pathMatch[1]
return u.searchParams.get('v')
}
} catch {

View file

@ -6,7 +6,6 @@ import { useCombos } from '../composables/useCombos.js'
import { useJsonExport } from '../composables/useJsonExport.js'
import { useKeyboardShortcut } from '../composables/useKeyboardShortcut.js'
import { renderMarkdown } from '../utils/markdown.js'
import { sessionRepository } from '../db/repositories/sessionRepository.js'
const { t } = useTranslation()
const router = useRouter()
@ -46,12 +45,7 @@ function goToExercise(exerciseId) {
async function onDelete() {
if (!combo.value) return
const sessionCount = await sessionRepository.countSessionsUsingItem(comboId.value, 'combo')
let msg = t('combos.deleteConfirm', { title: combo.value.title })
if (sessionCount > 0) {
msg += '\n\n' + t('combos.deleteConfirmUsage', { sessions: sessionCount })
}
const confirmed = window.confirm(msg)
const confirmed = window.confirm(t('combos.deleteConfirm', { title: combo.value.title }))
if (!confirmed) return
await remove(comboId.value)
router.push({ name: 'combos' })

View file

@ -9,8 +9,6 @@ import { useOnlineStatus } from '../composables/useOnlineStatus.js'
import { renderMarkdown } from '../utils/markdown.js'
import { getYouTubeId } from '../utils/youtube.js'
import { executionLogRepository } from '../db/repositories/executionLogRepository.js'
import { sessionRepository } from '../db/repositories/sessionRepository.js'
import { comboRepository } from '../db/repositories/comboRepository.js'
const { t } = useTranslation()
const router = useRouter()
@ -47,19 +45,11 @@ function goBack() {
async function onDelete() {
if (!exercise.value) return
const [count, sessionCount, comboCount] = await Promise.all([
executionLogRepository.countItemsByExercise(exerciseId.value),
sessionRepository.countSessionsUsingItem(exerciseId.value, 'exercise'),
comboRepository.countCombosUsingExercise(exerciseId.value),
])
let msg =
const count = await executionLogRepository.countItemsByExercise(exerciseId.value)
const msg =
count > 0
? t('exercises.deleteConfirmWithHistory', { title: exercise.value.title, count })
: t('exercises.deleteConfirm', { title: exercise.value.title })
if (sessionCount > 0 || comboCount > 0) {
msg +=
'\n\n' + t('exercises.deleteConfirmUsage', { sessions: sessionCount, combos: comboCount })
}
const confirmed = window.confirm(msg)
if (!confirmed) return
await remove(exerciseId.value)

View file

@ -7,7 +7,6 @@ import { useJsonExport } from '../composables/useJsonExport.js'
import { useKeyboardShortcut } from '../composables/useKeyboardShortcut.js'
import { renderMarkdown } from '../utils/markdown.js'
import { executionLogRepository } from '../db/repositories/executionLogRepository.js'
import { collectionRepository } from '../db/repositories/collectionRepository.js'
const { t } = useTranslation()
const router = useRouter()
@ -59,17 +58,11 @@ function getItemTypeLabel(itemType) {
async function onDelete() {
if (!session.value) return
const [count, collectionCount] = await Promise.all([
executionLogRepository.countLogsBySession(sessionId.value),
collectionRepository.countCollectionsUsingSession(sessionId.value),
])
let msg =
const count = await executionLogRepository.countLogsBySession(sessionId.value)
const msg =
count > 0
? t('sessions.deleteConfirmWithHistory', { title: session.value.title, count })
: t('sessions.deleteConfirm', { title: session.value.title })
if (collectionCount > 0) {
msg += '\n\n' + t('sessions.deleteConfirmUsage', { collections: collectionCount })
}
const confirmed = window.confirm(msg)
if (!confirmed) return
await remove(sessionId.value)

View file

@ -274,8 +274,7 @@ async function saveEditSuffix(entry) {
await loadSuffixes()
cancelEditSuffix()
} catch (e) {
console.error('Suffix rename failed:', e)
editSuffixError.value = t('import.suffixRenameFailed')
editSuffixError.value = e.message
}
}

View file

@ -14,7 +14,6 @@ Step 2 — Settings Tests
- test_restore_db_button: Restore DB button is visible and enabled
- test_restore_db_cancel_dialog: Declining confirm dialog aborts the restore
- test_restore_db_flow: Full restore round-trip with spinner and report
- test_restore_db_keeps_current_schema_version: Restore keeps the live DB's schema_version stamp
- test_restore_db_invalid_file: Uploading a non-SQLite file shows error report
- test_restore_db_incomplete_db: Uploading a DB missing required tables shows error
- test_export_import_enabled: Export/Import buttons are present and enabled (wired in Step 3b)
@ -24,7 +23,6 @@ Step 2 — Settings Tests
- test_initialize_db_flow: Full initialize flow resets database and reloads page
"""
import os
import sqlite3
import tempfile
from playwright.sync_api import Page, expect
@ -463,56 +461,6 @@ def test_restore_db_flow(page: Page, app_url: str, tmp_path_factory):
expect(report).not_to_be_visible()
def test_restore_db_keeps_current_schema_version(page: Page, app_url: str, tmp_path_factory):
"""After a restore, the live DB keeps its current schema_version stamp
instead of inheriting the backup's (F3). A backup stamped with an older
version must not leave the live DB looking migration-pending, or the next
launch would re-run migrations against an already-adapted schema.
Note: while only schema v1 exists this can only simulate an older stamp;
the first real v2 migration must ship a full end-to-end restore test of an
actual older-version backup.
"""
_go_to_settings(page, app_url)
current_version = page.evaluate(
"async () => (await window.__db.selectOne("
"'SELECT MAX(version) AS v FROM schema_version')).v"
)
tmp_dir = tmp_path_factory.mktemp("restore_version")
db_path = str(tmp_dir / "old-stamp.sqlite3")
download = _download_via_button(page, '[data-testid="settings-download-db"]')
download.save_as(db_path)
# Stamp the backup with an older schema version, as if it were taken
# before a (future) migration
conn = sqlite3.connect(db_path)
conn.execute("UPDATE schema_version SET version = 0")
conn.commit()
conn.close()
page.once('dialog', lambda dialog: dialog.accept())
with page.expect_file_chooser() as fc_info:
page.locator('[data-testid="settings-restore-db"]').click()
file_chooser = fc_info.value
file_chooser.set_files(db_path)
report = page.locator('[data-testid="restore-report"]')
expect(report).to_be_visible(timeout=15000)
expect(report.locator('.report-success')).to_be_visible()
page.locator('[data-testid="restore-report-dismiss"]').click()
restored_version = page.evaluate(
"async () => (await window.__db.selectOne("
"'SELECT MAX(version) AS v FROM schema_version')).v"
)
assert restored_version == current_version, (
f"Live DB schema_version must stay {current_version} after restore, "
f"got {restored_version}"
)
def test_restore_db_invalid_file(page: Page, app_url: str):
"""Uploading a non-SQLite file shows an error report."""
_go_to_settings(page, app_url)

View file

@ -8,8 +8,6 @@ Step 3 — Exercise Management Tests
- test_edit_exercise: Edit an exercise and verify changes
- test_delete_exercise: Delete an exercise with confirmation
- test_delete_exercise_cancel: Cancelling delete does not remove exercise
- test_delete_exercise_used_in_session: Delete confirm mentions usage; no session_item orphans
- test_exercise_video_embed_and_shorts_urls: embed/shorts YouTube URLs render as thumbnails
- test_search_exercises: Search filters exercises by title
- test_search_no_results: Search with no matches shows empty message
- test_search_clear_button: Clear button empties the search field and restores the full list
@ -173,46 +171,6 @@ def test_create_exercise_full(page: Page, app_url: str):
_clean_exercises(page)
def test_exercise_video_embed_and_shorts_urls(page: Page, app_url: str):
"""YouTube /embed/<id> and /shorts/<id> URLs are recognized like watch
URLs and render as thumbnails on the detail view."""
_go_to_exercises(page, app_url)
_clean_exercises(page)
exercise_id = page.evaluate(
"""async () => {
const uuid = await window.__repos.settings.get('user_uuid');
return window.__repos.exercises.create({
title: 'Video URL Variants',
description: '',
asymmetric: false,
alternate: false,
image_urls: [],
video_urls: [
'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
'https://www.youtube.com/embed/dQw4w9WgXcQ',
'https://www.youtube.com/shorts/dQw4w9WgXcQ',
],
default_reps: 10,
default_weight: 0,
created_by: uuid
});
}"""
)
page.goto(f"{app_url}/#/exercises/{exercise_id}")
page.wait_for_selector('[data-testid="exercise-detail-video-0"]', timeout=5000)
for i in range(3):
video = page.locator(f'[data-testid="exercise-detail-video-{i}"]')
expect(video).to_have_class("youtube-thumb")
expect(video.locator("img")).to_have_attribute(
"src", "https://img.youtube.com/vi/dQw4w9WgXcQ/mqdefault.jpg"
)
_clean_exercises(page)
def test_exercise_appears_in_list(page: Page, app_url: str):
"""Created exercise appears in the list view with correct info."""
_go_to_exercises(page, app_url)
@ -372,79 +330,6 @@ def test_delete_exercise_cancel(page: Page, app_url: str):
_clean_exercises(page)
def test_delete_exercise_used_in_session(page: Page, app_url: str):
"""Deleting an exercise used in a session and a combo warns about the
usage, and after deletion no session_item rows reference it (no orphans)."""
_go_to_exercises(page, app_url)
_clean_exercises(page)
exercise_id = page.evaluate(
"""async () => {
const uuid = await window.__repos.settings.get('user_uuid');
return window.__repos.exercises.create({
title: 'Used Everywhere',
description: '',
asymmetric: false,
alternate: false,
image_urls: [],
video_urls: [],
default_reps: 10,
default_weight: 0,
created_by: uuid
});
}"""
)
page.evaluate(
f"""async () => {{
const uuid = await window.__repos.settings.get('user_uuid');
const sessionId = await window.__repos.sessions.create({{
title: 'Session Using Exercise', description: '', created_by: uuid
}});
await window.__repos.sessions.setItems(sessionId, [
{{ item_id: '{exercise_id}', item_type: 'exercise', repetitions: 10, weight: 0 }},
]);
const comboId = await window.__repos.combos.create({{
title: 'Combo Using Exercise', description: '', type: 'NONE', created_by: uuid
}});
await window.__repos.combos.setExercises(comboId, ['{exercise_id}']);
}}"""
)
dialog_message = {}
def on_dialog(dialog):
dialog_message["text"] = dialog.message
dialog.accept()
page.on("dialog", on_dialog)
page.goto(f"{app_url}/#/exercises/{exercise_id}")
page.wait_for_selector('[data-testid="exercise-delete-btn"]', timeout=5000)
page.click('[data-testid="exercise-delete-btn"]')
page.wait_for_timeout(500)
msg = dialog_message.get("text", "")
assert "1 session(s)" in msg, f"Confirm must mention session usage, got: {msg}"
assert "1 combo(s)" in msg, f"Confirm must mention combo usage, got: {msg}"
orphans = page.evaluate(
f"""async () => (await window.__db.selectAll(
"SELECT * FROM session_item WHERE item_id = '{exercise_id}'"
)).length"""
)
assert orphans == 0, f"Expected no orphaned session_item rows, got {orphans}"
# Clean up the seeded session/combo
page.evaluate(
"""async () => {
for (const s of await window.__repos.sessions.getAll())
await window.__repos.sessions.delete(s.id)
for (const c of await window.__repos.combos.getAll())
await window.__repos.combos.delete(c.id)
}"""
)
def test_search_exercises(page: Page, app_url: str):
"""Search filters exercises by title."""
_go_to_exercises(page, app_url)

View file

@ -16,7 +16,6 @@ Step 3b — Exercise JSON Export/Import Tests
- test_settings_import_button: Settings import button works
- test_suffix_management_visible: Suffix section visible after import from different user
- test_suffix_rename_cascade: Renaming suffix updates titles across exercises
- test_suffix_rename_collision_rolls_back: Colliding rename fails atomically, nothing renamed
- test_suffix_delete: Suffix can be deleted from settings
- test_import_as_mine_button_visible: Import-as-mine buttons visible on exercises and settings
- test_import_as_mine_no_suffix_no_collision: Foreign exercises imported as own without suffix
@ -920,72 +919,6 @@ def test_suffix_rename_cascade(page: Page, app_url: str):
_clean_all(page)
def test_suffix_rename_collision_rolls_back(page: Page, app_url: str):
"""A rename that would create a duplicate title fails atomically: an error
is shown and nothing (suffix entry or titles) was renamed."""
_go_to_exercises(page, app_url)
_clean_all(page)
other_uuid = "rename-atomic-uuid-6666"
# Suffixed exercise 'Squats [CH]' plus a local 'Squats [CW]' that the
# rename CH → CW would duplicate (exercise.title is UNIQUE)
page.evaluate(
f"""async () => {{
await window.__repos.suffixes.create({{
user_uuid: '{other_uuid}',
user_name: 'Charlie',
suffix: 'CH',
}});
await window.__repos.exercises.createWithId('atomic-ex-1', {{
title: 'Squats [CH]',
description: 'Test',
asymmetric: false,
alternate: false,
image_urls: [],
video_urls: [],
default_reps: 10,
default_weight: 0,
created_by: '{other_uuid}',
}});
const userId = await window.__repos.settings.get('user_uuid')
await window.__repos.exercises.createWithId('atomic-ex-2', {{
title: 'Squats [CW]',
description: 'Blocker',
asymmetric: false,
alternate: false,
image_urls: [],
video_urls: [],
default_reps: 10,
default_weight: 0,
created_by: userId,
}});
}}"""
)
_go_to_settings(page, app_url)
page.click('[data-testid="suffix-edit-btn"]')
page.fill('[data-testid="suffix-edit-input"]', "CW")
page.click('[data-testid="suffix-save-btn"]')
# The rename must fail with a visible error
expect(page.locator('[data-testid="suffix-edit-error"]')).to_be_visible()
# ... and nothing must have been renamed (transaction rolled back)
state = page.evaluate(
f"""async () => {{
const suffix = await window.__repos.suffixes.getByUuid('{other_uuid}')
const ex = await window.__repos.exercises.getById('atomic-ex-1')
return {{ suffix: suffix.suffix, title: ex.title }}
}}"""
)
assert state["suffix"] == "CH", f"Suffix entry must be unchanged, got {state['suffix']}"
assert state["title"] == "Squats [CH]", f"Exercise title must be unchanged, got {state['title']}"
_clean_all(page)
def test_suffix_delete(page: Page, app_url: str):
"""Suffix can be deleted from settings."""
_go_to_exercises(page, app_url)

View file

@ -8,7 +8,6 @@ Step 4 — Combo Management Tests
- test_edit_combo: Edit a combo and verify changes
- test_delete_combo: Delete a combo with confirmation
- test_delete_combo_cancel: Cancelling delete does not remove combo
- test_delete_combo_used_in_session: Delete confirm mentions usage; no session_item orphans
- test_search_combos: Search filters combos by title
- test_search_no_results: Search with no matches shows empty message
- test_search_clear_button: Clear button empties the search field and restores the full list
@ -299,64 +298,6 @@ def test_delete_combo_cancel(page: Page, app_url: str):
_clean_combos(page)
def test_delete_combo_used_in_session(page: Page, app_url: str):
"""Deleting a combo used in a session warns about the usage, and after
deletion no session_item rows reference it (no orphans)."""
_go_to_combos(page, app_url)
_clean_combos(page)
ex_id = _create_exercise_via_repo(page, "Combo Member Exercise")
combo_id = page.evaluate(
f"""async () => {{
const uuid = await window.__repos.settings.get('user_uuid');
const comboId = await window.__repos.combos.create({{
title: 'Combo In Session', description: '', type: 'NONE', created_by: uuid
}});
await window.__repos.combos.setExercises(comboId, ['{ex_id}']);
const sessionId = await window.__repos.sessions.create({{
title: 'Session Using Combo', description: '', created_by: uuid
}});
await window.__repos.sessions.setItems(sessionId, [
{{ item_id: comboId, item_type: 'combo', repetitions: 1, weight: 0 }},
]);
return comboId;
}}"""
)
dialog_message = {}
def on_dialog(dialog):
dialog_message["text"] = dialog.message
dialog.accept()
page.on("dialog", on_dialog)
page.goto(f"{app_url}/#/combos/{combo_id}")
page.wait_for_selector('[data-testid="combo-delete-btn"]', timeout=5000)
page.click('[data-testid="combo-delete-btn"]')
page.wait_for_timeout(500)
msg = dialog_message.get("text", "")
assert "1 session(s)" in msg, f"Confirm must mention session usage, got: {msg}"
orphans = page.evaluate(
f"""async () => (await window.__db.selectAll(
"SELECT * FROM session_item WHERE item_id = '{combo_id}'"
)).length"""
)
assert orphans == 0, f"Expected no orphaned session_item rows, got {orphans}"
# Clean up the seeded session and exercise
page.evaluate(
"""async () => {
for (const s of await window.__repos.sessions.getAll())
await window.__repos.sessions.delete(s.id)
for (const e of await window.__repos.exercises.getAll())
await window.__repos.exercises.delete(e.id)
}"""
)
def test_search_combos(page: Page, app_url: str):
"""Search filters combos by title."""
_go_to_combos(page, app_url)

View file

@ -16,7 +16,6 @@ Step 7 — Full Data Import/Export Tests
- test_import_as_mine_combo: Import-as-mine button works on combo list
- test_import_as_mine_session: Import-as-mine button works on session list
- test_execution_logs_same_user_roundtrip: Execution logs survive a same-user export/import cycle
- test_execution_logs_same_user_reimport_without_delete: Re-import without delete skips logs cleanly
- test_execution_log_id_remapping: Log item exercise_id is remapped when the exercise gets a new_id
- test_session_with_combo_item_remapping: Session combo item_id follows remap when combo gets new_id
- test_collection_session_remapping: Collection session ref follows remap when session gets new_id
@ -692,46 +691,6 @@ def test_execution_logs_same_user_roundtrip(page: Page, app_url: str):
assert result["sessionExists"], "Log session_id must reference an existing session after reimport"
def test_execution_logs_same_user_reimport_without_delete(page: Page, app_url: str):
"""Re-importing a full export without deleting anything reports execution
logs as skipped (no constraint errors, no duplicate logs)."""
_go_to(page, app_url, "#/exercises")
_clean_all(page)
ex_id = _create_exercise(page, "Reimport Exercise")
session_id = _create_session(
page,
"Reimport Session",
[{"item_id": ex_id, "item_type": "exercise", "repetitions": 10, "weight": 0}],
)
_create_execution_log(page, session_id, ex_id)
json_str = _build_full_export_json(page)
# Re-import immediately — everything still exists locally
_trigger_import_with_json(page, json_str)
expect(page.locator('[data-testid="import-apply"]')).to_be_visible(timeout=10000)
page.locator('[data-testid="import-apply"]').click()
expect(page.locator('[data-testid="import-report-step"]')).to_be_visible(timeout=15000)
# No constraint errors in the report
error_count = page.locator('[data-testid^="import-report-error-"]').count()
assert error_count == 0, f"Expected no import errors, got {error_count}"
# The colliding log is reported as skipped, not imported
expect(page.locator('[data-testid="import-report-skipped"]')).to_be_visible()
assert page.locator('[data-testid="import-report-logs-imported"]').count() == 0, (
"Existing execution log must be skipped, not re-imported"
)
page.click('[data-testid="import-done"]')
page.wait_for_timeout(500)
# No duplicate logs in the database
log_count = page.evaluate("async () => (await window.__repos.executionLogs.getAll()).length")
assert log_count == 1, f"Expected 1 execution log after reimport, got {log_count}"
def test_execution_log_id_remapping(page: Page, app_url: str):
"""When an exercise gets new_id on collision, the execution log item's exercise_id is remapped."""
_go_to(page, app_url, "#/exercises")

View file

@ -4,7 +4,6 @@ Step 9 — Statistics
- test_stats_shows_empty_when_no_date: StatsView shows empty message when no creation date
- test_stats_date_format_english: Date is formatted as MM/DD/YYYY in English
- test_stats_date_format_french: Date is formatted as DD/MM/YYYY in French
- test_stats_date_format_danish: Date is formatted as DD.MM.YYYY in Danish
- test_stats_date_format_updates_on_language_change: Switching language updates the date format reactively
- test_stats_date_range_tabs_visible: All four date range tabs are rendered
- test_stats_summary_cards_zero_when_no_data: Summary cards show 0 when no execution logs exist
@ -17,7 +16,6 @@ Step 9 — Statistics
- test_stats_volume_chart_renders: Volume chart canvas renders when sessions have weight data
- test_stats_progression_chart_renders: Progression chart renders after selecting an exercise
- test_stats_streak_consecutive_days: Streak counts consecutive days with sessions
- test_stats_streak_ignores_aborted_sessions: Aborted sessions do not extend the streak
"""
from datetime import datetime, timedelta, timezone
@ -117,7 +115,6 @@ def _create_log_with_items(
reps: int = 10,
weight: float = 0.0,
completed: bool = True,
status: str = 'completed',
) -> str:
"""Seed one execution log with one item."""
finished_at = started_at # same timestamp is fine for tests
@ -126,8 +123,7 @@ def _create_log_with_items(
const logId = await window.__repos.executionLogs.create({{
session_id: '{session_id}',
started_at: '{started_at}',
finished_at: '{finished_at}',
status: {repr(status)}
finished_at: '{finished_at}'
}})
await window.__repos.executionLogs.addItem({{
log_id: logId,
@ -241,28 +237,6 @@ def test_stats_date_format_french(page: Page, app_url: str):
page.wait_for_timeout(300)
def test_stats_date_format_danish(page: Page, app_url: str):
"""Date is formatted as DD.MM.YYYY in Danish (not the US fallback)."""
_set_date_and_reload(page, app_url, _KNOWN_DATE)
page.click('a[href="#/settings"]')
page.wait_for_url("**/#/settings", timeout=5000)
page.wait_for_selector('[data-testid="settings-language"]')
page.select_option('[data-testid="settings-language"]', 'da')
page.wait_for_timeout(500)
_go_to_stats(page)
text = page.locator('[data-testid="stats-using-since"]').text_content()
assert '15.03.2026' in text, f"Expected Danish date format, got: {text}"
page.click('a[href="#/settings"]')
page.wait_for_url("**/#/settings", timeout=5000)
page.wait_for_selector('[data-testid="settings-language"]')
page.select_option('[data-testid="settings-language"]', 'en')
page.wait_for_timeout(300)
def test_stats_date_format_updates_on_language_change(page: Page, app_url: str):
"""Switching language updates the date format reactively without navigation."""
_set_date_and_reload(page, app_url, _KNOWN_DATE)
@ -536,28 +510,3 @@ def test_stats_streak_consecutive_days(page: Page, app_url: str):
streak_val = page.locator('[data-testid="stats-streak"] .stat-value').text_content()
assert int(streak_val) >= 3, f"Expected streak ≥ 3, got {streak_val}"
def test_stats_streak_ignores_aborted_sessions(page: Page, app_url: str):
"""Aborted sessions do not extend the streak: one completed log today plus
one aborted log yesterday must give a streak of 1, not 2."""
_wait_for_db(page, app_url)
_delete_all_logs(page)
_delete_all_exercises(page)
_delete_all_sessions(page)
ex_id = _create_exercise(page, 'Aborted Streak Exercise')
sess_id = _create_session(page, 'Aborted Streak Session')
_create_log_with_items(page, sess_id, ex_id, _iso_days_ago(0), status='completed')
_create_log_with_items(page, sess_id, ex_id, _iso_days_ago(1), status='aborted')
page.reload()
page.wait_for_function(
"document.documentElement.getAttribute('data-db-ready') === 'true'",
timeout=15000,
)
_go_to_stats(page)
streak_val = page.locator('[data-testid="stats-streak"] .stat-value').text_content()
assert int(streak_val) == 1, f"Expected streak 1 (aborted must not count), got {streak_val}"

View file

@ -74,7 +74,7 @@ trainUs/
Lines of code by area, measured with [cloc](https://github.com/AlDanial/cloc) (generated files, dependencies, and the Hugo theme excluded):
| Area | Files | Code lines | Main languages |
| ---------------------- | ----: | ---------: | ------------------------ |
| ------------------------ | ----: | ---------: | -------------------------------- |
| `src/` (application) | 81 | 13,973 | Vue SFC, JavaScript, CSS |
| `tests/` (Playwright) | 37 | 7,371 | Python |
| `website/` (docs site) | 49 | 4,732 | Markdown, CSS, HTML |
@ -794,7 +794,7 @@ Action mapping deliberately reuses the on-screen handlers rather than inventing
`useSessionExecution.js` calls `speak()` at two moments:
| Moment | Spoken text | Hook |
| ----------------------------------- | ------------------------------ | --------------------------------------------------------------------------------- |
| ---------------------------------- | ------------------------------- | ------------------------------------------------------------------ |
| A step becomes current | The exercise title | `loadStepState()` — every step, including the first |
| EMOM: 10 s left in the current slot | `"Next: {title}"` (translated) | `_emomSounds(remaining)`, alongside the existing beep thresholds |
| TABATA: rest phase opens | `"Next: {title}"` | `_startRestPhase(step)`, right where the on-screen "Next:" label is already shown |
@ -826,10 +826,10 @@ Four tabs control which period is shown: **7d**, **30d** (default), **3m**, **Al
#### Summary cards
| Card | Query |
| -------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
| -------------- | ----------------------------------------------------------------------------------------- |
| Sessions | Count of `execution_log` rows in the period |
| Exercises done | Count of `execution_log_item` (completed) rows in the period |
| Day streak | Consecutive calendar days (ending today) with at least one **completed** log (aborted runs don't count); always across all history |
| Day streak | Consecutive calendar days (ending today) with at least one log; always across all history |
Streak uses `setUTCHours(0,0,0,0)` for date comparison to match SQLite's `DATE(started_at)` UTC extraction.
@ -1014,12 +1014,12 @@ isStale = (effectiveDate absent) OR (now_utc effectiveDate) > backup_reminde
#### Date formatting
`src/utils/dateFormatter.js` provides two locale-aware pure functions used throughout the app. Both delegate to `Intl.DateTimeFormat` with a fixed app-language → BCP-47 map (`en` → `en-US`, `fr``fr-FR`, `da``da-DK`, same mapping as `useSpeech.js`) and 2-digit day/month, so output is deterministic per app language regardless of the OS locale:
`src/utils/dateFormatter.js` provides two locale-aware pure functions used throughout the app:
| Function | English | French | Danish |
| ----------------------------- | ----------------------- | ----------------- | ----------------- |
| `formatDate(date, lang?)` | `MM/DD/YYYY` | `DD/MM/YYYY` | `DD.MM.YYYY` |
| `formatDateTime(date, lang?)` | date + `hh:mm:ss AM/PM` | date + `HH:MM:SS` | date + `HH.MM.SS` |
| Function | English | French |
| ----------------------------- | ------------------ | ---------------------- |
| `formatDate(date, lang?)` | `MM/DD/YYYY` | `DD/MM/YYYY` |
| `formatDateTime(date, lang?)` | `toLocaleString()` | `DD/MM/YYYY, HH:MM:SS` |
The optional `lang` parameter allows Vue computed properties to pass the reactive language ref, ensuring date displays update immediately on language switch.

View file

@ -74,7 +74,7 @@ trainUs/
Lignes de code par zone, mesurées avec [cloc](https://github.com/AlDanial/cloc) (fichiers générés, dépendances et thème Hugo exclus) :
| Zone | Fichiers | Lignes de code | Langages principaux |
| ------------------------- | -------: | -------------: | ------------------------ |
| -------------------------- | -------: | --------------: | --------------------------------- |
| `src/` (application) | 81 | 13 973 | Vue SFC, JavaScript, CSS |
| `tests/` (Playwright) | 37 | 7 371 | Python |
| `website/` (site de docs) | 49 | 4 732 | Markdown, CSS, HTML |
@ -766,10 +766,10 @@ Quatre onglets contrôlent la période affichée : **7j**, **30j** (par défaut)
#### Cartes de résumé
| Carte | Requête |
| ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| ------------------ | ---------------------------------------------------------------------------------------------------------------------- |
| Séances | Nombre de lignes `execution_log` sur la période |
| Exercices réalisés | Nombre de lignes `execution_log_item` (complétées) sur la période |
| Série de jours | Jours calendaires consécutifs (jusqu'à aujourd'hui) avec au moins un journal **complété** (les séances abandonnées ne comptent pas) ; toujours calculée sur tout l'historique |
| Série de jours | Jours calendaires consécutifs (jusqu'à aujourd'hui) avec au moins un journal ; toujours calculée sur tout l'historique |
La série utilise `setUTCHours(0,0,0,0)` pour comparer les dates et correspondre à l'extraction UTC `DATE(started_at)` de SQLite.
@ -956,12 +956,12 @@ isStale = (effectiveDate absente) OU (now_utc effectiveDate) > backup_remind
#### Formatage des dates
`src/utils/dateFormatter.js` fournit deux fonctions pures, sensibles à la locale, utilisées partout dans l'application. Les deux délèguent à `Intl.DateTimeFormat` avec une correspondance fixe langue → BCP-47 (`en` → `en-US`, `fr``fr-FR`, `da``da-DK`, la même que `useSpeech.js`) et jour/mois sur 2 chiffres, pour une sortie déterministe par langue quelle que soit la locale du système :
`src/utils/dateFormatter.js` fournit deux fonctions pures, sensibles à la locale, utilisées partout dans l'application :
| Fonction | Anglais | Français | Danois |
| ----------------------------- | ----------------------- | ----------------- | ----------------- |
| `formatDate(date, lang?)` | `MM/DD/YYYY` | `DD/MM/YYYY` | `DD.MM.YYYY` |
| `formatDateTime(date, lang?)` | date + `hh:mm:ss AM/PM` | date + `HH:MM:SS` | date + `HH.MM.SS` |
| Fonction | Anglais | Français |
| ----------------------------- | ------------------ | ---------------------- |
| `formatDate(date, lang?)` | `MM/DD/YYYY` | `DD/MM/YYYY` |
| `formatDateTime(date, lang?)` | `toLocaleString()` | `DD/MM/YYYY, HH:MM:SS` |
Le paramètre optionnel `lang` permet aux propriétés calculées (`computed`) de Vue de passer la ref réactive de langue, pour que l'affichage des dates se mette à jour immédiatement lors d'un changement de langue.

View file

@ -185,7 +185,7 @@ The edit form is identical to the create form but pre-populated with current dat
### Deleting an Exercise
Click the **trash** icon in the action bar. A confirmation dialog appears. If the exercise has past execution entries, the dialog shows how many will be deleted with it; if it is used in sessions or combos, the dialog also shows how many, since the exercise will be removed from them. Confirming permanently deletes the exercise — along with any execution log entries that reference it — and returns you to the list. The rest of each affected session log (its other exercises) is unaffected.
Click the **trash** icon in the action bar. A confirmation dialog appears. If the exercise has past execution entries, the dialog shows how many will be deleted with it. Confirming permanently deletes the exercise — along with any execution log entries that reference it — and returns you to the list. The rest of each affected session log (its other exercises) is unaffected.
### Searching Exercises
@ -249,7 +249,7 @@ Displays combo information including the ordered exercise list with reps and wei
### Deleting a Combo
Click the **trash** icon in the action bar. If the combo is used in sessions, the confirmation dialog shows how many, since the combo will be removed from them. Confirming permanently deletes the combo.
Click the **trash** icon in the action bar. Confirming permanently deletes the combo.
---
@ -312,7 +312,7 @@ The edit form is identical to the create form but pre-populated with current dat
### Deleting a Session
Click the **trash** icon in the action bar. A confirmation dialog appears; if the session has past execution logs, it shows how many will be deleted with it, and if the session belongs to collections, it shows how many, since the session will be removed from them. Confirming permanently deletes the session along with all of its execution history — this cannot be undone.
Click the **trash** icon in the action bar. A confirmation dialog appears; if the session has past execution logs, it shows how many will be deleted with it. Confirming permanently deletes the session along with all of its execution history — this cannot be undone.
---
@ -511,7 +511,7 @@ This feature depends on your browser and OS supporting the Media Session API —
### Voice Announcements
During execution, a synthesized voice announces the exercise title whenever a new step begins — handy when you're not looking at the screen. For EMOM and TABATA combos, the app also pre-announces the _next_ exercise ("Next: Squat") a few seconds ahead: 10 seconds before an EMOM slot ends, and as soon as a TABATA rest phase starts.
During execution, a synthesized voice announces the exercise title whenever a new step begins — handy when you're not looking at the screen. For EMOM and TABATA combos, the app also pre-announces the *next* exercise ("Next: Squat") a few seconds ahead: 10 seconds before an EMOM slot ends, and as soon as a TABATA rest phase starts.
The voice follows your selected language and shares the same volume as the audio beeps. You can turn it off in **Settings → Sound** with the **Voice announcements** checkbox; it depends on your browser supporting speech synthesis — where it isn't supported, the app works exactly as before, just silently.

View file

@ -185,7 +185,7 @@ Le formulaire d'édition est identique au formulaire de création, pré-rempli a
### Supprimer un exercice
Cliquez sur l'icône **corbeille** dans la barre d'action. Une boîte de dialogue de confirmation apparaît ; si l'exercice a déjà été exécuté, elle indique combien d'entrées de journal seront supprimées avec lui, et s'il est utilisé dans des séances ou des combos, elle indique combien, car l'exercice en sera retiré. En confirmant, l'exercice est supprimé définitivement — ainsi que les entrées de journal d'exécution qui le concernent — et vous revenez à la liste.
Cliquez sur l'icône **corbeille** dans la barre d'action. Une boîte de dialogue de confirmation apparaît ; si l'exercice a déjà été exécuté, elle indique combien d'entrées de journal seront supprimées avec lui. En confirmant, l'exercice est supprimé définitivement — ainsi que les entrées de journal d'exécution qui le concernent — et vous revenez à la liste.
> Le reste de chaque séance enregistrée (les autres exercices de ce même journal) reste intact.
@ -251,7 +251,7 @@ Affiche les informations du combo, y compris la liste ordonnée des exercices av
### Supprimer un combo
Cliquez sur l'icône **corbeille** dans la barre d'action. Si le combo est utilisé dans des séances, la boîte de confirmation indique combien, car le combo en sera retiré. En confirmant, le combo est supprimé définitivement.
Cliquez sur l'icône **corbeille** dans la barre d'action. En confirmant, le combo est supprimé définitivement.
---
@ -314,7 +314,7 @@ Le formulaire d'édition est identique au formulaire de création, pré-rempli a
### Supprimer une séance
Cliquez sur l'icône **corbeille** dans la barre d'action. Une boîte de dialogue de confirmation apparaît ; si la séance a des journaux d'exécution passés, elle indique combien seront supprimés avec elle, et si la séance appartient à des collections, elle indique combien, car la séance en sera retirée. En confirmant, la séance est supprimée définitivement, ainsi que tout son historique d'exécution — cette action est irréversible.
Cliquez sur l'icône **corbeille** dans la barre d'action. Une boîte de dialogue de confirmation apparaît ; si la séance a des journaux d'exécution passés, elle indique combien seront supprimés avec elle. En confirmant, la séance est supprimée définitivement, ainsi que tout son historique d'exécution — cette action est irréversible.
---
@ -513,7 +513,7 @@ Cette fonctionnalité dépend de la prise en charge de la Media Session API par
### Annonces vocales
Pendant l'exécution, une voix synthétisée annonce le nom de l'exercice à chaque nouvelle étape — pratique quand vous ne regardez pas l'écran. Pour les combos EMOM et TABATA, l'application annonce aussi à l'avance le _prochain_ exercice (« Suivant : Squat ») quelques secondes avant : 10 secondes avant la fin d'un créneau EMOM, et dès le début d'une phase de repos TABATA.
Pendant l'exécution, une voix synthétisée annonce le nom de l'exercice à chaque nouvelle étape — pratique quand vous ne regardez pas l'écran. Pour les combos EMOM et TABATA, l'application annonce aussi à l'avance le *prochain* exercice (« Suivant : Squat ») quelques secondes avant : 10 secondes avant la fin d'un créneau EMOM, et dès le début d'une phase de repos TABATA.
La voix suit la langue sélectionnée et partage le même volume que les bips sonores. Vous pouvez la désactiver dans **Paramètres → Son** avec la case **Annonces vocales** ; elle dépend de la prise en charge de la synthèse vocale par votre navigateur — là où elle n'est pas prise en charge, l'application fonctionne exactement comme avant, simplement en silence.