Compare commits
10 commits
13f3b305bd
...
efeeb164b8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
efeeb164b8 | ||
|
|
0bc20f81d8 | ||
|
|
166cf87e95 | ||
|
|
e3847edae1 | ||
|
|
a6b4723ac6 | ||
|
|
f324ffb2cb | ||
|
|
1a3f294b31 | ||
|
|
c5ee817ebb | ||
|
|
7e9c825af3 | ||
|
|
cd8636546a |
|
|
@ -54,6 +54,14 @@ website/ # Hugo project site (docs + blog), two separate sections
|
||||||
- `npm run preview` -- Preview production build
|
- `npm run preview` -- Preview production build
|
||||||
- `npm run format` -- Format code with Prettier
|
- `npm run format` -- Format code with Prettier
|
||||||
- `npm run format:check` -- Check formatting
|
- `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
|
## Plan
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -64,9 +64,9 @@ are behaviour fixes; Phase 3 is code quality; Phase 4 is documentation drift.
|
||||||
1. Exclude `schema_version` from `tablesToRestore` (the live DB's stamp is
|
1. Exclude `schema_version` from `tablesToRestore` (the live DB's stamp is
|
||||||
already correct by construction), **or**
|
already correct by construction), **or**
|
||||||
2. Re-stamp `SCHEMA_VERSION` into `schema_version` after a successful restore.
|
2. Re-stamp `SCHEMA_VERSION` into `schema_version` after a successful restore.
|
||||||
Option 1 is simpler and keeps the restore loop generic. Also add a comment in
|
Option 1 is simpler and keeps the restore loop generic. Also add a comment in
|
||||||
`src/db/releases.js` explaining the interaction, so the first real migration
|
`src/db/releases.js` explaining the interaction, so the first real migration
|
||||||
ships with a restore test.
|
ships with a restore test.
|
||||||
- **Tests:** Hard to test end-to-end while only schema v1 exists. Minimum now:
|
- **Tests:** Hard to test end-to-end while only schema v1 exists. Minimum now:
|
||||||
a test asserting that after restoring a valid backup, `MAX(version)` in
|
a test asserting that after restoring a valid backup, `MAX(version)` in
|
||||||
`schema_version` equals `SCHEMA_VERSION` (guards option 1/2 regardless of
|
`schema_version` equals `SCHEMA_VERSION` (guards option 1/2 regardless of
|
||||||
|
|
@ -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
|
2. Extend delete confirmations to include usage counts (new i18n keys ×3
|
||||||
languages).
|
languages).
|
||||||
3. On exercise delete, also `DELETE FROM session_item WHERE item_id = ? AND
|
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:
|
- **Decision to validate first:** blocking vs. informative confirm — proposal:
|
||||||
keep it a confirm (informative), consistent with current UX.
|
keep it a confirm (informative), consistent with current UX.
|
||||||
- **Tests:** Extend `tests/test_step3_exercises.py` / `test_step4_combos.py`:
|
- **Tests:** Extend `tests/test_step3_exercises.py` / `test_step4_combos.py`:
|
||||||
|
|
@ -200,16 +200,16 @@ As Phase 1–3 fixes land, record the notable ones in `docs/decisions-log.md`
|
||||||
|
|
||||||
## Suggested execution order
|
## Suggested execution order
|
||||||
|
|
||||||
| Order | Fix | Why this order |
|
| Order | Fix | Why this order |
|
||||||
| ----- | --- | -------------- |
|
| ----- | ------- | ------------------------------------------------------------- |
|
||||||
| 1 | F2 | Trivial, immediately user-visible stat fix |
|
| 1 | F2 | Trivial, immediately user-visible stat fix |
|
||||||
| 2 | F1 | High-value, small, adds the missing re-import regression test |
|
| 2 | F1 | High-value, small, adds the missing re-import regression test |
|
||||||
| 3 | F3 | Small, defuses the migration time bomb before it's forgotten |
|
| 3 | F3 | Small, defuses the migration time bomb before it's forgotten |
|
||||||
| 4 | F5 | Small, closes the only non-atomic multi-statement write |
|
| 4 | F5 | Small, closes the only non-atomic multi-statement write |
|
||||||
| 5 | F6 | Trivial |
|
| 5 | F6 | Trivial |
|
||||||
| 6 | F4 | Larger; needs a UX decision validated first |
|
| 6 | F4 | Larger; needs a UX decision validated first |
|
||||||
| 7 | F7–F9 | Quality pass, no behaviour change |
|
| 7 | F7–F9 | Quality pass, no behaviour change |
|
||||||
| 8 | F10–F12 | Docs sweep (F12 happens incrementally alongside the fixes) |
|
| 8 | F10–F12 | Docs sweep (F12 happens incrementally alongside the fixes) |
|
||||||
|
|
||||||
**Out of scope (reviewed, judged acceptable):** N+1 loops in
|
**Out of scope (reviewed, judged acceptable):** N+1 loops in
|
||||||
`getAllWithItems`-style repository methods (local DB, small data), the
|
`getAllWithItems`-style repository methods (local DB, small data), the
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,27 @@
|
||||||
|
|
||||||
Record of architectural and technical decisions made during development.
|
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)
|
## 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`.
|
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)
|
# TrainUs — Implementation Plan (v3)
|
||||||
|
|
||||||
> **Last updated:** 2026-07-06
|
> **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. Full suite green on Firefox 2026-07-06 (374 passed, 1 skipped).
|
> **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.
|
||||||
|
|
||||||
## Overview
|
## 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. 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 & Danish. Version sourced from `package.json`, injected by Vite at build time, stored in DB. Two modes: Edit (CRUD) and Execution (timers, logging). Single deployment (latest version at site root) with a stable database location and automatic startup migration.
|
||||||
|
|
||||||
## Steps
|
## Steps
|
||||||
|
|
||||||
|
|
@ -427,7 +427,7 @@ ALTER TABLE execution_log_item ADD COLUMN round_number INTEGER DEFAULT NULL;
|
||||||
|
|
||||||
- `StatsView.vue` at `#/stats`
|
- `StatsView.vue` at `#/stats`
|
||||||
- Displays database creation date: "You are using TrainUs since [formatted date]"
|
- Displays database creation date: "You are using TrainUs since [formatted date]"
|
||||||
- Date formatting uses centralized `dateFormatter.js` utility with locale-aware formatting (DD/MM/YYYY for French, MM/DD/YYYY for English)
|
- Date formatting uses centralized `dateFormatter.js` utility with locale-aware formatting via `Intl.DateTimeFormat` (MM/DD/YYYY English, DD/MM/YYYY French, DD.MM.YYYY Danish)
|
||||||
- `chart.js` + `vue-chartjs` v5
|
- `chart.js` + `vue-chartjs` v5
|
||||||
- **Summary cards:** total sessions, total exercises done, current day streak
|
- **Summary cards:** total sessions, total exercises done, current day streak
|
||||||
- **Date range filter:** quick tabs — 7d / 30d / 3m / All (default: 30d)
|
- **Date range filter:** quick tabs — 7d / 30d / 3m / All (default: 30d)
|
||||||
|
|
|
||||||
71
src/composables/createCrudComposable.js
Normal file
71
src/composables/createCrudComposable.js
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
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,54 +1,4 @@
|
||||||
import { ref } from 'vue'
|
|
||||||
import { collectionRepository } from '../db/repositories/collectionRepository.js'
|
import { collectionRepository } from '../db/repositories/collectionRepository.js'
|
||||||
|
import { createCrudComposable } from './createCrudComposable.js'
|
||||||
|
|
||||||
export function useCollections() {
|
export const useCollections = createCrudComposable(collectionRepository, 'collections')
|
||||||
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,54 +1,4 @@
|
||||||
import { ref } from 'vue'
|
|
||||||
import { comboRepository } from '../db/repositories/comboRepository.js'
|
import { comboRepository } from '../db/repositories/comboRepository.js'
|
||||||
|
import { createCrudComposable } from './createCrudComposable.js'
|
||||||
|
|
||||||
export function useCombos() {
|
export const useCombos = createCrudComposable(comboRepository, 'combos')
|
||||||
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,54 +1,4 @@
|
||||||
import { ref } from 'vue'
|
|
||||||
import { exerciseRepository } from '../db/repositories/exerciseRepository.js'
|
import { exerciseRepository } from '../db/repositories/exerciseRepository.js'
|
||||||
|
import { createCrudComposable } from './createCrudComposable.js'
|
||||||
|
|
||||||
export function useExercises() {
|
export const useExercises = createCrudComposable(exerciseRepository, 'exercises')
|
||||||
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,7 +98,8 @@ function renderSessionItemsHtml(log) {
|
||||||
|
|
||||||
async function fetchIconAsBase64() {
|
async function fetchIconAsBase64() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/icon.svg')
|
// Resolve against BASE_URL so subdirectory deploys (base: './') still find the icon
|
||||||
|
const res = await fetch(import.meta.env.BASE_URL + 'icon.svg')
|
||||||
if (!res.ok) return null
|
if (!res.ok) return null
|
||||||
const text = await res.text()
|
const text = await res.text()
|
||||||
const bytes = new TextEncoder().encode(text)
|
const bytes = new TextEncoder().encode(text)
|
||||||
|
|
|
||||||
|
|
@ -538,10 +538,13 @@ export function useJsonImport() {
|
||||||
report,
|
report,
|
||||||
errors,
|
errors,
|
||||||
) {
|
) {
|
||||||
// Collect all logs to import from the three analysis buckets
|
// 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
|
||||||
|
|
||||||
const logsToImport = [
|
const logsToImport = [
|
||||||
...(analysis.noCollision || []).map((log) => ({ log, action: 'insert' })),
|
...(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 })),
|
...(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.
|
* Provides keyboard navigation for a list of items using arrow keys.
|
||||||
*
|
*
|
||||||
* @param {number} itemCount - The number of items in the list
|
* @param {import('vue').Ref<number>} itemCount - Ref holding the number of items in the list
|
||||||
* @param {Function} onSelect - Callback when Enter is pressed on a focused item (receives index)
|
* @param {Function} onSelect - Callback when Enter is pressed on a focused item (receives index)
|
||||||
* @returns {{ focusedIndex: Ref<number>, onKeydown: Function, focusItem: Function }}
|
* @returns {{ focusedIndex: import('vue').Ref<number>, onKeydown: Function, focusItem: Function }}
|
||||||
*/
|
*/
|
||||||
export function useListNavigation(itemCount, onSelect) {
|
export function useListNavigation(itemCount, onSelect) {
|
||||||
const focusedIndex = ref(-1)
|
const focusedIndex = ref(-1)
|
||||||
|
|
|
||||||
|
|
@ -1,54 +1,4 @@
|
||||||
import { ref } from 'vue'
|
|
||||||
import { sessionRepository } from '../db/repositories/sessionRepository.js'
|
import { sessionRepository } from '../db/repositories/sessionRepository.js'
|
||||||
|
import { createCrudComposable } from './createCrudComposable.js'
|
||||||
|
|
||||||
export function useSessions() {
|
export const useSessions = createCrudComposable(sessionRepository, 'sessions')
|
||||||
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,3 +1,12 @@
|
||||||
|
// 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 = [
|
export const RELEASES = [
|
||||||
{
|
{
|
||||||
schemaVersion: 1,
|
schemaVersion: 1,
|
||||||
|
|
|
||||||
|
|
@ -20,15 +20,13 @@ export const collectionRepository = {
|
||||||
},
|
},
|
||||||
|
|
||||||
async getAll() {
|
async getAll() {
|
||||||
const collections = await db.selectAll('SELECT * FROM collection ORDER BY label')
|
return db.selectAll(
|
||||||
for (const c of collections) {
|
`SELECT c.*, COUNT(cs.session_id) AS sessionCount
|
||||||
const row = await db.selectOne(
|
FROM collection c
|
||||||
'SELECT COUNT(*) AS sessionCount FROM collection_session WHERE collection_id = ?',
|
LEFT JOIN collection_session cs ON cs.collection_id = c.id
|
||||||
[c.id],
|
GROUP BY c.id
|
||||||
)
|
ORDER BY c.label`,
|
||||||
c.sessionCount = row ? row.sessionCount : 0
|
)
|
||||||
}
|
|
||||||
return collections
|
|
||||||
},
|
},
|
||||||
|
|
||||||
async getAllWithSessions() {
|
async getAllWithSessions() {
|
||||||
|
|
@ -88,6 +86,14 @@ 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) {
|
async getSessions(collectionId) {
|
||||||
return db.selectAll(
|
return db.selectAll(
|
||||||
`SELECT cs.position, cs.session_id, s.title
|
`SELECT cs.position, cs.session_id, s.title
|
||||||
|
|
|
||||||
|
|
@ -64,7 +64,23 @@ export const comboRepository = {
|
||||||
},
|
},
|
||||||
|
|
||||||
async delete(id) {
|
async delete(id) {
|
||||||
return db.run('DELETE FROM combo WHERE id = ?', [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
|
||||||
},
|
},
|
||||||
|
|
||||||
async createWithId(id, combo) {
|
async createWithId(id, combo) {
|
||||||
|
|
|
||||||
|
|
@ -158,7 +158,7 @@ export const executionLogRepository = {
|
||||||
|
|
||||||
async getDistinctSessionDays() {
|
async getDistinctSessionDays() {
|
||||||
return db.selectAll(
|
return db.selectAll(
|
||||||
`SELECT DATE(started_at) AS day FROM execution_log GROUP BY DATE(started_at) ORDER BY day DESC`,
|
`SELECT DATE(started_at) AS day FROM execution_log WHERE status = 'completed' GROUP BY DATE(started_at) ORDER BY day DESC`,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -113,7 +113,15 @@ export const exerciseRepository = {
|
||||||
},
|
},
|
||||||
|
|
||||||
async delete(id) {
|
async delete(id) {
|
||||||
return db.run('DELETE FROM exercise WHERE id = ?', [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] },
|
||||||
|
])
|
||||||
},
|
},
|
||||||
|
|
||||||
async search(query) {
|
async search(query) {
|
||||||
|
|
|
||||||
|
|
@ -74,6 +74,14 @@ 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) {
|
async getItems(sessionId) {
|
||||||
const exerciseRows = await db.selectAll(
|
const exerciseRows = await db.selectAll(
|
||||||
`SELECT si.position, si.item_id, si.item_type, si.repetitions, si.weight, e.title
|
`SELECT si.position, si.item_id, si.item_type, si.repetitions, si.weight, e.title
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,8 @@ export const suffixRepository = {
|
||||||
/**
|
/**
|
||||||
* Renames a suffix across all entity title/label columns.
|
* Renames a suffix across all entity title/label columns.
|
||||||
* Updates suffix table entry + all titles containing [OLD_SUFFIX] to [NEW_SUFFIX].
|
* 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) {
|
async renameSuffix(userUuid, newSuffix) {
|
||||||
const existing = await this.getByUuid(userUuid)
|
const existing = await this.getByUuid(userUuid)
|
||||||
|
|
@ -43,27 +45,29 @@ export const suffixRepository = {
|
||||||
|
|
||||||
const oldPattern = `[${existing.suffix}]`
|
const oldPattern = `[${existing.suffix}]`
|
||||||
const newPattern = `[${newSuffix}]`
|
const newPattern = `[${newSuffix}]`
|
||||||
|
const params = [oldPattern, newPattern, oldPattern]
|
||||||
|
|
||||||
// Update suffix table
|
await db.batch([
|
||||||
await db.run('UPDATE suffix SET suffix = ? WHERE user_uuid = ?', [newSuffix, userUuid])
|
{
|
||||||
|
sql: 'UPDATE suffix SET suffix = ? WHERE user_uuid = ?',
|
||||||
// Update titles across entity tables
|
params: [newSuffix, userUuid],
|
||||||
await db.run(
|
},
|
||||||
"UPDATE exercise SET title = REPLACE(title, ?, ?) WHERE title LIKE '%' || ? || '%'",
|
{
|
||||||
[oldPattern, newPattern, oldPattern],
|
sql: "UPDATE exercise SET title = REPLACE(title, ?, ?) WHERE title LIKE '%' || ? || '%'",
|
||||||
)
|
params,
|
||||||
await db.run("UPDATE combo SET title = REPLACE(title, ?, ?) WHERE title LIKE '%' || ? || '%'", [
|
},
|
||||||
oldPattern,
|
{
|
||||||
newPattern,
|
sql: "UPDATE combo SET title = REPLACE(title, ?, ?) WHERE title LIKE '%' || ? || '%'",
|
||||||
oldPattern,
|
params,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sql: "UPDATE session SET title = REPLACE(title, ?, ?) WHERE title LIKE '%' || ? || '%'",
|
||||||
|
params,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sql: "UPDATE collection SET label = REPLACE(label, ?, ?) WHERE label LIKE '%' || ? || '%'",
|
||||||
|
params,
|
||||||
|
},
|
||||||
])
|
])
|
||||||
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,8 +246,15 @@ function handleImportDb(bytes) {
|
||||||
)
|
)
|
||||||
const currentTableNames = currentTableRows.map((r) => r[0])
|
const currentTableNames = currentTableRows.map((r) => r[0])
|
||||||
|
|
||||||
// Only restore tables that exist in both databases
|
// Only restore tables that exist in both databases.
|
||||||
const tablesToRestore = importedTableNames.filter((t) => currentTableNames.includes(t))
|
// 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',
|
||||||
|
)
|
||||||
|
|
||||||
// 7. Table-by-table copy inside a single transaction so a mid-restore
|
// 7. Table-by-table copy inside a single transaction so a mid-restore
|
||||||
// failure cannot leave the live DB partially wiped.
|
// failure cannot leave the live DB partially wiped.
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,7 @@
|
||||||
"detail": "Øvelsesdetaljer",
|
"detail": "Øvelsesdetaljer",
|
||||||
"deleteConfirm": "Er du sikker på, at du vil slette \"{{title}}\"? Dette kan ikke fortrydes.",
|
"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.",
|
"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.",
|
"deleted": "Øvelse slettet.",
|
||||||
"form": {
|
"form": {
|
||||||
"title": "Titel",
|
"title": "Titel",
|
||||||
|
|
@ -90,6 +91,7 @@
|
||||||
"edit": "Rediger kombo",
|
"edit": "Rediger kombo",
|
||||||
"detail": "Kombodetailer",
|
"detail": "Kombodetailer",
|
||||||
"deleteConfirm": "Er du sikker på, at du vil slette \"{{title}}\"? Dette kan ikke fortrydes.",
|
"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.",
|
"deleted": "Kombo slettet.",
|
||||||
"exercises": "øvelser",
|
"exercises": "øvelser",
|
||||||
"timerConfig": "Timer-konfiguration",
|
"timerConfig": "Timer-konfiguration",
|
||||||
|
|
@ -158,6 +160,7 @@
|
||||||
"detail": "Sessionsdetaljer",
|
"detail": "Sessionsdetaljer",
|
||||||
"deleteConfirm": "Er du sikker på, at du vil slette \"{{title}}\"? Dette kan ikke fortrydes.",
|
"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.",
|
"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.",
|
"deleted": "Session slettet.",
|
||||||
"items": "elementer",
|
"items": "elementer",
|
||||||
"delete": "Slet session",
|
"delete": "Slet session",
|
||||||
|
|
@ -347,6 +350,7 @@
|
||||||
"suffixPreview": "Titler vil se sådan ud: \"{{title}} [{{suffix}}]\"",
|
"suffixPreview": "Titler vil se sådan ud: \"{{title}} [{{suffix}}]\"",
|
||||||
"suffixRequired": "Suffiks er påkrævet.",
|
"suffixRequired": "Suffiks er påkrævet.",
|
||||||
"suffixTaken": "Dette suffiks bruges allerede af en anden bruger.",
|
"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:",
|
"collisionExplanation": "{{count}} element(er) er i konflikt med eksisterende data. Vælg hvad der skal gøres for hvert:",
|
||||||
"replace": "Erstat",
|
"replace": "Erstat",
|
||||||
"skip": "Spring over",
|
"skip": "Spring over",
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,7 @@
|
||||||
"detail": "Exercise Details",
|
"detail": "Exercise Details",
|
||||||
"deleteConfirm": "Are you sure you want to delete \"{{title}}\"? This cannot be undone.",
|
"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.",
|
"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.",
|
"deleted": "Exercise deleted.",
|
||||||
"form": {
|
"form": {
|
||||||
"title": "Title",
|
"title": "Title",
|
||||||
|
|
@ -90,6 +91,7 @@
|
||||||
"edit": "Edit Combo",
|
"edit": "Edit Combo",
|
||||||
"detail": "Combo Details",
|
"detail": "Combo Details",
|
||||||
"deleteConfirm": "Are you sure you want to delete \"{{title}}\"? This cannot be undone.",
|
"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.",
|
"deleted": "Combo deleted.",
|
||||||
"exercises": "exercises",
|
"exercises": "exercises",
|
||||||
"timerConfig": "Timer Configuration",
|
"timerConfig": "Timer Configuration",
|
||||||
|
|
@ -158,6 +160,7 @@
|
||||||
"detail": "Session Details",
|
"detail": "Session Details",
|
||||||
"deleteConfirm": "Are you sure you want to delete \"{{title}}\"? This cannot be undone.",
|
"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.",
|
"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.",
|
"deleted": "Session deleted.",
|
||||||
"items": "items",
|
"items": "items",
|
||||||
"delete": "Delete session",
|
"delete": "Delete session",
|
||||||
|
|
@ -347,6 +350,7 @@
|
||||||
"suffixPreview": "Titles will look like: \"{{title}} [{{suffix}}]\"",
|
"suffixPreview": "Titles will look like: \"{{title}} [{{suffix}}]\"",
|
||||||
"suffixRequired": "Suffix is required.",
|
"suffixRequired": "Suffix is required.",
|
||||||
"suffixTaken": "This suffix is already used by another user.",
|
"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:",
|
"collisionExplanation": "{{count}} item(s) conflict with existing data. Choose what to do for each:",
|
||||||
"replace": "Replace",
|
"replace": "Replace",
|
||||||
"skip": "Skip",
|
"skip": "Skip",
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,7 @@
|
||||||
"detail": "Détails de l'exercice",
|
"detail": "Détails de l'exercice",
|
||||||
"deleteConfirm": "Êtes-vous sûr de vouloir supprimer « {{title}} » ? Cette action est irréversible.",
|
"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.",
|
"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é.",
|
"deleted": "Exercice supprimé.",
|
||||||
"form": {
|
"form": {
|
||||||
"title": "Titre",
|
"title": "Titre",
|
||||||
|
|
@ -90,6 +91,7 @@
|
||||||
"edit": "Modifier le Combo",
|
"edit": "Modifier le Combo",
|
||||||
"detail": "Détails du Combo",
|
"detail": "Détails du Combo",
|
||||||
"deleteConfirm": "Êtes-vous sûr de vouloir supprimer « {{title}} » ? Cette action est irréversible.",
|
"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é.",
|
"deleted": "Combo supprimé.",
|
||||||
"exercises": "exercices",
|
"exercises": "exercices",
|
||||||
"timerConfig": "Configuration du minuteur",
|
"timerConfig": "Configuration du minuteur",
|
||||||
|
|
@ -158,6 +160,7 @@
|
||||||
"detail": "Détails de la séance",
|
"detail": "Détails de la séance",
|
||||||
"deleteConfirm": "Êtes-vous sûr de vouloir supprimer « {{title}} » ? Cette action est irréversible.",
|
"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.",
|
"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.",
|
"deleted": "Séance supprimée.",
|
||||||
"items": "éléments",
|
"items": "éléments",
|
||||||
"delete": "Supprimer la séance",
|
"delete": "Supprimer la séance",
|
||||||
|
|
@ -347,6 +350,7 @@
|
||||||
"suffixPreview": "Les titres ressembleront à : « {{title}} [{{suffix}}] »",
|
"suffixPreview": "Les titres ressembleront à : « {{title}} [{{suffix}}] »",
|
||||||
"suffixRequired": "Le suffixe est requis.",
|
"suffixRequired": "Le suffixe est requis.",
|
||||||
"suffixTaken": "Ce suffixe est déjà utilisé par un autre utilisateur.",
|
"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 :",
|
"collisionExplanation": "{{count}} élément(s) sont en conflit avec des données existantes. Choisissez quoi faire pour chacun :",
|
||||||
"replace": "Remplacer",
|
"replace": "Remplacer",
|
||||||
"skip": "Ignorer",
|
"skip": "Ignorer",
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,24 @@
|
||||||
import i18next from 'i18next'
|
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) {
|
export function formatDate(date, lang) {
|
||||||
if (!date) return ''
|
if (!date) return ''
|
||||||
const d = date instanceof Date ? date : new Date(date)
|
const d = date instanceof Date ? date : new Date(date)
|
||||||
if (isNaN(d.getTime())) return ''
|
if (isNaN(d.getTime())) return ''
|
||||||
|
|
||||||
lang = lang || i18next.language || 'en'
|
return new Intl.DateTimeFormat(toLangTag(lang), {
|
||||||
const pad = (n) => String(n).padStart(2, '0')
|
day: '2-digit',
|
||||||
const dd = pad(d.getDate())
|
month: '2-digit',
|
||||||
const mm = pad(d.getMonth() + 1)
|
year: 'numeric',
|
||||||
const yyyy = d.getFullYear()
|
}).format(d)
|
||||||
|
|
||||||
if (lang === 'fr') {
|
|
||||||
return `${dd}/${mm}/${yyyy}`
|
|
||||||
}
|
|
||||||
return `${mm}/${dd}/${yyyy}`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatItemDuration(ms) {
|
export function formatItemDuration(ms) {
|
||||||
|
|
@ -46,17 +50,12 @@ export function formatDateTime(date, lang) {
|
||||||
const d = date instanceof Date ? date : new Date(date)
|
const d = date instanceof Date ? date : new Date(date)
|
||||||
if (isNaN(d.getTime())) return ''
|
if (isNaN(d.getTime())) return ''
|
||||||
|
|
||||||
lang = lang || i18next.language || 'en'
|
return new Intl.DateTimeFormat(toLangTag(lang), {
|
||||||
const pad = (n) => String(n).padStart(2, '0')
|
day: '2-digit',
|
||||||
const dd = pad(d.getDate())
|
month: '2-digit',
|
||||||
const mm = pad(d.getMonth() + 1)
|
year: 'numeric',
|
||||||
const yyyy = d.getFullYear()
|
hour: '2-digit',
|
||||||
const HH = pad(d.getHours())
|
minute: '2-digit',
|
||||||
const min = pad(d.getMinutes())
|
second: '2-digit',
|
||||||
const ss = pad(d.getSeconds())
|
}).format(d)
|
||||||
|
|
||||||
if (lang === 'fr') {
|
|
||||||
return `${dd}/${mm}/${yyyy}, ${HH}:${min}:${ss}`
|
|
||||||
}
|
|
||||||
return d.toLocaleString()
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@ export function getYouTubeId(url) {
|
||||||
return u.pathname.slice(1)
|
return u.pathname.slice(1)
|
||||||
}
|
}
|
||||||
if (u.hostname.includes('youtube.com')) {
|
if (u.hostname.includes('youtube.com')) {
|
||||||
|
const pathMatch = u.pathname.match(/^\/(?:embed|shorts)\/([^/?]+)/)
|
||||||
|
if (pathMatch) return pathMatch[1]
|
||||||
return u.searchParams.get('v')
|
return u.searchParams.get('v')
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import { useCombos } from '../composables/useCombos.js'
|
||||||
import { useJsonExport } from '../composables/useJsonExport.js'
|
import { useJsonExport } from '../composables/useJsonExport.js'
|
||||||
import { useKeyboardShortcut } from '../composables/useKeyboardShortcut.js'
|
import { useKeyboardShortcut } from '../composables/useKeyboardShortcut.js'
|
||||||
import { renderMarkdown } from '../utils/markdown.js'
|
import { renderMarkdown } from '../utils/markdown.js'
|
||||||
|
import { sessionRepository } from '../db/repositories/sessionRepository.js'
|
||||||
|
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
@ -45,7 +46,12 @@ function goToExercise(exerciseId) {
|
||||||
|
|
||||||
async function onDelete() {
|
async function onDelete() {
|
||||||
if (!combo.value) return
|
if (!combo.value) return
|
||||||
const confirmed = window.confirm(t('combos.deleteConfirm', { title: combo.value.title }))
|
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)
|
||||||
if (!confirmed) return
|
if (!confirmed) return
|
||||||
await remove(comboId.value)
|
await remove(comboId.value)
|
||||||
router.push({ name: 'combos' })
|
router.push({ name: 'combos' })
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,8 @@ import { useOnlineStatus } from '../composables/useOnlineStatus.js'
|
||||||
import { renderMarkdown } from '../utils/markdown.js'
|
import { renderMarkdown } from '../utils/markdown.js'
|
||||||
import { getYouTubeId } from '../utils/youtube.js'
|
import { getYouTubeId } from '../utils/youtube.js'
|
||||||
import { executionLogRepository } from '../db/repositories/executionLogRepository.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 { t } = useTranslation()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
@ -45,11 +47,19 @@ function goBack() {
|
||||||
|
|
||||||
async function onDelete() {
|
async function onDelete() {
|
||||||
if (!exercise.value) return
|
if (!exercise.value) return
|
||||||
const count = await executionLogRepository.countItemsByExercise(exerciseId.value)
|
const [count, sessionCount, comboCount] = await Promise.all([
|
||||||
const msg =
|
executionLogRepository.countItemsByExercise(exerciseId.value),
|
||||||
|
sessionRepository.countSessionsUsingItem(exerciseId.value, 'exercise'),
|
||||||
|
comboRepository.countCombosUsingExercise(exerciseId.value),
|
||||||
|
])
|
||||||
|
let msg =
|
||||||
count > 0
|
count > 0
|
||||||
? t('exercises.deleteConfirmWithHistory', { title: exercise.value.title, count })
|
? t('exercises.deleteConfirmWithHistory', { title: exercise.value.title, count })
|
||||||
: t('exercises.deleteConfirm', { title: exercise.value.title })
|
: 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)
|
const confirmed = window.confirm(msg)
|
||||||
if (!confirmed) return
|
if (!confirmed) return
|
||||||
await remove(exerciseId.value)
|
await remove(exerciseId.value)
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import { useJsonExport } from '../composables/useJsonExport.js'
|
||||||
import { useKeyboardShortcut } from '../composables/useKeyboardShortcut.js'
|
import { useKeyboardShortcut } from '../composables/useKeyboardShortcut.js'
|
||||||
import { renderMarkdown } from '../utils/markdown.js'
|
import { renderMarkdown } from '../utils/markdown.js'
|
||||||
import { executionLogRepository } from '../db/repositories/executionLogRepository.js'
|
import { executionLogRepository } from '../db/repositories/executionLogRepository.js'
|
||||||
|
import { collectionRepository } from '../db/repositories/collectionRepository.js'
|
||||||
|
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
@ -58,11 +59,17 @@ function getItemTypeLabel(itemType) {
|
||||||
|
|
||||||
async function onDelete() {
|
async function onDelete() {
|
||||||
if (!session.value) return
|
if (!session.value) return
|
||||||
const count = await executionLogRepository.countLogsBySession(sessionId.value)
|
const [count, collectionCount] = await Promise.all([
|
||||||
const msg =
|
executionLogRepository.countLogsBySession(sessionId.value),
|
||||||
|
collectionRepository.countCollectionsUsingSession(sessionId.value),
|
||||||
|
])
|
||||||
|
let msg =
|
||||||
count > 0
|
count > 0
|
||||||
? t('sessions.deleteConfirmWithHistory', { title: session.value.title, count })
|
? t('sessions.deleteConfirmWithHistory', { title: session.value.title, count })
|
||||||
: t('sessions.deleteConfirm', { title: session.value.title })
|
: t('sessions.deleteConfirm', { title: session.value.title })
|
||||||
|
if (collectionCount > 0) {
|
||||||
|
msg += '\n\n' + t('sessions.deleteConfirmUsage', { collections: collectionCount })
|
||||||
|
}
|
||||||
const confirmed = window.confirm(msg)
|
const confirmed = window.confirm(msg)
|
||||||
if (!confirmed) return
|
if (!confirmed) return
|
||||||
await remove(sessionId.value)
|
await remove(sessionId.value)
|
||||||
|
|
|
||||||
|
|
@ -274,7 +274,8 @@ async function saveEditSuffix(entry) {
|
||||||
await loadSuffixes()
|
await loadSuffixes()
|
||||||
cancelEditSuffix()
|
cancelEditSuffix()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
editSuffixError.value = e.message
|
console.error('Suffix rename failed:', e)
|
||||||
|
editSuffixError.value = t('import.suffixRenameFailed')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ Step 2 — Settings Tests
|
||||||
- test_restore_db_button: Restore DB button is visible and enabled
|
- 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_cancel_dialog: Declining confirm dialog aborts the restore
|
||||||
- test_restore_db_flow: Full restore round-trip with spinner and report
|
- 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_invalid_file: Uploading a non-SQLite file shows error report
|
||||||
- test_restore_db_incomplete_db: Uploading a DB missing required tables shows error
|
- 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)
|
- test_export_import_enabled: Export/Import buttons are present and enabled (wired in Step 3b)
|
||||||
|
|
@ -23,6 +24,7 @@ Step 2 — Settings Tests
|
||||||
- test_initialize_db_flow: Full initialize flow resets database and reloads page
|
- test_initialize_db_flow: Full initialize flow resets database and reloads page
|
||||||
"""
|
"""
|
||||||
import os
|
import os
|
||||||
|
import sqlite3
|
||||||
import tempfile
|
import tempfile
|
||||||
from playwright.sync_api import Page, expect
|
from playwright.sync_api import Page, expect
|
||||||
|
|
||||||
|
|
@ -461,6 +463,56 @@ def test_restore_db_flow(page: Page, app_url: str, tmp_path_factory):
|
||||||
expect(report).not_to_be_visible()
|
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):
|
def test_restore_db_invalid_file(page: Page, app_url: str):
|
||||||
"""Uploading a non-SQLite file shows an error report."""
|
"""Uploading a non-SQLite file shows an error report."""
|
||||||
_go_to_settings(page, app_url)
|
_go_to_settings(page, app_url)
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,8 @@ Step 3 — Exercise Management Tests
|
||||||
- test_edit_exercise: Edit an exercise and verify changes
|
- test_edit_exercise: Edit an exercise and verify changes
|
||||||
- test_delete_exercise: Delete an exercise with confirmation
|
- test_delete_exercise: Delete an exercise with confirmation
|
||||||
- test_delete_exercise_cancel: Cancelling delete does not remove exercise
|
- 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_exercises: Search filters exercises by title
|
||||||
- test_search_no_results: Search with no matches shows empty message
|
- 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
|
- test_search_clear_button: Clear button empties the search field and restores the full list
|
||||||
|
|
@ -171,6 +173,46 @@ def test_create_exercise_full(page: Page, app_url: str):
|
||||||
_clean_exercises(page)
|
_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):
|
def test_exercise_appears_in_list(page: Page, app_url: str):
|
||||||
"""Created exercise appears in the list view with correct info."""
|
"""Created exercise appears in the list view with correct info."""
|
||||||
_go_to_exercises(page, app_url)
|
_go_to_exercises(page, app_url)
|
||||||
|
|
@ -330,6 +372,79 @@ def test_delete_exercise_cancel(page: Page, app_url: str):
|
||||||
_clean_exercises(page)
|
_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):
|
def test_search_exercises(page: Page, app_url: str):
|
||||||
"""Search filters exercises by title."""
|
"""Search filters exercises by title."""
|
||||||
_go_to_exercises(page, app_url)
|
_go_to_exercises(page, app_url)
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ Step 3b — Exercise JSON Export/Import Tests
|
||||||
- test_settings_import_button: Settings import button works
|
- test_settings_import_button: Settings import button works
|
||||||
- test_suffix_management_visible: Suffix section visible after import from different user
|
- 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_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_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_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
|
- test_import_as_mine_no_suffix_no_collision: Foreign exercises imported as own without suffix
|
||||||
|
|
@ -919,6 +920,72 @@ def test_suffix_rename_cascade(page: Page, app_url: str):
|
||||||
_clean_all(page)
|
_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):
|
def test_suffix_delete(page: Page, app_url: str):
|
||||||
"""Suffix can be deleted from settings."""
|
"""Suffix can be deleted from settings."""
|
||||||
_go_to_exercises(page, app_url)
|
_go_to_exercises(page, app_url)
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ Step 4 — Combo Management Tests
|
||||||
- test_edit_combo: Edit a combo and verify changes
|
- test_edit_combo: Edit a combo and verify changes
|
||||||
- test_delete_combo: Delete a combo with confirmation
|
- test_delete_combo: Delete a combo with confirmation
|
||||||
- test_delete_combo_cancel: Cancelling delete does not remove combo
|
- 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_combos: Search filters combos by title
|
||||||
- test_search_no_results: Search with no matches shows empty message
|
- 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
|
- test_search_clear_button: Clear button empties the search field and restores the full list
|
||||||
|
|
@ -298,6 +299,64 @@ def test_delete_combo_cancel(page: Page, app_url: str):
|
||||||
_clean_combos(page)
|
_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):
|
def test_search_combos(page: Page, app_url: str):
|
||||||
"""Search filters combos by title."""
|
"""Search filters combos by title."""
|
||||||
_go_to_combos(page, app_url)
|
_go_to_combos(page, app_url)
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ Step 7 — Full Data Import/Export Tests
|
||||||
- test_import_as_mine_combo: Import-as-mine button works on combo list
|
- 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_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_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_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_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
|
- test_collection_session_remapping: Collection session ref follows remap when session gets new_id
|
||||||
|
|
@ -691,6 +692,46 @@ 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"
|
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):
|
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."""
|
"""When an exercise gets new_id on collision, the execution log item's exercise_id is remapped."""
|
||||||
_go_to(page, app_url, "#/exercises")
|
_go_to(page, app_url, "#/exercises")
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ Step 9 — Statistics
|
||||||
- test_stats_shows_empty_when_no_date: StatsView shows empty message when no creation date
|
- 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_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_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_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_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
|
- test_stats_summary_cards_zero_when_no_data: Summary cards show 0 when no execution logs exist
|
||||||
|
|
@ -16,6 +17,7 @@ Step 9 — Statistics
|
||||||
- test_stats_volume_chart_renders: Volume chart canvas renders when sessions have weight data
|
- 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_progression_chart_renders: Progression chart renders after selecting an exercise
|
||||||
- test_stats_streak_consecutive_days: Streak counts consecutive days with sessions
|
- 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
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
|
@ -115,6 +117,7 @@ def _create_log_with_items(
|
||||||
reps: int = 10,
|
reps: int = 10,
|
||||||
weight: float = 0.0,
|
weight: float = 0.0,
|
||||||
completed: bool = True,
|
completed: bool = True,
|
||||||
|
status: str = 'completed',
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Seed one execution log with one item."""
|
"""Seed one execution log with one item."""
|
||||||
finished_at = started_at # same timestamp is fine for tests
|
finished_at = started_at # same timestamp is fine for tests
|
||||||
|
|
@ -123,7 +126,8 @@ def _create_log_with_items(
|
||||||
const logId = await window.__repos.executionLogs.create({{
|
const logId = await window.__repos.executionLogs.create({{
|
||||||
session_id: '{session_id}',
|
session_id: '{session_id}',
|
||||||
started_at: '{started_at}',
|
started_at: '{started_at}',
|
||||||
finished_at: '{finished_at}'
|
finished_at: '{finished_at}',
|
||||||
|
status: {repr(status)}
|
||||||
}})
|
}})
|
||||||
await window.__repos.executionLogs.addItem({{
|
await window.__repos.executionLogs.addItem({{
|
||||||
log_id: logId,
|
log_id: logId,
|
||||||
|
|
@ -237,6 +241,28 @@ def test_stats_date_format_french(page: Page, app_url: str):
|
||||||
page.wait_for_timeout(300)
|
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):
|
def test_stats_date_format_updates_on_language_change(page: Page, app_url: str):
|
||||||
"""Switching language updates the date format reactively without navigation."""
|
"""Switching language updates the date format reactively without navigation."""
|
||||||
_set_date_and_reload(page, app_url, _KNOWN_DATE)
|
_set_date_and_reload(page, app_url, _KNOWN_DATE)
|
||||||
|
|
@ -510,3 +536,28 @@ def test_stats_streak_consecutive_days(page: Page, app_url: str):
|
||||||
|
|
||||||
streak_val = page.locator('[data-testid="stats-streak"] .stat-value').text_content()
|
streak_val = page.locator('[data-testid="stats-streak"] .stat-value').text_content()
|
||||||
assert int(streak_val) >= 3, f"Expected streak ≥ 3, got {streak_val}"
|
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}"
|
||||||
|
|
|
||||||
|
|
@ -73,12 +73,12 @@ trainUs/
|
||||||
|
|
||||||
Lines of code by area, measured with [cloc](https://github.com/AlDanial/cloc) (generated files, dependencies, and the Hugo theme excluded):
|
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 |
|
| Area | Files | Code lines | Main languages |
|
||||||
| ------------------------ | ----: | ---------: | -------------------------------- |
|
| ---------------------- | ----: | ---------: | ------------------------ |
|
||||||
| `src/` (application) | 81 | 13,973 | Vue SFC, JavaScript, CSS |
|
| `src/` (application) | 81 | 13,973 | Vue SFC, JavaScript, CSS |
|
||||||
| `tests/` (Playwright) | 37 | 7,371 | Python |
|
| `tests/` (Playwright) | 37 | 7,371 | Python |
|
||||||
| `website/` (docs site) | 49 | 4,732 | Markdown, CSS, HTML |
|
| `website/` (docs site) | 49 | 4,732 | Markdown, CSS, HTML |
|
||||||
| `docs/` | 4 | 1,500 | Markdown |
|
| `docs/` | 4 | 1,500 | Markdown |
|
||||||
|
|
||||||
Within `src/`, Vue single-file components make up the bulk of application code (9,045 lines across 32 components), with 4,065 lines of plain JavaScript (composables, DB layer, utilities, router).
|
Within `src/`, Vue single-file components make up the bulk of application code (9,045 lines across 32 components), with 4,065 lines of plain JavaScript (composables, DB layer, utilities, router).
|
||||||
|
|
||||||
|
|
@ -793,11 +793,11 @@ Action mapping deliberately reuses the on-screen handlers rather than inventing
|
||||||
|
|
||||||
`useSessionExecution.js` calls `speak()` at two moments:
|
`useSessionExecution.js` calls `speak()` at two moments:
|
||||||
|
|
||||||
| Moment | Spoken text | Hook |
|
| Moment | Spoken text | Hook |
|
||||||
| ---------------------------------- | ------------------------------- | ------------------------------------------------------------------ |
|
| ----------------------------------- | ------------------------------ | --------------------------------------------------------------------------------- |
|
||||||
| A step becomes current | The exercise title | `loadStepState()` — every step, including the first |
|
| 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 |
|
| 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 |
|
| TABATA: rest phase opens | `"Next: {title}"` | `_startRestPhase(step)`, right where the on-screen "Next:" label is already shown |
|
||||||
|
|
||||||
AMRAP and NONE combos only get the step-change announcement — matching the on-screen UI, which likewise shows no "next" preview until the step actually advances for those types.
|
AMRAP and NONE combos only get the step-change announcement — matching the on-screen UI, which likewise shows no "next" preview until the step actually advances for those types.
|
||||||
|
|
||||||
|
|
@ -825,11 +825,11 @@ Four tabs control which period is shown: **7d**, **30d** (default), **3m**, **Al
|
||||||
|
|
||||||
#### Summary cards
|
#### Summary cards
|
||||||
|
|
||||||
| Card | Query |
|
| Card | Query |
|
||||||
| -------------- | ----------------------------------------------------------------------------------------- |
|
| -------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| Sessions | Count of `execution_log` rows in the period |
|
| Sessions | Count of `execution_log` rows in the period |
|
||||||
| Exercises done | Count of `execution_log_item` (completed) 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 log; always across all history |
|
| Day streak | Consecutive calendar days (ending today) with at least one **completed** log (aborted runs don't count); always across all history |
|
||||||
|
|
||||||
Streak uses `setUTCHours(0,0,0,0)` for date comparison to match SQLite's `DATE(started_at)` UTC extraction.
|
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
|
#### Date formatting
|
||||||
|
|
||||||
`src/utils/dateFormatter.js` provides two locale-aware pure functions used throughout the app:
|
`src/utils/dateFormatter.js` provides two locale-aware pure functions used throughout the app. Both delegate to `Intl.DateTimeFormat` with a fixed app-language → BCP-47 map (`en` → `en-US`, `fr` → `fr-FR`, `da` → `da-DK`, same mapping as `useSpeech.js`) and 2-digit day/month, so output is deterministic per app language regardless of the OS locale:
|
||||||
|
|
||||||
| Function | English | French |
|
| Function | English | French | Danish |
|
||||||
| ----------------------------- | ------------------ | ---------------------- |
|
| ----------------------------- | ----------------------- | ----------------- | ----------------- |
|
||||||
| `formatDate(date, lang?)` | `MM/DD/YYYY` | `DD/MM/YYYY` |
|
| `formatDate(date, lang?)` | `MM/DD/YYYY` | `DD/MM/YYYY` | `DD.MM.YYYY` |
|
||||||
| `formatDateTime(date, lang?)` | `toLocaleString()` | `DD/MM/YYYY, HH:MM:SS` |
|
| `formatDateTime(date, lang?)` | date + `hh:mm:ss AM/PM` | date + `HH:MM:SS` | date + `HH.MM.SS` |
|
||||||
|
|
||||||
The optional `lang` parameter allows Vue computed properties to pass the reactive language ref, ensuring date displays update immediately on language switch.
|
The optional `lang` parameter allows Vue computed properties to pass the reactive language ref, ensuring date displays update immediately on language switch.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -73,12 +73,12 @@ 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) :
|
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 |
|
| Zone | Fichiers | Lignes de code | Langages principaux |
|
||||||
| -------------------------- | -------: | --------------: | --------------------------------- |
|
| ------------------------- | -------: | -------------: | ------------------------ |
|
||||||
| `src/` (application) | 81 | 13 973 | Vue SFC, JavaScript, CSS |
|
| `src/` (application) | 81 | 13 973 | Vue SFC, JavaScript, CSS |
|
||||||
| `tests/` (Playwright) | 37 | 7 371 | Python |
|
| `tests/` (Playwright) | 37 | 7 371 | Python |
|
||||||
| `website/` (site de docs) | 49 | 4 732 | Markdown, CSS, HTML |
|
| `website/` (site de docs) | 49 | 4 732 | Markdown, CSS, HTML |
|
||||||
| `docs/` | 4 | 1 500 | Markdown |
|
| `docs/` | 4 | 1 500 | Markdown |
|
||||||
|
|
||||||
Dans `src/`, les composants Vue monofichiers constituent l'essentiel du code applicatif (9 045 lignes réparties sur 32 composants), avec 4 065 lignes de JavaScript pur (composables, couche DB, utilitaires, router).
|
Dans `src/`, les composants Vue monofichiers constituent l'essentiel du code applicatif (9 045 lignes réparties sur 32 composants), avec 4 065 lignes de JavaScript pur (composables, couche DB, utilitaires, router).
|
||||||
|
|
||||||
|
|
@ -765,11 +765,11 @@ Quatre onglets contrôlent la période affichée : **7j**, **30j** (par défaut)
|
||||||
|
|
||||||
#### Cartes de résumé
|
#### Cartes de résumé
|
||||||
|
|
||||||
| Carte | Requête |
|
| Carte | Requête |
|
||||||
| ------------------ | ---------------------------------------------------------------------------------------------------------------------- |
|
| ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| Séances | Nombre de lignes `execution_log` sur la période |
|
| 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 |
|
| 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 ; toujours calculée sur tout l'historique |
|
| 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 |
|
||||||
|
|
||||||
La série utilise `setUTCHours(0,0,0,0)` pour comparer les dates et correspondre à l'extraction UTC `DATE(started_at)` de SQLite.
|
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
|
#### Formatage des dates
|
||||||
|
|
||||||
`src/utils/dateFormatter.js` fournit deux fonctions pures, sensibles à la locale, utilisées partout dans l'application :
|
`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 :
|
||||||
|
|
||||||
| Fonction | Anglais | Français |
|
| Fonction | Anglais | Français | Danois |
|
||||||
| ----------------------------- | ------------------ | ---------------------- |
|
| ----------------------------- | ----------------------- | ----------------- | ----------------- |
|
||||||
| `formatDate(date, lang?)` | `MM/DD/YYYY` | `DD/MM/YYYY` |
|
| `formatDate(date, lang?)` | `MM/DD/YYYY` | `DD/MM/YYYY` | `DD.MM.YYYY` |
|
||||||
| `formatDateTime(date, lang?)` | `toLocaleString()` | `DD/MM/YYYY, HH:MM:SS` |
|
| `formatDateTime(date, lang?)` | date + `hh:mm:ss AM/PM` | date + `HH:MM:SS` | date + `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.
|
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
|
### 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. 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; 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.
|
||||||
|
|
||||||
### Searching Exercises
|
### Searching Exercises
|
||||||
|
|
||||||
|
|
@ -249,7 +249,7 @@ Displays combo information including the ordered exercise list with reps and wei
|
||||||
|
|
||||||
### Deleting a Combo
|
### Deleting a Combo
|
||||||
|
|
||||||
Click the **trash** icon in the action bar. Confirming permanently deletes the 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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -312,7 +312,7 @@ The edit form is identical to the create form but pre-populated with current dat
|
||||||
|
|
||||||
### Deleting a Session
|
### 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. 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, 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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -511,7 +511,7 @@ This feature depends on your browser and OS supporting the Media Session API —
|
||||||
|
|
||||||
### Voice Announcements
|
### 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.
|
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
|
### 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. 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, 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.
|
||||||
|
|
||||||
> Le reste de chaque séance enregistrée (les autres exercices de ce même journal) reste intact.
|
> 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
|
### Supprimer un combo
|
||||||
|
|
||||||
Cliquez sur l'icône **corbeille** dans la barre d'action. En confirmant, le combo est supprimé définitivement.
|
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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -314,7 +314,7 @@ Le formulaire d'édition est identique au formulaire de création, pré-rempli a
|
||||||
|
|
||||||
### Supprimer une séance
|
### 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. 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, 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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -513,7 +513,7 @@ Cette fonctionnalité dépend de la prise en charge de la Media Session API par
|
||||||
|
|
||||||
### Annonces vocales
|
### 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.
|
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