Compare commits
No commits in common. "efeeb164b877de8b10c0ce21b96f86e73ae86d31" and "13f3b305bd6fbbae38f2e8a028954135e330d4db" have entirely different histories.
efeeb164b8
...
13f3b305bd
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -201,7 +201,7 @@ As Phase 1–3 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 |
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
||||
|
|
|
|||
|
|
@ -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 F1–F12 (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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 })),
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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`,
|
||||
)
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
)
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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' })
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue