Initial commit
This commit is contained in:
commit
352a3d5e88
1
.claude/scheduled_tasks.lock
Normal file
1
.claude/scheduled_tasks.lock
Normal file
|
|
@ -0,0 +1 @@
|
|||
{"sessionId":"5ade3667-a4e8-4863-b973-cb39073bdd6a","pid":5817,"procStart":"18931","acquiredAt":1781606447105}
|
||||
52
.claude/settings.json
Normal file
52
.claude/settings.json
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Read",
|
||||
"Glob",
|
||||
"Grep",
|
||||
"Edit",
|
||||
"Write",
|
||||
"MultiEdit",
|
||||
"Bash(git *)",
|
||||
"Bash(ls *)",
|
||||
"Bash(echo *)",
|
||||
"Bash(cat *)",
|
||||
"Bash(grep *)",
|
||||
"Bash(head *)",
|
||||
"Bash(tail *)",
|
||||
"Bash(find *)",
|
||||
"Bash(sort *)",
|
||||
"Bash(sed *)",
|
||||
"Bash(awk *)",
|
||||
"Bash(xargs *)",
|
||||
"Bash(mkdir *)",
|
||||
"Bash(cd *)",
|
||||
"Bash(curl *)",
|
||||
"Bash(nohup *)",
|
||||
"Bash(sleep *)",
|
||||
"Bash(seq *)",
|
||||
"Bash(kill *)",
|
||||
"Bash(pkill *)",
|
||||
"Bash(lsof *)",
|
||||
"Bash(true *)",
|
||||
"Bash(break *)",
|
||||
"Bash(npm run *)",
|
||||
"Bash(npx prettier*)",
|
||||
"Bash(python *)",
|
||||
"Bash(python3 *)",
|
||||
"Bash(pip *)",
|
||||
"Bash(./venv/bin/python *)",
|
||||
"Bash(cd tests && .venv/bin/python *)",
|
||||
"Bash(cd *)"
|
||||
],
|
||||
"ask": ["WebFetch", "WebSearch", "Bash(git push*)", "Bash(curl *)"],
|
||||
"deny": [
|
||||
"Read(*.env)",
|
||||
"Read(*.env.*)",
|
||||
"Glob(*.env)",
|
||||
"Glob(*.env.*)",
|
||||
"Grep(*.env)",
|
||||
"Grep(*.env.*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
84
.github/workflows/ci.yml
vendored
Normal file
84
.github/workflows/ci.yml
vendored
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
lint-format:
|
||||
name: Lint & Format
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Check formatting (Prettier)
|
||||
run: npm run format:check
|
||||
|
||||
- name: Lint (ESLint)
|
||||
run: npm run lint
|
||||
|
||||
build:
|
||||
name: Build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build production bundle
|
||||
run: npm run build
|
||||
|
||||
test:
|
||||
name: Tests (${{ matrix.browser }})
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
browser: [firefox, chromium]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install Node dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
cache: 'pip'
|
||||
cache-dependency-path: tests/requirements.txt
|
||||
|
||||
- name: Install Python dependencies
|
||||
run: pip install -r tests/requirements.txt
|
||||
|
||||
- name: Install Playwright browsers
|
||||
run: python -m playwright install --with-deps ${{ matrix.browser }}
|
||||
|
||||
- name: Run tests
|
||||
run: python -m pytest tests/ --browser ${{ matrix.browser }} -v
|
||||
env:
|
||||
CI: true
|
||||
37
.gitignore
vendored
Normal file
37
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
# Build output
|
||||
dist/
|
||||
dev-dist/
|
||||
|
||||
# SQLite WASM (copied from node_modules at build time)
|
||||
public/sqlite3.wasm
|
||||
public/sqlite3-opfs-async-proxy.js
|
||||
|
||||
# Python venv
|
||||
tests/.venv/
|
||||
__pycache__/
|
||||
.pytest_cache/
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
|
||||
# Editor
|
||||
.idea/
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# temp files
|
||||
tmp/
|
||||
|
||||
# Hugo build output
|
||||
website/public/
|
||||
website/resources/
|
||||
4
.prettierignore
Normal file
4
.prettierignore
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
dist/
|
||||
node_modules/
|
||||
public/sqlite3-opfs-async-proxy.js
|
||||
website/layouts/
|
||||
6
.prettierrc
Normal file
6
.prettierrc
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"semi": false,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all",
|
||||
"printWidth": 100
|
||||
}
|
||||
121
AGENTS.md
Normal file
121
AGENTS.md
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
# Agents Rules
|
||||
|
||||
## Project Overview
|
||||
|
||||
TrainUs is a fitness tracking progressive web application built with:
|
||||
|
||||
- **Frontend**: Vue 3 (Composition API) + Vue Router
|
||||
- **Database**: SQLite WASM (client-side, in-browser)
|
||||
- **Build tool**: Vite 7
|
||||
- **Styling**: Custom CSS + Bootstrap Icons
|
||||
- **i18n**: i18next + i18next-vue (English, French & Danish)
|
||||
- **Tests**: Playwright (Python) with pytest
|
||||
- **Formatting**: Prettier
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
src/ # All application source code
|
||||
components/ # Reusable Vue components
|
||||
composables/ # Vue composables (shared logic)
|
||||
db/ # SQLite WASM database layer
|
||||
i18n/ # Internationalization config and translations
|
||||
router/ # Vue Router configuration
|
||||
styles/ # CSS styles
|
||||
utils/ # Utility functions
|
||||
views/ # Page-level Vue components
|
||||
App.vue # Root component
|
||||
main.js # Application entry point
|
||||
public/ # Static assets served as-is
|
||||
tests/ # Playwright (Python) test suite
|
||||
conftest.py # Pytest fixtures (Vite dev server, browser config)
|
||||
test_step*.py # Test files per feature step
|
||||
docs/ # Non-website documentation
|
||||
tutorials/ # Step tutorials (EN & FR)
|
||||
tutorials/codelabs/ # Google Codelabs format (EN & FR)
|
||||
decisions-log.md # Decision log
|
||||
plan.md # Feature plan and progress tracking
|
||||
specification.md # Project specification
|
||||
website/ # Hugo project site (docs + blog), two separate sections
|
||||
# by audience, each with its own nav entry
|
||||
content/docs/ # User docs ("Documentation" in nav). Diataxis split,
|
||||
# both languages: demarrage-rapide/ (tutorial),
|
||||
# guides-pratiques.{en,fr}.md (how-to), user-guide/
|
||||
# (reference), comprendre.{en,fr}.md (explanation).
|
||||
content/developers/ # Contributor docs ("Developers"/"Développeurs" in nav):
|
||||
# technical.en.md, technical.fr.md
|
||||
content/blog/ # Blog posts (news, release announcements)
|
||||
```
|
||||
|
||||
## Build & Run Commands
|
||||
|
||||
- `npm run dev` -- Start Vite dev server
|
||||
- `npm run build` -- Production build to `dist/`
|
||||
- `npm run preview` -- Preview production build
|
||||
- `npm run format` -- Format code with Prettier
|
||||
- `npm run format:check` -- Check formatting
|
||||
|
||||
## Plan
|
||||
|
||||
- Prepare a step-by-step plan to implement one feature at a time (do not rush the full application in one shot)
|
||||
- Validate the plan with me before doing anything
|
||||
- Validation is required between each step
|
||||
|
||||
## Code
|
||||
|
||||
Respect these principles:
|
||||
|
||||
- KISS (Keep It Simple, Stupid)
|
||||
- SOLID (Single Responsibility, Open/Closed, Liskov Substitution, Interface Segregation, Dependency Inversion)
|
||||
- DRY (Don't Repeat Yourself)
|
||||
- Minimal impact: prefer small, focused changes over large refactors
|
||||
- All application source code goes in `src/`
|
||||
- All new Vue components use Composition API with `<script setup>`
|
||||
- Use existing composables and utilities before creating new ones
|
||||
|
||||
## Documentation
|
||||
|
||||
- Keep the decision log updated in `docs/decisions-log.md` with date, decision, and rationale
|
||||
- Use Mermaid diagrams to illustrate architecture and flows (prefer: flowchart, sequence diagram, state diagram)
|
||||
- `README.md` at project root presents an overview of the application and how to get started
|
||||
- All documentation files go in `docs/`
|
||||
- **Technical Documentation** (`website/content/developers/`, "Developers"/"Développeurs" nav entry):
|
||||
- Technical overview (`technical.en.md`): what the app does, tech stack, global architecture
|
||||
- Description of each feature with rationale (why this approach, what was considered and rejected)
|
||||
- **User Documentation** (`website/content/docs/`, "Documentation" nav entry):
|
||||
- Diataxis-style split, in both English and French: `demarrage-rapide/`
|
||||
(tutorial), `guides-pratiques.{en,fr}.md` (how-to), `user-guide/`
|
||||
(reference — kept this name across languages so the bundle pairs as a
|
||||
Hugo translation), `comprendre.{en,fr}.md` (explanation). Text-based
|
||||
preferred, screenshots where they clarify a screen or flow.
|
||||
- **Tutorials & Codelabs (for each step):**
|
||||
- A tutorial in `docs/tutorials/` explaining what was done (commands, key files, code snippets)
|
||||
- Both in English (`_en` suffix) and French (`_fr` suffix)
|
||||
- Tutorials and codelabs are optional — only generate when explicitly requested
|
||||
|
||||
## Tests
|
||||
|
||||
- Tests are written using Playwright in Python (see `tests/requirements.txt` for dependencies)
|
||||
- The Vite dev server starts automatically via the `app_url` fixture in `tests/conftest.py` on port 5199
|
||||
- Test files follow the naming convention `test_step<N>_<feature_name>.py`
|
||||
- Tests are documented with a short description per test function
|
||||
- **Browser targets**: Firefox (primary), Chromium (must also pass)
|
||||
- Test both "green" paths (happy flow) and "red" paths (bad inputs, edge cases, error states)
|
||||
- All test files go in `tests/`
|
||||
- The dev server is stopped automatically by the pytest fixture after tests complete
|
||||
|
||||
## Schema Version
|
||||
|
||||
- **Do NOT increment `SCHEMA_VERSION` without asking first.**
|
||||
- In-place schema changes (adding columns directly in `SCHEMA_SQL`) are acceptable as long as the app has not been released to users.
|
||||
- A version bump is only needed once a schema has been deployed — at that point, a migration script must accompany the bump. Ask before doing either.
|
||||
|
||||
## Definition of Done
|
||||
|
||||
Before a step is considered complete, all of the following must be true:
|
||||
|
||||
- [ ] Code implemented in `src/`
|
||||
- [ ] `docs/plan.md` updated to track what is done and what remains
|
||||
- [ ] Documentation updated (`website/content/developers/technical.en.md`, `website/content/docs/user-guide/index.en.md`, `docs/decisions-log.md`)
|
||||
- [ ] Tests written and passing on both Firefox and Chromium
|
||||
- [ ] Manual validation by the user before moving to the next step
|
||||
660
LICENSE.md
Normal file
660
LICENSE.md
Normal file
|
|
@ -0,0 +1,660 @@
|
|||
# GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
|
||||
Version 3, 19 November 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc.
|
||||
<https://fsf.org/>
|
||||
|
||||
Everyone is permitted to copy and distribute verbatim copies of this
|
||||
license document, but changing it is not allowed.
|
||||
|
||||
## Preamble
|
||||
|
||||
The GNU Affero General Public License is a free, copyleft license for
|
||||
software and other kinds of works, specifically designed to ensure
|
||||
cooperation with the community in the case of network server software.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
our General Public Licenses are intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains
|
||||
free software for all its users.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
Developers that use our General Public Licenses protect your rights
|
||||
with two steps: (1) assert copyright on the software, and (2) offer
|
||||
you this License which gives you legal permission to copy, distribute
|
||||
and/or modify the software.
|
||||
|
||||
A secondary benefit of defending all users' freedom is that
|
||||
improvements made in alternate versions of the program, if they
|
||||
receive widespread use, become available for other developers to
|
||||
incorporate. Many developers of free software are heartened and
|
||||
encouraged by the resulting cooperation. However, in the case of
|
||||
software used on network servers, this result may fail to come about.
|
||||
The GNU General Public License permits making a modified version and
|
||||
letting the public access it on a server without ever releasing its
|
||||
source code to the public.
|
||||
|
||||
The GNU Affero General Public License is designed specifically to
|
||||
ensure that, in such cases, the modified source code becomes available
|
||||
to the community. It requires the operator of a network server to
|
||||
provide the source code of the modified version running there to the
|
||||
users of that server. Therefore, public use of a modified version, on
|
||||
a publicly accessible server, gives the public access to the source
|
||||
code of the modified version.
|
||||
|
||||
An older license, called the Affero General Public License and
|
||||
published by Affero, was designed to accomplish similar goals. This is
|
||||
a different license, not a version of the Affero GPL, but Affero has
|
||||
released a new version of the Affero GPL which permits relicensing
|
||||
under this license.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
## TERMS AND CONDITIONS
|
||||
|
||||
### 0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU Affero General Public
|
||||
License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds
|
||||
of works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of
|
||||
an exact copy. The resulting work is called a "modified version" of
|
||||
the earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user
|
||||
through a computer network, with no transfer of a copy, is not
|
||||
conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices" to
|
||||
the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
### 1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work for
|
||||
making modifications to it. "Object code" means any non-source form of
|
||||
a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users can
|
||||
regenerate automatically from other parts of the Corresponding Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that same
|
||||
work.
|
||||
|
||||
### 2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not convey,
|
||||
without conditions so long as your license otherwise remains in force.
|
||||
You may convey covered works to others for the sole purpose of having
|
||||
them make modifications exclusively for you, or provide you with
|
||||
facilities for running those works, provided that you comply with the
|
||||
terms of this License in conveying all material for which you do not
|
||||
control copyright. Those thus making or running the covered works for
|
||||
you must do so exclusively on your behalf, under your direction and
|
||||
control, on terms that prohibit them from making any copies of your
|
||||
copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under the
|
||||
conditions stated below. Sublicensing is not allowed; section 10 makes
|
||||
it unnecessary.
|
||||
|
||||
### 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such
|
||||
circumvention is effected by exercising rights under this License with
|
||||
respect to the covered work, and you disclaim any intention to limit
|
||||
operation or modification of the work as a means of enforcing, against
|
||||
the work's users, your or third parties' legal rights to forbid
|
||||
circumvention of technological measures.
|
||||
|
||||
### 4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
### 5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these
|
||||
conditions:
|
||||
|
||||
- a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
- b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under
|
||||
section 7. This requirement modifies the requirement in section 4
|
||||
to "keep intact all notices".
|
||||
- c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
- d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
### 6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms of
|
||||
sections 4 and 5, provided that you also convey the machine-readable
|
||||
Corresponding Source under the terms of this License, in one of these
|
||||
ways:
|
||||
|
||||
- a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
- b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the Corresponding
|
||||
Source from a network server at no charge.
|
||||
- c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
- d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
- e) Convey the object code using peer-to-peer transmission,
|
||||
provided you inform other peers where the object code and
|
||||
Corresponding Source of the work are being offered to the general
|
||||
public at no charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal,
|
||||
family, or household purposes, or (2) anything designed or sold for
|
||||
incorporation into a dwelling. In determining whether a product is a
|
||||
consumer product, doubtful cases shall be resolved in favor of
|
||||
coverage. For a particular product received by a particular user,
|
||||
"normally used" refers to a typical or common use of that class of
|
||||
product, regardless of the status of the particular user or of the way
|
||||
in which the particular user actually uses, or expects or is expected
|
||||
to use, the product. A product is a consumer product regardless of
|
||||
whether the product has substantial commercial, industrial or
|
||||
non-consumer uses, unless such uses represent the only significant
|
||||
mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to
|
||||
install and execute modified versions of a covered work in that User
|
||||
Product from a modified version of its Corresponding Source. The
|
||||
information must suffice to ensure that the continued functioning of
|
||||
the modified object code is in no case prevented or interfered with
|
||||
solely because modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or
|
||||
updates for a work that has been modified or installed by the
|
||||
recipient, or for the User Product in which it has been modified or
|
||||
installed. Access to a network may be denied when the modification
|
||||
itself materially and adversely affects the operation of the network
|
||||
or violates the rules and protocols for communication across the
|
||||
network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
### 7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders
|
||||
of that material) supplement the terms of this License with terms:
|
||||
|
||||
- a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
- b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
- c) Prohibiting misrepresentation of the origin of that material,
|
||||
or requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
- d) Limiting the use for publicity purposes of names of licensors
|
||||
or authors of the material; or
|
||||
- e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
- f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions
|
||||
of it) with contractual assumptions of liability to the recipient,
|
||||
for any liability that these contractual assumptions directly
|
||||
impose on those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions; the
|
||||
above requirements apply either way.
|
||||
|
||||
### 8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your license
|
||||
from a particular copyright holder is reinstated (a) provisionally,
|
||||
unless and until the copyright holder explicitly and finally
|
||||
terminates your license, and (b) permanently, if the copyright holder
|
||||
fails to notify you of the violation by some reasonable means prior to
|
||||
60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
### 9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or run
|
||||
a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
### 10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
### 11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims owned
|
||||
or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within the
|
||||
scope of its coverage, prohibits the exercise of, or is conditioned on
|
||||
the non-exercise of one or more of the rights that are specifically
|
||||
granted under this License. You may not convey a covered work if you
|
||||
are a party to an arrangement with a third party that is in the
|
||||
business of distributing software, under which you make payment to the
|
||||
third party based on the extent of your activity of conveying the
|
||||
work, and under which the third party grants, to any of the parties
|
||||
who would receive the covered work from you, a discriminatory patent
|
||||
license (a) in connection with copies of the covered work conveyed by
|
||||
you (or copies made from those copies), or (b) primarily for and in
|
||||
connection with specific products or compilations that contain the
|
||||
covered work, unless you entered into that arrangement, or that patent
|
||||
license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
### 12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under
|
||||
this License and any other pertinent obligations, then as a
|
||||
consequence you may not convey it at all. For example, if you agree to
|
||||
terms that obligate you to collect a royalty for further conveying
|
||||
from those to whom you convey the Program, the only way you could
|
||||
satisfy both those terms and this License would be to refrain entirely
|
||||
from conveying the Program.
|
||||
|
||||
### 13. Remote Network Interaction; Use with the GNU General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, if you modify the
|
||||
Program, your modified version must prominently offer all users
|
||||
interacting with it remotely through a computer network (if your
|
||||
version supports such interaction) an opportunity to receive the
|
||||
Corresponding Source of your version by providing access to the
|
||||
Corresponding Source from a network server at no charge, through some
|
||||
standard or customary means of facilitating copying of software. This
|
||||
Corresponding Source shall include the Corresponding Source for any
|
||||
work covered by version 3 of the GNU General Public License that is
|
||||
incorporated pursuant to the following paragraph.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the work with which it is combined will remain governed by version
|
||||
3 of the GNU General Public License.
|
||||
|
||||
### 14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions
|
||||
of the GNU Affero General Public License from time to time. Such new
|
||||
versions will be similar in spirit to the present version, but may
|
||||
differ in detail to address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the Program
|
||||
specifies that a certain numbered version of the GNU Affero General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU Affero General Public License, you may choose any version ever
|
||||
published by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future versions
|
||||
of the GNU Affero General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
### 15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT
|
||||
WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT
|
||||
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
|
||||
A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND
|
||||
PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE
|
||||
DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR
|
||||
CORRECTION.
|
||||
|
||||
### 16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR
|
||||
CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES
|
||||
ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT
|
||||
NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR
|
||||
LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM
|
||||
TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER
|
||||
PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.
|
||||
|
||||
### 17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
## How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these
|
||||
terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest to
|
||||
attach them to the start of each source file to most effectively state
|
||||
the exclusion of warranty; and each file should have at least the
|
||||
"copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper
|
||||
mail.
|
||||
|
||||
If your software can interact with users remotely through a computer
|
||||
network, you should also make sure that it provides a way for users to
|
||||
get its source. For example, if your program is a web application, its
|
||||
interface could display a "Source" link that leads users to an archive
|
||||
of the code. There are many ways you could offer source, and different
|
||||
solutions will be better for different programs; see section 13 for
|
||||
the specific requirements.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or
|
||||
school, if any, to sign a "copyright disclaimer" for the program, if
|
||||
necessary. For more information on this, and how to apply and follow
|
||||
the GNU AGPL, see <https://www.gnu.org/licenses/>.
|
||||
254
README.md
Normal file
254
README.md
Normal file
|
|
@ -0,0 +1,254 @@
|
|||
# TrainUs
|
||||
|
||||
A local-first PWA fitness training application. Plan, track, and execute your workouts entirely on your device — no server needed.
|
||||
|
||||
> [!WARNING]
|
||||
> IA has been massively used.
|
||||
> I have the technical skills to carry out this kind of project, but not the time. The rise of AI tools changed the equation: what would have taken me months of evenings and weekends became achievable. AI didn’t replace expertise — it made possible what wasn’t, purely due to time constraints. It was also an opportunity to learn how to work with these tools, which is a benefit in itself: I got to test and compare quite a few models and assistants throughout the development.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Vue 3** + **Vite** — reactive UI framework with fast build tooling
|
||||
- **SQLite WASM** with **OPFS** — client-side database persistence
|
||||
- **i18next** — internationalization (English, French & Danish)
|
||||
- **Chart.js** — workout statistics
|
||||
- **vite-plugin-pwa** + Workbox — offline support and install prompt
|
||||
- **Playwright** (Python) — end-to-end testing
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Node.js ≥ 18
|
||||
- Python ≥ 3.10 (for tests)
|
||||
|
||||
### Development
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev # dev server at http://localhost:5173
|
||||
npm run lint # ESLint (src/)
|
||||
npm run format # Prettier (auto-fix)
|
||||
npm run format:check # Prettier (check only)
|
||||
```
|
||||
|
||||
The dev server runs at `http://localhost:5173` with COOP/COEP headers enabled (precautionary; the SAH-pool VFS used by this app does not strictly require them). The Playwright test suite runs its own instance on port 5199 instead (see `tests/README.md`).
|
||||
|
||||
> **Note:** OPFS persistence requires a modern browser (Firefox 111+, Chrome 102+, Safari 16.4+) and does not work in private browsing mode. If OPFS is unavailable, the app displays a compatibility error.
|
||||
|
||||
### Build
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
Produces a static `dist/` folder ready for deployment.
|
||||
|
||||
### Deploy
|
||||
|
||||
TrainUs uses a single deployment: copy the `dist/` folder to the site root, replacing the previous build (no per-version folders). User data is preserved across updates — it lives at a stable OPFS location, and a startup migration step applies any schema changes.
|
||||
|
||||
TrainUs is a static site — it works on any web server or CDN. COOP/COEP headers are not required for the SAH-pool VFS used here, but are recommended for defense-in-depth and may be required if the VFS is ever changed:
|
||||
|
||||
#### Nginx
|
||||
|
||||
```nginx
|
||||
location / {
|
||||
add_header Cross-Origin-Opener-Policy "same-origin";
|
||||
add_header Cross-Origin-Embedder-Policy "require-corp";
|
||||
}
|
||||
```
|
||||
|
||||
#### Apache
|
||||
|
||||
```apache
|
||||
<IfModule mod_headers.c>
|
||||
Header always set Cross-Origin-Opener-Policy "same-origin"
|
||||
Header always set Cross-Origin-Embedder-Policy "require-corp"
|
||||
</IfModule>
|
||||
```
|
||||
|
||||
#### Caddy
|
||||
|
||||
```caddy
|
||||
header {
|
||||
Cross-Origin-Opener-Policy "same-origin"
|
||||
Cross-Origin-Embedder-Policy "require-corp"
|
||||
}
|
||||
```
|
||||
|
||||
#### Netlify (`public/_headers`)
|
||||
|
||||
```
|
||||
/*
|
||||
Cross-Origin-Opener-Policy: same-origin
|
||||
Cross-Origin-Embedder-Policy: require-corp
|
||||
```
|
||||
|
||||
#### Vercel (`vercel.json`)
|
||||
|
||||
```json
|
||||
{
|
||||
"headers": [
|
||||
{
|
||||
"source": "/(.*)",
|
||||
"headers": [
|
||||
{ "key": "Cross-Origin-Opener-Policy", "value": "same-origin" },
|
||||
{ "key": "Cross-Origin-Embedder-Policy", "value": "require-corp" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### Preview production build locally
|
||||
|
||||
```bash
|
||||
npm run preview
|
||||
```
|
||||
|
||||
Serves `dist/` at `http://localhost:4173` with the correct headers (injected by Vite).
|
||||
|
||||
### Tests
|
||||
|
||||
#### Setup (first time)
|
||||
|
||||
```bash
|
||||
cd tests
|
||||
python -m venv .venv
|
||||
.venv/bin/pip install -r requirements.txt
|
||||
.venv/bin/playwright install firefox chromium
|
||||
```
|
||||
|
||||
#### Running tests
|
||||
|
||||
| Command | Browsers | Mode |
|
||||
| :----------------------------- | :----------------- | :--------------------------- |
|
||||
| npm run test | firefox | sequential |
|
||||
| npm run test:chromium | chromium | sequential |
|
||||
| npm run test:parallel | firefox + chromium | parallel |
|
||||
| npm run test:parallel:firefox | firefox | parallel |
|
||||
| npm run test:parallel:chromium | chromium | parallel |
|
||||
| npm run test:parallel:n | firefox + chromium | parallel, fixed worker count |
|
||||
|
||||
```bash
|
||||
# Sequential — Firefox only (simple)
|
||||
npm run test
|
||||
# or: cd tests && .venv/bin/python -m pytest --browser firefox -v
|
||||
|
||||
# Parallel — both browsers, all CPU cores (fastest)
|
||||
npm run test:parallel
|
||||
|
||||
# Parallel — Firefox only, all CPU cores
|
||||
npm run test:parallel:firefox
|
||||
|
||||
# Parallel — both browsers, limited to N workers (default 4)
|
||||
WORKERS=2 npm run test:parallel:n
|
||||
|
||||
# Via shell script — both browsers, auto workers
|
||||
./tests/run_parallel.sh
|
||||
|
||||
# Via shell script — Firefox only, 4 workers
|
||||
PYTEST_WORKERS=4 PYTEST_BROWSERS="firefox" ./tests/run_parallel.sh
|
||||
|
||||
# Single test file, Firefox only
|
||||
cd tests && .venv/bin/python -m pytest test_step2_settings.py --browser firefox -v
|
||||
|
||||
# Single test file in parallel via shell script
|
||||
./tests/run_parallel.sh test_step3_exercises.py
|
||||
```
|
||||
|
||||
### Website
|
||||
|
||||
The project site (docs + blog) is a Hugo static site under `website/`.
|
||||
|
||||
#### Prerequisites
|
||||
|
||||
- [Hugo](https://gohugo.io/installation/) ≥ 0.123 extended edition
|
||||
|
||||
#### Development
|
||||
|
||||
```bash
|
||||
npm run site:dev # Hugo dev server at http://localhost:1313 (with drafts)
|
||||
```
|
||||
|
||||
#### Build
|
||||
|
||||
```bash
|
||||
npm run site:build # Minified output in website/public/
|
||||
```
|
||||
|
||||
#### Deploy
|
||||
|
||||
The site is a static folder — copy `website/public/` to any web host. Before building for production, set two values in `website/hugo.toml`:
|
||||
|
||||
```toml
|
||||
baseURL = "https://yourdomain.example/trainus/" # full URL to the site root
|
||||
|
||||
[params]
|
||||
appUrl = "https://yourdomain.example/app/" # leave empty to show "Coming soon"
|
||||
```
|
||||
|
||||
No special server headers are required for the website itself (COOP/COEP are only needed for the app).
|
||||
|
||||
## Documentation
|
||||
|
||||
You can access the website [here](https://kaivalya.eu/projects/trainUs).
|
||||
|
||||
### Project
|
||||
|
||||
- [Technical Documentation](website/content/developers/technical.en.md) -- Architecture, tech stack, data model, and feature details
|
||||
- [User Documentation](website/content/docs/user-guide/index.en.md) -- How each feature works from the user's perspective
|
||||
- [Implementation Plan](docs/plan.md) -- Step-by-step feature plan and progress tracking
|
||||
- [Decisions Log](docs/decisions-log.md) -- All design decisions with rationale
|
||||
|
||||
## Credits
|
||||
|
||||
### Icons
|
||||
|
||||
The app icon is [Mighty Force](https://game-icons.net/1x1/delapouite/mighty-force.html) by [Delapouite](https://delapouite.com), sourced from [game-icons.net](https://game-icons.net) and licensed under [CC BY 3.0](https://creativecommons.org/licenses/by/3.0/). Thank you for making such a great icon library freely available!
|
||||
|
||||
UI icons are provided by [Bootstrap Icons](https://icons.getbootstrap.com), licensed under MIT.
|
||||
|
||||
### Third-Party Software
|
||||
|
||||
| Package | License |
|
||||
| -------------------------------------------------------------------------------------------------------------------------- | -------------------- |
|
||||
| [Vue 3](https://github.com/vuejs/core) | MIT |
|
||||
| [Vue Router](https://github.com/vuejs/router) | MIT |
|
||||
| [Vite](https://github.com/vitejs/vite) | MIT |
|
||||
| [@vitejs/plugin-vue](https://github.com/vitejs/vite-plugin-vue) | MIT |
|
||||
| [vite-plugin-pwa](https://github.com/vite-pwa/vite-plugin-pwa) | MIT |
|
||||
| [Workbox](https://github.com/GoogleChrome/workbox) (bundled by vite-plugin-pwa) | Apache-2.0 |
|
||||
| [@sqlite.org/sqlite-wasm](https://sqlite.org/wasm/doc/trunk/index.md) | Apache-2.0 |
|
||||
| [Bootstrap Icons](https://icons.getbootstrap.com) | MIT |
|
||||
| [Mighty Force](https://game-icons.net/1x1/delapouite/mighty-force.html) by [Delapouite](https://delapouite.com) (app icon) | CC BY 3.0 |
|
||||
| [Chart.js](https://github.com/chartjs/Chart.js) | MIT |
|
||||
| [vue-chartjs](https://github.com/apertureless/vue-chartjs) | MIT |
|
||||
| [DOMPurify](https://github.com/cure53/DOMPurify) | Apache-2.0 / MPL-2.0 |
|
||||
| [marked](https://github.com/markedjs/marked) | MIT |
|
||||
| [i18next](https://github.com/i18next/i18next) | MIT |
|
||||
| [i18next-vue](https://github.com/i18next/i18next-vue) | MIT |
|
||||
| [Prettier](https://github.com/prettier/prettier) | MIT |
|
||||
| [Playwright](https://github.com/microsoft/playwright) | Apache-2.0 |
|
||||
| [pytest](https://github.com/pytest-dev/pytest) | MIT |
|
||||
| [pytest-playwright](https://github.com/microsoft/playwright-python) | Apache-2.0 |
|
||||
| [pytest-xdist](https://github.com/pytest-dev/pytest-xdist) | MIT |
|
||||
|
||||
## License
|
||||
|
||||
TrainUs — an open, local-first, offline-capable fitness tracking application where you can create and share your own training sessions.
|
||||
Copyright (C) 2026 [David Florance](http://kaivalya.eu)
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
764
docs/decisions-log.md
Normal file
764
docs/decisions-log.md
Normal file
|
|
@ -0,0 +1,764 @@
|
|||
# Decisions Log
|
||||
|
||||
Record of architectural and technical decisions made during development.
|
||||
|
||||
## 2026-06-18 — Clear (×) button added to list search fields
|
||||
|
||||
**Decision:** Added a clear button inside the search input on the four list views with a search bar (`ExerciseListView`, `ComboListView`, `SessionListView`, `CollectionsView`). The button (Bootstrap `bi-x-lg` icon) only renders when the field has a value, sits absolutely positioned inside the input (`padding-right` added to make room), and on click empties `searchQuery` and refocuses the input via a new `clearSearch()` function in each view. The click handler uses `@mousedown.prevent` instead of `@click` so the button doesn't steal focus from the input first (which would otherwise fire `onSearchBlur` while the field still holds its old, non-empty value). Added a shared i18n key `common.clearSearch` (EN/FR/DA) for its `title`/`aria-label` rather than per-entity keys, since the action is identical across all four views. Each view gets its own `data-testid="<entity>-search-clear"` to match the existing per-view `data-testid` convention for the search bar/input/toggle. The pattern is duplicated across the four `.vue` files rather than extracted into a shared component, consistent with how the rest of the search bar (debounce, toggle, blur-to-close) is already duplicated there.
|
||||
|
||||
**Rationale:** Requested by the user as a small UX improvement — clearing the field previously required selecting and deleting the text manually, or toggling search off and back on (which also closes the bar). `@mousedown.prevent` was chosen over reordering the blur/clear logic because it's the standard idiom for "clear button inside a focused input" and avoids a state-machine edge case where blur could close the search bar before the field is cleared.
|
||||
|
||||
## 2026-06-16 — "Add a New Theme" how-to now covers all supported languages
|
||||
|
||||
**Decision:** Fixed `website/content/developers/how-to-guides.{en,fr}.md`'s "Add a New Theme" recipe, which only had the new contributor add the theme's display label to `en.json` and `fr.json` — missing `da.json`, even though Danish is a real, shipped language (same gap just fixed elsewhere in "Add a New Language", see the entry above). Merged the two language-specific steps into one ("add the display label in every supported language"), added the `da.json` example, noted that `i18next`'s `fallbackLng: 'en'` (from `src/i18n/index.js`) means a missing label degrades to English silently rather than erroring, and added a step in the smoke-test checklist to verify the label in all three languages before considering the theme done. Renumbered the touch-point list and step headings accordingly (6 steps → 5).
|
||||
|
||||
**Rationale:** A theme contributed by following the old recipe would ship with a Danish UI silently showing an English theme name — a real, easy-to-miss gap given `fallbackLng` doesn't surface it as an error. Caught by the user immediately after the equivalent fix to "Add a New Language".
|
||||
|
||||
## 2026-06-16 — Corrected language-count and COOP/COEP claims in developer docs
|
||||
|
||||
**Decision:** Fixed two inaccuracies in `website/content/developers/how-to-guides.{en,fr}.md` and `technical.{en,fr}.md`, both inherited unchanged from the pre-split single-page `technical.en.md`:
|
||||
|
||||
1. "Add a New Language" used Danish (`da`) as the worked example for a language being added, with an `// ← new` comment — misleading, since `da` is already a real, shipped locale (`src/i18n/locales/da.json`, registered in `src/i18n/index.js`, and selectable today in Settings per `src/views/SettingsView.vue`). Reworked the recipe to use Spanish (`es`) as the example instead, and added a note up front that TrainUs already ships three languages (English, French, Danish), not just the EN/FR pair the project website itself emphasizes. Also corrected the Reference page's "Available settings" table, which still listed the language selector as "(English / Français)" only.
|
||||
2. "Deploy TrainUs" stated the COOP/COEP headers **must** be present "or the browser blocks OPFS access" — contradicted by the same reference page's own §2 ("Does not require COOP/COEP headers") and by the R17 README correction (2026-06-12, see below). Reworded to match README's accurate framing: not strictly required by the `opfs-sahpool` VFS actually used, recommended as defense-in-depth, and the dev server already sets them as a precaution.
|
||||
|
||||
**Rationale:** Both inaccuracies were latent in the original single-page `technical.en.md` and simply carried forward during the Diataxis split rather than introduced by it; the user caught them after reading the new how-to pages. Fixed in both languages since both were translated from the same (incorrect) source text.
|
||||
|
||||
## 2026-06-16 — French developer documentation ported to the EN Diataxis split
|
||||
|
||||
**Decision:** Ported the four-page developer docs Diataxis split (see the "English developer documentation split Diataxis-style" entry below) to French, completing the deferred follow-up: `getting-started.fr.md` (tutorial, slug `demarrage-rapide`), `how-to-guides.fr.md` (how-to, slug `guides-pratiques`), `technical.fr.md` (reference — replaced the old "moved to EN" stub with the full translated content, title changed from "Documentation technique" to "Référence" and weight corrected from 5 to 3 to match the EN page's position, mirroring `user-guide/index.fr.md`'s "Référence" title), `understanding-the-architecture.fr.md` (explanation, slug `comprendre-l-architecture`). Basenames match the EN files for Hugo's translation pairing (confirmed via the language switcher rendering correctly on each page); French-friendly URLs come from `slug:` front matter, the same technique used for the EN user-docs port. Internal cross-page anchors (e.g. `technical.fr.md#1-architecture`, `how-to-guides.fr.md#déployer-trainus`) were verified against Hugo's actual generated heading IDs after a full `hugo --minify` build — no `REF_NOT_FOUND` and no broken anchors.
|
||||
|
||||
**Rationale:** The English split was done first specifically because the project already had two real how-to documents in English; with that done and validated, finishing the French side closes out the deferred TODO in `docs/website-plan.md` §8.1 and brings developer docs to parity with user docs (both now fully bilingual, 4-page Diataxis splits).
|
||||
|
||||
## 2026-06-16 — English developer documentation split Diataxis-style
|
||||
|
||||
**Decision:** Restructured the English developer documentation from a single page (`website/content/developers/technical.en.md`) into a four-page Diataxis split, mirroring the structure already used for user docs: `getting-started.en.md` (tutorial — new content: prerequisites, clone/install/dev, a one-line-per-folder tour of `src/`, running the Playwright suite, and one small guided edit to close the dev loop before sending the reader to the how-to page), `how-to-guides.en.md` (how-to — four recipes: "Add a New Language" and "Add a New Theme" ported from the pre-existing `docs/how-to-add-a-language.md` and `docs/how-to-add-a-theme.md`, plus two new recipes, "Deploy TrainUs" extracted from the old §7 and "Add a Database Migration" synthesized from the old §2/§6 narrative into actual steps), `technical.en.md` (reference — kept the filename/weight-3 slot, trimmed of rationale prose and the deploy header recipes, otherwise unchanged: tech stack, project structure, schema, ER diagram, composables, the full §3 feature-by-feature description, routing, i18n, versioning, build commands, Annex A), and `understanding-the-architecture.en.md` (explanation — new: why no backend, why SQLite runs in a Worker, why the `opfs-sahpool` VFS with no fallback, why the migration system is designed the way it is, plus a condensed pass over a handful of notable per-feature design decisions — the save dialog vs. native picker, partial logs on exit, asymmetric side-flip strategies, per-combo-type rounds calculation — and the content-width-cap rationale). The two superseded `docs/how-to-add-a-*.md` files were replaced with "moved to the website" stubs, matching the existing pattern for `docs/technical-documentation.md` / `docs/user-documentation.md` (whose own stub was also corrected — it still pointed at the pre-split `website/content/docs/technical.en.md` path). `docs/release-procedure.md` and `AGENTS.md`'s Definition of Done checklist had the same stale path and were updated to `website/content/developers/technical.en.md` / `website/content/docs/user-guide/index.en.md`.
|
||||
|
||||
French (`technical.fr.md`) is **not** restructured in this step — the user explicitly asked to start with English, since the existing how-to content was already English-only. Porting the split to French is left for later, the reverse order of how the user docs split was done (FR first, then EN).
|
||||
|
||||
**Rationale:** The user-docs Diataxis split improved discoverability enough that the same treatment was requested for developer docs. Reusing the two pre-existing, real how-to documents as the seed for the how-to page (rather than writing recipes from scratch) kept the content grounded in material the project owner had already written and validated, and matches the precedent of `docs/technical-documentation.md`/`docs/user-documentation.md` being retired into stubs once their content moved to the website.
|
||||
|
||||
## 2026-06-16 — English user documentation ported to the FR Diataxis split
|
||||
|
||||
**Decision:** Restructured the English user documentation from a single page (`user-guide.en.md`) into the same four-page Diataxis split already used for French (see the 2026-06-16 "French user documentation split Diataxis-style" entry below): `demarrage-rapide/index.en.md` (tutorial — what TrainUs is, local-first principles and trade-offs, GUI overview, the two modes; also kept the browser-requirements and PWA-install note that used to open the old single-page guide, since that's tutorial-appropriate content), `guides-pratiques.en.md` (how-to — same six recipes as the FR page, written natively rather than translated literally), `user-guide/index.en.md` (reference — the existing EN content reordered to match the FR section order, with the EMOM/AMRAP/TABATA combo screenshots and a Markdown cheat-sheet appendix added, both previously FR-only), and `comprendre.en.md` (explanation — why local-first, JSON vs SQLite, import behaviour, timer logic). The reference page kept the `user-guide` bundle name (no language-specific renaming) so it still pairs as a Hugo translation with `user-guide/index.fr.md`; the other three pages also share their basename with the FR files for the same reason, but get an English-friendly rendered URL via `slug:` front matter (`/en/docs/getting-started/`, `/en/docs/how-to-guides/`, `/en/docs/understanding-trainus/`) instead of inheriting the French words. Screenshots are genuinely English-UI captures (not reused FR images) — generated via `scripts/screenshots/all_pages.py --lang en` and `scripts/screenshots/combo_timers.py --lang en`, which already supported a `--lang` flag added during the FR work specifically for this future EN port.
|
||||
|
||||
**Rationale:** The FR rewrite's decision log explicitly deferred this ("English documentation is **not** restructured ... porting it to the same split is left for later"); the user asked for that follow-up now that they're happy with the FR result. Reusing the FR page structure (rather than re-deriving an EN-specific organization) keeps the two languages structurally interchangeable, which matters for the lang-switcher and for anyone maintaining both versions going forward. English slugs were chosen over French-derived ones because the rendered URL is user-facing and worth getting right, while the underlying filename is purely an internal pairing mechanism Hugo already required to match.
|
||||
|
||||
## 2026-06-16 — Corrected user-guide claims about delete cascade behaviour
|
||||
|
||||
**Decision:** Fixed `website/content/docs/user-guide.en.md` and `website/content/docs/user-guide/index.fr.md`: the "Deleting a Session" section wrongly claimed execution history is "preserved" with a "missing session reference" after deleting a session — that described the `ON DELETE SET NULL` alternative considered (and rejected) in R15 of `docs/improvement-plan.md`, not the cascade behaviour actually shipped. Corrected both pages to state that deleting a session also permanently deletes its execution history, and that the confirmation dialog shows the affected count beforehand. Also added the equivalent missing detail to "Deleting an Exercise" (EN had no mention of history impact at all; FR already had a partial note, now aligned with the same confirm-count wording). No code changed — `src/db/schema.js` keeps `ON DELETE CASCADE` on `execution_log.session_id` and `execution_log_item.exercise_id`, confirmed still in effect via a live Playwright/Firefox check against the actual app.
|
||||
|
||||
**Rationale:** R15 (`docs/improvement-plan.md`, implemented, see `docs/plan.md` R15 row) chose the "minimum" fix — count-aware confirm dialogs (`exercises.deleteConfirmWithHistory` / `sessions.deleteConfirmWithHistory`) — over the more invasive `SET NULL` schema change, but the user-guide was never updated to match and kept describing the rejected option. Leaving it as-is would mislead users into believing deleted sessions' history survives.
|
||||
|
||||
---
|
||||
|
||||
## 2026-06-16 — Separate user docs and developer docs into two Hugo sections
|
||||
|
||||
**Decision:** Replaced the `audience: "user"|"contributor"` front matter tag (added earlier the same day, see entry below) with an actual structural split: contributor docs moved from `website/content/docs/` to a new `website/content/developers/` section (`technical.fr.md`, `technical.en.md`, unchanged content), each with its own top-level nav entry — "Documentation" / "Développeurs" (FR) and "Documentation" / "Developers" (EN) in `hugo.toml`. The `audience` field is now redundant (the section itself is the signal) and was removed from all six docs/developers pages. The shared list rendering (sort by weight, front-matter description, top-level heading preview) was extracted from `layouts/docs/list.html` into `layouts/partials/doc-list.html` so the new `layouts/developers/list.html` doesn't duplicate it. `content/docs/_index.*.md` descriptions were updated to drop the now-inaccurate "technical reference" mention; new `content/developers/_index.fr.md` / `_index.en.md` were added.
|
||||
|
||||
**Rationale:** A front-matter tag with no visible effect doesn't actually separate the two audiences for a visitor — landing on `/docs/` still mixed user guides with technical/architecture content under one menu link. Two sections with distinct nav entries make the split obvious without requiring readers to know the tag exists. Nothing was deployed yet, so breaking the old `/docs/technical...` URLs has no cost.
|
||||
|
||||
---
|
||||
|
||||
## 2026-06-16 — French user documentation split Diataxis-style
|
||||
|
||||
**Decision:** Rewrote the French user documentation (previously a stub redirecting to the English page) as four pages under `website/content/docs/`: `demarrage-rapide/` (tutorial — local-first principles, GUI overview, the two modes), `guides-pratiques.fr.md` (how-to — sending a session to a friend, importing JSON, backing up data, syncing two devices, Yoga/running tips), `user-guide/` (reference — kept this filename for continuity with `user-guide.en.md`; full screen-by-screen description, keyboard shortcuts, a Markdown cheat-sheet table), and `comprendre.fr.md` (explanation — why local-first, JSON vs SQLite, how EMOM/AMRAP/TABATA timers work). Added `audience: "user"` / `"contributor"` front matter on all docs pages as a foundation for future audience tagging (no visual badge built yet). The two pages with screenshots (`demarrage-rapide/`, `user-guide/`) are Hugo leaf bundles (`index.fr.md` + images alongside) so image references stay relative, avoiding the `baseURL` prefix bug that affects absolute `/static/...` paths. `website/layouts/docs/list.html` already sorts by `.Pages.ByWeight`, so the five FR docs pages order correctly without further changes. English documentation is **not** restructured — `user-guide.en.md` stays a single page; porting it to the same split is left for later.
|
||||
|
||||
Screenshots were generated with `scripts/screenshots/all_pages.py` (fixed: it hung on the native `window.confirm()` shown when leaving an in-progress execution screen — added a `page.on("dialog", ...)` auto-accept handler; also added a `--lang en|fr` flag so screenshots can be captured in either UI language) and a new `scripts/screenshots/combo_timers.py` script (seeds an EMOM and a TABATA combo to capture the EMOM/TABATA running screens and the TABATA rest screen, none of which `all_pages.py` covers since it only seeds an AMRAP combo).
|
||||
|
||||
**Rationale:** `docs/specification.md`'s "Réécriture Documentation" chapter asked for a Diataxis-inspired structure distinguishing contributor vs. user docs, and explicitly requested the two new how-to guides (backup, device sync) plus combo timer screenshots including the TABATA rest screen. Page bundles were chosen over `static/` to sidestep a known baseURL bug rather than fix it as part of an unrelated doc change. The reference page kept the `user-guide` filename (vs. renaming to something Diataxis-flavored) to preserve the Hugo translation pairing with `user-guide.en.md` and avoid touching `AGENTS.md`'s named deliverable.
|
||||
|
||||
---
|
||||
|
||||
## 2026-06-12 — Single deployment + startup DB migration (Step 16)
|
||||
|
||||
**Decision:** Replaced the "versioned deployments with isolated data" model. The latest build is deployed alone at the site root, the OPFS database lives at a stable location (`.trainus` directory, no app-version suffix), and schema changes are applied by an automatic migration step at startup. The `RELEASES` registry (`src/db/releases.js`) is the single source of truth: `adaptScript` was renamed `migrationScript` (one SQL script per `schemaVersion` bump) and is shared by the startup migration and the restore-adaptation pipeline. Each migration runs in a transaction together with its `schema_version` stamp; on failure it rolls back and the app shows a blocking error. Before any migration runs, a blocking screen forces the user to download a backup — the Update button is enabled only after the download. A DB newer than the app (`dbVersion > SCHEMA_VERSION`) fails safe with a blocking `DB_NEWER_THAN_APP` error, data untouched. Existing `.trainus-x.y.z` directories are orphaned on purpose (nothing has shipped). `SCHEMA_VERSION` stays at 1.
|
||||
|
||||
**Rationale:** OPFS is _origin_-scoped, not path-scoped, so versioned deployment folders never truly isolated data — they only added deployment complexity while orphaning user data on every release, even without a schema change. The restore pipeline already paid the migration-code cost; reusing its scripts for startup migration removes the manual backup/restore UX on updates. Transactions guarantee a failed migration leaves the DB at the previous version; the forced pre-migration backup covers semantic corruption.
|
||||
|
||||
---
|
||||
|
||||
## 2026-06-09 — License display: dialog instead of dedicated page
|
||||
|
||||
**Decision:** Replaced the `/license` route and `LicenseView.vue` page with a stacked modal approach. The About dialog now shows the notice text inline (rendered as Markdown from `src/assets/notice.md?raw`) and an added **License: GNU AGPL v3** row. Clicking "View License" opens `LicenseDialog.vue` on top of the About dialog; closing it returns to About without any navigation.
|
||||
|
||||
**Rationale:** A full page navigation broke the "About" context and was disproportionate for legal text. A stacked modal keeps the user in place, matches the existing modal pattern, and the full license text is still bundled offline via `src/assets/license.md?raw`.
|
||||
|
||||
---
|
||||
|
||||
## 2026-06-08 — License: GNU AGPL v3
|
||||
|
||||
**Decision:** The project is licensed under the **GNU Affero General Public License v3 (AGPL v3)**. The full license text is in `LICENSE.md` at the repository root and `src/assets/license.md` (bundled offline via Vite `?raw`). The copyright notice is in `notice.md` / `src/assets/notice.md`. Contact: trainus.steadfast012@passmail.net.
|
||||
|
||||
**Rationale:** AGPL v3 ensures that any modified version of the app served over a network must also publish its source. Bundling both files into the JS build guarantees offline availability, consistent with the PWA-first philosophy.
|
||||
|
||||
---
|
||||
|
||||
## 2026-06-08 — About dialog added to Settings
|
||||
|
||||
**Decision:** Added an **About TrainUs** card at the bottom of the Settings page. Clicking it opens `AboutDialog.vue`, a modal that displays the app version (`__APP_VERSION__`), the DB schema version (`SCHEMA_VERSION`), the author (David Florance), placeholder links for the repository and website, and a disabled "View License" button with a "License to be determined." note.
|
||||
|
||||
**Rationale:** Centralises version and authorship information in a discoverable place without cluttering the main UI. Placeholder links and the disabled license button provide the correct structure now so they can be filled in once the repository is published and a license is chosen, requiring only a URL swap rather than a structural change.
|
||||
|
||||
---
|
||||
|
||||
## 2026-06-07 — EMOM repetitions semantics corrected
|
||||
|
||||
**Decision:** For EMOM combo items in a session, `repetitions` now means **total duration in minutes** (not number of rounds). The queue builder computes `totalRounds = floor(repetitions / numExercises)`, so each exercise still occupies exactly one 60-second slot and the total execution time equals `repetitions` minutes. The session form now labels the field **Duration (min)** for EMOM items, consistent with AMRAP.
|
||||
|
||||
**Rationale:** The previous behaviour (`totalRounds = repetitions`) made the total EMOM duration grow with the number of exercises in the combo, which was counter-intuitive and inconsistent with how AMRAP works. Users think in minutes when setting up a timed workout. The new formula keeps the interface label and the actual timer duration consistent.
|
||||
|
||||
---
|
||||
|
||||
## 2026-06-07 — Home page: history section, inline exercise summaries, HTML export
|
||||
|
||||
**Decision:**
|
||||
|
||||
- Increased recent sessions from 3 to 4.
|
||||
- Added a **History section** below recent sessions: all execution logs loaded at mount, grouped by calendar day (descending), filtered client-side by two date pickers (default last 30 days). No extra DB query on filter change.
|
||||
- Each session card (recent and history) now shows an inline compact summary of exercises performed (reps, weight, timing) and a **play button (▶)** to re-execute.
|
||||
- Added a **HTML export button** in the action bar (visible only when data exists) via `useHtmlExport.js`. The generated file is fully self-contained: app icon embedded as base64, inline CSS, no external dependencies. Play buttons, navigation links, and date pickers are excluded from the export.
|
||||
|
||||
**Rationale:** The history section turns the home page into a lightweight training journal without a dedicated log view. Client-side filtering avoids extra DB queries on every date change. The HTML export provides a shareable, printable snapshot of activity that works in any browser without the app.
|
||||
|
||||
---
|
||||
|
||||
## 2026-06-02 — Add Danish locale
|
||||
|
||||
**Decision:** Added Danish (`da`) as a supported UI language, with a full translation of all keys in `src/i18n/locales/da.json`, registered in `src/i18n/index.js`, and selectable via the Settings language dropdown.
|
||||
|
||||
**Rationale:** Demonstrates the language addition workflow described in `docs/how-to-add-a-language.md` and broadens the app's reach to Danish speakers.
|
||||
|
||||
---
|
||||
|
||||
## 2026-06-02 — Test fixture strategy for import/export tests
|
||||
|
||||
**Decision:** Static JSON files in `tests/fixtures/` for cross-user scenarios with fixed IDs; dynamic construction (via `page.evaluate`) for same-user roundtrip scenarios.
|
||||
|
||||
**Rationale:** Cross-user tests require known, stable UUIDs so assertions can reference them directly — a static file is self-documenting and removes multi-line `json.dumps` blocks from test code. Same-user roundtrip tests need the live `user_uuid` from the browser DB at test time, so they must be built dynamically. Two fixture files: `cross_user_id_collision.json` (extracted from the existing combo FK test) and `cross_user_full.json` (exercises + combo + session with both exercise and combo items + collection + execution log, all referencing fixture UUIDs in the `facade00-*` range).
|
||||
|
||||
---
|
||||
|
||||
## 2026-05-31 — Add TrainUs theme
|
||||
|
||||
**Decision:** Add a third theme ("TrainUs") that uses the app logo colours — blue `#1E7FC0` (navbar, primary button, input border) and teal `#12A892` (action bar, hover state, success colour) — on a light blue-tinted background.
|
||||
|
||||
**Rationale:** Provides a branded colour experience beyond the generic light/dark pair, matching the visual identity already present in the SVG icon.
|
||||
|
||||
---
|
||||
|
||||
## 2026-05-26 — Step 10: PWA Finalization & Polish
|
||||
|
||||
### Service worker: vite-plugin-pwa (Workbox) over hand-rolled sw.js
|
||||
|
||||
- **Context:** The original `public/sw.js` used `self.__APP_VERSION__` which Vite never substitutes in a plain static file, causing the cache name to always fall back to `1.0.0`. No precaching meant the app did not work offline.
|
||||
- **Decision:** Replace with `vite-plugin-pwa` + Workbox. `registerType: 'autoUpdate'` silently activates new SW versions. `maximumFileSizeToCacheInBytes: 10 MB` to cover `sqlite3.wasm`. `manifest: false` to keep our hand-authored `public/manifest.json`.
|
||||
- **Rationale:** Workbox handles content-hash-based cache busting automatically; the app shell and WASM binary are all precached at install time, enabling true offline support.
|
||||
|
||||
### Install prompt: composable singleton + action bar button
|
||||
|
||||
- **Context:** The `beforeinstallprompt` event must be captured at page load, before it fires.
|
||||
- **Decision:** Module-level event listeners in `useInstallPrompt.js` (singleton ref `canInstall`). `InstallPrompt.vue` teleports a download icon + dismiss × into the action bar when `canInstall` is true; dismissed for the session only (no persistence needed).
|
||||
- **Rationale:** Singleton ensures the event is captured regardless of when the composable is called. Session-only dismiss avoids noisy storage writes; user will see it again on next visit if they haven't installed.
|
||||
|
||||
### Accessibility scope: targeted, not exhaustive
|
||||
|
||||
- **Context:** "ARIA labels, keyboard nav, focus management" could mean auditing every component.
|
||||
- **Decision:** Focus on critical flows — navigation, all forms, execution stepper, modal dialogs. Every icon-only actionbar button across all views also gets `aria-label` (mechanical addition of 1 attribute per button).
|
||||
- **Rationale:** Screen reader coverage for the flows a user actually executes is more valuable than a theoretical full audit. Full ARIA audit deferred to Step 11.
|
||||
|
||||
## 2026-05-26 — Step 9: Statistics
|
||||
|
||||
### Chart library: Chart.js 4 + vue-chartjs 5
|
||||
|
||||
- **Context:** Plan specified Chart.js via vue-chartjs; needed Vue 3 Composition API support.
|
||||
- **Decision:** `chart.js@^4` + `vue-chartjs@^5`. Registered only the needed Chart.js components (tree-shaking) in `StatsView.vue` rather than globally.
|
||||
- **Rationale:** vue-chartjs 5 is the Vue 3-compatible major version; registering locally avoids side effects in other routes that don't use charts.
|
||||
|
||||
### Streak calculation: UTC midnight for date comparison
|
||||
|
||||
- **Context:** SQLite `DATE(started_at)` extracts the UTC calendar date from ISO-8601 timestamps. JavaScript `new Date().setHours(0,0,0,0)` uses local time, creating a mismatch in non-UTC timezones.
|
||||
- **Decision:** Use `setUTCHours(0,0,0,0)` and `toISOString().slice(0,10)` for streak iteration.
|
||||
- **Rationale:** Ensures the JS streak walk and the SQLite date grouping use the same calendar boundary; avoids streak being under-reported by one day in timezones ahead of UTC.
|
||||
|
||||
### Volume metric: reps × weight
|
||||
|
||||
- **Context:** Need a single "effort" number per session to chart over time.
|
||||
- **Decision:** Volume = `SUM(reps_done × weight_used)` for completed items. Bodyweight exercises (weight=0) contribute 0, so the chart only shows weight-bearing volume.
|
||||
- **Rationale:** Standard fitness volume definition; keeps the metric meaningful. Users can still track bodyweight frequency via the Frequency chart.
|
||||
|
||||
### Statistics export: deferred to Step 9b
|
||||
|
||||
- **Context:** User requested statistics export be planned.
|
||||
- **Decision:** Defer CSV/summary export to Step 9b; not included in Step 9.
|
||||
- **Rationale:** Step 9 scope is already substantial; export logic is independent and can be added without touching existing stats code.
|
||||
|
||||
## 2026-03-10 — Initial Decisions
|
||||
|
||||
### Framework: Vue 3 + Vite
|
||||
|
||||
- **Context:** App has complex CRUD forms, reactive i18n/theme, timers, drag-reorder, charts.
|
||||
- **Decision:** Vue 3 with Composition API over Vanilla JS.
|
||||
- **Rationale:** Vanilla JS would require reinventing reactivity and component lifecycle. Vue provides reactive data binding, composables, and a rich ecosystem while staying lightweight.
|
||||
|
||||
### i18n: i18next + i18next-vue
|
||||
|
||||
- **Context:** Spec requires multi-language support, easy to translate.
|
||||
- **Decision:** Use i18next (open-source, MIT) rather than a custom solution.
|
||||
- **Rationale:** Proven library with JSON locale files, pluralization, interpolation, reactive Vue integration.
|
||||
|
||||
### Database: Official SQLite WASM + OPFS
|
||||
|
||||
- **Context:** Spec requires SQLite with OPFS persistence, local-first.
|
||||
- **Decision:** Official SQLite WASM build with OPFS VFS.
|
||||
- **Rationale:** Best maintained, native OPFS support, Apache-licensed.
|
||||
|
||||
### App version strategy
|
||||
|
||||
- **Context:** Need version at build time (OPFS namespacing, SW) and runtime (export metadata).
|
||||
- **Decision:** Source of truth in `package.json`, injected by Vite `define`, stored in DB `settings` on init.
|
||||
|
||||
### Single user per device
|
||||
|
||||
- **Context:** Spec says one user per device.
|
||||
- **Decision:** No `users` table. UUID + name stored in `settings` key/value table.
|
||||
|
||||
### Charts: Chart.js via vue-chartjs
|
||||
|
||||
- **Context:** Statistics page needs progression, volume, frequency charts.
|
||||
- **Decision:** Chart.js — good balance of features (~60KB gzipped) and ease of use.
|
||||
|
||||
### Routing: Hash mode
|
||||
|
||||
- **Context:** App deployed as static files, no server-side routing control.
|
||||
- **Decision:** Vue Router with `createWebHashHistory()`.
|
||||
- **Rationale:** Hash routes (`#/home`) work without server configuration.
|
||||
|
||||
### ID collision on import: UUID prefix
|
||||
|
||||
- **Context:** Users exchange data files; IDs may collide.
|
||||
- **Decision:** When imported entity ID exists and belongs to a different user (by UUID), prefix imported ID with source user's short UUID (first 8 chars).
|
||||
|
||||
---
|
||||
|
||||
## 2026-03-13 — Step 1: Database Layer
|
||||
|
||||
### OPFS VFS: SAH Pool over standard OPFS
|
||||
|
||||
- **Context:** Two OPFS VFS options available: standard `opfs` VFS (requires sub-worker + SharedArrayBuffer) and `opfs-sahpool` (SyncAccessHandle pool).
|
||||
- **Decision:** Use `opfs-sahpool` VFS.
|
||||
- **Rationale:** Highest performance, works on all major browsers since March 2023, no COOP/COEP requirement (though we have them), simpler architecture (no sub-worker). Trade-off: no concurrent connections, which is fine for a single-user PWA.
|
||||
|
||||
### Worker-based architecture
|
||||
|
||||
- **Context:** OPFS APIs are only available in Web Worker threads.
|
||||
- **Decision:** Run SQLite in a dedicated Web Worker (`src/db/worker.js`), communicate from main thread via `postMessage()` with Promise-based wrappers.
|
||||
- **Rationale:** Clean separation of concerns, non-blocking main thread, required by OPFS.
|
||||
|
||||
### Table naming: `collection` instead of `group`
|
||||
|
||||
- **Context:** `GROUP` is a SQL reserved keyword. Originally named `grp`, but that was confusing.
|
||||
- **Decision:** Rename `grp` → `collection` and `group_session` → `collection_session`.
|
||||
- **Rationale:** `collection` clearly describes a set of sessions without SQL keyword conflicts.
|
||||
|
||||
### WASM file serving strategy
|
||||
|
||||
- **Context:** The `sqlite3.wasm` binary must be accessible at a known URL for the worker to load it.
|
||||
- **Decision:** Copy to `public/` via a Vite plugin (`copySqliteWasm`), serve at `/sqlite3.wasm`. Added to `.gitignore`.
|
||||
- **Rationale:** Works identically in dev and production. No complex asset pipeline needed.
|
||||
|
||||
### Repository + composable pattern
|
||||
|
||||
- **Context:** Need CRUD access from Vue components through an async worker-based DB.
|
||||
- **Decision:** Two layers — repositories (pure async CRUD) and composables (reactive `ref` wrappers).
|
||||
- **Rationale:** Repositories are reusable outside Vue (e.g., import/export). Composables provide reactive state for components. Clean separation per SOLID.
|
||||
|
||||
### No in-memory fallback — block app if OPFS unavailable
|
||||
|
||||
- **Context:** Originally the worker fell back to `:memory:` if OPFS was unavailable, meaning data would be silently lost on browser close.
|
||||
- **Decision:** Remove the fallback. If OPFS init fails, propagate the error and display a full-screen error message asking the user to update their browser or exit private browsing.
|
||||
- **Rationale:** Silent data loss is worse than a clear error. OPFS is supported on all major browsers since March 2023 (Firefox 111, Chrome 102, Safari 16.4). The only realistic failure cases are private browsing mode or very outdated browsers.
|
||||
|
||||
### Loading screen during DB initialization
|
||||
|
||||
- **Context:** Database initialization is async and may take a moment.
|
||||
- **Decision:** Block the app with a spinner overlay until the DB is ready. Three states: loading → ready → error.
|
||||
- **Rationale:** Prevents users from interacting with the app before data is available.
|
||||
|
||||
### Rename `asymmetric_mode` to `alternate`
|
||||
|
||||
- **Context:** The `asymmetric_mode` TEXT field was vague. The intent is a boolean: should the exercise alternate sides at each repetition?
|
||||
- **Decision:** Replace `asymmetric_mode TEXT` with `alternate INTEGER NOT NULL DEFAULT 0` (boolean, 0/1).
|
||||
- **Rationale:** Simpler, clearer semantics. Boolean stored as INTEGER per SQLite convention.
|
||||
|
||||
### COMBO type: added NONE
|
||||
|
||||
- **Context:** Combos can be SUPERSET, CIRCUIT, or EMOM, but standalone exercises (not grouped) also appear as session items.
|
||||
- **Decision:** Add `'NONE'` as a valid combo type.
|
||||
- **Rationale:** Allows a "combo" of a single exercise, simplifying session item management — every item is a combo reference.
|
||||
|
||||
### Session item repetitions
|
||||
|
||||
- **Context:** A session may include the same combo/exercise multiple times (e.g., 3 rounds of a circuit).
|
||||
- **Decision:** Add `repetitions INTEGER NOT NULL DEFAULT 1` to `session_item`.
|
||||
- **Rationale:** Avoids duplicating rows for repeated items. Keeps data normalized.
|
||||
|
||||
### Home page: activity summary instead of collection list
|
||||
|
||||
- **Context:** Originally the Home page was the collection list.
|
||||
- **Decision:** Home shows last 3 executed sessions and session counts for the last 7/30 days.
|
||||
- **Rationale:** More useful landing page — quick overview of recent activity.
|
||||
|
||||
### Collections get their own page
|
||||
|
||||
- **Context:** Collections were displayed on the Home page.
|
||||
- **Decision:** Move collections to a dedicated route (`#/collections`) with its own nav link.
|
||||
- **Rationale:** Cleaner information architecture. Home focuses on activity summary.
|
||||
|
||||
---
|
||||
|
||||
## 2026-03-15 — Step 2: Settings
|
||||
|
||||
### Settings page: v-model for select elements
|
||||
|
||||
- **Context:** Using `:value` binding on `<select>` elements did not properly update the selected option when the reactive value changed.
|
||||
- **Decision:** Switch to `v-model` for all form inputs (select, text input).
|
||||
- **Rationale:** Vue 3's `v-model` handles two-way binding on native form elements correctly. `:value` alone doesn't reliably drive `<select>` option selection.
|
||||
|
||||
### App shell: guard content behind DB readiness
|
||||
|
||||
- **Context:** The `<div class="content">` section (containing `<router-view>`) was outside the `v-if/v-else` chain in `App.vue`, so views mounted before the DB was ready. This caused `onMounted` hooks in views to fail when calling DB queries.
|
||||
- **Decision:** Wrap both `<nav>` and `<div class="content">` inside a `<template v-else>` block, so the entire app shell only renders after DB initialization completes.
|
||||
- **Rationale:** Prevents race conditions where components query the DB before it's ready. The loading overlay already covers the screen during initialization.
|
||||
|
||||
### Settings startup restoration in main.js
|
||||
|
||||
- **Context:** Language and theme settings must be applied before the first render to avoid a flash of default values.
|
||||
- **Decision:** Read `language` and `theme` from the DB during the `db.init()` promise chain, before setting `data-db-ready`. Apply `i18next.changeLanguage()` and `data-theme` attribute before the Vue app renders.
|
||||
- **Rationale:** Eliminates visual flash of English text or light theme when the user has a different preference. Settings are available synchronously when components mount.
|
||||
|
||||
### Code formatting: Prettier
|
||||
|
||||
- **Context:** No code formatter was configured. Inconsistent formatting across files.
|
||||
- **Decision:** Add Prettier with `semi: false`, `singleQuote: true`, `trailingComma: "all"`, `printWidth: 100`.
|
||||
- **Rationale:** Enforces consistent formatting automatically. No semicolons and single quotes align with Vue ecosystem conventions. `format:check` script can be used in CI.
|
||||
|
||||
### Export/Import: disabled placeholder
|
||||
|
||||
- **Context:** Settings page needed Export/Import buttons, but the feature is planned for Step 7.
|
||||
- **Decision:** Show disabled buttons with a "coming soon" message.
|
||||
- **Rationale:** Establishes the UI structure early without implementing the feature prematurely.
|
||||
|
||||
---
|
||||
|
||||
## 2026-03-25 — Step 3b Planning: Exercise JSON Export/Import
|
||||
|
||||
### New step: Step 3b — Exercise JSON Export/Import
|
||||
|
||||
- **Context:** Exercise management (Step 3) would benefit from immediate export/import capability. Waiting until Step 7 (full data export) delays useful functionality. Exercises have no foreign key dependencies, making them the simplest entity to start with.
|
||||
- **Decision:** Add Step 3b after Step 3 to build the JSON export/import infrastructure on exercises first. The full metadata envelope format is implemented now (`appName`, `appVersion`, `schemaVersion`, `exportDate`, `exportedBy`). Step 7 then extends this to all entities.
|
||||
- **Rationale:** Incremental validation of the export/import pattern on the simplest entity. Infrastructure is reusable in Step 7 without rewriting.
|
||||
|
||||
### ID collision strategy: suffix on title instead of UUID prefix on ID
|
||||
|
||||
- **Context:** Original plan (Step 7) prefixed the internal ID with the source user's 8-char UUID on collision. This breaks alphabetical ordering of entity titles and is not visible to the user in the UI.
|
||||
- **Decision:** On collision with a different user (`created_by` differs), generate a fresh UUID and rename the title to `"Original Title from SUFFIX"`. The suffix is prompted to the user, pre-filled with the exporter's `name` from JSON metadata. Same-user collisions offer Replace/Skip instead.
|
||||
- **Rationale:** User-visible naming, preserves alphabetical sort order, flexible suffix, clear data provenance. Distinguishing same-user (Replace/Skip) from different-user (suffix) avoids unnecessary prompts for re-imports.
|
||||
|
||||
### Export/Import UI: three locations
|
||||
|
||||
- **Context:** Need to decide where export/import controls are placed in the UI.
|
||||
- **Decision:** Wire buttons into three locations: exercise list view (bulk), exercise detail view (single export), and Settings page (with entity filter dialog — only "Exercises" active in Step 3b, others disabled with "Coming soon").
|
||||
- **Rationale:** List view covers bulk operations, detail view enables sharing a single exercise, Settings page provides a centralized data management hub that scales to all entities in Step 7.
|
||||
|
||||
### Download Database (raw SQLite file)
|
||||
|
||||
- **Context:** Users need a way to back up their data as a raw database file.
|
||||
- **Decision:** Add a "Download Database" button that exports the SQLite database using `sqlite3_js_db_export()` and triggers a browser download of the `.sqlite3` file.
|
||||
- **Rationale:** Simple, reliable backup mechanism. The exported file is a standard SQLite database that can be restored or inspected with any SQLite tool.
|
||||
|
||||
### Restore Database: `sqlite3_deserialize` over `sqlite3_js_db_import`
|
||||
|
||||
- **Context:** Need to import a `.sqlite3` file back into the running OPFS database. The obvious choice `sqlite3_js_db_import` does not exist in sqlite-wasm v3.51.2-build7.
|
||||
- **Decision:** Use `sqlite3.capi.sqlite3_deserialize()` to load the uploaded file into a temporary `:memory:` DB, then validate and copy table by table into the production DB.
|
||||
- **Rationale:** `sqlite3_deserialize` is available through the CAPI and works reliably. Loading into `:memory:` first allows full validation before touching production data.
|
||||
- **Implementation notes:** Requires manual WASM heap allocation: `wasm.alloc(n)` + `wasm.heap8u().set(bytes, ptr)` + deserialize with `FREEONCLOSE | RESIZEABLE` flags.
|
||||
|
||||
### Restore Database: validation pipeline with version compatibility
|
||||
|
||||
- **Context:** Restoring arbitrary files into the production DB is risky — the file could be corrupted, incomplete, or from an incompatible schema version.
|
||||
- **Decision:** Multi-step validation pipeline: (1) deserialize into `:memory:`, (2) check required tables, (3) compare schema versions, (4) run compatibility check with potential adapt scripts, (5) table-by-table copy inside a transaction.
|
||||
- **Rationale:** Each step catches a specific failure mode with a clear error code. The transaction ensures atomicity — if any table fails to copy, the entire operation is rolled back.
|
||||
|
||||
### Release registry (`src/db/releases.js`)
|
||||
|
||||
- **Context:** Need to track schema compatibility between database versions for the restore flow.
|
||||
- **Decision:** A `RELEASES` array where each entry declares `schemaVersion`, `appVersion`, `dbBreak` (boolean), and `adaptScript` (SQL or null). A `checkCompatibility()` function walks the release history to determine if migration is possible.
|
||||
- **Rationale:** Single source of truth for version compatibility. The `dbBreak` flag provides an explicit signal for breaking schema changes. Adapt scripts allow incremental migration without rejecting slightly older databases.
|
||||
|
||||
### Restore UI: in-place update instead of page reload
|
||||
|
||||
- **Context:** After a successful database restore, the UI needs to reflect the restored data (e.g., username, language, theme).
|
||||
- **Decision:** After restore, re-fetch settings from the DB and apply language/theme changes in-place. No `window.location.reload()`.
|
||||
- **Rationale:** Page reload would lose the restore report. In-place update provides better UX — the user sees the report and the restored settings simultaneously.
|
||||
|
||||
### Restore report with translated error codes
|
||||
|
||||
- **Context:** The restore flow can fail in multiple ways (invalid file, incomplete DB, incompatible version, etc.).
|
||||
- **Decision:** Return a structured report object from the worker and display it in a dedicated card with translated error codes.
|
||||
- **Rationale:** Specific error messages help users understand what went wrong. Translation keys follow the pattern `settings.restoreErrorCodes.{CODE}` for easy i18n.
|
||||
|
||||
---
|
||||
|
||||
## 2026-03-27 — Step 2b: Schema Hardening & Documentation Restructuring
|
||||
|
||||
### UNIQUE constraints on entity titles
|
||||
|
||||
- **Context:** Entity tables (`exercise`, `combo`, `session`, `collection`) allowed duplicate titles, which would cause confusion in lists and during import/export collision resolution.
|
||||
- **Decision:** Add `UNIQUE` constraint on `exercise.title`, `combo.title`, `session.title`, and `collection.label` directly in the schema (no version bump — app not yet released).
|
||||
- **Rationale:** Enforces data integrity at the database level. Prevents the need for application-level deduplication logic. Since the app hasn't been released yet, this is a safe in-place change.
|
||||
|
||||
### Suffix table for import disambiguation
|
||||
|
||||
- **Context:** When importing data from another user, title collisions need resolution. The chosen strategy appends a suffix to imported titles (e.g., `"Push-ups [JD]"`). Need to persist the mapping between external user UUIDs and their assigned suffixes.
|
||||
- **Decision:** Add a `suffix` table: `user_uuid TEXT PRIMARY KEY, user_name TEXT NOT NULL, suffix TEXT NOT NULL UNIQUE`. This is part of the v1 schema (no version bump).
|
||||
- **Rationale:** Persisting suffixes ensures consistency across multiple imports from the same user. The UNIQUE constraint on `suffix` prevents two users from having the same tag. The table also serves as a registry of known external users.
|
||||
|
||||
### Documentation file renaming
|
||||
|
||||
- **Context:** `docs/technical-overview.md` and `docs/user-guide.md` had names that didn't match the conventions described in `AGENTS.md` (`technical-documentation.md`, `user-documentation.md`).
|
||||
- **Decision:** Rename the files to match the canonical names and update all references across the project (README.md, AGENTS.md, .opencode/ configs, commands).
|
||||
- **Rationale:** Consistent naming reduces confusion when the project conventions reference `technical-documentation.md` but the actual file is `technical-overview.md`.
|
||||
|
||||
### README documentation section with bilingual tutorial links
|
||||
|
||||
- **Context:** The README had a minimal documentation section with only 4 links and no reference to tutorials or codelabs.
|
||||
- **Decision:** Restructure the section into "Project" (all doc files with descriptions) and "Tutorials" (table with en/fr links for each step).
|
||||
- **Rationale:** Makes all documentation discoverable from the README. Bilingual links using `(en / fr)` format are concise and match the project's dual-language approach.
|
||||
|
||||
---
|
||||
|
||||
## 2026-03-28 — Step 3: Exercise Management (Edit Mode)
|
||||
|
||||
### Action bar buttons via Vue Teleport with `defer`
|
||||
|
||||
- **Context:** The action bar is defined in `App.vue` but individual views need to inject their own buttons (New, Search, Edit, Delete, Back).
|
||||
- **Decision:** Add a `<div id="actionbar-actions">` target in `App.vue`. Views use `<Teleport to="#actionbar-actions" defer>` to inject buttons.
|
||||
- **Rationale:** Keeps the action bar shell in `App.vue` while letting each view own its buttons. The `defer` attribute (Vue 3.5+) is critical — it resolves the Teleport target in the next render cycle, avoiding the race condition where the target doesn't exist yet when the component mounts within a `v-else` block.
|
||||
|
||||
### Validation utility module
|
||||
|
||||
- **Context:** Exercise form needs field validation (required, min value, URL format). Future entity forms will need the same validators.
|
||||
- **Decision:** Create `src/utils/validation.js` with reusable validation functions (`validateRequired`, `validateMin`, `validateUrl`) that return i18n error keys.
|
||||
- **Rationale:** DRY approach — validators are decoupled from components and return translation keys instead of hardcoded strings, making them language-independent. Reusable across all future entity forms.
|
||||
|
||||
### Three exercise views instead of one
|
||||
|
||||
- **Context:** Exercises need list, create/edit, and detail views. Could use a single view with conditional rendering or multiple dedicated views.
|
||||
- **Decision:** Three separate views: `ExerciseListView.vue` (list + search), `ExerciseFormView.vue` (create/edit, distinguished by route name), `ExerciseDetailView.vue` (read-only + media).
|
||||
- **Rationale:** Single Responsibility Principle. Each view has one purpose. The form view handles both create and edit via `route.name` check, reducing duplication while keeping concerns separate.
|
||||
|
||||
### Search with debounced reactive filtering
|
||||
|
||||
- **Context:** Exercise search needs to filter the list as the user types.
|
||||
- **Decision:** Use a `watch` on the search query with a 250ms `setTimeout` debounce, calling the repository's `search()` method (server-side SQL `LIKE` filter).
|
||||
- **Rationale:** Debouncing prevents excessive database queries on every keystroke. The SQL `LIKE` query is performed in the worker thread, keeping the main thread responsive. Clearing the search input triggers `fetchAll()` to restore the full list.
|
||||
|
||||
### YouTube video embedding with privacy-enhanced mode
|
||||
|
||||
- **Context:** Exercise detail view needs to display videos. YouTube URLs are the most common video format users will paste.
|
||||
- **Decision:** Extract YouTube video IDs from various URL formats (`youtube.com/watch?v=`, `youtu.be/`) and embed using `youtube-nocookie.com` (privacy-enhanced mode). Non-YouTube URLs get a link fallback.
|
||||
- **Rationale:** Privacy-enhanced mode avoids third-party cookies. The fallback link ensures any video URL is accessible even if it's not YouTube.
|
||||
|
||||
---
|
||||
|
||||
## 2026-03-30 — Step 3b: Exercise JSON Export/Import
|
||||
|
||||
### JSON envelope format for export/import
|
||||
|
||||
- **Context:** Need a structured format for exporting and importing exercise data that can be extended to all entity types.
|
||||
- **Decision:** Extensible metadata+data envelope: `{ metadata: { appName, appVersion, schemaVersion, exportDate, exportedBy }, data: { exercises: [...] } }`. Designed to be extended for all entity types in Step 7.
|
||||
- **Rationale:** Clean separation of metadata and payload. The envelope carries provenance information (who exported, when, which version) enabling validation and collision detection on import.
|
||||
|
||||
### Reusable ModalDialog component
|
||||
|
||||
- **Context:** Export/import flows require multiple dialogs (import confirmation, export filter, collision resolution).
|
||||
- **Decision:** Created first reusable Vue component (`src/components/ModalDialog.vue`) for all dialogs: import, export filter, collision resolution. Consistent pattern for future dialogs.
|
||||
- **Rationale:** DRY approach — a single modal component with slots avoids duplicating dialog boilerplate across views. Establishes a consistent UX pattern for all future dialogs.
|
||||
|
||||
### Import collision strategy implementation
|
||||
|
||||
- **Context:** When importing exercises, ID or title collisions may occur between the local database and the imported data.
|
||||
- **Decision:** Same-user: Replace/Skip per item with batch Replace All/Skip All. Different-user: suffix-based title disambiguation with `[SUFFIX]` pattern, new UUID on ID collision, update-in-place for same external user same ID.
|
||||
- **Rationale:** Distinguishing same-user from different-user collisions provides the right level of control. Same-user imports are likely re-imports (Replace/Skip is sufficient). Different-user imports need disambiguation to preserve both versions.
|
||||
|
||||
### Suffix rename cascade
|
||||
|
||||
- **Context:** When a user renames a suffix assigned to an external user, all titles containing the old suffix must be updated consistently across all entity types.
|
||||
- **Decision:** Renaming a suffix uses SQL `REPLACE()` across exercise, combo, session, and collection tables in a single operation, keeping titles consistent.
|
||||
- **Rationale:** A single SQL operation per table ensures atomicity and consistency. Using `REPLACE()` avoids loading all entities into memory for string manipulation.
|
||||
|
||||
---
|
||||
|
||||
## 2026-04-21 — Database Creation Date & Centralized Date Formatting
|
||||
|
||||
### `db_created_at` setting for backup reminder fallback and stats display
|
||||
|
||||
- **Context:** The backup reminder (Step 3c) showed immediately on fresh databases because `last_backup_at` was absent, treating "never backed up" as stale. This was jarring for new users who just installed the app. Additionally, the Statistics page needed a way to show when the user started using TrainUs.
|
||||
- **Decision:** Store `db_created_at` (ISO-8601 UTC timestamp) on first launch in the `settings` table. The backup reminder uses `db_created_at` as a fallback when `last_backup_at` is absent, giving new users a grace period equal to the reminder interval (default 7 days). The Statistics page displays "You are using TrainUs since [date]" using this value.
|
||||
- **Rationale:** Provides a sensible grace period for new users while still reminding them to back up after a reasonable time. The creation date is also useful information for the user to see in Statistics.
|
||||
|
||||
### Centralized `dateFormatter.js` utility
|
||||
|
||||
- **Context:** Date formatting was scattered across components (`SettingsView`, `ImportDialog`, `backupFilename.js`) with inconsistent approaches — manual padding, `toLocaleString()`, `toLocaleDateString()`, and ISO string slicing. Adding date formatting to StatsView would have added yet another implementation.
|
||||
- **Decision:** Create `src/utils/dateFormatter.js` with two pure functions: `formatDate(date, lang?)` for date-only and `formatDateTime(date, lang?)` for date + time. Both accept an optional `lang` parameter (falling back to `i18next.language`), returning `DD/MM/YYYY` (French) or `MM/DD/YYYY` (English). Components pass the reactive language ref as the second argument to ensure date formatting updates immediately on language switch.
|
||||
- **Rationale:** DRY — single source of truth for date formatting. Consistent behavior across all components. The optional `lang` parameter enables reactive updates without requiring the utility to depend on i18next directly (it's a fallback, not a requirement).
|
||||
|
||||
---
|
||||
|
||||
## 2026-04-20 — Step 3c: Backup Filename & Reminder
|
||||
|
||||
### Backup filename: versioned timestamp pattern
|
||||
|
||||
- **Context:** Manual database downloads used a static filename (`trainus-{version}.sqlite3`), making it impossible to distinguish between backups or sort them chronologically.
|
||||
- **Decision:** Rename to `trainus-{version}-backup-YYYYMMDD-HHhMMmSSs.sqlite3` using local time. Letters `h`, `m`, `s` replace colons for filesystem compatibility (Windows does not allow colons in filenames).
|
||||
- **Rationale:** Sortable, version-stamped, and filesystem-safe. Local time is more intuitive for users than UTC in filenames.
|
||||
|
||||
### Backup reminder: persistent warning button
|
||||
|
||||
- **Context:** Users may forget to back up their data, especially after making significant changes.
|
||||
- **Decision:** Add a non-intrusive warning button in the action bar that appears when no backup exists or the last backup is older than a configurable threshold (default 7 days). The button is not dismissible — it persists until a backup is made or the threshold is adjusted.
|
||||
- **Rationale:** A persistent reminder ensures the user is never left without a visible prompt to back up. Removing the dismiss option avoids the risk of users permanently ignoring the reminder.
|
||||
|
||||
### No automatic backups
|
||||
|
||||
- **Context:** Could implement scheduled or event-driven automatic backups stored in OPFS.
|
||||
- **Decision:** Keep backups manual-only. The Download / Restore buttons from Step 2 remain the only backup mechanism.
|
||||
- **Rationale:** Avoids surprise downloads, OPFS quota concerns, and complexity. Users control when and where backups are saved (browser Downloads folder).
|
||||
|
||||
### "Click = success" assumption for download tracking
|
||||
|
||||
- **Context:** Browsers do not expose a reliable "download completed" event for anchor-based downloads.
|
||||
- **Decision:** Treat `a.click()` as success and set `last_backup_at` immediately after. If the export throws before `a.click()`, the timestamp is not updated.
|
||||
- **Rationale:** Pragmatic approach. The download is triggered synchronously, and the user's save action is the best available signal of success. Logged as a known limitation.
|
||||
|
||||
### Shared state in useBackupStatus composable
|
||||
|
||||
- **Context:** The backup reminder needs to be visible across all pages, mounted once in `App.vue`.
|
||||
- **Decision:** Use module-level `ref` variables for `lastBackupAt`, `backupReminderDays`, and `dismissed` state, shared across all calls to `useBackupStatus()`.
|
||||
- **Rationale:** Ensures a single source of truth for backup status across the entire app. The composable provides reactive state that updates consistently regardless of which component calls it.
|
||||
|
||||
---
|
||||
|
||||
## 2026-05-01 — Step 4: Combo Management
|
||||
|
||||
### Combo type NONE for plain supersets
|
||||
|
||||
- **Context:** Combos can be EMOM, AMRAP, or TABATA, but users also need plain grouped sets (supersets) without a timed format.
|
||||
- **Decision:** Add `'NONE'` as the default combo type. Combos with type NONE behave as plain ordered exercise groups.
|
||||
- **Rationale:** Avoids forcing users to choose a timed format when they just want to group exercises. A single neutral type keeps the data model consistent.
|
||||
|
||||
### Per-combo exercise reps and weight stored in junction table
|
||||
|
||||
- **Context:** Each exercise in a combo may need different reps and weight (e.g., 12 push-ups followed by 8 squats at 50 kg).
|
||||
- **Decision:** Store `default_reps` and `default_weight` on the `combo_exercise` junction row, not on the exercise itself.
|
||||
- **Rationale:** The junction table is the natural place for combo-specific parameters. Exercise defaults remain unchanged and are used as fallback when no combo context exists.
|
||||
|
||||
### `setExercises()` replace-all strategy
|
||||
|
||||
- **Context:** Updating exercises in a combo requires handling reordering, additions, and removals simultaneously.
|
||||
- **Decision:** `comboRepository.setExercises()` deletes all existing `combo_exercise` rows for the combo and re-inserts the provided array in order.
|
||||
- **Rationale:** Simpler than diffing the old and new sets. The operation is wrapped in a single transaction, so it's atomic and fast for the small number of exercises a combo typically contains.
|
||||
|
||||
---
|
||||
|
||||
## 2026-05-11 — Step 7: Full Import / Export
|
||||
|
||||
### Extend JSON envelope to all entity types
|
||||
|
||||
- **Context:** Step 3b built the export/import infrastructure for exercises only. Step 7 extends it to combos, sessions, collections, execution logs, and settings.
|
||||
- **Decision:** Add `combos`, `sessions`, `collections`, `executionLogs`, and `settings` arrays to the `data` section of the JSON envelope. The format is backward-compatible — single-entity exercise files continue to work with the legacy flow.
|
||||
- **Rationale:** One envelope format for all entities. Consumers only need to handle the keys that are present.
|
||||
|
||||
### Cross-entity reference remapping on import
|
||||
|
||||
- **Context:** When an entity's ID changes during import (new UUID generated due to collision), all entities that reference it must also be updated.
|
||||
- **Decision:** After resolving all ID mappings, update `combo_exercise.exercise_id`, `session_item.item_id`, `collection_session.session_id`, `execution_log.session_id`, and `execution_log_item.exercise_id` in a single pass before committing.
|
||||
- **Rationale:** Without remapping, importing an exercise that gets a new ID would break any combos or sessions that reference it by the original ID.
|
||||
|
||||
### Settings import with identity exclusions
|
||||
|
||||
- **Context:** Importing settings from another user's export should not overwrite the local user's identity.
|
||||
- **Decision:** Always skip `user_uuid`, `app_version`, `db_created_at`, and `last_backup_at` during settings import. Import `user_name`, `language`, `theme`, and `backup_reminder_days`.
|
||||
- **Rationale:** UUID and creation date identify the local database. Overwriting them would corrupt provenance tracking for future exports.
|
||||
|
||||
### Execution log import: session-dependent
|
||||
|
||||
- **Context:** Execution logs reference sessions by ID. If the session doesn't exist in the target database, the log is meaningless.
|
||||
- **Decision:** Execution logs are imported only if the referenced session exists locally or was imported in the same batch.
|
||||
- **Rationale:** Orphaned execution logs would appear in the history without a matching session, breaking the UI. Silently skipping them is preferable to importing garbage data.
|
||||
|
||||
### Generalized suffix strategy across all entity types
|
||||
|
||||
- **Context:** The suffix system (Step 3b) was designed for exercises. With full export/import, the same collision strategy must apply to combos, sessions, and collections.
|
||||
- **Decision:** Apply the same `[SUFFIX]` disambiguation to all entity types that have a title/label field. The suffix is prompted once per external user and reused across all entity types from that user in the same import.
|
||||
- **Rationale:** A consistent UX regardless of which entity types are in the imported file. One suffix prompt per user keeps the dialog simple.
|
||||
|
||||
---
|
||||
|
||||
## 2026-05-05 — Step 4b: Combo Exercise Reps & Weight
|
||||
|
||||
### Per-exercise reps and weight in combos
|
||||
|
||||
- **Context:** Combos allow grouping exercises, but all exercises in a combo shared the same default reps and weight from the exercise definition. Users need different reps/weight for each exercise within a combo (e.g., 12 reps of push-ups followed by 8 reps of squats with 50kg).
|
||||
- **Decision:** Add `default_reps` (INTEGER, default 1) and `default_weight` (REAL, default 0.0) columns to the `combo_exercise` junction table. These override the exercise's own defaults when the combo is used. No schema version bump since the app has not been released yet.
|
||||
- **Rationale:** Allows flexible per-exercise configuration within combos while keeping the exercise definitions unchanged. The junction table is the natural place for combo-specific exercise parameters.
|
||||
|
||||
### Renamed "series" to "reps" in combo context
|
||||
|
||||
- **Context:** The original plan used "series" (sets) for the count of exercise repetitions within a combo. This was potentially confusing since "series" has a specific meaning in fitness (a group of repetitions), and the exercise table already uses "reps" for repetitions.
|
||||
- **Decision:** Rename `default_series` to `default_reps` throughout the combo feature. The field represents the number of repetitions for each exercise within the combo, consistent with the exercise table's terminology.
|
||||
- **Rationale:** Consistency with existing terminology. "Reps" is more universally understood in fitness contexts and aligns with the exercise table's `default_reps` field.
|
||||
|
||||
---
|
||||
|
||||
## 2026-05-11 — Initialize Database Feature
|
||||
|
||||
### Initialize DB: delete all data and reinitialize defaults
|
||||
|
||||
- **Context:** Users need a way to completely reset the database to its initial state without reinstalling the app or manually clearing browser storage.
|
||||
- **Decision:** Add an "Initialize Database" button in Settings that deletes all data from all tables and reinitializes default settings (UUID, user name, language, theme). A confirmation dialog with a mandatory checkbox ensures the user understands the action is irreversible. After confirmation, the page reloads to reflect the fresh state.
|
||||
- **Rationale:** Simpler than deleting the OPFS directory and reinitializing the worker. Deleting table contents preserves the database connection and schema, then `_initDefaults()` recreates the essential settings. The checkbox confirmation prevents accidental data loss.
|
||||
|
||||
### Initialize DB: table deletion order respects foreign keys
|
||||
|
||||
- **Context:** Tables have foreign key relationships that must be respected when deleting data.
|
||||
- **Decision:** Delete tables in dependency order: child tables first (`execution_log_item`, `execution_log`, `collection_session`, `session_item`, `combo_exercise`), then parent tables (`suffix`, `collection`, `session`, `combo`, `exercise`), and finally `settings`. Foreign keys are temporarily disabled during the operation.
|
||||
|
||||
---
|
||||
|
||||
## 2026-05-11 — Step 6: Collection Management & Home Page
|
||||
|
||||
### Collection form: session selector with ordered list
|
||||
|
||||
- **Context:** Collections need to group sessions in a specific order. Users need to add, remove, and reorder sessions.
|
||||
- **Decision:** CollectionFormView uses a dropdown to select sessions, an "Add Session" button, and an ordered list with move up/down/remove controls. The same session cannot be added twice to a collection.
|
||||
- **Rationale:** Mirrors the pattern from SessionFormView (exercise/combo selector) and ComboFormView (exercise selector), maintaining consistency across the app. Preventing duplicate sessions avoids confusion in execution order.
|
||||
|
||||
### Collection list: session count badge
|
||||
|
||||
- **Context:** Users should see at a glance how many sessions a collection contains.
|
||||
- **Decision:** `collectionRepository.getAll()` performs a COUNT query on `collection_session` for each collection, returning a `sessionCount` field displayed as a badge on the card.
|
||||
- **Rationale:** A lightweight COUNT query is more efficient than loading all session data for the list view. The badge provides useful context without requiring the user to open the detail view.
|
||||
|
||||
### Home page: stat cards + recent sessions list
|
||||
|
||||
- **Context:** The Home page should provide a quick overview of training activity.
|
||||
- **Decision:** Two stat cards (sessions in last 7 days, sessions in last 30 days) and a list of the 3 most recent execution logs with session titles and dates.
|
||||
- **Rationale:** Stat cards give at-a-glance metrics. Recent sessions provide quick access to re-execute or review. The 3-session limit keeps the page concise while still showing meaningful history.
|
||||
|
||||
### Home page: execution logs as the data source
|
||||
|
||||
- **Context:** Need to determine which sessions have been "executed" for the Home page.
|
||||
- **Decision:** Use the `execution_log` table as the source of truth. `getRecent(3)` returns the 3 most recent logs with session titles via JOIN. `getCountSince(isoDate)` counts logs since a given date.
|
||||
- **Rationale:** Execution logs are already created when sessions are executed (Step 8). The Home page reads from existing data without requiring new tables or fields.
|
||||
|
||||
---
|
||||
|
||||
## 2026-05-11 — Step 5: Session Management
|
||||
|
||||
### Session items: weight column added to session_item
|
||||
|
||||
- **Context:** Sessions need per-item weight configuration, similar to how combos have per-exercise reps and weight. The `session_item` table already had `repetitions` but no weight field.
|
||||
- **Decision:** Add `weight REAL NOT NULL DEFAULT 0.0` to `session_item` table. No schema version bump since the app has not been released yet.
|
||||
- **Rationale:** Allows users to specify the weight for each exercise or combo within a session, overriding the exercise's default weight. Consistent with the combo exercise pattern.
|
||||
|
||||
## 2026-05-26 — Step 8: Execution Mode
|
||||
|
||||
### Flat queue model for session execution
|
||||
|
||||
- **Context:** A session can contain exercises and combos. Combos can have multiple rounds (`repetitions` on the session item). Execution needs to step through each item in order, expanding combos into individual exercise steps.
|
||||
- **Decision:** Build a flat array of step objects at execution start. A combo with N rounds and M exercises expands into N×M steps. The composable manages a `currentIndex` cursor.
|
||||
- **Rationale:** A flat queue is simple to iterate, easy to inspect for "next step" preview, and straightforward to splice when adding extra rounds. Alternatives (tree structure, recursive state machine) added complexity without benefit.
|
||||
|
||||
### Final-round screen only at the last round
|
||||
|
||||
- **Context:** Combos have multiple rounds. Between rounds the experience should feel continuous; only after the last exercise of the last round should the user see options.
|
||||
- **Decision:** Each step carries `isFinalComboStep: boolean`. Only when this flag is true does pressing Done show the final-round screen instead of auto-advancing.
|
||||
- **Rationale:** Auto-advancing between non-final rounds reduces interruptions. The final-round pause allows the user to decide to continue, add another round, or exit.
|
||||
|
||||
### Prefill from most recent execution log
|
||||
|
||||
- **Context:** Users benefit from seeing their previous reps/weight when re-running a session.
|
||||
- **Decision:** On execution start, query the most recent `execution_log` for the session and build a map `exerciseId → {reps_done, weight_used, side}`. Steps prefill from this map, falling back to session item defaults.
|
||||
- **Rationale:** Per-session history is more accurate than per-exercise history — a session may use the same exercise at different weights in different sessions.
|
||||
|
||||
### Schema: combo_id and round_number added to execution_log_item (in-place)
|
||||
|
||||
- **Context:** Statistics (Step 9) need to distinguish exercise log entries that came from combos vs. standalone, and to know which round they came from.
|
||||
- **Decision:** Add nullable `combo_id TEXT` and `round_number INTEGER` columns to `execution_log_item` directly in SCHEMA_SQL. No version bump — the app has not been released.
|
||||
- **Rationale:** In-place schema changes are acceptable before any release. Schema version bumps (and the migration system they require) are reserved for changes made after a version has been deployed to users.
|
||||
|
||||
### Session items: polymorphic JOIN for titles
|
||||
|
||||
- **Context:** `session_item` references either an exercise or a combo via `item_id` + `item_type`. Getting item titles requires joining with the correct table.
|
||||
- **Decision:** `getItems()` in `sessionRepository` executes two separate queries (one joining with `exercise`, one joining with `combo`) and merges the results sorted by position.
|
||||
- **Rationale:** SQLite doesn't support conditional JOINs on different tables in a single query. Two queries are simple, efficient, and easy to maintain.
|
||||
- **Rationale:** Prevents foreign key constraint violations. Disabling foreign keys during the bulk delete is safe since all tables are being cleared atomically.
|
||||
|
||||
### Markdown rendering in description fields
|
||||
|
||||
- **Date:** 2026-06-02
|
||||
- **Context:** Users wanted richer formatting in exercise, combo, and session descriptions.
|
||||
- **Decision:** Use `marked` (GFM parser) + `DOMPurify` (HTML sanitizer) to render descriptions as HTML in the three detail views. Descriptions are **not** shown in list views (removed for cleaner cards). A "Basic Markdown supported" hint appears next to the description label in all three form views.
|
||||
- **Rationale:** `marked` is lightweight and defaults to GFM. `DOMPurify` is required when using `v-html` to prevent XSS. The hint says "basic" rather than "GitHub Flavored Markdown" because some GFM extensions (e.g., alerts) are not supported by `marked` out of the box.
|
||||
|
||||
### Schema: status column added to execution_log (in-place, R4-B)
|
||||
|
||||
- **Date:** 2026-06-12
|
||||
- **Context:** `exit()` and route-abandon both terminate a session early, but the log was indistinguishable from a completed session. Stats counted all started sessions regardless of completion.
|
||||
- **Decision:** Add `status TEXT NOT NULL DEFAULT 'completed' CHECK(status IN ('completed','aborted'))` to `execution_log` in-place (no version bump — pre-release).
|
||||
- **Rationale:** Honest data model. Stats queries filter `WHERE status = 'completed'`. An aborted session where 0 exercises were done should not count as a "workout".
|
||||
|
||||
### ESLint added (R8)
|
||||
|
||||
- **Date:** 2026-06-12
|
||||
- **Context:** Only Prettier was configured. Bugs like duplicate object keys (`no-dupe-keys`) shipped silently.
|
||||
- **Decision:** Flat ESLint config with `@eslint/js` recommended + `eslint-plugin-vue/flat/recommended` + `eslint-config-prettier`. `vue/no-v-html` disabled globally — all `v-html` uses go through `renderMarkdown()` (DOMPurify).
|
||||
- **Rationale:** Vue-specific rules catch unused refs and missing `:key`s. Prettier config ensures no formatting conflicts.
|
||||
|
||||
### LIKE wildcard escaping in search (R10)
|
||||
|
||||
- **Date:** 2026-06-12
|
||||
- **Context:** All repository `search()` methods passed user input directly into `LIKE '%…%'`. Searching for `100%` matched everything starting with `100`; `Push_up` couldn't be found with a literal underscore.
|
||||
- **Decision:** Add `escapeLike(s)` utility (escapes `\`, `%`, `_`) and append `ESCAPE '\\'` to all LIKE clauses.
|
||||
- **Rationale:** Minimal change; correct semantics. The parameterization already prevented injection; this fixes result correctness.
|
||||
|
||||
### JSON import: title conflicts folded into the Replace/Skip screen
|
||||
|
||||
- **Date:** 2026-06-16
|
||||
- **Context:** Import conflict detection was `id`-only. An imported item whose title matched an existing item with a _different_ id either failed silently (same-user) or only offered a forced rename (cross-user, after suffixing) — no Replace/Skip choice, and same-batch references to the conflicting imported id (e.g. a combo's `exercise_id`) were never redirected.
|
||||
- **Decision:** Detect title conflicts (different id, same title) during analysis and bucket them into the same `sameUserCollisions` array as id conflicts, so the existing Replace/Skip UI (including batch Replace All/Skip All) handles both without new screens. Resolve same-user collisions against `collision.existing.id` instead of `collision.imported.id` so Replace updates the existing row in place and Skip redirects same-batch references to it via the existing `idMap` mechanism. For cross-user imports, the suffixed-title conflict (almost always a re-import from the same external user) now resolves the same way instead of forcing a rename.
|
||||
- **Decision:** Removed `analyzeExercises`, `applySameUserImport`, `applyDifferentUserImport`, and `checkTitleConflict` from `useJsonImport.js`, and the "legacy exercise-only" branch in `ImportDialog.vue`. These turned out to be dead code: the apply step already always went through `applyAllImport`, and `analyzeAll` already produces an identical analysis shape for exercise-only envelopes.
|
||||
- **Rationale:** Reusing the existing collision UI avoids inventing a parallel "merge" concept the backlog explicitly wanted to avoid. Deleting confirmed-dead code prevents the next reader (the backlog text itself cited the dead `applySameUserImport` as if it were live) from being misled about which code path actually runs.
|
||||
|
||||
### Save filename prompt on every disk save
|
||||
|
||||
- **Date:** 2026-06-16
|
||||
- **Context:** All file downloads (JSON exports, the home HTML export, the SQLite backup) saved silently under an auto-generated name with no chance to rename before saving.
|
||||
- **Decision:** Add a custom Vue modal (`SaveFileDialog.vue`, mounted once in `App.vue`) that prompts for a filename — prefilled with the previous auto-generated default, text-selected, with a fixed/read-only extension — before any download fires. Backed by a singleton composable `useSaveFilePrompt.js` (`promptFilename(defaultFilename)` → `Promise<string|null>`, `null` on cancel). The three existing download chokepoints (`downloadJson`, `downloadBackup`, the local `downloadHtml` in `useHtmlExport.js`) call it and no-op on cancel, so no view-level call site changed. The forced pre-migration backup screen goes through the same dialog; cancelling it leaves the Update button disabled.
|
||||
- **Rationale:** A native `showSaveFilePicker()` dialog was rejected — unsupported in Firefox, the project's primary test browser. Fixing the extension (rather than letting the whole filename be edited) avoids users accidentally producing a file the app can't recognize by type. Centralizing the prompt in the 3 existing low-level download functions, instead of at each of the ~15 export-button call sites, kept the change minimal-impact.
|
||||
- **Found/fixed along the way:** `ModalDialog`'s `z-index: 1000` sat below the blocking `.db-overlay`'s `z-index: 9999`, so the dialog was unclickable when opened from the migration screen — bumped to `10000`. `useJsonExport.js`'s `downloadJson` calls, and the `onExportAll`/`onExportHtml` view handlers, weren't awaited — added `await` throughout since the call now blocks on user input.
|
||||
|
||||
### Export respects active search filter and bundles referenced entities
|
||||
|
||||
- **Date:** 2026-06-18
|
||||
- **Context:** Two related export bugs. (1) Clicking export on the exercises/combos/sessions/collections list views always exported every row, ignoring an active search filter — exporting the unfiltered list when the user clearly intended to export just the matches. (2) Exporting a single combo/session/collection (or a filtered list of them), or using the Settings export-filter dialog with a partial selection, only included lightweight FK references (id/title) to the exercises/combos/sessions it depends on, not the full entities — so importing that file on a device that didn't already have those dependencies left dangling references (e.g. a session item pointing at an exercise that was never imported).
|
||||
- **Decision:** `exportAllExercises/Combos/Sessions/Collections` now take an optional `query` and use the corresponding `search`/new `searchWithExercises`/`searchWithItems`/`searchWithSessions` repository method when one is active; the four list views pass their current trimmed search query. Added `collectComboDependencies`/`collectSessionDependencies`/`collectCollectionDependencies` helpers in `useJsonExport.js` that walk the nested FK references to fetch full dependent entities, used by `exportCombo(s)`, `exportSession(s)`, `exportCollection(s)`, and cascaded into `exportByFilter` (collections → sessions → combos → exercises) so a partial checkbox selection still produces a self-contained, importable file.
|
||||
- **Rationale:** Matches user intent (export = "what I'm looking at") and makes every export entry point produce a file that imports correctly standalone, without changing the import side at all — existing collision detection already handles re-importing entities that turn out to already exist locally.
|
||||
|
||||
### COOP/COEP headers clarification (R17)
|
||||
|
||||
- **Date:** 2026-06-12
|
||||
- **Context:** README and technical docs stated that COOP/COEP headers were "required" for OPFS. The SAH-pool VFS (`opfs-sahpool`) does not use `SharedArrayBuffer` and does not require these headers.
|
||||
- **Decision:** Update README and technical docs to say headers are "not required" for the SAH-pool VFS, kept as a precaution.
|
||||
- **Rationale:** Accurate documentation prevents unnecessary server configuration burden for deployers.
|
||||
879
docs/plan.md
Normal file
879
docs/plan.md
Normal file
|
|
@ -0,0 +1,879 @@
|
|||
# TrainUs — Implementation Plan (v3)
|
||||
|
||||
> **Last updated:** 2026-06-09
|
||||
> **Status:** Step 15 complete — all steps done; GNU AGPL v3 license + notice added; license shown via stacked modal in About dialog
|
||||
|
||||
## 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.
|
||||
|
||||
## Steps
|
||||
|
||||
### Step 0 — Project Scaffolding & Infrastructure
|
||||
|
||||
**Status:** Done
|
||||
|
||||
- Initialize Vue 3 + Vite project in `src/`
|
||||
- Configure Vite: dev server with `Cross-Origin-Opener-Policy` / `Cross-Origin-Embedder-Policy` headers, build output to `dist/`, inject app version via `define` from `package.json`
|
||||
- Set up folder structure: `src/components/`, `src/composables/`, `src/db/`, `src/i18n/`, `src/styles/`, `src/views/`, `src/router/`, `src/utils/`
|
||||
- Port CSS from sample into `src/styles/` — CSS variable system, add dark mode variable set
|
||||
- Integrate Bootstrap Icons (via `bootstrap-icons` npm package)
|
||||
- Set up Vue Router (hash mode): `#/home`, `#/exercises`, `#/combos`, `#/sessions`, `#/stats`, `#/settings`
|
||||
- Create app shell layout component: sidebar nav (desktop ≥ 700px) / bottom nav (mobile), action bar, `<router-view>` content area — faithful to sample
|
||||
- Set up **i18next** + **vue-i18next**: `src/i18n/locales/en.json`, `src/i18n/locales/fr.json`, reactive language switching
|
||||
- Create `manifest.json` (PWA) with app name, icons placeholder, versioned start URL
|
||||
- Set up basic service worker (precache shell)
|
||||
- Write initial `README.md`, create `docs/technical-documentation.md`, `docs/decisions-log.md`
|
||||
- Set up Playwright Python project in `tests/` with `conftest.py`, targeting Firefox
|
||||
|
||||
**Verification:** `npm run dev` serves the app, responsive shell renders, routes switch views, i18n renders English keys, Playwright can launch in Firefox
|
||||
|
||||
---
|
||||
|
||||
### Step 1 — Database Layer (SQLite WASM + OPFS)
|
||||
|
||||
**Status:** Done
|
||||
|
||||
- Integrate official SQLite WASM build
|
||||
- Create `src/db/database.js` — initializes SQLite in a Web Worker with OPFS VFS, namespaces DB file by app version (e.g., `trainus-1.0.0.sqlite3`)
|
||||
- SQL schema: `exercise`, `combo`, `combo_exercise`, `session`, `session_item`, `collection`, `collection_session`, `settings` (key/value), `execution_log`, `execution_log_item`, `schema_version`
|
||||
- On first launch: generate UUID, store `user_uuid`, `user_name`, `app_version`, `db_created_at` in `settings` table
|
||||
- Repository layer (`src/db/repositories/`) — one per entity with `create`, `getById`, `getAll`, `update`, `delete`, `search`
|
||||
- Vue composables (`src/composables/`) wrapping repositories for reactive data in components
|
||||
|
||||
**Verification:** Playwright tests — DB initializes, UUID + app version persisted, CRUD records, data survives reload
|
||||
|
||||
---
|
||||
|
||||
### Step 2 — Settings
|
||||
|
||||
**Status:** Done
|
||||
|
||||
- Create `SettingsView.vue` at `#/settings`
|
||||
- **Language selector:** dropdown (English / French) — calls i18next `changeLanguage()`, persisted in `settings` table, loaded at startup
|
||||
- **User name editor:** text input, persisted in `settings`
|
||||
- **UUID display:** read-only info field
|
||||
- **Dark/light mode toggle:** switches CSS variable set on `<html>`, persisted in `settings`
|
||||
- **Download Database:** export raw SQLite file via `sqlite3_js_db_export()`
|
||||
- **Restore Database:** file picker → confirm dialog → validation pipeline (deserialize into `:memory:` → check required tables → version compatibility → adapt scripts if needed → table-by-table copy in transaction) → spinner during process → detailed report card (success with table/row counts, or translated error codes)
|
||||
- **Release registry** (`src/db/releases.js`): tracks schema versions and breaking changes for restore compatibility
|
||||
- Placeholder sections for Export/Import (wired in Step 7)
|
||||
|
||||
**Verification:** Playwright tests — change language (all labels update reactively), change name (persists on reload), toggle theme (colors switch), red path: empty name rejected, download DB, restore flow (round-trip), restore with invalid file, restore with incomplete DB, cancel dialog aborts restore
|
||||
|
||||
---
|
||||
|
||||
### Step 2b — Schema Hardening & Documentation Restructuring
|
||||
|
||||
**Status:** Done
|
||||
|
||||
- **Schema changes (in-place, no version bump — not yet released):**
|
||||
- Add `UNIQUE` constraint on `exercise.title`, `combo.title`, `session.title`, `collection.label`
|
||||
- Add `suffix` table: `user_uuid TEXT PRIMARY KEY`, `user_name TEXT NOT NULL`, `suffix TEXT NOT NULL UNIQUE` — registry of known external users for import disambiguation
|
||||
- **Documentation restructuring:**
|
||||
- Rename `docs/technical-overview.md` → `docs/technical-documentation.md`; enrich with full DB schema (ER diagram), architecture details, data flows
|
||||
- Rename `docs/user-guide.md` → `docs/user-documentation.md`
|
||||
- Update all references across project files: `README.md`, `AGENTS.md`, `docs/plan.md`, `.opencode/` config files
|
||||
- Rewrite `README.md` Documentation section: link all docs and all tutorials/codelabs with short descriptions; bilingual links use `(en / fr)` format
|
||||
|
||||
**Verification:** Playwright tests — UNIQUE constraints enforced, suffix table exists, all doc links resolve correctly
|
||||
|
||||
---
|
||||
|
||||
### Step 3 — Exercise Management (Edit Mode)
|
||||
|
||||
**Status:** Done
|
||||
|
||||
- `ExerciseListView.vue`: card/list display, search bar (reactive filter by title)
|
||||
- `ExerciseFormView.vue`: form with all fields (title, description, asymmetric toggle + sub-option, image URLs, video URLs, default reps, default weight) — uses `v-model` bindings
|
||||
- `ExerciseDetailView.vue`: read-only, embedded images/videos (iframe for YouTube)
|
||||
- Routes: `#/exercises`, `#/exercises/new`, `#/exercises/:id`, `#/exercises/:id/edit`
|
||||
- Input validation (title required, reps ≥ 0, weight ≥ 0, URL format)
|
||||
- Action bar: New, Search buttons (via Vue Teleport with `defer`)
|
||||
- Validation utility module (`src/utils/validation.js`)
|
||||
- i18n keys for all labels, validation messages, and form elements (EN + FR)
|
||||
|
||||
**Verification:** Playwright tests — create, view, edit, delete, search, validation rejects bad input — 17 tests passing on Firefox and Chromium
|
||||
|
||||
---
|
||||
|
||||
### Step 3b — Exercise JSON Export/Import
|
||||
|
||||
**Status:** Done
|
||||
|
||||
- **JSON envelope format:** `{ metadata: { appName, appVersion, schemaVersion, exportDate, exportedBy: { name, uuid } }, data: { exercises: [...] } }` — extensible for all entities in Step 7
|
||||
- **Export service** (`src/composables/useJsonExport.js`): builds envelope, serializes selected entity arrays
|
||||
- **Import service** (`src/composables/useJsonImport.js`): parses envelope, validates `appName` + `schemaVersion`, detects ID collisions, applies import
|
||||
- **Export features:**
|
||||
- Exercise list view: "Export" action bar button — exports all exercises
|
||||
- Exercise detail view: "Export" button — exports single exercise
|
||||
- Settings page: enable "Export Data" button with entity filter dialog (checkboxes per entity type; only "Exercises" active, others disabled with "Coming soon")
|
||||
- Downloaded file: `trainus-exercises-YYYY-MM-DD.json`
|
||||
- **Import features:**
|
||||
- Exercise list view: "Import" action bar button — file picker for `.json`
|
||||
- Settings page: enable "Import Data" button — same file picker
|
||||
- Import flow: parse → validate envelope → preview summary ("X exercises from NAME on DATE") → collision detection → result report
|
||||
- **ID collision strategy:**
|
||||
- **No collision:** insert directly
|
||||
- **Same user** (`created_by` = local `user_uuid`):
|
||||
- Different ID: import as new exercise
|
||||
- Same ID: show confirmation dialog with export date ("Overwrite 'Exercise X'? Exported on DATE") — options: Replace / Skip / Replace All / Skip All (batch)
|
||||
- If an update causes a title conflict with a different existing exercise: prompt user to enter a new title
|
||||
- **Different user** (`created_by` ≠ local `user_uuid`):
|
||||
- If user UUID exists in `suffix` table: inform user the existing suffix will be used
|
||||
- If user UUID not in `suffix` table: prompt for a new suffix (pre-filled with exporter's name), validate uniqueness against existing suffixes, save `(user_uuid, user_name, suffix)` to `suffix` table
|
||||
- Same `created_by` as existing object AND same ID: treat as update (no new ID generated)
|
||||
- Different `created_by` from existing object AND same ID: generate a new UUID for import
|
||||
- Title format: `"Original Title [SUFFIX]"` — if this title is already taken, prompt user to enter a new base title (suffix is always appended)
|
||||
- Single dialog handles all collisions: grouped by type, batch actions (Replace All / Skip All), one suffix input per external user
|
||||
- **Settings — Suffix management:**
|
||||
- New section in Settings: list all entries from `suffix` table (user name, UUID, suffix)
|
||||
- Edit suffix: changing a suffix updates all titles containing `[OLD_SUFFIX]` to `[NEW_SUFFIX]` across `exercise`, `combo`, `session`, and `collection` tables
|
||||
- **i18n:** all new labels in `en.json` / `fr.json`
|
||||
|
||||
**Verification:** Playwright tests — export/import round-trip, same-user collision (replace + skip + batch Replace All / Skip All), different-user collision (suffix prompt, title with [SUFFIX], new ID generated), update from same external user (no new ID), title conflict prompts, suffix rename cascades to titles, invalid file rejection, Settings suffix management, Settings page export filter, single exercise export from detail view
|
||||
|
||||
---
|
||||
|
||||
### Step 3c — Backup Filename & Reminder
|
||||
|
||||
**Status:** Done
|
||||
|
||||
Scope is deliberately minimal: no automatic backups, no OPFS-stored backups, no schema change. The manual Download / Restore buttons from Step 2 remain the only backup mechanism. This step only:
|
||||
|
||||
1. Renames the downloaded file so each manual backup is unique, sortable, and carries the app version.
|
||||
2. Tracks when the user last downloaded (or restored) a backup.
|
||||
3. Adds a non-intrusive warning indicator reminding the user to back up after a configurable number of days.
|
||||
|
||||
- **Filename convention:** manual DB download renamed from `trainus-{version}.sqlite3` to `trainus-{version}-backup-YYYYMMDD-HHhMMmSSs.sqlite3` using local time (example for `1.0.0` at 2026-03-23 15:24:06 local → `trainus-1.0.0-backup-20260323-15h24m06s.sqlite3`). Letters `h`, `m`, `s` replace the Windows-invalid colon.
|
||||
- **New settings keys (no schema change; uses existing `settings` key/value table):**
|
||||
- `last_backup_at` — ISO-8601 UTC timestamp (`...Z`). Set on successful Download **and** on successful Restore. Absent = never backed up.
|
||||
- `backup_reminder_days` — integer stored as string, default `"7"`. User-configurable in Settings (valid range 1–365).
|
||||
- `db_created_at` — ISO-8601 UTC timestamp set on first launch. Used as fallback for backup reminder when `last_backup_at` is absent, so fresh databases don't immediately show a stale warning. Also displayed on the Statistics page.
|
||||
- **Backup reminder indicator:**
|
||||
- Warning-styled button, leftmost in the action bar (`#actionbar-actions`), visible on every page when `now - last_backup_at > backup_reminder_days` OR when `last_backup_at` is absent.
|
||||
- Icon: `bi-exclamation-triangle-fill`. Tooltip: "Last backup {{days}} day(s) ago — click to back up" or "No backup yet — click to create one" (translated).
|
||||
- Click: navigate to `#/settings` and focus/scroll the Database section.
|
||||
- A secondary `×` dismisses the reminder for the current session only (not persisted). Reloads re-show it if still stale.
|
||||
- Hidden entirely when backup is fresh.
|
||||
- **Settings UI additions (Database section):**
|
||||
- Display "Last backup: {{localDateTime}}" or "Never backed up".
|
||||
- Number input "Remind me every N days" (1–365; default 7) with inline validation.
|
||||
- **i18n:** new EN + FR keys under `settings.backup.*` and `backup.reminder.*`.
|
||||
- **New files:**
|
||||
- `src/utils/backupFilename.js` — pure `formatBackupFilename(version, date)`.
|
||||
- `src/composables/useBackupStatus.js` — reactive `lastBackupAt`, `daysSinceBackup`, `isStale`, `dismissed`, `markBackedUp()`, `dismiss()`.
|
||||
- `src/components/BackupReminder.vue` — teleported warning button.
|
||||
- `tests/test_step3c_backup.py` — Playwright tests (Firefox + Chromium).
|
||||
- **Modified files:**
|
||||
- `src/views/SettingsView.vue` — use new filename, set `last_backup_at` on Download/Restore success, render Database section additions.
|
||||
- `src/App.vue` — mount `<BackupReminder>` once at app level so it teleports into each page's action bar.
|
||||
- `src/i18n/locales/en.json`, `fr.json` — new translation keys.
|
||||
|
||||
**Verification:** Playwright tests — download uses new filename pattern, `last_backup_at` set on download, reminder appears after threshold, reminder hidden when fresh, reminder hidden after dismiss within same session, reminder reappears on reload if still stale, clicking reminder navigates to Settings, Settings shows last backup date, changing `backup_reminder_days` re-evaluates reminder visibility, restore updates `last_backup_at` to now, invalid threshold rejected, filename is filesystem-safe (no colons, zero-padded), download failure does not update `last_backup_at`.
|
||||
|
||||
---
|
||||
|
||||
### Step 4 — Combo Management (Edit Mode)
|
||||
|
||||
**Status:** Done
|
||||
|
||||
- `ComboListView.vue`, `ComboFormView.vue`, `ComboDetailView.vue`
|
||||
- Type picker (EMOM / AMRAP / TABATA / NONE), exercise selector (pick from existing, reorder with up/down buttons), timer config (varies by type: duration for EMOM/AMRAP, rounds for TABATA)
|
||||
- Routes: `#/combos`, `#/combos/new`, `#/combos/:id`, `#/combos/:id/edit`
|
||||
- `comboRepository` updated with `createWithId` and `replaceAll` for import support
|
||||
|
||||
**Verification:** Playwright tests — create combo with exercises, reorder, type change, validation, edit, delete, search
|
||||
|
||||
---
|
||||
|
||||
### Step 4b — Combo Exercise Reps & Weight
|
||||
|
||||
**Status:** Done
|
||||
|
||||
Add `default_reps` and `default_weight` fields to exercises within combos. Each exercise in a combo has its own reps count and weight, shown in edit mode and used as defaults during execution.
|
||||
|
||||
- **Schema migration:**
|
||||
- Add `default_reps INTEGER NOT NULL DEFAULT 1` and `default_weight REAL NOT NULL DEFAULT 0.0` columns to `combo_exercise` table
|
||||
- Increment `SCHEMA_VERSION` from 1 to 2
|
||||
- Add migration script for existing databases (`ALTER TABLE combo_exercise ADD COLUMN ...`)
|
||||
- **Repository changes (`src/db/repositories/comboRepository.js`):**
|
||||
- Update `setExercises()` to accept array of objects `{ exerciseId, position, defaultReps, defaultWeight }` instead of plain IDs
|
||||
- Update `getExercises()` to return `default_reps` and `default_weight` columns alongside existing fields
|
||||
- **ComboFormView.vue:**
|
||||
- Add inline reps and weight inputs per exercise in the ordered list
|
||||
- Reps: number input, default 1, validation ≥ 1
|
||||
- Weight: number input, default 0.0, validation ≥ 0
|
||||
- Display format in list: exercise title + "× {reps}" + " @ {weight}kg" (or just title if both are defaults)
|
||||
- **ComboDetailView.vue:**
|
||||
- Display reps and weight for each exercise in the list (e.g., "3 × 50 kg" format next to exercise name)
|
||||
- **i18n (`en.json` / `fr.json`):**
|
||||
- `combos.form.reps` / `combos.form.repsLabel`
|
||||
- `combos.form.weight` / `combos.form.weightLabel`
|
||||
- `combos.exercise.repsWeight` — display format string
|
||||
- Validation messages for reps < 1 and weight < 0
|
||||
- **Import/Export impact:**
|
||||
- `useJsonExport.js`: include `default_reps` and `default_weight` in combo exercise export data
|
||||
- `useJsonImport.js`: handle these fields on import (no special collision logic needed)
|
||||
- **Modified files:**
|
||||
- `src/db/schema.js` — add columns, bump version
|
||||
- `src/db/repositories/comboRepository.js` — update `setExercises` and `getExercises`
|
||||
- `src/views/ComboFormView.vue` — add reps/weight inputs per exercise
|
||||
- `src/views/ComboDetailView.vue` — display reps/weight
|
||||
- `src/composables/useJsonExport.js` — include new fields
|
||||
- `src/composables/useJsonImport.js` — handle new fields
|
||||
- `src/i18n/locales/en.json`, `fr.json` — new translation keys
|
||||
|
||||
**Verification:** Playwright tests — create combo with exercises and set reps/weight (saved correctly), edit combo and change reps/weight (updated), detail view displays reps/weight, validation rejects reps < 1 and weight < 0, export/import round-trip preserves reps/weight values
|
||||
|
||||
---
|
||||
|
||||
### Step 5 — Session Management (Edit Mode)
|
||||
|
||||
**Status:** Done
|
||||
|
||||
- `SessionListView.vue`, `SessionFormView.vue`, `SessionDetailView.vue`
|
||||
- Ordered list of items (add exercise OR combo), reorder with up/down buttons
|
||||
- **Per-item reps and weight:** each session item has its own `repetitions` (already in schema) and a new `weight` field, similar to combo exercises. These override the exercise's defaults when the session is executed.
|
||||
- Add `weight REAL NOT NULL DEFAULT 0.0` to `session_item` table (in-place, no version bump — not yet released)
|
||||
- Inline weight input per item in the form (number input, default 0.0, validation ≥ 0)
|
||||
- Display format in list: item title + "× {reps}" + " @ {weight}kg" (or just title if both are defaults)
|
||||
- Routes: `#/sessions`, `#/sessions/new`, `#/sessions/:id`, `#/sessions/:id/edit`
|
||||
- **Repository changes (`src/db/repositories/sessionRepository.js`):**
|
||||
- Update `setItems()` to accept array of objects `{ itemId, itemType, position, repetitions, weight }`
|
||||
- Update `getItems()` to return `weight` alongside existing fields, with titles from JOIN queries
|
||||
- **Import/Export impact:**
|
||||
- `useJsonExport.js`: include `weight` in session item export data
|
||||
- `useJsonImport.js`: handle this field on import
|
||||
- **i18n (`en.json` / `fr.json`):**
|
||||
- `sessions.form.weight` / `sessions.form.weightLabel`
|
||||
- `sessions.item.repsWeight` — display format string
|
||||
- Validation message for weight < 0
|
||||
|
||||
**Verification:** Playwright tests — create session with exercises/combos and set reps/weight (saved correctly), edit session and change reps/weight (updated), detail view displays reps/weight, validation rejects weight < 0, export/import round-trip preserves reps/weight values
|
||||
|
||||
---
|
||||
|
||||
### Step 6 — Collection Management & Home Page
|
||||
|
||||
**Status:** Done
|
||||
|
||||
- `CollectionsView.vue` (at `#/collections`), `CollectionFormView.vue`, `CollectionDetailView.vue`
|
||||
- Label + ordered list of sessions, reorder with up/down buttons
|
||||
- Routes: `#/collections`, `#/collections/new`, `#/collections/:id`, `#/collections/:id/edit`
|
||||
- `HomeView.vue` (at `#/home`): stat cards for sessions in last 7/30 days, last 4 executed sessions with inline exercise summaries and play buttons, history section grouped by day with date filter (default last 30 days)
|
||||
- `collectionRepository.getAll()` enriched with `sessionCount` via JOIN query
|
||||
- App.vue `navKeyMap` updated to map collection sub-routes to `collections` nav key
|
||||
- `useHtmlExport.js`: generates a self-contained HTML export of the home page (embedded icon, stats, recent sessions, filtered history — no play buttons or links); triggered by an action bar button visible when data exists
|
||||
|
||||
**Verification:** Playwright tests — CRUD collections, add/reorder/remove sessions, search, validation, duplicate label rejected, navigate to session from collection detail, home empty state, home shows recent 4 sessions, home shows correct 7-day and 30-day counts, history grouped by day, history date filter, play button on recent card, HTML export button present/absent — 23 tests passing on Firefox and Chromium
|
||||
|
||||
---
|
||||
|
||||
### Step 7 — Import / Export (Full Data)
|
||||
|
||||
**Status**: Done
|
||||
|
||||
**What was implemented**:
|
||||
|
||||
- Extended JSON envelope `data` to include all entity types: `combos`, `sessions`, `collections`, `executionLogs`, `settings`
|
||||
- Cross-entity reference remapping on import: ID collision resolution updates all referencing entities (`combo_exercise.exercise_id`, `session_item.item_id`, `collection_session.session_id`, `execution_log.session_id`, `execution_log_item.exercise_id`)
|
||||
- Enabled all entity checkboxes in Settings export filter dialog (combos, sessions, collections, execution logs, settings)
|
||||
- Added export/import buttons to combo, session, and collection list + detail views
|
||||
- Generalized suffix collision strategy to all entity types (title/label field)
|
||||
- Full export: all entities + settings in one JSON file (`trainus-full-YYYY-MM-DD.json`)
|
||||
- Single entity export from detail views (combo, session, collection)
|
||||
- "Export All Data" button in Settings for one-click full export
|
||||
- ImportDialog generalized to handle multiple entity types with collision resolution per entity type
|
||||
- Repository additions: `createWithId`, `replaceAll`, `getAllWithX` for combo, session, collection, executionLog
|
||||
|
||||
**Decisions made**:
|
||||
|
||||
- Settings import excludes `user_uuid`, `app_version`, `db_created_at`, `last_backup_at` to preserve local identity
|
||||
- Execution logs are imported only if the referenced session exists (or was imported in the same batch)
|
||||
- Suffix is applied once per external user and affects all entity types from that user
|
||||
- Import dialog detects multi-entity envelopes and uses the new generalized flow; single-entity exercise files use the legacy flow for backward compatibility
|
||||
|
||||
**What remains**:
|
||||
|
||||
- Manual validation by the user
|
||||
- Some tests need refinement for Firefox browser compatibility
|
||||
|
||||
**Additional tests added (2026-06-02)**:
|
||||
|
||||
- `test_execution_logs_same_user_roundtrip`: execution log + item survive export → clear → reimport
|
||||
- `test_execution_log_id_remapping`: log item `exercise_id` is remapped when exercise gets `new_id`
|
||||
- `test_session_with_combo_item_remapping`: session `item_type=combo` item_id follows combo remap
|
||||
- `test_collection_session_remapping`: collection session ref follows session `new_id` remap
|
||||
- `test_settings_import_roundtrip`: `language` and `theme` applied from settings-only envelope
|
||||
- `test_single_entity_export_buttons_session_collection`: export button visible on session/collection detail
|
||||
- Refactored `test_new_id_propagation_to_combo` to load from `tests/fixtures/cross_user_id_collision.json`
|
||||
- Added `tests/fixtures/cross_user_full.json` for cross-user remapping tests
|
||||
|
||||
---
|
||||
|
||||
### Step 8 — Execution Mode (Core)
|
||||
|
||||
**Status:** Done
|
||||
|
||||
#### Decisions
|
||||
|
||||
| Decision | Choice | Rationale |
|
||||
| --------------------- | ---------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Combo repetitions | Full sequence × N rounds | `repetitions` on a session combo item = number of full passes through the combo exercise list |
|
||||
| Schema change | Add `combo_id` + `round_number` to `execution_log_item` (v2 → v3) | Enables accurate per-combo statistics in Step 9; nullable columns, no FK constraint |
|
||||
| Abandon behavior | Save partial log | Completed items saved, unfinished omitted; session appears in history as partial |
|
||||
| Mid-round transition | Auto-advance silently | Only the final round of a combo shows a pause screen |
|
||||
| Final-round screen | Continue + One More | After the last exercise of the last combo round: "Continue →" advances to the next session item; "+ One More Round" appends one extra round to the queue (repeatable) |
|
||||
| Prefill source | Last run of this session | Reps/weight prefilled from the most recent `execution_log` for this specific session; falls back to item/exercise defaults if no prior log |
|
||||
| Asymmetric side logic | `alternate=true` → flip side per step; `alternate=false` → flip side per set | Matches the exercise definition; side logged in `execution_log_item.side` |
|
||||
|
||||
#### Execution Queue Model
|
||||
|
||||
When a session starts, the app builds a flat linear **queue** of steps from the session items. A combo with `repetitions = N` unrolls into `N × (number of exercises in combo)` steps:
|
||||
|
||||
```
|
||||
Session items:
|
||||
1. Exercise A ×3 reps, 50kg → 1 step
|
||||
2. Combo "HIIT" ×2 rounds → 4 steps (2 exercises × 2 rounds)
|
||||
Round 1: Push-up ×10, Squat ×15
|
||||
Round 2: Push-up ×10, Squat ×15
|
||||
3. Exercise B ×5 reps → 1 step
|
||||
|
||||
Queue (flat):
|
||||
[0] Exercise A (reps=3, weight=50)
|
||||
[1] Push-up — HIIT, Round 1/2
|
||||
[2] Squat — HIIT, Round 1/2
|
||||
↓ auto-advance (not last round)
|
||||
[3] Push-up — HIIT, Round 2/2
|
||||
[4] Squat — HIIT, Round 2/2
|
||||
↓ final-round screen: [Continue →] [+ One More Round]
|
||||
[5] Exercise B (reps=5, weight=0)
|
||||
```
|
||||
|
||||
Each step carries: `exerciseId`, `exerciseTitle`, `reps`, `weight`, `comboId?`, `comboTitle?`, `roundNumber?`, `totalRounds?`, `asymmetric`, `alternate`, `side?`.
|
||||
|
||||
#### UI Layout
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ Monday Workout [✕ exit] │
|
||||
│ ████████████░░░░░ 3 / 6 steps │
|
||||
├─────────────────────────────────────────┤
|
||||
│ ▶ NOW │
|
||||
│ ┌───────────────────────────────────┐ │
|
||||
│ │ Push-up │ │
|
||||
│ │ Combo: HIIT — Round 2 of 2 │ │ ← combo context (hidden for exercises)
|
||||
│ │ │ │
|
||||
│ │ Reps: [ 10 ] │ │
|
||||
│ │ Weight: [ 0 ] kg │ │
|
||||
│ │ Side: RIGHT ↔ │ │ ← only for asymmetric exercises
|
||||
│ │ │ │
|
||||
│ │ [ ✓ Done ] │ │
|
||||
│ └───────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ↓ NEXT │
|
||||
│ Squat — 15 reps (HIIT, Round 2) │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Final-round transition screen (replaces the card after the last exercise of the last combo round):
|
||||
|
||||
```
|
||||
│ ✓ HIIT — 2 rounds complete │
|
||||
│ │
|
||||
│ [ Continue → ] [ + One More Round ] │
|
||||
```
|
||||
|
||||
#### Schema change (v2 → v3)
|
||||
|
||||
Two nullable columns added to `execution_log_item`:
|
||||
|
||||
```sql
|
||||
ALTER TABLE execution_log_item ADD COLUMN combo_id TEXT DEFAULT NULL;
|
||||
ALTER TABLE execution_log_item ADD COLUMN round_number INTEGER DEFAULT NULL;
|
||||
```
|
||||
|
||||
`NULL` for standalone exercises; populated for exercises inside a combo. No FK constraint (combo may have been deleted before statistics are viewed).
|
||||
|
||||
#### Files to create / modify
|
||||
|
||||
**New:**
|
||||
|
||||
- `src/views/SessionExecuteView.vue` — execution UI (stepper, current/next display, reps/weight inputs, side indicator, final-round screen)
|
||||
- `src/composables/useSessionExecution.js` — queue building, current/next step state, prefill logic, done/advance/exit logic, save to log
|
||||
|
||||
**Modified:**
|
||||
|
||||
- `src/db/schema.js` — add `combo_id` + `round_number` columns, bump `SCHEMA_VERSION` 2 → 3, add migration script
|
||||
- `src/db/repositories/executionLogRepository.js` — add `getLastItemsBySession(sessionId)` returning map `exerciseId → { reps_done, weight_used, side }` from most recent log for that session
|
||||
- `src/router/index.js` — add `{ path: '/sessions/:id/execute', name: 'session-execute', component: SessionExecuteView }`
|
||||
- `src/views/SessionDetailView.vue` — add play button in action bar
|
||||
- `src/views/SessionListView.vue` — add play icon per session row
|
||||
- `src/i18n/locales/en.json`, `fr.json` — new keys under `execution.*`
|
||||
|
||||
**Not in this step (deferred):**
|
||||
|
||||
- Timers (EMOM/AMRAP/TABATA) → Step 12
|
||||
- Statistics charts → Step 9
|
||||
|
||||
**Verification:** Playwright tests — start session navigates to execute view, steps advance on Done, reps/weight editable and saved, asymmetric side shown and logged, combo rounds auto-advance between non-final rounds, final-round screen shows Continue + One More, One More appends extra round, Continue advances to next session item, exit mid-session saves partial log (done items only), re-execute prefills from last session run, session with no prior log prefills from defaults, progress bar reflects current position
|
||||
|
||||
---
|
||||
|
||||
### Step 9 — Statistics
|
||||
|
||||
**Status:** Done
|
||||
|
||||
- `StatsView.vue` at `#/stats`
|
||||
- Displays database creation date: "You are using TrainUs since [formatted date]"
|
||||
- Date formatting uses centralized `dateFormatter.js` utility with locale-aware formatting (DD/MM/YYYY for French, MM/DD/YYYY for English)
|
||||
- `chart.js` + `vue-chartjs` v5
|
||||
- **Summary cards:** total sessions, total exercises done, current day streak
|
||||
- **Date range filter:** quick tabs — 7d / 30d / 3m / All (default: 30d)
|
||||
- **Frequency** (bar): sessions grouped by week or month (toggle)
|
||||
- **Volume** (bar): total volume (`reps_done × weight_used`) per session execution over time
|
||||
- **Progression** (line): reps + weight over time for a selected exercise (one at a time)
|
||||
- `src/composables/useStats.js` — all stat queries
|
||||
|
||||
**Deferred to Step 9b:** Statistics export (e.g. CSV export of chart data / execution summary)
|
||||
|
||||
**Verification:** Playwright tests — creation date displayed, date format changes with language, summary cards show correct counts, cards respond to date range change, streak count correct, frequency/volume/progression charts render, no-data messages shown when period is empty
|
||||
|
||||
---
|
||||
|
||||
### Step 10 — PWA Finalization & Polish
|
||||
|
||||
**Status:** Done
|
||||
|
||||
- Migrated from hand-rolled `public/sw.js` to `vite-plugin-pwa` + Workbox (`registerType: 'autoUpdate'`, 10 MB cache limit to cover `sqlite3.wasm`)
|
||||
- `manifest.json` updated: `purpose` field on both icons, both icon files now exist (192 × 512 placeholder PNGs)
|
||||
- `index.html`: removed manual SW registration script (auto-injected by vite-plugin-pwa), added `<link rel="icon">` and `<link rel="apple-touch-icon">`
|
||||
- Install prompt: `useInstallPrompt.js` composable + `InstallPrompt.vue` component (session-dismissible, teleported into action bar, shows only when `beforeinstallprompt` fires)
|
||||
- Accessibility (critical flows): `aria-label` on all icon-only actionbar buttons across every view, `role="progressbar"` + `aria-valuenow/min/max` on execution progress bar, `role="alert"` + `aria-describedby` on form error messages, `required`/`aria-required` on required inputs, `role="dialog"` + `aria-modal` + `aria-labelledby` + focus-on-mount + Escape key on `ModalDialog`
|
||||
- Responsive 400px: wrapping breakpoints added to exercise, combo, session, and collection form views for dense list items and form action rows
|
||||
- Placeholder icons generated (dark bg + blue "T"); real SVG logo deferred
|
||||
|
||||
**Verification:** 10/11 tests pass on Firefox and Chromium (1 skipped: progress bar test requires a pre-existing session in the DB)
|
||||
|
||||
---
|
||||
|
||||
### Step 11 — Documentation & Final Testing
|
||||
|
||||
**Status:** Done
|
||||
|
||||
- Full test suite run: **434 passed, 2 skipped** on Firefox + Chromium — no failures
|
||||
- `docs/technical-documentation.md`: complete rewrite — architecture, full ER diagram (updated for all columns), routing table, all feature sections (Steps 0–10), i18n, versioning, build & deploy
|
||||
- `docs/user-documentation.md`: updated — added missing Sessions section, generalized Export/Import section to cover all entity types, added PWA install info
|
||||
- `docs/decisions-log.md`: catch-up entries added for Steps 4 and 7
|
||||
- `tests/README.md`: new file — setup, run commands, browser targets, file inventory
|
||||
- `README.md`: updated tech stack (removed step annotations, added vite-plugin-pwa), added tutorial rows for Steps 3, 3b, 5
|
||||
- `docs/plan.md`: Step 11 marked done
|
||||
|
||||
**Verification:** all tests green on Firefox + Chromium, docs complete, manual walkthrough by user
|
||||
|
||||
---
|
||||
|
||||
### Step 12 — Timers & Combo Execution (Optional — Stretch Goal)
|
||||
|
||||
**Status:** Done
|
||||
|
||||
#### Timer behavior per combo type
|
||||
|
||||
**EMOM (Every Minute On the Minute)** — timer drives advancement:
|
||||
|
||||
- `repetitions` on the session item = total duration in **minutes**. The number of rounds is `floor(repetitions / number_of_exercises_in_combo)`. The session form displays the field as **Duration (min)** for EMOM items (same as AMRAP).
|
||||
- Each exercise occupies one 60-second slot. When the slot timer reaches 0, the app auto-advances to the next step (the log item is saved as completed even if the user never tapped Done). Tapping Done early advances immediately and resets the timer for the next slot.
|
||||
- Sound pattern per slot: `bip` at 30 s remaining, `bip` at 2 s remaining, `bip` at 1 s remaining, `BIP` at 0 s.
|
||||
|
||||
**AMRAP (As Many Rounds As Possible)** — one countdown for the whole combo, user advances manually:
|
||||
|
||||
- Duration = `repetitions` minutes (the `repetitions` field label changes to "Duration (min)" in session form, detail, and execute views when the item is an AMRAP combo).
|
||||
- The user taps Done to advance through exercises and rounds as fast as they can.
|
||||
- Sound pattern: `BIP` at every elapsed full minute, except the final minute which uses the EMOM pattern — `bip` at 30 s remaining, `bip` at 2 s remaining, `bip` at 1 s remaining, `BIP` at 0 s.
|
||||
- When the timer reaches 0: show a "Time's up" screen → user taps Continue to advance to the next session item.
|
||||
|
||||
**TABATA** — timer drives each phase automatically:
|
||||
|
||||
- Work time and rest time are per-combo, stored in `timer_config` JSON (defaults: work = 20 s, rest = 10 s).
|
||||
- Per exercise slot: work phase starts with `BIP` → countdown → auto-transition to rest phase → rest phase starts with `BIP` → countdown → auto-advance to next exercise.
|
||||
- After the last exercise of the last round: show the existing final-round screen (Continue / One More Round from Step 8).
|
||||
- Duration = `(workTime + restTime) × number_of_exercises_in_combo × rounds`.
|
||||
- `ComboFormView` shows "Work time (s)" and "Rest time (s)" number inputs (min 1) when type = TABATA.
|
||||
|
||||
#### Sounds (Web Audio API)
|
||||
|
||||
Two sound types synthesised via `AudioContext` — no audio assets required:
|
||||
|
||||
| Name | Frequency | Duration | Gain |
|
||||
| ----- | --------- | -------- | ---- |
|
||||
| `bip` | 440 Hz | 0.1 s | low |
|
||||
| `BIP` | 880 Hz | 0.3 s | high |
|
||||
|
||||
#### Pause
|
||||
|
||||
A pause button is visible whenever a timer is active. Pausing freezes the countdown and suppresses scheduled sounds. Resuming continues from the frozen value. Pause works in both work and rest phases (TABATA).
|
||||
|
||||
#### UI additions
|
||||
|
||||
**Timer display** — shown inside the exercise card for timed combos:
|
||||
|
||||
```
|
||||
│ Push-up │
|
||||
│ Combo: HIIT — Round 2 of 2 │
|
||||
│ │
|
||||
│ 00:42 │ ← turns red when < 10 s
|
||||
│ │
|
||||
│ [⏸ Pause] [ ✓ Done ] │
|
||||
```
|
||||
|
||||
**TABATA rest screen** — replaces the exercise card during rest phase:
|
||||
|
||||
```
|
||||
│ REST │
|
||||
│ Next: Squat │
|
||||
│ │
|
||||
│ 00:08 │
|
||||
│ │
|
||||
│ [⏸ Pause] │
|
||||
```
|
||||
|
||||
**AMRAP "Time's up" screen** — shown when the overall AMRAP timer reaches 0:
|
||||
|
||||
```
|
||||
│ ⏱ Time's up — HIIT │
|
||||
│ │
|
||||
│ [ Continue → ] │
|
||||
```
|
||||
|
||||
#### Schema
|
||||
|
||||
No schema version bump needed. The `timer_config` column already exists on `combo` as a JSON text field:
|
||||
|
||||
- TABATA: `{ "workTime": 20, "restTime": 10 }`
|
||||
- EMOM / AMRAP: `{}` or `null` (duration derived from `repetitions` at runtime)
|
||||
|
||||
#### Files to create / modify
|
||||
|
||||
**New:**
|
||||
|
||||
| File | Purpose |
|
||||
| --------------------------------- | -------------------------------------------------------------------------------- |
|
||||
| `src/composables/useTimer.js` | Core countdown: start, pause, resume, reset, per-second tick with callback hooks |
|
||||
| `src/composables/useAudioCues.js` | Web Audio API helpers: `playBip()`, `playBIP()`, AudioContext lifecycle |
|
||||
| `src/components/TimerDisplay.vue` | Visual countdown (MM:SS), red-when-low styling, pause/resume button |
|
||||
|
||||
**Modified:**
|
||||
|
||||
| File | Change |
|
||||
| ---------------------------------------- | --------------------------------------------------------------------------------------------------------------- |
|
||||
| `src/composables/useSessionExecution.js` | Wire timer into step lifecycle: EMOM/TABATA auto-advance on expiry; AMRAP time-up; sound scheduling |
|
||||
| `src/views/SessionExecuteView.vue` | Render `<TimerDisplay>`, TABATA rest screen, AMRAP time-up screen, pause state |
|
||||
| `src/views/ComboFormView.vue` | Work/rest time inputs shown only when type = TABATA; saved to `timer_config` |
|
||||
| `src/views/SessionFormView.vue` | AMRAP combo item label: "Repetitions" → "Duration (min)" |
|
||||
| `src/views/SessionDetailView.vue` | Same AMRAP label change |
|
||||
| `src/i18n/locales/en.json` + `fr.json` | New keys under `execution.timer.*`, `combos.form.workTime`, `combos.form.restTime`, `sessions.form.durationMin` |
|
||||
| `docs/plan.md` | Step marked done |
|
||||
|
||||
**Note:** Once implemented, Step 10 (PWA) and Step 11 (documentation) will need a follow-up review pass.
|
||||
|
||||
**Verification:** Playwright tests — EMOM timer auto-advances at 0 s, EMOM sounds fire at 30/2/1/0 s, EMOM manual Done advances early and resets timer; AMRAP BIP fires every elapsed minute, AMRAP last-minute bip/bip/BIP pattern fires correctly, AMRAP time-up screen appears at 0 and Continue advances; TABATA work→rest→next-exercise cycle, BIP at start of each phase, pause works in both phases; TABATA ComboForm shows work/rest inputs only for TABATA type and values round-trip correctly; session form shows "Duration (min)" for AMRAP combo items; non-timed combos (NONE) show no timer and behave as in Step 8; mixed session (timed + non-timed items) works end-to-end; pause freezes countdown and suppresses sounds; resume continues from frozen value
|
||||
|
||||
---
|
||||
|
||||
### Step 13 — Markdown in Description Fields
|
||||
|
||||
**Status:** Done
|
||||
|
||||
- Enabled `marked` + `DOMPurify` for rendering description fields in exercise, combo, and session detail views (already used as dependencies; this step wired them into the detail views)
|
||||
- Added "Basic Markdown supported" hint text next to each description label in create and edit forms
|
||||
- Description is intentionally **not** shown on list view cards (only in detail views)
|
||||
- `src/utils/markdown.js` centralises the sanitized rendering pipeline; all three detail views call it via `v-html`
|
||||
|
||||
**New / modified files:**
|
||||
|
||||
- `src/utils/markdown.js` — `renderMarkdown(text)` using `marked.parse` + `DOMPurify.sanitize`
|
||||
- `src/views/ExerciseDetailView.vue`, `src/views/ComboDetailView.vue`, `src/views/SessionDetailView.vue` — render description via `v-html`
|
||||
- `src/views/ExerciseFormView.vue`, `src/views/ComboFormView.vue`, `src/views/SessionFormView.vue` — hint text alongside description label
|
||||
- `src/i18n/locales/en.json`, `fr.json` — `"markdownHint"` translation key
|
||||
|
||||
**Verification:** Playwright tests — markdown hint visible on exercise/combo/session forms; bold, italic, and list items render as HTML in exercise detail; bold renders in combo and session details; description absent from all list cards
|
||||
|
||||
---
|
||||
|
||||
### Step 14 — Form Keyboard Shortcuts
|
||||
|
||||
**Status:** Done
|
||||
|
||||
- `Ctrl+Enter` saves any create or edit form, even when a text field is focused
|
||||
- `Escape` cancels the form and navigates back (to list on new forms, to detail on edit forms), even when a text field is focused
|
||||
- Save button exposes a `title` attribute showing the shortcut hint (visible as a native tooltip on hover)
|
||||
- Shortcuts are attached to the form element's `keydown` event and skip when the browser is composing (IME)
|
||||
- Applies to Exercise, Combo, Session, and Collection forms
|
||||
|
||||
**Modified files:**
|
||||
|
||||
- `src/views/ExerciseFormView.vue`, `src/views/ComboFormView.vue`, `src/views/SessionFormView.vue`, `src/views/CollectionFormView.vue` — keydown handler, save button `title`
|
||||
- `src/i18n/locales/en.json`, `fr.json` — `"saveShortcutHint"` translation key
|
||||
|
||||
**Verification:** Playwright tests — `Ctrl+Enter` saves from body and from within an input; `Ctrl+Enter` with empty title shows validation error (does not save); `Escape` from body and from within an input navigates back; `Escape` on edit form returns to detail view; Save button title contains the shortcut hint; shortcuts confirmed on session, combo, and collection forms
|
||||
|
||||
---
|
||||
|
||||
### Step 15 — Audio Cues Refinement
|
||||
|
||||
**Status:** Done
|
||||
|
||||
Iterative refinement of the audio cue timing introduced in Step 12, driven by deterministic fake-clock tests that exposed subtle off-by-one issues in the scheduling logic.
|
||||
|
||||
- **TABATA:** `playBip` fires at the exact halfway point of each work and rest interval (not at 30 s absolute like EMOM — relative to the phase duration)
|
||||
- **TABATA:** `bip, bip, BIP` countdown fires at `r = 2`, `r = 1`, and phase expiry for both work and rest phases
|
||||
- **AMRAP:** `playBip` (soft) fires at each elapsed full minute tick; the half-time signal uses `playBIP` (loud) — both corrected to fire on the right tick
|
||||
- Tests use `page.clock.install()` to control fake time, making timer assertions instant and deterministic without real `setTimeout` waits
|
||||
|
||||
**Modified files:**
|
||||
|
||||
- `src/composables/useSessionExecution.js` — `_tabataCountdown`, `_amrapSounds`, `_maybeStartAmrapTimer` helpers adjusted for correct relative timing
|
||||
- `tests/test_step15_audio_cues.py` — new test file using `AudioContext` mock + `page.clock`
|
||||
|
||||
**Verification:** Playwright tests — TABATA bip at half work/rest interval; bip/bip/BIP countdown at end of work and rest phases; AMRAP per-minute soft bip fires correctly; all assertions driven by fake clock
|
||||
|
||||
---
|
||||
|
||||
### Step 16 — Single Deployment + Startup DB Migration
|
||||
|
||||
**Status:** Implemented — detailed plan in [`plan-step16-single-deployment-migration.md`](plan-step16-single-deployment-migration.md); awaiting manual validation by the user
|
||||
|
||||
Replaces the "versioned deployments with isolated data" model: a single deployment (latest version at site root), a stable OPFS database location (no version suffix), and a transactional migration step at app startup driven by the `RELEASES` registry. When a migration is pending, a blocking screen forces the user to download a backup before the update runs. No `SCHEMA_VERSION` bump (stays at 1).
|
||||
|
||||
**Key files:**
|
||||
|
||||
- `src/db/releases.js` — `adaptScript` renamed to `migrationScript`; new `getMigrationScripts(from, to)` helper shared by startup migration and restore adaptation
|
||||
- `src/db/worker.js` — stable OPFS directory (`.trainus`); `getSchemaStatus` (fresh / up-to-date / incompatible / migration-pending) and `runMigrations` (one transaction per release, rollback on failure) message handlers
|
||||
- `src/db/database.js` — init pauses on `migration-pending` (sets `db.migrationPending`); `runPendingMigrations()`; error codes `DB_NEWER_THAN_APP` / `DB_MIGRATION_FAILED`
|
||||
- `src/App.vue` — blocking migration screen (forced backup download enables the Update button) + error-code → translated message mapping
|
||||
- `src/composables/useBackupDownload.js` — backup download logic shared by SettingsView and the migration screen
|
||||
- `tests/test_step16_migration.py` — green + red paths, Firefox + Chromium
|
||||
|
||||
**Verification:** Playwright tests — fresh install stamps `schema_version`; data persists across reloads; OPFS dir is unversioned; full migration flow (forced backup → Update → data intact); DB-newer-than-app blocks safely; migration screen cannot be bypassed. Migration-failure rollback test deferred until a real v2 migration script exists.
|
||||
|
||||
---
|
||||
|
||||
### Improvement Plan (post-v1.0 review) — `docs/improvement-plan.md`
|
||||
|
||||
**Status:** Implemented — awaiting manual validation by the user
|
||||
|
||||
All items from the improvement plan have been implemented. See `docs/improvement-plan.md` for the original descriptions and DoDs.
|
||||
|
||||
| Item | Description | Status |
|
||||
| ---- | -------------------------------------------------------- | ------- |
|
||||
| R1 | Batch transactions for multi-statement repository writes | ✅ Done |
|
||||
| R2 | Atomic full-DB restore (single transaction) | ✅ Done |
|
||||
| R3 | Wall-clock timer (timestamp-based, drift-free) | ✅ Done |
|
||||
| R4-B | Execution log status column + route-leave guard | ✅ Done |
|
||||
| R5 | DB worker crash → error overlay + immediate reject | ✅ Done |
|
||||
| R6 | Remove duplicate `getAllWithItems` key | ✅ Done |
|
||||
| R7 | Deduplicate AMRAP round-adder (`_appendRound`) | ✅ Done |
|
||||
| R8 | Add ESLint (flat config, 0 errors/warnings) | ✅ Done |
|
||||
| R9 | Fix O(n²) import (Map-based title lookup) | ✅ Done |
|
||||
| R10 | Escape LIKE wildcards in all `search()` methods | ✅ Done |
|
||||
| R11 | `validateMin` returns `{key, params}` for interpolation | ✅ Done |
|
||||
| R12 | 404 catch-all route → redirect to /home | ✅ Done |
|
||||
| R13 | Superseded by Step 16 | ✅ N/A |
|
||||
| R14 | Cross-user import skips settings | ✅ Done |
|
||||
| R15 | Delete confirms show history count | ✅ Done |
|
||||
| R16 | GitHub Actions CI (lint + build + test) | ✅ Done |
|
||||
| R17 | Documentation refresh (locale claims, COOP/COEP, lint) | ✅ Done |
|
||||
| R18 | Test-infra: isolate tmp paths per pytest run | ✅ Done |
|
||||
|
||||
**Key files changed:** `src/db/worker.js`, `src/db/database.js`, `src/db/schema.js`, `src/db/repositories/*.js`, `src/composables/useTimer.js`, `src/composables/useSessionExecution.js`, `src/composables/useJsonImport.js`, `src/views/SessionExecuteView.vue`, `src/views/ExerciseDetailView.vue`, `src/views/SessionDetailView.vue`, `src/router/index.js`, `src/utils/escapeLike.js`, `src/utils/validation.js`, `eslint.config.js`, `.github/workflows/ci.yml`, `tests/conftest.py`, `tests/test_step17_transactions.py`, `tests/test_step18_execution_abandon.py`, `tests/test_step19_db_resilience.py`, `tests/test_step20_search_escaping.py`
|
||||
|
||||
---
|
||||
|
||||
### Website — Hugo static site (`website/`)
|
||||
|
||||
**Status:** Steps 1–5 done (2026-06-14) — awaiting manual validation
|
||||
|
||||
Hugo 0.123.7 static site under `website/`. FR default at `/`, EN at `/en/`. Deployed to a subpath of the personal domain on Uberspace (manual deploy, no CI). No tutorials section. Blog lives on this project site.
|
||||
|
||||
| Step | Description | Status |
|
||||
| ---- | ------------------------------------------------------------------------------- | ------- |
|
||||
| W1 | Scaffold: `hugo new site`, `hugo.toml`, npm scripts, `.gitignore` | ✅ Done |
|
||||
| W2 | Base theme: `baseof.html`, partials, `main.css`, `single.html`, `list.html` | ✅ Done |
|
||||
| W3 | Home page: hero, greyed CTA button, features grid, i18n strings | ✅ Done |
|
||||
| W4 | Docs: port EN docs, FR skeletons, Mermaid hook, stub old docs, update AGENTS.md | ✅ Done |
|
||||
| W5 | Blog: `_index`, first FR post + EN skeleton, RSS autodiscovery verified | ✅ Done |
|
||||
| W6 | Deploy: set `baseURL`, set `params.appUrl`, upload `dist/` to Uberspace | 🔲 TODO |
|
||||
| W7 | EN docs: port `user-guide.en.md` to the FR Diataxis split (2026-06-16) | ✅ Done |
|
||||
|
||||
**Key files:** `website/hugo.toml`, `website/assets/css/main.css`, `website/layouts/`, `website/content/docs/`, `website/content/blog/`, `website/i18n/`
|
||||
|
||||
**Known quirk:** Mermaid render hook must be at `layouts/_default/_markup/render-codeblock-mermaid.html` (not `layouts/_markup/` — Ubuntu pkg 0.123.7 only picks up the `_default/` path).
|
||||
|
||||
---
|
||||
|
||||
### Backlog — JSON import: title-conflict resolution UX
|
||||
|
||||
**Status:** ✅ Done (2026-06-16)
|
||||
|
||||
**Found:** 2026-06-16, while writing the user documentation and tracing `useJsonImport.js` to answer a question about what drives import conflict detection. Conflict detection during import was `id`-only: a title match against a _different_ existing row either silently failed (same-user) or only offered a forced rename (cross-user, after suffixing).
|
||||
|
||||
**What changed:**
|
||||
|
||||
- `_analyzeEntityList` now detects title conflicts (different id, same title) for same-user imports and folds them into the existing `sameUserCollisions` bucket — the same Replace/Skip screen (with batch Replace All / Skip All) used for id conflicts handles them with zero new UI.
|
||||
- `_importEntityList` resolves same-user collisions against `collision.existing.id` (not `collision.imported.id`), so Replace updates the existing row in place and Skip leaves it untouched — both redirect any same-batch reference to the imported id (e.g. a combo's `exercise_id`) to the existing row's id via the same `idMap` mechanism already used for cross-user `new_id` remapping.
|
||||
- Cross-user title conflicts (suffixed title collides with a previously-imported item from the same external user) now also resolve via Replace/Skip instead of a forced rename — `ImportDialog.vue` folds them into the same per-entity-type resolution arrays once detected in `onApply`.
|
||||
- **Cleanup:** `analyzeExercises`, `applySameUserImport`, `applyDifferentUserImport`, and `checkTitleConflict` in `useJsonImport.js` turned out to be dead code (confirmed via grep — never called for applying; `analyzeAll` already produces an identical shape for exercise-only envelopes). Removed, along with the "legacy exercise-only" branch in `ImportDialog.vue`'s `onMounted` and the now-unreachable forced-rename `titleConflict` step/state. `analyzeAll`/`applyAllImport` now handle every envelope shape.
|
||||
|
||||
**Key files changed:** `src/composables/useJsonImport.js`, `src/components/ImportDialog.vue`, `src/i18n/locales/{en,fr,da}.json`, `tests/test_step21_import_title_conflicts.py` (new), `website/content/docs/comprendre.fr.md`, `website/content/docs/user-guide/index.fr.md`, `website/content/docs/user-guide.en.md`, `website/content/developers/technical.en.md`.
|
||||
|
||||
**Out of scope (unchanged):** an id-collision where the _new_ title also conflicts with an unrelated third row still surfaces as a raw DB error, same as before.
|
||||
|
||||
---
|
||||
|
||||
### Backlog — Save filename prompt on every disk save
|
||||
|
||||
**Status:** ✅ Done (2026-06-16)
|
||||
|
||||
**Found:** 2026-06-16, user request — every file save (JSON exports, home HTML export, SQLite backup) should ask for a filename, prefilled with the previous default, before saving.
|
||||
|
||||
**What changed:**
|
||||
|
||||
- New singleton composable `useSaveFilePrompt.js` (`promptFilename(defaultFilename)` → `Promise<string|null>`, splits the default into stem + extension) and new component `SaveFileDialog.vue` (editable stem, fixed extension, Enter-to-confirm), mounted once in `App.vue` so it's available app-wide, including the blocking pre-migration backup screen.
|
||||
- The 3 existing low-level download functions — `downloadJson` (`jsonEnvelope.js`), `downloadBackup` (`useBackupDownload.js`), the local `downloadHtml` (`useHtmlExport.js`) — call the prompt before building the blob/anchor and no-op on cancel. No view-level export call site changed.
|
||||
- `downloadBackup()` now returns `false` on cancel; `App.vue`'s `onMigrationBackup` only enables the Update button if the save actually completed.
|
||||
- **Fix found along the way:** `ModalDialog`'s `z-index: 1000` was below the blocking `.db-overlay`'s `9999`, making the dialog unclickable from the migration screen — bumped to `10000`.
|
||||
- **Fix found along the way:** a few export call sites (`useJsonExport.js`'s `downloadJson` calls, `SettingsView.vue`'s `onExportAll`, `HomeView.vue`'s `onExportHtml`) weren't awaited; added `await` since the call now blocks on user input.
|
||||
|
||||
**Key files changed:** `src/composables/useSaveFilePrompt.js` (new), `src/components/SaveFileDialog.vue` (new), `src/utils/jsonEnvelope.js`, `src/composables/useBackupDownload.js`, `src/composables/useHtmlExport.js`, `src/composables/useJsonExport.js`, `src/components/ModalDialog.vue`, `src/App.vue`, `src/views/SettingsView.vue`, `src/views/HomeView.vue`, `src/i18n/locales/{en,fr,da}.json`, `tests/test_step22_save_filename_prompt.py` (new), `tests/test_step2_settings.py`, `tests/test_step3c_backup.py`, `tests/test_step16_migration.py` (updated to confirm the dialog before asserting on the download), `website/content/developers/technical.en.md`, `website/content/docs/user-guide.en.md`, `docs/decisions-log.md`.
|
||||
|
||||
---
|
||||
|
||||
### Backlog — Clear (×) button on list search fields
|
||||
|
||||
**Status:** ✅ Done (2026-06-18)
|
||||
|
||||
**Found:** 2026-06-18, user request — the search field on the four list views (Exercises, Combos, Sessions, Collections) had no quick way to clear the text short of selecting/deleting it manually or toggling search off and back on.
|
||||
|
||||
**What changed:**
|
||||
|
||||
- Each of `ExerciseListView.vue`, `ComboListView.vue`, `SessionListView.vue`, `CollectionsView.vue` gained a `clearSearch()` function and a clear button (`bi-x-lg`) rendered inside the search input, visible only when the field has text. The button uses `@mousedown.prevent` (not `@click`) so it clears focus-safely without triggering the existing blur-to-close-search-bar logic on the old value.
|
||||
- New shared i18n key `common.clearSearch` (EN/FR/DA) for the button's `title`/`aria-label`.
|
||||
- New `data-testid="<entity>-search-clear"` per view, following the existing `<entity>-search-*` convention.
|
||||
|
||||
**Key files changed:** `src/views/ExerciseListView.vue`, `src/views/ComboListView.vue`, `src/views/SessionListView.vue`, `src/views/CollectionsView.vue`, `src/i18n/locales/{en,fr,da}.json`, `tests/test_step3_exercises.py`, `tests/test_step4_combos.py`, `tests/test_step5_sessions.py`, `tests/test_step6_collections_home.py`, `website/content/docs/user-guide/index.{en,fr}.md`, `website/content/developers/technical.{en,fr}.md`, `docs/decisions-log.md`.
|
||||
|
||||
---
|
||||
|
||||
## Data Model
|
||||
|
||||
```mermaid
|
||||
erDiagram
|
||||
SETTINGS {
|
||||
text key PK
|
||||
text value
|
||||
}
|
||||
EXERCISE {
|
||||
text id PK
|
||||
text title UK
|
||||
text description
|
||||
boolean asymmetric
|
||||
boolean alternate
|
||||
text image_urls
|
||||
text video_urls
|
||||
integer default_reps
|
||||
real default_weight
|
||||
text created_by
|
||||
}
|
||||
COMBO {
|
||||
text id PK
|
||||
text title UK
|
||||
text description
|
||||
text type
|
||||
text timer_config
|
||||
text created_by
|
||||
}
|
||||
COMBO_EXERCISE {
|
||||
text combo_id FK
|
||||
text exercise_id FK
|
||||
integer position
|
||||
integer default_reps
|
||||
real default_weight
|
||||
}
|
||||
SESSION {
|
||||
text id PK
|
||||
text title UK
|
||||
text description
|
||||
text created_by
|
||||
}
|
||||
SESSION_ITEM {
|
||||
text session_id FK
|
||||
text item_id
|
||||
text item_type
|
||||
integer position
|
||||
integer repetitions
|
||||
real weight
|
||||
}
|
||||
COLLECTION {
|
||||
text id PK
|
||||
text label UK
|
||||
text created_by
|
||||
}
|
||||
COLLECTION_SESSION {
|
||||
text collection_id FK
|
||||
text session_id FK
|
||||
integer position
|
||||
}
|
||||
EXECUTION_LOG {
|
||||
text id PK
|
||||
text session_id FK
|
||||
datetime started_at
|
||||
datetime finished_at
|
||||
}
|
||||
EXECUTION_LOG_ITEM {
|
||||
text id PK
|
||||
text log_id FK
|
||||
text exercise_id FK
|
||||
text combo_id
|
||||
integer round_number
|
||||
integer reps_done
|
||||
real weight_used
|
||||
text side
|
||||
boolean completed
|
||||
datetime timestamp
|
||||
}
|
||||
SUFFIX {
|
||||
text user_uuid PK
|
||||
text user_name
|
||||
text suffix UK
|
||||
}
|
||||
|
||||
COMBO ||--|{ COMBO_EXERCISE : contains
|
||||
EXERCISE ||--o{ COMBO_EXERCISE : "used in"
|
||||
SESSION ||--|{ SESSION_ITEM : contains
|
||||
COLLECTION ||--|{ COLLECTION_SESSION : contains
|
||||
SESSION ||--o{ COLLECTION_SESSION : "used in"
|
||||
SESSION ||--o{ EXECUTION_LOG : "executed as"
|
||||
EXECUTION_LOG ||--|{ EXECUTION_LOG_ITEM : records
|
||||
EXERCISE ||--o{ EXECUTION_LOG_ITEM : tracks
|
||||
```
|
||||
|
||||
`SETTINGS` stores: `user_uuid`, `user_name`, `app_version`, `language`, `theme`, `db_created_at`, `last_backup_at`, `backup_reminder_days` as key/value rows. `created_by` on entities = UUID string for export provenance. `SUFFIX` maps external user UUIDs to short suffix strings used to disambiguate imported titles (e.g., `"Push-ups [JD]"`).
|
||||
|
||||
---
|
||||
|
||||
## Decisions
|
||||
|
||||
| Decision | Choice | Rationale |
|
||||
| ---------------- | -------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Framework | Vue 3 + Vite | Reactive framework needed for form-heavy CRUD, timers, i18n |
|
||||
| i18n | i18next + vue-i18next | Proven open-source, MIT licensed, rich features |
|
||||
| App version | package.json → Vite inject → DB settings | Single source of truth, available at build + runtime |
|
||||
| User model | Single user per device, identity in settings | No multi-user complexity needed |
|
||||
| Settings order | Step 2 (early) | Language/theme switching before entity CRUD |
|
||||
| SQLite | Official WASM with OPFS VFS | Best maintained, native OPFS |
|
||||
| Charts | Chart.js via vue-chartjs | Good balance of features and size |
|
||||
| Routing | Vue Router, hash mode | No server config needed |
|
||||
| ID collisions | Suffix on title + new UUID | Preserves alphabetical sort; "Title [SUFFIX]" is user-readable; suffix pre-filled with exporter name; batch Replace All / Skip All for same-user conflicts |
|
||||
| OPFS namespacing | Stable directory `.trainus`, no version suffix (Step 16) | OPFS is origin-scoped, so versioned directories never truly isolated data; a stable location preserves data across app updates, with startup migrations handling schema changes |
|
||||
| Timer config | EMOM/AMRAP/TABATA params as JSON | Flexible, stored in `timer_config` column |
|
||||
101
docs/release-procedure.md
Normal file
101
docs/release-procedure.md
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
# Release Procedure
|
||||
|
||||
Describes how to publish a new TrainUs release. A local script prepares everything on your machine; all server-side steps (Forgejo, deployment, website) are done manually afterwards.
|
||||
|
||||
## Pre-release checklist
|
||||
|
||||
Before running the script, verify:
|
||||
|
||||
- [ ] All tests pass on both browsers: `npm run test:parallel`
|
||||
- [ ] `docs/plan.md` is up to date
|
||||
- [ ] Documentation updated (`website/content/developers/technical.en.md`, `website/content/docs/user-guide/index.en.md`, `docs/decisions-log.md`)
|
||||
- [ ] Working tree is clean on `main`
|
||||
|
||||
## Step 1 — Run the local script
|
||||
|
||||
```bash
|
||||
./scripts/release.sh
|
||||
```
|
||||
|
||||
The script asks for the new version (semver, e.g. `1.1.0`), then displays the full plan and asks for confirmation before doing anything. It then executes the following steps in order, printing what it does before each one:
|
||||
|
||||
Before displaying the plan, the script lists all commits since the last tag (or all commits if no tag exists yet). This helps you choose the right version bump (patch/minor/major) and serves as the raw material for the release notes.
|
||||
|
||||
| # | What | Command / output |
|
||||
| --- | ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 1 | Bump version | `npm version X.Y.Z --no-git-tag-version` → updates `package.json` and `package-lock.json` |
|
||||
| 2 | Build the app | `npm run build` → outputs to `dist/` |
|
||||
| 3 | Release commit | `git commit -m "chore: release vX.Y.Z"` on the current branch |
|
||||
| 4 | Annotated tag | `git tag -a vX.Y.Z -m "Release vX.Y.Z"` — local only, not pushed |
|
||||
| 5 | Source archive | `git archive --format=zip HEAD --output=trainus-vX.Y.Z-src.zip` — tracked files only; excludes `node_modules/`, `dist/`, `.git/`, and untracked files |
|
||||
| 6 | Draft release notes | Writes `trainus-vX.Y.Z-notes.md` — a Markdown template pre-filled with the commit list, ready to edit |
|
||||
|
||||
Nothing is pushed or sent anywhere during this step.
|
||||
|
||||
### Artifacts produced
|
||||
|
||||
| File | Purpose |
|
||||
| ------------------------- | ------------------------------------------------------------------ |
|
||||
| `dist/` | Built PWA — deploy to your static host |
|
||||
| `trainus-vX.Y.Z-src.zip` | Source archive — attach to the Forgejo release |
|
||||
| `trainus-vX.Y.Z-notes.md` | Draft release notes — edit, then paste as the Forgejo release body |
|
||||
|
||||
## Step 2 — Edit the release notes
|
||||
|
||||
Open `trainus-vX.Y.Z-notes.md` in your editor. The file contains:
|
||||
|
||||
- A template with "What's new", "Bug fixes", and "Breaking changes" sections
|
||||
- The raw commit list since the last release, for reference
|
||||
|
||||
Rewrite the sections into polished prose, remove the raw commit list, then save the file.
|
||||
|
||||
## Step 3 — Push to Forgejo
|
||||
|
||||
```bash
|
||||
git push origin main
|
||||
git push origin vX.Y.Z
|
||||
```
|
||||
|
||||
## Step 4 — Create the release on Forgejo
|
||||
|
||||
In your Forgejo repository → **Releases → New release**:
|
||||
|
||||
- **Tag**: `vX.Y.Z`
|
||||
- **Title**: `TrainUs vX.Y.Z`
|
||||
- **Body**: release notes (what's new, bug fixes, breaking changes)
|
||||
- **Attachments**: `trainus-vX.Y.Z-src.zip` (created by the script)
|
||||
|
||||
Publish the release. You can then delete the local archive.
|
||||
|
||||
## Step 5 — Deploy the app
|
||||
|
||||
Upload the contents of `dist/` to your static host (the live PWA).
|
||||
|
||||
## Step 6 — Deploy the website
|
||||
|
||||
```bash
|
||||
npm run site:build # outputs to website/public/
|
||||
```
|
||||
|
||||
Upload the contents of `website/public/` to your website host.
|
||||
|
||||
## Step 7 — Publish the release blog post
|
||||
|
||||
1. Duplicate `website/content/blog/release-template.en.md`
|
||||
2. Rename it `YYYY-MM-DD-release-vX.Y.Z.en.md` (use today's date)
|
||||
3. Fill in every `{PLACEHOLDER}` field
|
||||
4. Set `draft: false` when the post is ready to publish
|
||||
5. Write the French version yourself: `YYYY-MM-DD-release-vX.Y.Z.fr.md`
|
||||
6. Rebuild and redeploy the website (step 5)
|
||||
|
||||
## Versioning
|
||||
|
||||
TrainUs uses **semantic versioning**: `MAJOR.MINOR.PATCH`
|
||||
|
||||
| Increment | When |
|
||||
| --------- | ---------------------------------- |
|
||||
| `PATCH` | Bug fixes only, no new features |
|
||||
| `MINOR` | New features, backwards-compatible |
|
||||
| `MAJOR` | Breaking change or major redesign |
|
||||
|
||||
Git tags use the `v` prefix convention: `v1.0.0`, `v1.1.0`, `v2.0.0`, etc.
|
||||
58
eslint.config.js
Normal file
58
eslint.config.js
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import js from '@eslint/js'
|
||||
import vue from 'eslint-plugin-vue'
|
||||
import prettier from 'eslint-config-prettier'
|
||||
|
||||
export default [
|
||||
{
|
||||
ignores: ['dist/**', 'tests/**', 'public/**', 'node_modules/**'],
|
||||
},
|
||||
js.configs.recommended,
|
||||
...vue.configs['flat/recommended'],
|
||||
prettier,
|
||||
{
|
||||
languageOptions: {
|
||||
globals: {
|
||||
__APP_VERSION__: 'readonly',
|
||||
__APP_NAME__: 'readonly',
|
||||
// Browser globals
|
||||
window: 'readonly',
|
||||
document: 'readonly',
|
||||
console: 'readonly',
|
||||
crypto: 'readonly',
|
||||
navigator: 'readonly',
|
||||
self: 'readonly',
|
||||
fetch: 'readonly',
|
||||
setTimeout: 'readonly',
|
||||
clearTimeout: 'readonly',
|
||||
setInterval: 'readonly',
|
||||
clearInterval: 'readonly',
|
||||
URL: 'readonly',
|
||||
URLSearchParams: 'readonly',
|
||||
Blob: 'readonly',
|
||||
File: 'readonly',
|
||||
FileReader: 'readonly',
|
||||
CustomEvent: 'readonly',
|
||||
Worker: 'readonly',
|
||||
Promise: 'readonly',
|
||||
Map: 'readonly',
|
||||
Set: 'readonly',
|
||||
// Web APIs
|
||||
AudioContext: 'readonly',
|
||||
TextEncoder: 'readonly',
|
||||
TextDecoder: 'readonly',
|
||||
btoa: 'readonly',
|
||||
atob: 'readonly',
|
||||
// Worker globals
|
||||
importScripts: 'readonly',
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
'no-unused-vars': [
|
||||
'warn',
|
||||
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_', caughtErrorsIgnorePattern: '^_' },
|
||||
],
|
||||
'vue/multi-word-component-names': 'off',
|
||||
'vue/no-v-html': 'off',
|
||||
},
|
||||
},
|
||||
]
|
||||
21
index.html
Normal file
21
index.html
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content="TrainUs — Local-first fitness training app" />
|
||||
<meta name="theme-color" content="#1a1a1a" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<link rel="icon" type="image/svg+xml" href="/icon.svg" />
|
||||
<link rel="icon" type="image/png" href="/icon-192.png" />
|
||||
<link rel="apple-touch-icon" href="/icon-192.png" />
|
||||
<title>TrainUs</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
11
jsconfig.json
Normal file
11
jsconfig.json
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"target": "ESNext",
|
||||
"jsx": "preserve",
|
||||
"baseUrl": "."
|
||||
},
|
||||
"include": ["src/**/*", "vite.config.js"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
15
notice.md
Normal file
15
notice.md
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
TrainUs — an open, local-first, offline-capable fitness tracking application where you can create and share your own training sessions.
|
||||
Copyright (C) 2026 [David Florance](http://kaivalya.eu)
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
7144
package-lock.json
generated
Normal file
7144
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
45
package.json
Normal file
45
package.json
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
{
|
||||
"name": "trainus",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"format": "prettier --write .",
|
||||
"format:check": "prettier --check .",
|
||||
"lint": "eslint src",
|
||||
"lint:fix": "eslint src --fix",
|
||||
"test": "cd tests && .venv/bin/python -m pytest --browser firefox -v",
|
||||
"test:chromium": "cd tests && .venv/bin/python -m pytest --browser chromium -v",
|
||||
"test:parallel": "cd tests && .venv/bin/python -m pytest --browser firefox --browser chromium --dist loadfile -n auto -v",
|
||||
"test:parallel:firefox": "cd tests && .venv/bin/python -m pytest --browser firefox --dist loadfile -n auto -v",
|
||||
"test:parallel:chromium": "cd tests && .venv/bin/python -m pytest --browser chromium --dist loadfile -n auto -v",
|
||||
"test:parallel:n": "cd tests && .venv/bin/python -m pytest --browser firefox --browser chromium --dist loadfile -n ${WORKERS:-4} -v",
|
||||
"site:dev": "cd website && hugo server --buildDrafts --port 1313",
|
||||
"site:build": "cd website && hugo --minify"
|
||||
},
|
||||
"dependencies": {
|
||||
"@sqlite.org/sqlite-wasm": "^3.51.2-build7",
|
||||
"bootstrap-icons": "^1.13.1",
|
||||
"chart.js": "^4.5.1",
|
||||
"dompurify": "^3.4.7",
|
||||
"i18next": "^25.8.17",
|
||||
"i18next-vue": "^5.4.0",
|
||||
"marked": "^18.0.4",
|
||||
"vue": "^3.5.25",
|
||||
"vue-chartjs": "^5.3.3",
|
||||
"vue-router": "^4.6.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@vitejs/plugin-vue": "^6.0.2",
|
||||
"eslint": "^10.5.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-vue": "^10.9.2",
|
||||
"prettier": "^3.8.1",
|
||||
"vite": "^7.3.1",
|
||||
"vite-plugin-pwa": "^1.3.0"
|
||||
}
|
||||
}
|
||||
BIN
public/icon-192.png
Normal file
BIN
public/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
BIN
public/icon-512.png
Normal file
BIN
public/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 42 KiB |
10
public/icon.svg
Normal file
10
public/icon.svg
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#1E7FC0"/>
|
||||
<stop offset="100%" stop-color="#12A892"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="512" height="512" rx="112" fill="url(#bg)"/>
|
||||
<path d="m256 28-32 128c-32-16-64-48-96-96 0 48 0 96 32 128-32 17-64 0-96-32 0 32 0 80 48 112-32 16-64 0-80-32 0 48 16 96 48 128-16 16-48 0-64-16 0 64 48 112 112 144h76.8l16.7-68.6-17.2-86.1-97.9 5s20.3-75.2 34.9-103.7c5-9.6 7.2-18 20-18.3 11.3 0 20.4 9.8 20.4 21.9 0 12-9.1 21.8-20.4 21.8-2.3 0-4.6-.5-6.6-1.3l-5.1 46.8c29.6-8.9 56.9-18.8 84-30.9 0-.1-.1-.2-.1-.3-6.2-8.8-10.4-21.5-10.4-35.7 0-14.1 4.1-26.8 10.4-35.7 6.1-8.9 14.1-13.7 22.5-13.7 8.5 0 16.5 4.8 22.6 13.7 6.2 8.9 10.2 21.6 10.2 35.7 0 14.2-4 26.9-10.2 35.7-.1.3-.5.7-.6.9 27.3 12.1 56.1 20.6 84.3 30.3l-5-46.8c-2.2.8-4.3 1.3-6.7 1.3-11.2 0-20.3-9.8-20.3-21.8 0-12.1 9.1-21.9 20.3-21.9 12.8.3 15.2 8.7 20 18.3 14.8 28.5 35 103.7 35 103.7l-97.9-5-17.2 86.1 16.7 68.6H384c64-32 112-80 112-144-16 16-48 32-64 16 32-32 48-80 48-128-16 32-48 48-80 32 48-32 48-80 48-112-32 32-64 48-96 32 32-32 32-80 32-128-32 48-64 80-96 96z" fill="#fff" fill-opacity="0.92"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
25
public/manifest.json
Normal file
25
public/manifest.json
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
{
|
||||
"name": "TrainUs",
|
||||
"short_name": "TrainUs",
|
||||
"description": "Local-first fitness training app",
|
||||
"lang": "en",
|
||||
"start_url": "./",
|
||||
"scope": "./",
|
||||
"display": "standalone",
|
||||
"background_color": "#1a1a1a",
|
||||
"theme_color": "#1a1a1a",
|
||||
"icons": [
|
||||
{
|
||||
"src": "./icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "./icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
1
public/mighty-force.svg
Normal file
1
public/mighty-force.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg style="height: 512px; width: 512px;" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path d="M0 0h512v512H0z" fill="#000" fill-opacity="1"></path><g class="" style="" transform="translate(0,0)"><path d="m256 28-32 128c-32-16-64-48-96-96 0 48 0 96 32 128-32 17-64 0-96-32 0 32 0 80 48 112-32 16-64 0-80-32 0 48 16 96 48 128-16 16-48 0-64-16 0 64 48 112 112 144h76.8l16.7-68.6-17.2-86.1-97.9 5s20.3-75.2 34.9-103.7c5-9.6 7.2-18 20-18.3 11.3 0 20.4 9.8 20.4 21.9 0 12-9.1 21.8-20.4 21.8-2.3 0-4.6-.5-6.6-1.3l-5.1 46.8c29.6-8.9 56.9-18.8 84-30.9 0-.1-.1-.2-.1-.3-6.2-8.8-10.4-21.5-10.4-35.7 0-14.1 4.1-26.8 10.4-35.7 6.1-8.9 14.1-13.7 22.5-13.7 8.5 0 16.5 4.8 22.6 13.7 6.2 8.9 10.2 21.6 10.2 35.7 0 14.2-4 26.9-10.2 35.7-.1.3-.5.7-.6.9 27.3 12.1 56.1 20.6 84.3 30.3l-5-46.8c-2.2.8-4.3 1.3-6.7 1.3-11.2 0-20.3-9.8-20.3-21.8 0-12.1 9.1-21.9 20.3-21.9 12.8.3 15.2 8.7 20 18.3 14.8 28.5 35 103.7 35 103.7l-97.9-5-17.2 86.1 16.7 68.6H384c64-32 112-80 112-144-16 16-48 32-64 16 32-32 48-80 48-128-16 32-48 48-80 32 48-32 48-80 48-112-32 32-64 48-96 32 32-32 32-80 32-128-32 48-64 80-96 96z" fill="#fff" fill-opacity="1"></path></g></svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
234
scripts/release.sh
Executable file
234
scripts/release.sh
Executable file
|
|
@ -0,0 +1,234 @@
|
|||
#!/usr/bin/env bash
|
||||
# TrainUs release script — local steps only.
|
||||
# Prepares a release commit, tag, source archive, and a draft release notes file.
|
||||
# Everything that touches Forgejo or a remote server is left to you (see checklist at the end).
|
||||
# Usage: ./scripts/release.sh
|
||||
set -euo pipefail
|
||||
|
||||
GREEN='\033[0;32m'; YELLOW='\033[1;33m'; CYAN='\033[0;36m'; BOLD='\033[1m'; NC='\033[0m'
|
||||
RED='\033[0;31m'; DIM='\033[2m'
|
||||
info() { echo -e " ${GREEN}✓${NC} $*"; }
|
||||
doing() { echo -e "\n${CYAN}▶${NC} $*"; }
|
||||
warn() { echo -e " ${YELLOW}⚠${NC} $*"; }
|
||||
error() { echo -e "\n${RED}✗ Error:${NC} $*" >&2; exit 1; }
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
cd "$ROOT_DIR"
|
||||
|
||||
# ── Prerequisites ─────────────────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo -e "${BOLD}Checking prerequisites...${NC}"
|
||||
|
||||
command -v npm >/dev/null 2>&1 || error "npm is not installed."
|
||||
command -v git >/dev/null 2>&1 || error "git is not installed."
|
||||
info "npm and git are available."
|
||||
|
||||
if [[ -n "$(git status --porcelain)" ]]; then
|
||||
error "Working tree is not clean. Commit or stash your changes before releasing."
|
||||
fi
|
||||
info "Working tree is clean."
|
||||
|
||||
CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD)
|
||||
if [[ "$CURRENT_BRANCH" != "main" ]]; then
|
||||
warn "Current branch is '$CURRENT_BRANCH', not 'main'."
|
||||
fi
|
||||
info "On branch: $CURRENT_BRANCH"
|
||||
|
||||
# ── Commits since last release ────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo -e "${BOLD}Changes since last release${NC}"
|
||||
|
||||
LAST_TAG=$(git describe --tags --abbrev=0 HEAD 2>/dev/null || echo "")
|
||||
|
||||
if [[ -n "$LAST_TAG" ]]; then
|
||||
echo " Last release: $LAST_TAG"
|
||||
echo " Commits since $LAST_TAG:"
|
||||
echo ""
|
||||
COMMIT_LOG=$(git log "${LAST_TAG}..HEAD" --oneline --no-merges)
|
||||
else
|
||||
warn "No previous tag found — listing all commits."
|
||||
echo ""
|
||||
COMMIT_LOG=$(git log --oneline --no-merges)
|
||||
fi
|
||||
|
||||
if [[ -z "$COMMIT_LOG" ]]; then
|
||||
warn "No commits since $LAST_TAG. Are you sure you want to release?"
|
||||
else
|
||||
while IFS= read -r line; do
|
||||
echo -e " ${DIM}${line}${NC}"
|
||||
done <<< "$COMMIT_LOG"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
# ── Version ───────────────────────────────────────────────────────────────────
|
||||
echo -e "${BOLD}Version${NC}"
|
||||
CURRENT_VERSION=$(node -p "require('./package.json').version")
|
||||
echo " Current version: $CURRENT_VERSION"
|
||||
echo -n " New version (semver, e.g. 1.1.0): "
|
||||
read -r VERSION
|
||||
|
||||
[[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] \
|
||||
|| error "Invalid format. Use MAJOR.MINOR.PATCH without a 'v' prefix."
|
||||
|
||||
TAG="v$VERSION"
|
||||
ARCHIVE="trainus-${TAG}-src.zip"
|
||||
NOTES_FILE="trainus-${TAG}-notes.md"
|
||||
|
||||
git tag --list | grep -q "^${TAG}$" \
|
||||
&& error "Tag $TAG already exists locally."
|
||||
|
||||
# ── Plan ──────────────────────────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo -e "${BOLD}The script will perform the following steps in order:${NC}"
|
||||
echo ""
|
||||
echo " 1. Bump version in package.json and package-lock.json"
|
||||
echo " $CURRENT_VERSION → $VERSION"
|
||||
echo ""
|
||||
echo " 2. Build the production app"
|
||||
echo " npm run build → dist/"
|
||||
echo ""
|
||||
echo " 3. Create a release commit on branch '$CURRENT_BRANCH'"
|
||||
echo " git commit -m \"chore: release $TAG\""
|
||||
echo ""
|
||||
echo " 4. Create an annotated git tag (local only, not pushed)"
|
||||
echo " git tag -a $TAG -m \"Release $TAG\""
|
||||
echo ""
|
||||
echo " 5. Create a source archive of the current commit"
|
||||
echo " $ARCHIVE (tracked files only, no node_modules, no dist)"
|
||||
echo ""
|
||||
echo " 6. Generate a draft release notes file for you to edit"
|
||||
echo " $NOTES_FILE (pre-filled with the commit list above)"
|
||||
echo ""
|
||||
echo -e "${YELLOW}Nothing will be pushed or sent anywhere. All steps are local.${NC}"
|
||||
echo ""
|
||||
echo -n " Continue? [y/N] "
|
||||
read -r CONFIRM
|
||||
[[ "$CONFIRM" =~ ^[Yy]$ ]] || { echo " Aborted."; exit 0; }
|
||||
|
||||
# ── Step 1 — Bump version ─────────────────────────────────────────────────────
|
||||
doing "Step 1/6 — Bumping version in package.json and package-lock.json"
|
||||
echo " Running: npm version $VERSION --no-git-tag-version"
|
||||
npm version "$VERSION" --no-git-tag-version --silent
|
||||
info "package.json and package-lock.json updated to $VERSION."
|
||||
|
||||
# ── Step 2 — Build ────────────────────────────────────────────────────────────
|
||||
doing "Step 2/6 — Building the production app"
|
||||
echo " Running: npm run build"
|
||||
npm run build
|
||||
info "Build complete. Output is in dist/."
|
||||
|
||||
# ── Step 3 — Commit ───────────────────────────────────────────────────────────
|
||||
doing "Step 3/6 — Creating release commit"
|
||||
echo " Running: git add package.json package-lock.json"
|
||||
echo " git commit -m \"chore: release $TAG\""
|
||||
git add package.json package-lock.json
|
||||
git commit -m "chore: release $TAG"
|
||||
info "Commit created: chore: release $TAG"
|
||||
|
||||
# ── Step 4 — Tag ──────────────────────────────────────────────────────────────
|
||||
doing "Step 4/6 — Creating annotated git tag"
|
||||
echo " Running: git tag -a $TAG -m \"Release $TAG\""
|
||||
git tag -a "$TAG" -m "Release $TAG"
|
||||
info "Tag $TAG created locally."
|
||||
|
||||
# ── Step 5 — Archive ──────────────────────────────────────────────────────────
|
||||
doing "Step 5/6 — Creating source archive"
|
||||
echo " Running: git archive --format=zip HEAD --output=$ARCHIVE"
|
||||
git archive --format=zip HEAD --output="$ARCHIVE"
|
||||
info "Archive created: $ARCHIVE"
|
||||
echo " Contains: all files tracked by git at commit $(git rev-parse --short HEAD)."
|
||||
echo " Excludes: node_modules/, dist/, .git/, and any untracked files."
|
||||
|
||||
# ── Step 6 — Release notes file ───────────────────────────────────────────────
|
||||
doing "Step 6/6 — Generating draft release notes"
|
||||
echo " Writing: $NOTES_FILE"
|
||||
|
||||
if [[ -n "$LAST_TAG" ]]; then
|
||||
RANGE_LABEL="since $LAST_TAG"
|
||||
else
|
||||
RANGE_LABEL="(no previous tag — all commits)"
|
||||
fi
|
||||
|
||||
{
|
||||
echo "# TrainUs $TAG — Release notes"
|
||||
echo ""
|
||||
echo "<!-- Edit this file before pasting into Forgejo. Delete comments. -->"
|
||||
echo ""
|
||||
echo "## What's new"
|
||||
echo ""
|
||||
echo "<!-- Describe new features. -->"
|
||||
echo ""
|
||||
echo "-"
|
||||
echo ""
|
||||
echo "## Bug fixes"
|
||||
echo ""
|
||||
echo "<!-- List bug fixes, or remove this section if none. -->"
|
||||
echo ""
|
||||
echo "-"
|
||||
echo ""
|
||||
echo "## Breaking changes"
|
||||
echo ""
|
||||
echo "None."
|
||||
echo ""
|
||||
echo "---"
|
||||
echo ""
|
||||
echo "## Commits $RANGE_LABEL"
|
||||
echo ""
|
||||
echo "<!-- For reference — trim or reword before publishing. -->"
|
||||
echo ""
|
||||
if [[ -n "$COMMIT_LOG" ]]; then
|
||||
while IFS= read -r line; do
|
||||
echo "- $line"
|
||||
done <<< "$COMMIT_LOG"
|
||||
else
|
||||
echo "- (none)"
|
||||
fi
|
||||
} > "$NOTES_FILE"
|
||||
|
||||
info "Draft release notes written to $NOTES_FILE."
|
||||
echo " Open it in your editor, rewrite the notes, then paste into Forgejo."
|
||||
|
||||
# ── Done — Manual checklist ───────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo -e "${GREEN}${BOLD}Local release steps complete.${NC}"
|
||||
echo ""
|
||||
echo -e "${BOLD}Artifacts created:${NC}"
|
||||
echo " $ARCHIVE ← attach to the Forgejo release"
|
||||
echo " $NOTES_FILE ← edit and paste as the release body"
|
||||
echo ""
|
||||
echo -e "${BOLD}Manual steps remaining:${NC}"
|
||||
echo ""
|
||||
echo -e " ${BOLD}1. Edit the release notes${NC}"
|
||||
echo " Open $NOTES_FILE and rewrite the notes into polished prose."
|
||||
echo ""
|
||||
echo -e " ${BOLD}2. Push the commit and tag to Forgejo${NC}"
|
||||
echo " git push origin $CURRENT_BRANCH"
|
||||
echo " git push origin $TAG"
|
||||
echo ""
|
||||
echo -e " ${BOLD}3. Create the release on Forgejo${NC}"
|
||||
echo " Go to your repository → Releases → New release"
|
||||
echo " Tag: $TAG"
|
||||
echo " Title: TrainUs $TAG"
|
||||
echo " Body: paste the contents of $NOTES_FILE"
|
||||
echo " Attach: $ARCHIVE"
|
||||
echo " Publish the release."
|
||||
echo ""
|
||||
echo -e " ${BOLD}4. Deploy the app${NC}"
|
||||
echo " Upload the contents of dist/ to your static host."
|
||||
echo ""
|
||||
echo -e " ${BOLD}5. Deploy the website${NC}"
|
||||
echo " npm run site:build"
|
||||
echo " Upload the contents of website/public/ to your website host."
|
||||
echo ""
|
||||
echo -e " ${BOLD}6. Publish the release blog post${NC}"
|
||||
echo " Duplicate website/content/blog/release-template.en.md"
|
||||
echo " Rename it: $(date +%Y-%m-%d)-release-${TAG}.en.md"
|
||||
echo " Fill in the placeholders — set draft: false when ready."
|
||||
echo " Write the French version yourself."
|
||||
echo " Rebuild and redeploy the website (step 5)."
|
||||
echo ""
|
||||
echo -e " ${BOLD}7. Clean up local artifacts${NC}"
|
||||
echo " Once uploaded and published: rm $ARCHIVE $NOTES_FILE"
|
||||
echo ""
|
||||
46
scripts/screenshots/README.md
Normal file
46
scripts/screenshots/README.md
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
# Screenshot scripts
|
||||
|
||||
Small, standalone Playwright (Python) scripts that drive the app in a browser to produce PNGs for visual investigations or documentation figures.
|
||||
|
||||
These are **not tests** — they contain no assertions. For the automated test suite, see `tests/`.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Playwright for Python installed (see `tests/requirements.txt`):
|
||||
```bash
|
||||
pip install -r tests/requirements.txt
|
||||
python -m playwright install firefox chromium
|
||||
```
|
||||
- Vite dev server running on port 5199:
|
||||
```bash
|
||||
npm run dev -- --port 5199 --strictPort
|
||||
```
|
||||
|
||||
## Running
|
||||
|
||||
```bash
|
||||
# All pages, modals and dialogs (seeds DB automatically)
|
||||
python scripts/screenshots/all_pages.py
|
||||
python scripts/screenshots/all_pages.py --browser chromium
|
||||
python scripts/screenshots/all_pages.py --width 390 --height 844 # mobile viewport
|
||||
python scripts/screenshots/all_pages.py --out-dir /tmp/shots
|
||||
|
||||
# Exercise list only
|
||||
python scripts/screenshots/exercise_list.py --help
|
||||
python scripts/screenshots/exercise_list.py # Firefox, 1920x1080
|
||||
python scripts/screenshots/exercise_list.py --browser chromium
|
||||
python scripts/screenshots/exercise_list.py --width 2560 --height 1440
|
||||
python scripts/screenshots/exercise_list.py --out /tmp/custom.png
|
||||
```
|
||||
|
||||
## Output location
|
||||
|
||||
By default, PNGs are written to `tmp/` at the project root (which is gitignored). Override with `--out PATH` if you need a different location.
|
||||
|
||||
## Conventions for new scripts
|
||||
|
||||
- One script per page/flow; name it after the page (e.g., `exercise_list.py`, `combo_detail.py`).
|
||||
- Use `argparse` with at least these flags: `--url`, `--browser`, `--width`, `--height`, `--out`.
|
||||
- Default the output to `tmp/<script-name>-<browser>-<WxH>.png`.
|
||||
- Seed the database through the UI if the target page requires data; skip seeding when cards already exist so the script is idempotent.
|
||||
- Keep the script free of assertions — if you want to verify behavior, add a test under `tests/` instead.
|
||||
380
scripts/screenshots/all_pages.py
Normal file
380
scripts/screenshots/all_pages.py
Normal file
|
|
@ -0,0 +1,380 @@
|
|||
"""
|
||||
Capture screenshots of every view, modal, and dialog in the TrainUs app.
|
||||
|
||||
The app is driven in English by default (use --lang fr for French screenshots)
|
||||
with a seeded database so each screenshot shows meaningful content rather
|
||||
than empty states.
|
||||
|
||||
Intended for visual investigations and documentation figures — not a test.
|
||||
|
||||
Prerequisites:
|
||||
- Playwright for Python installed (see `tests/requirements.txt`)
|
||||
- Playwright browsers installed: `python -m playwright install firefox chromium`
|
||||
- Vite dev server running: `npm run dev -- --port 5199 --strictPort`
|
||||
|
||||
Usage (from project root):
|
||||
|
||||
python scripts/screenshots/all_pages.py
|
||||
python scripts/screenshots/all_pages.py --browser chromium
|
||||
python scripts/screenshots/all_pages.py --lang fr
|
||||
python scripts/screenshots/all_pages.py --out-dir /tmp/screenshots
|
||||
python scripts/screenshots/all_pages.py --no-mobile
|
||||
|
||||
Options:
|
||||
|
||||
--url URL App URL (default: http://localhost:5199)
|
||||
--browser NAME firefox | chromium | webkit (default: firefox)
|
||||
--lang LANG en | fr (default: en)
|
||||
--out-dir PATH Output directory root (default: tmp/screenshots)
|
||||
--no-mobile Skip the mobile (390×844) pass
|
||||
|
||||
Exit code: 0 on success, 1 on failure.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import traceback
|
||||
from pathlib import Path
|
||||
|
||||
from playwright.sync_api import Page, sync_playwright
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Seed helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_SEED_SCRIPT = """
|
||||
async () => {
|
||||
const uuid = await window.__repos.settings.get('user_uuid');
|
||||
|
||||
// ── exercises ──────────────────────────────────────────────────────────
|
||||
const ex1 = await window.__repos.exercises.create({
|
||||
title: 'Push-ups',
|
||||
description: '**Classic** bodyweight push-up.\\n\\n- Keep your back straight\\n- Full range of motion',
|
||||
asymmetric: false, alternate: false, image_urls: [], video_urls: [],
|
||||
default_reps: 15, default_weight: 0, created_by: uuid
|
||||
});
|
||||
const ex2 = await window.__repos.exercises.create({
|
||||
title: 'Squats',
|
||||
description: 'Bodyweight squat — great for legs and glutes.',
|
||||
asymmetric: false, alternate: false, image_urls: [], video_urls: [],
|
||||
default_reps: 20, default_weight: 0, created_by: uuid
|
||||
});
|
||||
const ex3 = await window.__repos.exercises.create({
|
||||
title: 'Deadlift',
|
||||
description: 'Barbell deadlift — compound posterior-chain movement.',
|
||||
asymmetric: false, alternate: false, image_urls: [], video_urls: [],
|
||||
default_reps: 5, default_weight: 100, created_by: uuid
|
||||
});
|
||||
const ex4 = await window.__repos.exercises.create({
|
||||
title: 'Single-leg Romanian Deadlift',
|
||||
description: 'Balance and hamstring exercise.\\n\\n*Asymmetric — alternate sides.*',
|
||||
asymmetric: true, alternate: true, image_urls: [], video_urls: [],
|
||||
default_reps: 10, default_weight: 20, created_by: uuid
|
||||
});
|
||||
|
||||
// ── regular combo ──────────────────────────────────────────────────────
|
||||
const comboId = await window.__repos.combos.create({
|
||||
title: 'Morning HIIT',
|
||||
description: 'Quick high-intensity warm-up combo.',
|
||||
type: 'NONE', timer_config: null, created_by: uuid
|
||||
});
|
||||
await window.__repos.combos.setExercises(comboId, [
|
||||
{ exerciseId: ex1, position: 0, defaultReps: 15, defaultWeight: 0 },
|
||||
{ exerciseId: ex2, position: 1, defaultReps: 20, defaultWeight: 0 }
|
||||
]);
|
||||
|
||||
// ── AMRAP combo ────────────────────────────────────────────────────────
|
||||
const amrapComboId = await window.__repos.combos.create({
|
||||
title: 'AMRAP Blast',
|
||||
description: '3-minute AMRAP: push-ups and squats. As many rounds as possible!',
|
||||
type: 'AMRAP', timer_config: {}, created_by: uuid
|
||||
});
|
||||
await window.__repos.combos.setExercises(amrapComboId, [
|
||||
{ exerciseId: ex1, position: 0, defaultReps: 10, defaultWeight: 0 },
|
||||
{ exerciseId: ex2, position: 1, defaultReps: 15, defaultWeight: 0 }
|
||||
]);
|
||||
|
||||
// ── regular session ────────────────────────────────────────────────────
|
||||
const sessId = await window.__repos.sessions.create({
|
||||
title: 'Full-Body Workout A',
|
||||
description: 'A balanced full-body session combining strength and cardio.',
|
||||
created_by: uuid
|
||||
});
|
||||
await window.__repos.sessions.setItems(sessId, [
|
||||
{ item_id: ex3, item_type: 'exercise', repetitions: 5, weight: 100.0 },
|
||||
{ item_id: comboId, item_type: 'combo', repetitions: 3, weight: 0.0 }
|
||||
]);
|
||||
|
||||
// ── AMRAP session (repetitions = minutes for AMRAP type) ───────────────
|
||||
const amrapSessId = await window.__repos.sessions.create({
|
||||
title: 'AMRAP Cardio',
|
||||
description: '3-minute AMRAP push-up/squat circuit.',
|
||||
created_by: uuid
|
||||
});
|
||||
await window.__repos.sessions.setItems(amrapSessId, [
|
||||
{ item_id: amrapComboId, item_type: 'combo', repetitions: 3, weight: 0.0 }
|
||||
]);
|
||||
|
||||
// ── collection ─────────────────────────────────────────────────────────
|
||||
const colId = await window.__repos.collections.create({
|
||||
label: 'Week 1 Plan', created_by: uuid
|
||||
});
|
||||
await window.__repos.collections.setSessions(colId, [sessId]);
|
||||
|
||||
// ── execution log so the home page shows history ───────────────────────
|
||||
const now = Date.now();
|
||||
const logId = await window.__repos.executionLogs.create({
|
||||
session_id: sessId,
|
||||
started_at: new Date(now - 3600000).toISOString(),
|
||||
finished_at: new Date(now - 3000000).toISOString()
|
||||
});
|
||||
await window.__repos.executionLogs.addItem({
|
||||
log_id: logId, exercise_id: ex3, combo_id: null,
|
||||
round_number: 1, reps_done: 5, weight_used: 100, side: null,
|
||||
completed: true, timestamp: new Date(now - 3500000).toISOString()
|
||||
});
|
||||
await window.__repos.executionLogs.addItem({
|
||||
log_id: logId, exercise_id: ex1, combo_id: comboId,
|
||||
round_number: 1, reps_done: 15, weight_used: 0, side: null,
|
||||
completed: true, timestamp: new Date(now - 3400000).toISOString()
|
||||
});
|
||||
|
||||
return { ex1, ex2, ex3, ex4, comboId, amrapComboId, sessId, amrapSessId, colId, logId };
|
||||
}
|
||||
"""
|
||||
|
||||
_COUNT_SCRIPT = """
|
||||
async () => (await window.__repos.exercises.getAll()).length
|
||||
"""
|
||||
|
||||
|
||||
def wait_for_db(page: Page, url: str) -> None:
|
||||
page.goto(url)
|
||||
page.wait_for_function(
|
||||
"document.documentElement.getAttribute('data-db-ready') === 'true'",
|
||||
timeout=15000,
|
||||
)
|
||||
|
||||
|
||||
def ensure_language(page: Page, lang: str) -> None:
|
||||
current = page.evaluate("async () => window.__repos.settings.get('language')")
|
||||
if current != lang:
|
||||
page.evaluate(f"async () => window.__repos.settings.set('language', '{lang}')")
|
||||
page.reload()
|
||||
page.wait_for_function(
|
||||
"document.documentElement.getAttribute('data-db-ready') === 'true'",
|
||||
timeout=15000,
|
||||
)
|
||||
|
||||
|
||||
def has_data(page: Page) -> bool:
|
||||
return page.evaluate(_COUNT_SCRIPT) > 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Screenshot helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def shot(page: Page, out_dir: Path, name: str, full_page: bool = True) -> None:
|
||||
time.sleep(0.4)
|
||||
path = out_dir / f"{name}.png"
|
||||
page.screenshot(path=str(path), full_page=full_page)
|
||||
print(f" ✓ {path.name}")
|
||||
|
||||
|
||||
def goto(page: Page, url: str, route: str, wait_selector: str | None = None) -> None:
|
||||
page.goto(f"{url}/#/{route}", wait_until="domcontentloaded")
|
||||
if wait_selector:
|
||||
page.wait_for_selector(wait_selector, timeout=8000)
|
||||
else:
|
||||
time.sleep(0.3)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main capture flow
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def capture_all(page: Page, url: str, out_dir: Path, lang: str) -> None:
|
||||
wait_for_db(page, url)
|
||||
ensure_language(page, lang)
|
||||
|
||||
if not has_data(page):
|
||||
print(" Seeding database…")
|
||||
ids = page.evaluate(_SEED_SCRIPT)
|
||||
print(" Seed complete.")
|
||||
else:
|
||||
raise RuntimeError("DB already has data — start with a fresh browser context")
|
||||
|
||||
sess_id = ids['sessId']
|
||||
amrap_sess_id = ids['amrapSessId']
|
||||
col_id = ids['colId']
|
||||
|
||||
# ── home (shows recent execution history after seed) ──────────────────
|
||||
goto(page, url, "home")
|
||||
shot(page, out_dir, "home")
|
||||
|
||||
# ── exercises ─────────────────────────────────────────────────────────
|
||||
goto(page, url, "exercises", '[data-testid="exercise-new-btn"]')
|
||||
shot(page, out_dir, "exercises")
|
||||
|
||||
exercises = page.evaluate("async () => window.__repos.exercises.getAll()")
|
||||
ex_id = exercises[0]["id"]
|
||||
|
||||
goto(page, url, f"exercises/{ex_id}", '[data-testid="exercise-detail-title"]')
|
||||
shot(page, out_dir, "exercise-detail")
|
||||
|
||||
goto(page, url, f"exercises/{ex_id}/edit", '[data-testid="exercise-title"]')
|
||||
shot(page, out_dir, "exercise-edit")
|
||||
|
||||
goto(page, url, "exercises/new", '[data-testid="exercise-title"]')
|
||||
shot(page, out_dir, "exercise-new")
|
||||
|
||||
# ── combos ────────────────────────────────────────────────────────────
|
||||
goto(page, url, "combos", '[data-testid="combo-new-btn"]')
|
||||
shot(page, out_dir, "combos")
|
||||
|
||||
combos = page.evaluate("async () => window.__repos.combos.getAll()")
|
||||
combo_id = combos[0]["id"]
|
||||
|
||||
goto(page, url, f"combos/{combo_id}", '[data-testid="combo-detail-title"]')
|
||||
shot(page, out_dir, "combo-detail")
|
||||
|
||||
goto(page, url, f"combos/{combo_id}/edit", '[data-testid="combo-title"]')
|
||||
shot(page, out_dir, "combo-edit")
|
||||
|
||||
goto(page, url, "combos/new", '[data-testid="combo-title"]')
|
||||
shot(page, out_dir, "combo-new")
|
||||
|
||||
# ── sessions ──────────────────────────────────────────────────────────
|
||||
goto(page, url, "sessions", '[data-testid="session-new-btn"]')
|
||||
shot(page, out_dir, "sessions")
|
||||
|
||||
goto(page, url, f"sessions/{sess_id}", '[data-testid="session-detail-title"]')
|
||||
shot(page, out_dir, "session-detail")
|
||||
|
||||
goto(page, url, f"sessions/{sess_id}/edit", '[data-testid="session-title"]')
|
||||
shot(page, out_dir, "session-edit")
|
||||
|
||||
goto(page, url, "sessions/new", '[data-testid="session-title"]')
|
||||
shot(page, out_dir, "session-new")
|
||||
|
||||
# ── session execute — regular (progress bar + current step) ───────────
|
||||
goto(page, url, f"sessions/{sess_id}/execute", '[data-testid="execute-progress"]')
|
||||
shot(page, out_dir, "session-execute")
|
||||
|
||||
# ── session execute — AMRAP timer running ─────────────────────────────
|
||||
# Navigate away first so Vue Router remounts SessionExecuteView (it reuses
|
||||
# the component when only params change, so onMounted/init() won't re-run
|
||||
# unless we force a different route in between).
|
||||
goto(page, url, "sessions", '[data-testid="session-new-btn"]')
|
||||
goto(page, url, f"sessions/{amrap_sess_id}/execute", '[data-testid="execute-timer"]')
|
||||
shot(page, out_dir, "session-execute-amrap")
|
||||
|
||||
# ── collections ───────────────────────────────────────────────────────
|
||||
goto(page, url, "collections", '[data-testid="collection-new-btn"]')
|
||||
shot(page, out_dir, "collections")
|
||||
|
||||
goto(page, url, f"collections/{col_id}", '[data-testid="collection-detail-title"]')
|
||||
shot(page, out_dir, "collection-detail")
|
||||
|
||||
goto(page, url, f"collections/{col_id}/edit", '[data-testid="collection-label"]')
|
||||
shot(page, out_dir, "collection-edit")
|
||||
|
||||
goto(page, url, "collections/new", '[data-testid="collection-label"]')
|
||||
shot(page, out_dir, "collection-new")
|
||||
|
||||
# ── stats ─────────────────────────────────────────────────────────────
|
||||
goto(page, url, "stats", '[data-testid="stats-date-range"]')
|
||||
shot(page, out_dir, "stats")
|
||||
|
||||
# ── settings — viewport-height slices (page is tall) ──────────────────
|
||||
goto(page, url, "settings", '[data-testid="settings-language"]')
|
||||
page.evaluate("window.scrollTo(0, 0)")
|
||||
shot(page, out_dir, "settings-top", full_page=False)
|
||||
page.evaluate("window.scrollTo(0, document.body.scrollHeight)")
|
||||
time.sleep(0.2)
|
||||
shot(page, out_dir, "settings-bottom", full_page=False)
|
||||
|
||||
# ── about dialog ──────────────────────────────────────────────────────
|
||||
page.evaluate("window.scrollTo(0, 0)")
|
||||
page.click('[data-testid="settings-about"]')
|
||||
page.wait_for_selector('[data-testid="modal-card"]')
|
||||
shot(page, out_dir, "about-dialog")
|
||||
|
||||
# ── license dialog (stacked on about) ─────────────────────────────────
|
||||
page.click('[data-testid="about-license-btn"]')
|
||||
page.wait_for_selector('[data-testid="license-dialog-content"]')
|
||||
shot(page, out_dir, "license-dialog")
|
||||
|
||||
close_btns = page.locator('[data-testid="modal-close"]')
|
||||
close_btns.last.click()
|
||||
close_btns.first.click()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def parse_args(argv: list[str]) -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
description=__doc__,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
)
|
||||
parser.add_argument("--url", default=os.environ.get("APP_URL", "http://localhost:5199"))
|
||||
parser.add_argument("--browser", default="firefox", choices=["firefox", "chromium", "webkit"])
|
||||
parser.add_argument("--lang", default="en", choices=["en", "fr"])
|
||||
parser.add_argument("--out-dir", default=None)
|
||||
parser.add_argument("--no-mobile", action="store_true")
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
def default_out_dir(browser: str, lang: str, width: int, height: int) -> Path:
|
||||
project_root = Path(__file__).resolve().parents[2]
|
||||
prefix = browser if lang == "en" else f"{lang}-{browser}"
|
||||
return project_root / "tmp" / "screenshots" / f"{prefix}-{width}x{height}"
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = parse_args(argv or sys.argv[1:])
|
||||
|
||||
passes = [(1920, 1080)]
|
||||
if not args.no_mobile:
|
||||
passes.append((390, 844))
|
||||
|
||||
with sync_playwright() as p:
|
||||
for (w, h) in passes:
|
||||
out_dir = (
|
||||
Path(args.out_dir) / f"{args.browser}-{w}x{h}"
|
||||
if args.out_dir
|
||||
else default_out_dir(args.browser, args.lang, w, h)
|
||||
)
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
print(f"\n── {args.browser} {w}×{h} → {out_dir}")
|
||||
|
||||
browser_type = getattr(p, args.browser)
|
||||
browser = browser_type.launch(headless=True)
|
||||
try:
|
||||
context = browser.new_context(viewport={"width": w, "height": h})
|
||||
page = context.new_page()
|
||||
page.on("dialog", lambda dialog: dialog.accept())
|
||||
capture_all(page, args.url, out_dir, args.lang)
|
||||
count = len(list(out_dir.glob("*.png")))
|
||||
print(f"\n Done — {count} screenshots saved")
|
||||
except Exception as exc:
|
||||
print(f"\n ERROR: {exc}", file=sys.stderr)
|
||||
traceback.print_exc()
|
||||
return 1
|
||||
finally:
|
||||
browser.close()
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
241
scripts/screenshots/combo_timers.py
Normal file
241
scripts/screenshots/combo_timers.py
Normal file
|
|
@ -0,0 +1,241 @@
|
|||
"""
|
||||
Capture screenshots of EMOM and TABATA combo timers in Execution Mode,
|
||||
including the TABATA rest screen — not covered by all_pages.py (which only
|
||||
seeds an AMRAP combo).
|
||||
|
||||
The app is driven in English by default (use --lang fr for French
|
||||
screenshots) with a seeded database so each screenshot shows meaningful
|
||||
content rather than empty states.
|
||||
|
||||
Intended for visual investigations and documentation figures — not a test.
|
||||
|
||||
Prerequisites:
|
||||
- Playwright for Python installed (see `tests/requirements.txt`)
|
||||
- Playwright browsers installed: `python -m playwright install firefox chromium`
|
||||
- Vite dev server running: `npm run dev -- --port 5199 --strictPort`
|
||||
|
||||
Usage (from project root):
|
||||
|
||||
python scripts/screenshots/combo_timers.py
|
||||
python scripts/screenshots/combo_timers.py --browser chromium
|
||||
python scripts/screenshots/combo_timers.py --lang fr
|
||||
python scripts/screenshots/combo_timers.py --out-dir /tmp/screenshots
|
||||
|
||||
Options:
|
||||
|
||||
--url URL App URL (default: http://localhost:5199)
|
||||
--browser NAME firefox | chromium | webkit (default: firefox)
|
||||
--lang LANG en | fr (default: en)
|
||||
--width N Viewport width in px (default: 1920)
|
||||
--height N Viewport height in px (default: 1080)
|
||||
--out-dir PATH Output directory (default: tmp/screenshots/combo-timers-<lang>-<browser>-<W>x<H>)
|
||||
|
||||
Exit code: 0 on success, 1 on failure.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import traceback
|
||||
from pathlib import Path
|
||||
|
||||
from playwright.sync_api import Page, sync_playwright
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Seed helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# TABATA work/rest kept short (6s / 5s) so the script doesn't have to wait
|
||||
# long to reach the rest screen.
|
||||
_SEED_SCRIPT = """
|
||||
async () => {
|
||||
const uuid = await window.__repos.settings.get('user_uuid');
|
||||
|
||||
const ex1 = await window.__repos.exercises.create({
|
||||
title: 'Jumping Jacks',
|
||||
description: 'Cardio warm-up movement.',
|
||||
asymmetric: false, alternate: false, image_urls: [], video_urls: [],
|
||||
default_reps: 20, default_weight: 0, created_by: uuid
|
||||
});
|
||||
const ex2 = await window.__repos.exercises.create({
|
||||
title: 'Mountain Climbers',
|
||||
description: 'Core and cardio movement.',
|
||||
asymmetric: false, alternate: false, image_urls: [], video_urls: [],
|
||||
default_reps: 20, default_weight: 0, created_by: uuid
|
||||
});
|
||||
|
||||
// ── EMOM combo — work phase is always a 60s slot per exercise ─────────
|
||||
const emomComboId = await window.__repos.combos.create({
|
||||
title: 'EMOM Cardio', description: '3-minute EMOM.',
|
||||
type: 'EMOM', timer_config: {}, created_by: uuid
|
||||
});
|
||||
await window.__repos.combos.setExercises(emomComboId, [
|
||||
{ exerciseId: ex1, position: 0, defaultReps: 20, defaultWeight: 0 }
|
||||
]);
|
||||
const emomSessId = await window.__repos.sessions.create({
|
||||
title: 'EMOM Session', description: '', created_by: uuid
|
||||
});
|
||||
await window.__repos.sessions.setItems(emomSessId, [
|
||||
{ item_id: emomComboId, item_type: 'combo', repetitions: 3, weight: 0.0 }
|
||||
]);
|
||||
|
||||
// ── TABATA combo ────────────────────────────────────────────────────
|
||||
const tabataComboId = await window.__repos.combos.create({
|
||||
title: 'TABATA Blast', description: 'Work/rest intervals.',
|
||||
type: 'TABATA', timer_config: { workTime: 6, restTime: 5 }, created_by: uuid
|
||||
});
|
||||
await window.__repos.combos.setExercises(tabataComboId, [
|
||||
{ exerciseId: ex1, position: 0, defaultReps: 0, defaultWeight: 0 },
|
||||
{ exerciseId: ex2, position: 1, defaultReps: 0, defaultWeight: 0 }
|
||||
]);
|
||||
const tabataSessId = await window.__repos.sessions.create({
|
||||
title: 'TABATA Session', description: '', created_by: uuid
|
||||
});
|
||||
await window.__repos.sessions.setItems(tabataSessId, [
|
||||
{ item_id: tabataComboId, item_type: 'combo', repetitions: 4, weight: 0.0 }
|
||||
]);
|
||||
|
||||
return { emomSessId, tabataSessId };
|
||||
}
|
||||
"""
|
||||
|
||||
_COUNT_SCRIPT = """
|
||||
async () => (await window.__repos.exercises.getAll()).length
|
||||
"""
|
||||
|
||||
|
||||
def wait_for_db(page: Page, url: str) -> None:
|
||||
page.goto(url)
|
||||
page.wait_for_function(
|
||||
"document.documentElement.getAttribute('data-db-ready') === 'true'",
|
||||
timeout=15000,
|
||||
)
|
||||
|
||||
|
||||
def ensure_language(page: Page, lang: str) -> None:
|
||||
current = page.evaluate("async () => window.__repos.settings.get('language')")
|
||||
if current != lang:
|
||||
page.evaluate(f"async () => window.__repos.settings.set('language', '{lang}')")
|
||||
page.reload()
|
||||
page.wait_for_function(
|
||||
"document.documentElement.getAttribute('data-db-ready') === 'true'",
|
||||
timeout=15000,
|
||||
)
|
||||
|
||||
|
||||
def has_data(page: Page) -> bool:
|
||||
return page.evaluate(_COUNT_SCRIPT) > 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Screenshot helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def shot(page: Page, out_dir: Path, name: str, full_page: bool = True) -> None:
|
||||
time.sleep(0.3)
|
||||
path = out_dir / f"{name}.png"
|
||||
page.screenshot(path=str(path), full_page=full_page)
|
||||
print(f" ✓ {path.name}")
|
||||
|
||||
|
||||
def goto(page: Page, url: str, route: str, wait_selector: str | None = None) -> None:
|
||||
page.goto(f"{url}/#/{route}", wait_until="domcontentloaded")
|
||||
if wait_selector:
|
||||
page.wait_for_selector(wait_selector, timeout=8000)
|
||||
else:
|
||||
time.sleep(0.3)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main capture flow
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def capture(page: Page, url: str, out_dir: Path, lang: str) -> None:
|
||||
wait_for_db(page, url)
|
||||
ensure_language(page, lang)
|
||||
|
||||
if not has_data(page):
|
||||
print(" Seeding database…")
|
||||
ids = page.evaluate(_SEED_SCRIPT)
|
||||
print(" Seed complete.")
|
||||
else:
|
||||
raise RuntimeError("DB already has data — start with a fresh browser context")
|
||||
|
||||
# ── EMOM — work phase starts immediately ───────────────────────────────
|
||||
goto(page, url, f"sessions/{ids['emomSessId']}/execute", '[data-testid="execute-timer"]')
|
||||
shot(page, out_dir, "combo-emom")
|
||||
|
||||
# ── TABATA — work phase ─────────────────────────────────────────────
|
||||
# Navigate away first so Vue Router remounts SessionExecuteView (it reuses
|
||||
# the component when only params change, see all_pages.py for the same
|
||||
# workaround).
|
||||
goto(page, url, "sessions", '[data-testid="session-new-btn"]')
|
||||
goto(page, url, f"sessions/{ids['tabataSessId']}/execute", '[data-testid="execute-timer"]')
|
||||
shot(page, out_dir, "combo-tabata-work")
|
||||
|
||||
# ── TABATA — rest phase (work duration is 6s in the seed above) ────────
|
||||
page.wait_for_selector('[data-testid="execute-tabata-rest"]', timeout=12000)
|
||||
shot(page, out_dir, "combo-tabata-rest")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def parse_args(argv: list[str]) -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
description=__doc__,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
)
|
||||
parser.add_argument("--url", default=os.environ.get("APP_URL", "http://localhost:5199"))
|
||||
parser.add_argument("--browser", default="firefox", choices=["firefox", "chromium", "webkit"])
|
||||
parser.add_argument("--lang", default="en", choices=["en", "fr"])
|
||||
parser.add_argument("--width", type=int, default=1920)
|
||||
parser.add_argument("--height", type=int, default=1080)
|
||||
parser.add_argument("--out-dir", default=None)
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
def default_out_dir(browser: str, lang: str, width: int, height: int) -> Path:
|
||||
project_root = Path(__file__).resolve().parents[2]
|
||||
prefix = f"combo-timers-{browser}" if lang == "en" else f"combo-timers-{lang}-{browser}"
|
||||
return project_root / "tmp" / "screenshots" / f"{prefix}-{width}x{height}"
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = parse_args(argv or sys.argv[1:])
|
||||
out_dir = (
|
||||
Path(args.out_dir)
|
||||
if args.out_dir
|
||||
else default_out_dir(args.browser, args.lang, args.width, args.height)
|
||||
)
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
print(f"\n── {args.browser} {args.width}×{args.height} → {out_dir}")
|
||||
|
||||
with sync_playwright() as p:
|
||||
browser_type = getattr(p, args.browser)
|
||||
browser = browser_type.launch(headless=True)
|
||||
try:
|
||||
context = browser.new_context(viewport={"width": args.width, "height": args.height})
|
||||
page = context.new_page()
|
||||
page.on("dialog", lambda dialog: dialog.accept())
|
||||
capture(page, args.url, out_dir, args.lang)
|
||||
count = len(list(out_dir.glob("*.png")))
|
||||
print(f"\n Done — {count} screenshots saved")
|
||||
except Exception as exc:
|
||||
print(f"\n ERROR: {exc}", file=sys.stderr)
|
||||
traceback.print_exc()
|
||||
return 1
|
||||
finally:
|
||||
browser.close()
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
130
scripts/screenshots/exercise_list.py
Normal file
130
scripts/screenshots/exercise_list.py
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
"""
|
||||
Screenshot the Exercise List page at a configurable viewport.
|
||||
|
||||
Intended for visual investigations (e.g., layout discussions, documentation
|
||||
figures). Not a test — no assertions; see `tests/` for the test suite.
|
||||
|
||||
Prerequisites:
|
||||
- Playwright for Python installed (see `tests/requirements.txt`)
|
||||
- Playwright browsers installed: `python -m playwright install firefox chromium`
|
||||
|
||||
Typical usage (from project root):
|
||||
|
||||
# 1. Start the Vite dev server in one terminal
|
||||
npm run dev -- --port 5199 --strictPort
|
||||
|
||||
# 2. In another terminal, run the script
|
||||
python scripts/screenshots/exercise_list.py
|
||||
|
||||
Options (all optional, override via CLI flags):
|
||||
|
||||
--url URL App URL (default: http://localhost:5199)
|
||||
--browser NAME firefox | chromium | webkit (default: firefox)
|
||||
--width N Viewport width in px (default: 1920)
|
||||
--height N Viewport height in px (default: 1080)
|
||||
--out PATH Output file path (default: tmp/exercise-list-<browser>-<W>x<H>.png)
|
||||
--no-seed Do not create sample exercises (use the existing DB as-is)
|
||||
|
||||
Exit code: 0 on success, 1 on failure.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
|
||||
SAMPLE_EXERCISES = [
|
||||
{"title": "Push-ups", "desc": "Classic bodyweight push-up", "reps": "15"},
|
||||
{"title": "Squats", "desc": "Bodyweight squat", "reps": "20"},
|
||||
{"title": "Plank", "desc": "Core stability hold", "reps": "60"},
|
||||
]
|
||||
|
||||
|
||||
def ensure_sample_exercises(page, url: str) -> None:
|
||||
"""Create a few exercises via the UI so the list has visible cards.
|
||||
|
||||
No-op if the list already contains at least one card.
|
||||
"""
|
||||
page.goto(f"{url}/#/exercises", wait_until="networkidle")
|
||||
cards = page.locator('[data-testid^="exercise-card-"]')
|
||||
if cards.count() > 0:
|
||||
return
|
||||
|
||||
for ex in SAMPLE_EXERCISES:
|
||||
page.goto(f"{url}/#/exercises/new", wait_until="networkidle")
|
||||
page.wait_for_selector('[data-testid="exercise-title"]', timeout=10000)
|
||||
page.locator('[data-testid="exercise-title"]').fill(ex["title"])
|
||||
page.locator('[data-testid="exercise-description"]').fill(ex["desc"])
|
||||
page.locator('[data-testid="exercise-reps"]').fill(ex["reps"])
|
||||
page.locator('[data-testid="exercise-save"]').click()
|
||||
# After save the app navigates to the detail page; just wait for URL
|
||||
# to move away from `/new`.
|
||||
page.wait_for_function(
|
||||
"() => !location.hash.endsWith('/exercises/new')",
|
||||
timeout=5000,
|
||||
)
|
||||
|
||||
|
||||
def parse_args(argv: list[str]) -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
parser.add_argument("--url", default=os.environ.get("APP_URL", "http://localhost:5199"))
|
||||
parser.add_argument("--browser", default="firefox", choices=["firefox", "chromium", "webkit"])
|
||||
parser.add_argument("--width", type=int, default=1920)
|
||||
parser.add_argument("--height", type=int, default=1080)
|
||||
parser.add_argument("--out", default=None, help="Output file path")
|
||||
parser.add_argument("--no-seed", action="store_true", help="Skip creating sample exercises")
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
def default_out_path(browser: str, width: int, height: int) -> Path:
|
||||
"""Default output goes to tmp/ at the project root (gitignored)."""
|
||||
project_root = Path(__file__).resolve().parents[2]
|
||||
tmp_dir = project_root / "tmp"
|
||||
tmp_dir.mkdir(parents=True, exist_ok=True)
|
||||
return tmp_dir / f"exercise-list-{browser}-{width}x{height}.png"
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = parse_args(argv or sys.argv[1:])
|
||||
out_path = Path(args.out) if args.out else default_out_path(args.browser, args.width, args.height)
|
||||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
viewport = {"width": args.width, "height": args.height}
|
||||
|
||||
with sync_playwright() as p:
|
||||
browser_type = getattr(p, args.browser)
|
||||
browser = browser_type.launch(headless=True)
|
||||
try:
|
||||
context = browser.new_context(viewport=viewport)
|
||||
page = context.new_page()
|
||||
|
||||
page.goto(args.url, wait_until="networkidle")
|
||||
# Wait for the app to finish DB init overlay, if any.
|
||||
try:
|
||||
page.wait_for_selector(".db-overlay", state="detached", timeout=10000)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not args.no_seed:
|
||||
ensure_sample_exercises(page, args.url)
|
||||
|
||||
page.goto(f"{args.url}/#/exercises", wait_until="networkidle")
|
||||
page.wait_for_selector('[data-testid^="exercise-card-"]', timeout=5000)
|
||||
# Let layout settle before capturing.
|
||||
time.sleep(0.5)
|
||||
|
||||
page.screenshot(path=str(out_path), full_page=True)
|
||||
print(f"Saved screenshot: {out_path}")
|
||||
return 0
|
||||
finally:
|
||||
browser.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
82
scripts/screenshots/overview_shots.py
Normal file
82
scripts/screenshots/overview_shots.py
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
"""One-off: capture the Exercises list screen (desktop + mobile, en + fr)
|
||||
to replace the home-page overview screenshots in demarrage-rapide docs."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
URL = "http://localhost:5199"
|
||||
OUT_DIR = Path("/tmp/overview-shots")
|
||||
OUT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
SEED_SCRIPT = """
|
||||
async () => {
|
||||
const uuid = await window.__repos.settings.get('user_uuid');
|
||||
await window.__repos.exercises.create({
|
||||
title: 'Push-ups',
|
||||
description: 'Classic bodyweight push-up.',
|
||||
asymmetric: false, alternate: false, image_urls: [], video_urls: [],
|
||||
default_reps: 15, default_weight: 0, created_by: uuid
|
||||
});
|
||||
await window.__repos.exercises.create({
|
||||
title: 'Squats',
|
||||
description: 'Bodyweight squat — great for legs and glutes.',
|
||||
asymmetric: false, alternate: false, image_urls: [], video_urls: [],
|
||||
default_reps: 20, default_weight: 0, created_by: uuid
|
||||
});
|
||||
await window.__repos.exercises.create({
|
||||
title: 'Deadlift',
|
||||
description: 'Barbell deadlift — compound posterior-chain movement.',
|
||||
asymmetric: false, alternate: false, image_urls: [], video_urls: [],
|
||||
default_reps: 5, default_weight: 100, created_by: uuid
|
||||
});
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
def wait_for_db(page):
|
||||
page.wait_for_function(
|
||||
"document.documentElement.getAttribute('data-db-ready') === 'true'",
|
||||
timeout=15000,
|
||||
)
|
||||
|
||||
|
||||
def capture(browser, lang: str, width: int, height: int, name: str):
|
||||
context = browser.new_context(viewport={"width": width, "height": height})
|
||||
page = context.new_page()
|
||||
page.goto(URL)
|
||||
wait_for_db(page)
|
||||
page.evaluate(f"async () => window.__repos.settings.set('language', '{lang}')")
|
||||
page.reload()
|
||||
wait_for_db(page)
|
||||
|
||||
count = page.evaluate("async () => (await window.__repos.exercises.getAll()).length")
|
||||
if count == 0:
|
||||
page.evaluate(SEED_SCRIPT)
|
||||
|
||||
page.goto(f"{URL}/#/exercises")
|
||||
page.wait_for_selector('[data-testid="exercise-new-btn"]', timeout=8000)
|
||||
time.sleep(0.5)
|
||||
out_path = OUT_DIR / f"{name}.{lang}.png"
|
||||
page.screenshot(path=str(out_path), full_page=True)
|
||||
print(f"saved {out_path}")
|
||||
context.close()
|
||||
|
||||
|
||||
def main():
|
||||
with sync_playwright() as p:
|
||||
browser = p.firefox.launch(headless=True)
|
||||
try:
|
||||
capture(browser, "en", 1920, 1080, "overview-desktop")
|
||||
capture(browser, "fr", 1920, 1080, "overview-desktop")
|
||||
capture(browser, "en", 390, 844, "overview-mobile")
|
||||
capture(browser, "fr", 390, 844, "overview-mobile")
|
||||
finally:
|
||||
browser.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
313
src/App.vue
Normal file
313
src/App.vue
Normal file
|
|
@ -0,0 +1,313 @@
|
|||
<script setup>
|
||||
import { useTranslation } from 'i18next-vue'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import BackupReminder from './components/BackupReminder.vue'
|
||||
import InstallPrompt from './components/InstallPrompt.vue'
|
||||
import SaveFileDialog from './components/SaveFileDialog.vue'
|
||||
import { db } from './db/database.js'
|
||||
import { useBackupDownload } from './composables/useBackupDownload.js'
|
||||
|
||||
const { t } = useTranslation()
|
||||
const route = useRoute()
|
||||
|
||||
function onNavKeydown(event) {
|
||||
if (!['ArrowRight', 'ArrowLeft', 'ArrowUp', 'ArrowDown'].includes(event.key)) return
|
||||
const links = Array.from(event.currentTarget.querySelectorAll('a'))
|
||||
const idx = links.indexOf(document.activeElement)
|
||||
if (idx === -1) return
|
||||
event.preventDefault()
|
||||
const isForward = event.key === 'ArrowRight' || event.key === 'ArrowDown'
|
||||
links[(idx + (isForward ? 1 : -1) + links.length) % links.length].focus()
|
||||
}
|
||||
|
||||
const dbReady = ref(false)
|
||||
const dbError = ref(null)
|
||||
const migrationPending = ref(null)
|
||||
|
||||
if (window.__dbReady) {
|
||||
window.__dbReady
|
||||
.then((status) => {
|
||||
if (status && status.migrationPending) {
|
||||
migrationPending.value = status.migrationPending
|
||||
} else {
|
||||
dbReady.value = true
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
dbError.value = err.message || String(err)
|
||||
})
|
||||
}
|
||||
|
||||
const dbErrorBody = computed(() => {
|
||||
if (dbError.value === 'DB_NEWER_THAN_APP') return t('error.dbNewerThanApp')
|
||||
if (dbError.value === 'DB_MIGRATION_FAILED') return t('error.dbMigrationFailed')
|
||||
return t('error.browserNotCompatible')
|
||||
})
|
||||
|
||||
// Blocking migration screen: backup is mandatory before the update can run
|
||||
const backupDownloaded = ref(false)
|
||||
const migrating = ref(false)
|
||||
const { downloadBackup } = useBackupDownload()
|
||||
|
||||
async function onMigrationBackup() {
|
||||
const saved = await downloadBackup()
|
||||
if (saved) backupDownloaded.value = true
|
||||
}
|
||||
|
||||
async function onMigrationUpdate() {
|
||||
migrating.value = true
|
||||
try {
|
||||
await db.runPendingMigrations()
|
||||
migrationPending.value = null
|
||||
document.documentElement.removeAttribute('data-db-migration')
|
||||
document.documentElement.setAttribute('data-db-ready', 'true')
|
||||
dbReady.value = true
|
||||
} catch (err) {
|
||||
dbError.value = err.message || String(err)
|
||||
document.documentElement.setAttribute('data-db-error', dbError.value)
|
||||
} finally {
|
||||
migrating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const dbConnectionLost = ref(false)
|
||||
onMounted(() => {
|
||||
window.addEventListener('db-connection-lost', () => {
|
||||
dbConnectionLost.value = true
|
||||
})
|
||||
})
|
||||
|
||||
function reloadPage() {
|
||||
window.location.reload()
|
||||
}
|
||||
|
||||
const pageTitle = computed(() => {
|
||||
const name = route.name
|
||||
// Map sub-route names to their parent nav key
|
||||
const navKeyMap = {
|
||||
'exercise-new': 'exercises',
|
||||
'exercise-detail': 'exercises',
|
||||
'exercise-edit': 'exercises',
|
||||
'combo-new': 'combos',
|
||||
'combo-detail': 'combos',
|
||||
'combo-edit': 'combos',
|
||||
'session-new': 'sessions',
|
||||
'session-detail': 'sessions',
|
||||
'session-edit': 'sessions',
|
||||
'session-execute': 'sessions',
|
||||
'collection-new': 'collections',
|
||||
'collection-detail': 'collections',
|
||||
'collection-edit': 'collections',
|
||||
}
|
||||
const navKey = navKeyMap[name] || name
|
||||
if (navKey && t(`nav.${navKey}`, '')) {
|
||||
return t(`nav.${navKey}`)
|
||||
}
|
||||
return t('app.name')
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="dbConnectionLost" class="db-overlay" data-testid="db-connection-lost">
|
||||
<div class="db-overlay-card">
|
||||
<i class="bi bi-plug db-error-icon"></i>
|
||||
<h1>{{ $t('error.dbInitFailed') }}</h1>
|
||||
<p>{{ $t('error.dbConnectionLost') }}</p>
|
||||
<button class="btn btn-primary" style="margin-top: 1rem" @click="reloadPage">
|
||||
{{ $t('error.reload') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="dbError" class="db-overlay" data-testid="db-error">
|
||||
<div class="db-overlay-card">
|
||||
<i class="bi bi-exclamation-triangle db-error-icon"></i>
|
||||
<h1>{{ $t('error.dbInitFailed') }}</h1>
|
||||
<p>{{ dbErrorBody }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="migrationPending" class="db-overlay" data-testid="db-migration">
|
||||
<div class="db-overlay-card">
|
||||
<i class="bi bi-database-up db-migration-icon"></i>
|
||||
<h1>{{ $t('migration.title') }}</h1>
|
||||
<p>
|
||||
{{ $t('migration.body', { from: migrationPending.from, to: migrationPending.to }) }}
|
||||
</p>
|
||||
<p>{{ $t('migration.backupRequired') }}</p>
|
||||
<div class="db-migration-actions">
|
||||
<button class="btn btn-primary" data-testid="migration-backup" @click="onMigrationBackup">
|
||||
<i class="bi bi-download"></i>
|
||||
{{ $t('migration.downloadBackup') }}
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
data-testid="migration-update"
|
||||
:disabled="!backupDownloaded || migrating"
|
||||
@click="onMigrationUpdate"
|
||||
>
|
||||
<span v-if="migrating" class="spinner spinner-inline"></span>
|
||||
{{ migrating ? $t('migration.updating') : $t('migration.update') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="!dbReady" class="db-overlay" data-testid="db-loading">
|
||||
<div class="db-overlay-card">
|
||||
<div class="spinner"></div>
|
||||
<p class="loading-text">{{ $t('app.loading') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<BackupReminder :key="route.fullPath" />
|
||||
<InstallPrompt />
|
||||
<nav class="navbar" :aria-label="$t('nav.mainNav')" @keydown="onNavKeydown">
|
||||
<div class="navbar-brand">{{ $t('app.name') }}</div>
|
||||
|
||||
<router-link to="/home">
|
||||
<i class="bi bi-house"></i>
|
||||
<span>{{ $t('nav.home') }}</span>
|
||||
</router-link>
|
||||
|
||||
<router-link to="/exercises">
|
||||
<i class="bi bi-person-arms-up"></i>
|
||||
<span>{{ $t('nav.exercises') }}</span>
|
||||
</router-link>
|
||||
|
||||
<router-link to="/combos">
|
||||
<i class="bi bi-layers"></i>
|
||||
<span>{{ $t('nav.combos') }}</span>
|
||||
</router-link>
|
||||
|
||||
<router-link to="/sessions">
|
||||
<i class="bi bi-journal-text"></i>
|
||||
<span>{{ $t('nav.sessions') }}</span>
|
||||
</router-link>
|
||||
|
||||
<router-link to="/collections">
|
||||
<i class="bi bi-collection"></i>
|
||||
<span>{{ $t('nav.collections') }}</span>
|
||||
</router-link>
|
||||
|
||||
<div class="navbar-spacer"></div>
|
||||
|
||||
<router-link to="/stats">
|
||||
<i class="bi bi-graph-up"></i>
|
||||
<span>{{ $t('nav.stats') }}</span>
|
||||
</router-link>
|
||||
|
||||
<router-link to="/settings">
|
||||
<i class="bi bi-gear"></i>
|
||||
<span>{{ $t('nav.settings') }}</span>
|
||||
</router-link>
|
||||
</nav>
|
||||
|
||||
<div class="content">
|
||||
<div class="actionbar">
|
||||
<h1>{{ pageTitle }}</h1>
|
||||
<div id="actionbar-actions"></div>
|
||||
</div>
|
||||
|
||||
<div class="content-body">
|
||||
<router-view />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<SaveFileDialog />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.db-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--color-background, #f5f5f5);
|
||||
z-index: 9999;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.db-overlay-card {
|
||||
text-align: center;
|
||||
max-width: 480px;
|
||||
}
|
||||
|
||||
.db-error-icon {
|
||||
font-size: 3rem;
|
||||
color: var(--color-danger, #dc3545);
|
||||
}
|
||||
|
||||
.db-migration-icon {
|
||||
font-size: 3rem;
|
||||
color: var(--color-primary, #4a90d9);
|
||||
}
|
||||
|
||||
.db-migration-actions {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.spinner-inline {
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
border-width: 2px;
|
||||
display: inline-block;
|
||||
vertical-align: -0.15em;
|
||||
margin: 0 0.35em 0 0;
|
||||
}
|
||||
|
||||
.db-overlay-card h1 {
|
||||
margin: 1rem 0 0.5rem;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.db-overlay-card p {
|
||||
color: var(--color-text-secondary, #666);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border: 4px solid var(--color-border, #ddd);
|
||||
border-top-color: var(--color-primary, #4a90d9);
|
||||
border-radius: 50%;
|
||||
margin: 0 auto 1rem;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 8px 18px;
|
||||
border-radius: 8px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--btn-primary-bg, #4a90d9);
|
||||
color: var(--btn-primary-color, #fff);
|
||||
}
|
||||
</style>
|
||||
660
src/assets/license.md
Normal file
660
src/assets/license.md
Normal file
|
|
@ -0,0 +1,660 @@
|
|||
# GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
|
||||
Version 3, 19 November 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc.
|
||||
<https://fsf.org/>
|
||||
|
||||
Everyone is permitted to copy and distribute verbatim copies of this
|
||||
license document, but changing it is not allowed.
|
||||
|
||||
## Preamble
|
||||
|
||||
The GNU Affero General Public License is a free, copyleft license for
|
||||
software and other kinds of works, specifically designed to ensure
|
||||
cooperation with the community in the case of network server software.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
our General Public Licenses are intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains
|
||||
free software for all its users.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
Developers that use our General Public Licenses protect your rights
|
||||
with two steps: (1) assert copyright on the software, and (2) offer
|
||||
you this License which gives you legal permission to copy, distribute
|
||||
and/or modify the software.
|
||||
|
||||
A secondary benefit of defending all users' freedom is that
|
||||
improvements made in alternate versions of the program, if they
|
||||
receive widespread use, become available for other developers to
|
||||
incorporate. Many developers of free software are heartened and
|
||||
encouraged by the resulting cooperation. However, in the case of
|
||||
software used on network servers, this result may fail to come about.
|
||||
The GNU General Public License permits making a modified version and
|
||||
letting the public access it on a server without ever releasing its
|
||||
source code to the public.
|
||||
|
||||
The GNU Affero General Public License is designed specifically to
|
||||
ensure that, in such cases, the modified source code becomes available
|
||||
to the community. It requires the operator of a network server to
|
||||
provide the source code of the modified version running there to the
|
||||
users of that server. Therefore, public use of a modified version, on
|
||||
a publicly accessible server, gives the public access to the source
|
||||
code of the modified version.
|
||||
|
||||
An older license, called the Affero General Public License and
|
||||
published by Affero, was designed to accomplish similar goals. This is
|
||||
a different license, not a version of the Affero GPL, but Affero has
|
||||
released a new version of the Affero GPL which permits relicensing
|
||||
under this license.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
## TERMS AND CONDITIONS
|
||||
|
||||
### 0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU Affero General Public
|
||||
License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds
|
||||
of works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of
|
||||
an exact copy. The resulting work is called a "modified version" of
|
||||
the earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user
|
||||
through a computer network, with no transfer of a copy, is not
|
||||
conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices" to
|
||||
the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
### 1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work for
|
||||
making modifications to it. "Object code" means any non-source form of
|
||||
a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users can
|
||||
regenerate automatically from other parts of the Corresponding Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that same
|
||||
work.
|
||||
|
||||
### 2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not convey,
|
||||
without conditions so long as your license otherwise remains in force.
|
||||
You may convey covered works to others for the sole purpose of having
|
||||
them make modifications exclusively for you, or provide you with
|
||||
facilities for running those works, provided that you comply with the
|
||||
terms of this License in conveying all material for which you do not
|
||||
control copyright. Those thus making or running the covered works for
|
||||
you must do so exclusively on your behalf, under your direction and
|
||||
control, on terms that prohibit them from making any copies of your
|
||||
copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under the
|
||||
conditions stated below. Sublicensing is not allowed; section 10 makes
|
||||
it unnecessary.
|
||||
|
||||
### 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such
|
||||
circumvention is effected by exercising rights under this License with
|
||||
respect to the covered work, and you disclaim any intention to limit
|
||||
operation or modification of the work as a means of enforcing, against
|
||||
the work's users, your or third parties' legal rights to forbid
|
||||
circumvention of technological measures.
|
||||
|
||||
### 4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
### 5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these
|
||||
conditions:
|
||||
|
||||
- a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
- b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under
|
||||
section 7. This requirement modifies the requirement in section 4
|
||||
to "keep intact all notices".
|
||||
- c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
- d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
### 6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms of
|
||||
sections 4 and 5, provided that you also convey the machine-readable
|
||||
Corresponding Source under the terms of this License, in one of these
|
||||
ways:
|
||||
|
||||
- a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
- b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the Corresponding
|
||||
Source from a network server at no charge.
|
||||
- c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
- d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
- e) Convey the object code using peer-to-peer transmission,
|
||||
provided you inform other peers where the object code and
|
||||
Corresponding Source of the work are being offered to the general
|
||||
public at no charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal,
|
||||
family, or household purposes, or (2) anything designed or sold for
|
||||
incorporation into a dwelling. In determining whether a product is a
|
||||
consumer product, doubtful cases shall be resolved in favor of
|
||||
coverage. For a particular product received by a particular user,
|
||||
"normally used" refers to a typical or common use of that class of
|
||||
product, regardless of the status of the particular user or of the way
|
||||
in which the particular user actually uses, or expects or is expected
|
||||
to use, the product. A product is a consumer product regardless of
|
||||
whether the product has substantial commercial, industrial or
|
||||
non-consumer uses, unless such uses represent the only significant
|
||||
mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to
|
||||
install and execute modified versions of a covered work in that User
|
||||
Product from a modified version of its Corresponding Source. The
|
||||
information must suffice to ensure that the continued functioning of
|
||||
the modified object code is in no case prevented or interfered with
|
||||
solely because modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or
|
||||
updates for a work that has been modified or installed by the
|
||||
recipient, or for the User Product in which it has been modified or
|
||||
installed. Access to a network may be denied when the modification
|
||||
itself materially and adversely affects the operation of the network
|
||||
or violates the rules and protocols for communication across the
|
||||
network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
### 7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders
|
||||
of that material) supplement the terms of this License with terms:
|
||||
|
||||
- a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
- b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
- c) Prohibiting misrepresentation of the origin of that material,
|
||||
or requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
- d) Limiting the use for publicity purposes of names of licensors
|
||||
or authors of the material; or
|
||||
- e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
- f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions
|
||||
of it) with contractual assumptions of liability to the recipient,
|
||||
for any liability that these contractual assumptions directly
|
||||
impose on those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions; the
|
||||
above requirements apply either way.
|
||||
|
||||
### 8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your license
|
||||
from a particular copyright holder is reinstated (a) provisionally,
|
||||
unless and until the copyright holder explicitly and finally
|
||||
terminates your license, and (b) permanently, if the copyright holder
|
||||
fails to notify you of the violation by some reasonable means prior to
|
||||
60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
### 9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or run
|
||||
a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
### 10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
### 11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims owned
|
||||
or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within the
|
||||
scope of its coverage, prohibits the exercise of, or is conditioned on
|
||||
the non-exercise of one or more of the rights that are specifically
|
||||
granted under this License. You may not convey a covered work if you
|
||||
are a party to an arrangement with a third party that is in the
|
||||
business of distributing software, under which you make payment to the
|
||||
third party based on the extent of your activity of conveying the
|
||||
work, and under which the third party grants, to any of the parties
|
||||
who would receive the covered work from you, a discriminatory patent
|
||||
license (a) in connection with copies of the covered work conveyed by
|
||||
you (or copies made from those copies), or (b) primarily for and in
|
||||
connection with specific products or compilations that contain the
|
||||
covered work, unless you entered into that arrangement, or that patent
|
||||
license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
### 12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under
|
||||
this License and any other pertinent obligations, then as a
|
||||
consequence you may not convey it at all. For example, if you agree to
|
||||
terms that obligate you to collect a royalty for further conveying
|
||||
from those to whom you convey the Program, the only way you could
|
||||
satisfy both those terms and this License would be to refrain entirely
|
||||
from conveying the Program.
|
||||
|
||||
### 13. Remote Network Interaction; Use with the GNU General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, if you modify the
|
||||
Program, your modified version must prominently offer all users
|
||||
interacting with it remotely through a computer network (if your
|
||||
version supports such interaction) an opportunity to receive the
|
||||
Corresponding Source of your version by providing access to the
|
||||
Corresponding Source from a network server at no charge, through some
|
||||
standard or customary means of facilitating copying of software. This
|
||||
Corresponding Source shall include the Corresponding Source for any
|
||||
work covered by version 3 of the GNU General Public License that is
|
||||
incorporated pursuant to the following paragraph.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the work with which it is combined will remain governed by version
|
||||
3 of the GNU General Public License.
|
||||
|
||||
### 14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions
|
||||
of the GNU Affero General Public License from time to time. Such new
|
||||
versions will be similar in spirit to the present version, but may
|
||||
differ in detail to address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the Program
|
||||
specifies that a certain numbered version of the GNU Affero General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU Affero General Public License, you may choose any version ever
|
||||
published by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future versions
|
||||
of the GNU Affero General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
### 15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT
|
||||
WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT
|
||||
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
|
||||
A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND
|
||||
PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE
|
||||
DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR
|
||||
CORRECTION.
|
||||
|
||||
### 16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR
|
||||
CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES
|
||||
ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT
|
||||
NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR
|
||||
LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM
|
||||
TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER
|
||||
PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.
|
||||
|
||||
### 17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
## How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these
|
||||
terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest to
|
||||
attach them to the start of each source file to most effectively state
|
||||
the exclusion of warranty; and each file should have at least the
|
||||
"copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper
|
||||
mail.
|
||||
|
||||
If your software can interact with users remotely through a computer
|
||||
network, you should also make sure that it provides a way for users to
|
||||
get its source. For example, if your program is a web application, its
|
||||
interface could display a "Source" link that leads users to an archive
|
||||
of the code. There are many ways you could offer source, and different
|
||||
solutions will be better for different programs; see section 13 for
|
||||
the specific requirements.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or
|
||||
school, if any, to sign a "copyright disclaimer" for the program, if
|
||||
necessary. For more information on this, and how to apply and follow
|
||||
the GNU AGPL, see <https://www.gnu.org/licenses/>.
|
||||
15
src/assets/notice.md
Normal file
15
src/assets/notice.md
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
**TrainUs — an open, local-first, offline-capable fitness tracking application where you can create and share your own training sessions.**
|
||||
Copyright (C) 2026 [David Florance](http://kaivalya.eu)
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
131
src/components/AboutDialog.vue
Normal file
131
src/components/AboutDialog.vue
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { SCHEMA_VERSION } from '../db/schema.js'
|
||||
import ModalDialog from './ModalDialog.vue'
|
||||
import LicenseDialog from './LicenseDialog.vue'
|
||||
import noticeText from '../assets/notice.md?raw'
|
||||
import { renderMarkdown } from '../utils/markdown.js'
|
||||
|
||||
defineEmits(['close'])
|
||||
|
||||
const appVersion = __APP_VERSION__
|
||||
const dbVersion = SCHEMA_VERSION
|
||||
const showLicense = ref(false)
|
||||
const noticeHtml = renderMarkdown(noticeText)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ModalDialog :title="$t('about.title')" @close="$emit('close')">
|
||||
<div class="about-row">
|
||||
<span class="about-label">{{ $t('about.appVersion') }}</span>
|
||||
<span class="about-value" data-testid="about-app-version">{{ appVersion }}</span>
|
||||
</div>
|
||||
|
||||
<div class="about-row">
|
||||
<span class="about-label">{{ $t('about.dbVersion') }}</span>
|
||||
<span class="about-value" data-testid="about-db-version">{{ dbVersion }}</span>
|
||||
</div>
|
||||
|
||||
<div class="about-row">
|
||||
<span class="about-label">{{ $t('about.author') }}</span>
|
||||
<span class="about-value">David Florance</span>
|
||||
</div>
|
||||
|
||||
<div class="about-row">
|
||||
<span class="about-label">{{ $t('about.license') }}</span>
|
||||
<span class="about-value" data-testid="about-license-name">GNU AGPL v3</span>
|
||||
</div>
|
||||
|
||||
<div class="about-row">
|
||||
<span class="about-label">{{ $t('about.links') }}</span>
|
||||
<span class="about-value about-links">
|
||||
<a
|
||||
href="https://kaivalya.eu/app-git/kaivalya/trainUs"
|
||||
class="about-link"
|
||||
data-testid="about-repo-link"
|
||||
>
|
||||
<i class="bi bi-github"></i>
|
||||
{{ $t('about.repository') }}
|
||||
</a>
|
||||
<a
|
||||
href="https://kaivalya.eu/projects/trainUs"
|
||||
class="about-link"
|
||||
data-testid="about-website-link"
|
||||
>
|
||||
<i class="bi bi-globe"></i>
|
||||
{{ $t('about.website') }}
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="about-notice" data-testid="about-notice" v-html="noticeHtml"></div>
|
||||
|
||||
<div class="about-license-row">
|
||||
<button class="btn" data-testid="about-license-btn" @click="showLicense = true">
|
||||
<i class="bi bi-file-text"></i>
|
||||
{{ $t('about.licenseBtn') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<LicenseDialog v-if="showLicense" @close="showLicense = false" />
|
||||
</ModalDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.about-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.about-label {
|
||||
flex: 0 0 auto;
|
||||
white-space: nowrap;
|
||||
color: var(--front-color2);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.about-value {
|
||||
flex: 1;
|
||||
font-weight: 600;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.about-links {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.about-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--front-color1);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.about-link i {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.about-notice {
|
||||
margin-top: 16px;
|
||||
padding: 12px;
|
||||
background: var(--bg-color2);
|
||||
border-radius: 8px;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
color: var(--front-color2);
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.about-license-row {
|
||||
margin-top: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
</style>
|
||||
117
src/components/BackupReminder.vue
Normal file
117
src/components/BackupReminder.vue
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
<script setup>
|
||||
import { onMounted, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useBackupStatus } from '../composables/useBackupStatus.js'
|
||||
import ModalDialog from './ModalDialog.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const { visible, daysSinceBackup, lastBackupAt, load, isStale, sessionReminderShown } =
|
||||
useBackupStatus()
|
||||
|
||||
const showModal = ref(false)
|
||||
|
||||
onMounted(async () => {
|
||||
await load()
|
||||
if (!sessionReminderShown.value) {
|
||||
sessionReminderShown.value = true
|
||||
if (isStale.value) {
|
||||
showModal.value = true
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
watch(
|
||||
() => router.currentRoute.value.fullPath,
|
||||
async () => {
|
||||
await load()
|
||||
},
|
||||
)
|
||||
|
||||
function handleClick() {
|
||||
router.push('/settings')
|
||||
}
|
||||
|
||||
function dismissModal() {
|
||||
showModal.value = false
|
||||
}
|
||||
|
||||
function goToSettings() {
|
||||
showModal.value = false
|
||||
router.push('/settings')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ModalDialog v-if="showModal" :title="$t('backup.modal.title')" @close="dismissModal">
|
||||
<div data-testid="backup-modal">
|
||||
<p class="modal-message">
|
||||
{{
|
||||
lastBackupAt
|
||||
? $t('backup.modal.body', { days: daysSinceBackup })
|
||||
: $t('backup.modal.bodyNever')
|
||||
}}
|
||||
</p>
|
||||
<div class="modal-actions">
|
||||
<button class="btn" data-testid="backup-modal-dismiss" @click="dismissModal">
|
||||
{{ $t('backup.modal.dismiss') }}
|
||||
</button>
|
||||
<button class="btn btn-primary" data-testid="backup-modal-settings" @click="goToSettings">
|
||||
{{ $t('backup.modal.goToSettings') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</ModalDialog>
|
||||
|
||||
<Teleport to="#actionbar-actions" defer>
|
||||
<button
|
||||
v-if="visible"
|
||||
class="backup-reminder-btn"
|
||||
data-testid="backup-reminder"
|
||||
:title="
|
||||
lastBackupAt
|
||||
? $t('backup.reminder.tooltip', { days: daysSinceBackup })
|
||||
: $t('backup.reminder.tooltipNever')
|
||||
"
|
||||
:aria-label="
|
||||
lastBackupAt
|
||||
? $t('backup.reminder.tooltip', { days: daysSinceBackup })
|
||||
: $t('backup.reminder.tooltipNever')
|
||||
"
|
||||
@click="handleClick"
|
||||
>
|
||||
<i class="bi bi-exclamation-triangle-fill"></i>
|
||||
</button>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.backup-reminder-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 4px 12px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background-color: var(--warning-color, #f0ad4e);
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.backup-reminder-btn i {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.modal-message {
|
||||
margin-bottom: 16px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
</style>
|
||||
196
src/components/CleanJournalDialog.vue
Normal file
196
src/components/CleanJournalDialog.vue
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
import { useTranslation } from 'i18next-vue'
|
||||
import ModalDialog from './ModalDialog.vue'
|
||||
import { useExecutionLogs } from '../composables/useExecutionLogs.js'
|
||||
|
||||
const emit = defineEmits(['close', 'cleaned'])
|
||||
|
||||
const { t } = useTranslation()
|
||||
const { getCountByDateRange, removeByDateRange } = useExecutionLogs()
|
||||
|
||||
const today = new Date().toISOString().slice(0, 10)
|
||||
|
||||
const fromDate = ref('')
|
||||
const toDate = ref('')
|
||||
const matchCount = ref(null)
|
||||
const confirmed = ref(false)
|
||||
const deleting = ref(false)
|
||||
const fromError = ref('')
|
||||
const toError = ref('')
|
||||
|
||||
watch([fromDate, toDate], async () => {
|
||||
confirmed.value = false
|
||||
fromError.value = ''
|
||||
toError.value = ''
|
||||
matchCount.value = null
|
||||
if (!fromDate.value || !toDate.value) return
|
||||
if (fromDate.value > toDate.value) {
|
||||
toError.value = t('cleanJournal.errorToBeforeFrom')
|
||||
return
|
||||
}
|
||||
matchCount.value = await getCountByDateRange(fromDate.value, toDate.value)
|
||||
})
|
||||
|
||||
async function onConfirm() {
|
||||
if (!fromDate.value || !toDate.value || fromDate.value > toDate.value) return
|
||||
if (!confirmed.value) return
|
||||
deleting.value = true
|
||||
try {
|
||||
await removeByDateRange(fromDate.value, toDate.value)
|
||||
emit('cleaned')
|
||||
emit('close')
|
||||
} finally {
|
||||
deleting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ModalDialog :title="$t('cleanJournal.title')" @close="emit('close')">
|
||||
<p class="hint">{{ $t('cleanJournal.hint') }}</p>
|
||||
|
||||
<div class="date-fields">
|
||||
<div class="date-field">
|
||||
<label for="clean-from">{{ $t('cleanJournal.from') }}</label>
|
||||
<input
|
||||
id="clean-from"
|
||||
v-model="fromDate"
|
||||
data-testid="clean-journal-from"
|
||||
type="date"
|
||||
:max="today"
|
||||
/>
|
||||
</div>
|
||||
<div class="date-field">
|
||||
<label for="clean-to">{{ $t('cleanJournal.to') }}</label>
|
||||
<input
|
||||
id="clean-to"
|
||||
v-model="toDate"
|
||||
data-testid="clean-journal-to"
|
||||
type="date"
|
||||
:max="today"
|
||||
/>
|
||||
<p v-if="toError" class="field-error" data-testid="clean-journal-date-error">
|
||||
{{ toError }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p
|
||||
v-if="matchCount !== null"
|
||||
class="match-count"
|
||||
:class="{ 'match-zero': matchCount === 0 }"
|
||||
data-testid="clean-journal-count"
|
||||
>
|
||||
<i class="bi bi-info-circle"></i>
|
||||
{{ $t('cleanJournal.matchCount', { count: matchCount }) }}
|
||||
</p>
|
||||
|
||||
<label
|
||||
v-if="matchCount !== null && matchCount > 0"
|
||||
class="checkbox-label"
|
||||
data-testid="clean-journal-confirm-label"
|
||||
>
|
||||
<input v-model="confirmed" type="checkbox" data-testid="clean-journal-confirm-checkbox" />
|
||||
<span>{{ $t('cleanJournal.confirmLabel') }}</span>
|
||||
</label>
|
||||
|
||||
<div class="confirm-actions">
|
||||
<button class="btn" data-testid="clean-journal-cancel" @click="emit('close')">
|
||||
{{ $t('import.cancel') }}
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-danger"
|
||||
data-testid="clean-journal-delete"
|
||||
:disabled="!confirmed || deleting || matchCount === 0 || matchCount === null"
|
||||
@click="onConfirm"
|
||||
>
|
||||
<i class="bi bi-trash"></i>
|
||||
{{ $t('cleanJournal.deleteBtn') }}
|
||||
</button>
|
||||
</div>
|
||||
</ModalDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.hint {
|
||||
font-size: 14px;
|
||||
color: var(--front-color2);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.date-fields {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.date-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
flex: 1;
|
||||
min-width: 130px;
|
||||
}
|
||||
|
||||
.date-field label {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.field-error {
|
||||
color: var(--btn-danger-bg);
|
||||
font-size: 13px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.match-count {
|
||||
margin-top: 12px;
|
||||
font-size: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--btn-danger-bg);
|
||||
}
|
||||
|
||||
.match-count.match-zero {
|
||||
color: var(--front-color2);
|
||||
}
|
||||
|
||||
.checkbox-label {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.checkbox-label input[type='checkbox'] {
|
||||
margin-top: 2px;
|
||||
flex-shrink: 0;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.checkbox-label span {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.confirm-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.confirm-actions button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
151
src/components/CreditsDialog.vue
Normal file
151
src/components/CreditsDialog.vue
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
<script setup>
|
||||
import ModalDialog from './ModalDialog.vue'
|
||||
|
||||
defineEmits(['close'])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ModalDialog :title="$t('credits.title')" @close="$emit('close')">
|
||||
<p class="credits-intro">{{ $t('credits.intro') }}</p>
|
||||
|
||||
<div class="credit-item">
|
||||
<div class="credit-header">
|
||||
<i class="bi bi-lightning-charge-fill credit-icon"></i>
|
||||
<strong>{{ $t('credits.appIcon.name') }}</strong>
|
||||
</div>
|
||||
<p class="credit-detail">{{ $t('credits.appIcon.description') }}</p>
|
||||
<p class="credit-meta">
|
||||
<span class="credit-license">CC BY 3.0</span>
|
||||
—
|
||||
<a href="https://game-icons.net" target="_blank" rel="noopener noreferrer">
|
||||
game-icons.net
|
||||
</a>
|
||||
—
|
||||
{{ $t('credits.appIcon.author') }}
|
||||
</p>
|
||||
<p class="credit-note">{{ $t('credits.gameIcons') }}</p>
|
||||
</div>
|
||||
|
||||
<div class="credit-item">
|
||||
<div class="credit-header">
|
||||
<i class="bi bi-grid-3x3-gap-fill credit-icon"></i>
|
||||
<strong>{{ $t('credits.bootstrapIcons.name') }}</strong>
|
||||
</div>
|
||||
<p class="credit-detail">{{ $t('credits.bootstrapIcons.description') }}</p>
|
||||
<p class="credit-meta">
|
||||
<span class="credit-license">MIT</span>
|
||||
—
|
||||
<a href="https://icons.getbootstrap.com" target="_blank" rel="noopener noreferrer">
|
||||
icons.getbootstrap.com
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="credit-item">
|
||||
<div class="credit-header">
|
||||
<i class="bi bi-box-seam credit-icon"></i>
|
||||
<strong>{{ $t('credits.thirdParties.title') }}</strong>
|
||||
</div>
|
||||
<p class="credit-detail credit-links">
|
||||
<a href="https://vuejs.org" target="_blank" rel="noopener noreferrer">Vue</a>,
|
||||
<a href="https://router.vuejs.org" target="_blank" rel="noopener noreferrer">Vue Router</a>,
|
||||
<a href="https://vitejs.dev" target="_blank" rel="noopener noreferrer">Vite</a>,
|
||||
<a href="https://sqlite.org/wasm/" target="_blank" rel="noopener noreferrer">SQLite WASM</a
|
||||
>, <a href="https://www.chartjs.org" target="_blank" rel="noopener noreferrer">Chart.js</a>,
|
||||
<a href="https://vue-chartjs.org" target="_blank" rel="noopener noreferrer">vue-chartjs</a>,
|
||||
<a href="https://www.i18next.com" target="_blank" rel="noopener noreferrer">i18next</a>,
|
||||
<a href="https://i18next.github.io/i18next-vue/" target="_blank" rel="noopener noreferrer"
|
||||
>i18next-vue</a
|
||||
>, <a href="https://prettier.io" target="_blank" rel="noopener noreferrer">Prettier</a>,
|
||||
<a href="https://vite-pwa-org.netlify.app" target="_blank" rel="noopener noreferrer"
|
||||
>vite-plugin-pwa</a
|
||||
>, <a href="https://marked.js.org" target="_blank" rel="noopener noreferrer">marked</a>,
|
||||
<a href="https://github.com/cure53/DOMPurify" target="_blank" rel="noopener noreferrer"
|
||||
>DOMPurify</a
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="credit-item">
|
||||
<div class="credit-header">
|
||||
<i class="bi bi-check2-square credit-icon"></i>
|
||||
<strong>{{ $t('credits.testingTools.title') }}</strong>
|
||||
</div>
|
||||
<p class="credit-detail credit-links">
|
||||
<a href="https://playwright.dev/python/" target="_blank" rel="noopener noreferrer"
|
||||
>Playwright</a
|
||||
>, <a href="https://pytest.org" target="_blank" rel="noopener noreferrer">pytest</a>
|
||||
</p>
|
||||
</div>
|
||||
</ModalDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.credits-intro {
|
||||
font-size: 14px;
|
||||
color: var(--front-color2);
|
||||
margin: 0 0 20px;
|
||||
}
|
||||
|
||||
.credit-item {
|
||||
padding: 14px 0;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.credit-item:last-child {
|
||||
border-bottom: none;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.credit-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.credit-icon {
|
||||
font-size: 16px;
|
||||
color: var(--front-color2);
|
||||
}
|
||||
|
||||
.credit-detail {
|
||||
font-size: 13px;
|
||||
color: var(--front-color2);
|
||||
margin: 0 0 6px;
|
||||
}
|
||||
|
||||
.credit-meta {
|
||||
font-size: 13px;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.credit-license {
|
||||
display: inline-block;
|
||||
padding: 1px 7px;
|
||||
border-radius: 10px;
|
||||
background-color: var(--btn-primary-bg);
|
||||
color: var(--btn-primary-color);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.credit-links {
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.credit-note {
|
||||
font-size: 12px;
|
||||
color: var(--front-color2);
|
||||
margin: 6px 0 0;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--front-color1);
|
||||
}
|
||||
</style>
|
||||
104
src/components/ExportFilterDialog.vue
Normal file
104
src/components/ExportFilterDialog.vue
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import ModalDialog from './ModalDialog.vue'
|
||||
|
||||
const emit = defineEmits(['close', 'export'])
|
||||
|
||||
const filters = ref({
|
||||
exercises: true,
|
||||
combos: true,
|
||||
sessions: true,
|
||||
collections: true,
|
||||
executionLogs: false,
|
||||
settings: false,
|
||||
})
|
||||
|
||||
function onExport() {
|
||||
emit('export', { ...filters.value })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ModalDialog :title="$t('export.filterTitle')" @close="$emit('close')">
|
||||
<p class="text-muted">{{ $t('export.filterDescription') }}</p>
|
||||
|
||||
<div class="filter-list">
|
||||
<label class="filter-item" data-testid="export-filter-exercises">
|
||||
<input v-model="filters.exercises" type="checkbox" />
|
||||
<span>{{ $t('nav.exercises') }}</span>
|
||||
</label>
|
||||
<label class="filter-item" data-testid="export-filter-combos">
|
||||
<input v-model="filters.combos" type="checkbox" />
|
||||
<span>{{ $t('nav.combos') }}</span>
|
||||
</label>
|
||||
<label class="filter-item" data-testid="export-filter-sessions">
|
||||
<input v-model="filters.sessions" type="checkbox" />
|
||||
<span>{{ $t('nav.sessions') }}</span>
|
||||
</label>
|
||||
<label class="filter-item" data-testid="export-filter-collections">
|
||||
<input v-model="filters.collections" type="checkbox" />
|
||||
<span>{{ $t('nav.collections') }}</span>
|
||||
</label>
|
||||
<label class="filter-item" data-testid="export-filter-execution-logs">
|
||||
<input v-model="filters.executionLogs" type="checkbox" />
|
||||
<span>{{ $t('export.executionLogs') }}</span>
|
||||
</label>
|
||||
<label class="filter-item" data-testid="export-filter-settings">
|
||||
<input v-model="filters.settings" type="checkbox" />
|
||||
<span>{{ $t('export.settings') }}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="export-actions">
|
||||
<button class="btn" data-testid="export-filter-cancel" @click="$emit('close')">
|
||||
{{ $t('import.cancel') }}
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
data-testid="export-filter-confirm"
|
||||
:disabled="
|
||||
!filters.exercises &&
|
||||
!filters.combos &&
|
||||
!filters.sessions &&
|
||||
!filters.collections &&
|
||||
!filters.executionLogs &&
|
||||
!filters.settings
|
||||
"
|
||||
@click="onExport"
|
||||
>
|
||||
<i class="bi bi-download"></i>
|
||||
{{ $t('settings.exportData') }}
|
||||
</button>
|
||||
</div>
|
||||
</ModalDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.filter-list {
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.filter-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 0;
|
||||
cursor: pointer;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.filter-item input[type='checkbox'] {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.export-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
</style>
|
||||
862
src/components/ImportDialog.vue
Normal file
862
src/components/ImportDialog.vue
Normal file
|
|
@ -0,0 +1,862 @@
|
|||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useTranslation } from 'i18next-vue'
|
||||
import ModalDialog from './ModalDialog.vue'
|
||||
import { useJsonImport, COLLISION_ACTION } from '../composables/useJsonImport.js'
|
||||
|
||||
const props = defineProps({
|
||||
envelope: { type: Object, required: true },
|
||||
localUuid: { type: String, required: true },
|
||||
asMine: { type: Boolean, default: false },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close', 'done'])
|
||||
|
||||
const { t } = useTranslation()
|
||||
const { analyzeAll, applyAllImport } = useJsonImport()
|
||||
|
||||
// State machine: 'loading' | 'suffix' | 'preview' | 'collisions' | 'applying' | 'report'
|
||||
const step = ref('loading')
|
||||
const analysis = ref(null)
|
||||
const applying = ref(false)
|
||||
const report = ref(null)
|
||||
|
||||
// Suffix input for different-user imports
|
||||
const suffixInput = ref('')
|
||||
const suffixError = ref('')
|
||||
|
||||
// Collision resolutions per entity type (also receives cross-user title-conflict
|
||||
// resolutions once they're discovered in onApply — see crossUserConflictsChecked)
|
||||
const exerciseResolutions = ref([])
|
||||
const comboResolutions = ref([])
|
||||
const sessionResolutions = ref([])
|
||||
const collectionResolutions = ref([])
|
||||
|
||||
// Guards against re-running findTitleConflicts after the user has already resolved them
|
||||
const crossUserConflictsChecked = ref(false)
|
||||
|
||||
const summary = computed(() => analysis.value?.summary || {})
|
||||
|
||||
const totalEntityCount = computed(() => {
|
||||
let count = 0
|
||||
if (analysis.value?.exercises) count += analysis.value.exercises.total
|
||||
if (analysis.value?.combos) count += analysis.value.combos.total
|
||||
if (analysis.value?.sessions) count += analysis.value.sessions.total
|
||||
if (analysis.value?.collections) count += analysis.value.collections.total
|
||||
if (analysis.value?.executionLogs) count += analysis.value.executionLogs.total
|
||||
return count
|
||||
})
|
||||
|
||||
// Derived from the resolution refs directly (not from `analysis`) so it stays correct
|
||||
// whether resolutions came from same-user id/title collisions or cross-user title conflicts.
|
||||
const hasCollisions = computed(() => totalCollisions.value > 0)
|
||||
|
||||
const totalCollisions = computed(
|
||||
() =>
|
||||
exerciseResolutions.value.length +
|
||||
comboResolutions.value.length +
|
||||
sessionResolutions.value.length +
|
||||
collectionResolutions.value.length,
|
||||
)
|
||||
|
||||
onMounted(async () => {
|
||||
analysis.value = await analyzeAll(props.envelope, { asMine: props.asMine })
|
||||
const a = analysis.value
|
||||
|
||||
if (a.summary.isSameUser) {
|
||||
// Initialize collision resolutions (id and title conflicts are both bucketed
|
||||
// into sameUserCollisions by useJsonImport.js)
|
||||
if (a.exercises?.sameUserCollisions?.length) {
|
||||
exerciseResolutions.value = a.exercises.sameUserCollisions.map((c) => ({
|
||||
...c,
|
||||
action: COLLISION_ACTION.SKIP,
|
||||
}))
|
||||
}
|
||||
if (a.combos?.sameUserCollisions?.length) {
|
||||
comboResolutions.value = a.combos.sameUserCollisions.map((c) => ({
|
||||
...c,
|
||||
action: COLLISION_ACTION.SKIP,
|
||||
}))
|
||||
}
|
||||
if (a.sessions?.sameUserCollisions?.length) {
|
||||
sessionResolutions.value = a.sessions.sameUserCollisions.map((c) => ({
|
||||
...c,
|
||||
action: COLLISION_ACTION.SKIP,
|
||||
}))
|
||||
}
|
||||
if (a.collections?.sameUserCollisions?.length) {
|
||||
collectionResolutions.value = a.collections.sameUserCollisions.map((c) => ({
|
||||
...c,
|
||||
action: COLLISION_ACTION.SKIP,
|
||||
}))
|
||||
}
|
||||
step.value = hasCollisions.value ? 'collisions' : 'preview'
|
||||
} else {
|
||||
if (a.suffixInfo?.needsSuffix) {
|
||||
suffixInput.value = a.summary.exporterName
|
||||
step.value = 'suffix'
|
||||
} else {
|
||||
step.value = 'preview'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// --- Actions ---
|
||||
|
||||
/**
|
||||
* Check title conflicts for a list of different-user entities against existing data.
|
||||
* Returns the matched existing row alongside the entry so the caller can offer
|
||||
* Replace/Skip against it (same as same-user collisions) instead of forcing a rename.
|
||||
*/
|
||||
async function findTitleConflicts(entries, entityType, titleField, suffix, getAll) {
|
||||
if (!entries?.length) return []
|
||||
const allEntities = await getAll()
|
||||
const conflicts = []
|
||||
for (const entry of entries) {
|
||||
const suffixedTitle = `${entry.imported[titleField]} [${suffix}]`
|
||||
if (entry.action === 'update' && entry.existing?.[titleField] === suffixedTitle) continue
|
||||
const conflict = allEntities.find(
|
||||
(e) => e[titleField] === suffixedTitle && e.id !== entry.imported.id,
|
||||
)
|
||||
if (conflict) {
|
||||
conflicts.push({ entity: entityType, entry, existing: conflict })
|
||||
}
|
||||
}
|
||||
return conflicts
|
||||
}
|
||||
|
||||
async function onApply() {
|
||||
applying.value = true
|
||||
step.value = 'applying'
|
||||
|
||||
try {
|
||||
const a = analysis.value
|
||||
|
||||
// For different-user imports, check title conflicts (on the suffixed title) once,
|
||||
// before applying. Found conflicts are folded into the same resolution arrays used
|
||||
// for same-user collisions, so the existing Replace/Skip screen handles them too.
|
||||
if (!a.summary.isSameUser && !crossUserConflictsChecked.value) {
|
||||
crossUserConflictsChecked.value = true
|
||||
const suffix = a.suffixInfo?.existingSuffix || suffixInput.value.trim()
|
||||
|
||||
const [exConflicts, coConflicts, seConflicts, clConflicts] = await Promise.all([
|
||||
findTitleConflicts(a.exercises?.differentUser, 'exercise', 'title', suffix, async () => {
|
||||
const { exerciseRepository } = await import('../db/repositories/exerciseRepository.js')
|
||||
return exerciseRepository.getAll()
|
||||
}),
|
||||
findTitleConflicts(a.combos?.differentUser, 'combo', 'title', suffix, async () => {
|
||||
const { comboRepository } = await import('../db/repositories/comboRepository.js')
|
||||
return comboRepository.getAll()
|
||||
}),
|
||||
findTitleConflicts(a.sessions?.differentUser, 'session', 'title', suffix, async () => {
|
||||
const { sessionRepository } = await import('../db/repositories/sessionRepository.js')
|
||||
return sessionRepository.getAll()
|
||||
}),
|
||||
findTitleConflicts(
|
||||
a.collections?.differentUser,
|
||||
'collection',
|
||||
'label',
|
||||
suffix,
|
||||
async () => {
|
||||
const { collectionRepository } =
|
||||
await import('../db/repositories/collectionRepository.js')
|
||||
return collectionRepository.getAll()
|
||||
},
|
||||
),
|
||||
])
|
||||
|
||||
const conflicts = [...exConflicts, ...coConflicts, ...seConflicts, ...clConflicts]
|
||||
|
||||
if (conflicts.length > 0) {
|
||||
for (const conflict of conflicts) {
|
||||
const resolutions = getResolutionsForType(conflict.entity)
|
||||
resolutions.value.push({
|
||||
imported: conflict.entry.imported,
|
||||
existing: conflict.existing,
|
||||
action: COLLISION_ACTION.SKIP,
|
||||
})
|
||||
}
|
||||
step.value = 'collisions'
|
||||
applying.value = false
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Apply import
|
||||
report.value = await applyAllImport(analysis.value, {
|
||||
suffix: analysis.value.suffixInfo?.existingSuffix || suffixInput.value.trim(),
|
||||
exerciseResolutions: exerciseResolutions.value,
|
||||
comboResolutions: comboResolutions.value,
|
||||
sessionResolutions: sessionResolutions.value,
|
||||
collectionResolutions: collectionResolutions.value,
|
||||
})
|
||||
|
||||
step.value = 'report'
|
||||
} catch (e) {
|
||||
report.value = {
|
||||
exercises: { imported: 0, skipped: 0, replaced: 0, updated: 0 },
|
||||
combos: { imported: 0, skipped: 0, replaced: 0, updated: 0 },
|
||||
sessions: { imported: 0, skipped: 0, replaced: 0, updated: 0 },
|
||||
collections: { imported: 0, skipped: 0, replaced: 0, updated: 0 },
|
||||
executionLogs: { imported: 0, skipped: 0 },
|
||||
settings: { imported: 0 },
|
||||
errors: [e.message],
|
||||
}
|
||||
step.value = 'report'
|
||||
} finally {
|
||||
applying.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onConfirmSuffix() {
|
||||
const trimmed = suffixInput.value.trim()
|
||||
if (!trimmed) {
|
||||
suffixError.value = t('import.suffixRequired')
|
||||
return
|
||||
}
|
||||
|
||||
const { suffixRepository } = await import('../db/repositories/suffixRepository.js')
|
||||
const existing = await suffixRepository.getBySuffix(trimmed)
|
||||
if (existing && existing.user_uuid !== analysis.value.summary.exporterUuid) {
|
||||
suffixError.value = t('import.suffixTaken')
|
||||
return
|
||||
}
|
||||
|
||||
suffixError.value = ''
|
||||
step.value = 'preview'
|
||||
}
|
||||
|
||||
function setAllCollisions(entityType, action) {
|
||||
const resolutions = getResolutionsForType(entityType)
|
||||
resolutions.value = resolutions.value.map((c) => ({ ...c, action }))
|
||||
}
|
||||
|
||||
function setCollisionAction(entityType, index, action) {
|
||||
const resolutions = getResolutionsForType(entityType)
|
||||
resolutions.value[index] = { ...resolutions.value[index], action }
|
||||
}
|
||||
|
||||
function getResolutionsForType(entityType) {
|
||||
switch (entityType) {
|
||||
case 'exercise':
|
||||
return exerciseResolutions
|
||||
case 'combo':
|
||||
return comboResolutions
|
||||
case 'session':
|
||||
return sessionResolutions
|
||||
case 'collection':
|
||||
return collectionResolutions
|
||||
default:
|
||||
return { value: [] }
|
||||
}
|
||||
}
|
||||
|
||||
function onDone() {
|
||||
emit('done', report.value)
|
||||
}
|
||||
|
||||
function formatDate(isoString) {
|
||||
try {
|
||||
return new Date(isoString).toLocaleDateString()
|
||||
} catch {
|
||||
return isoString
|
||||
}
|
||||
}
|
||||
|
||||
const totalImported = computed(() => {
|
||||
if (!report.value) return 0
|
||||
let count = 0
|
||||
for (const key of ['exercises', 'combos', 'sessions', 'collections', 'executionLogs']) {
|
||||
const r = report.value[key]
|
||||
if (r) {
|
||||
count += (r.imported || 0) + (r.replaced || 0) + (r.updated || 0)
|
||||
}
|
||||
}
|
||||
return count
|
||||
})
|
||||
|
||||
const totalSkipped = computed(() => {
|
||||
if (!report.value) return 0
|
||||
let count = 0
|
||||
for (const key of ['exercises', 'combos', 'sessions', 'collections', 'executionLogs']) {
|
||||
const r = report.value[key]
|
||||
if (r) count += r.skipped || 0
|
||||
}
|
||||
return count
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ModalDialog
|
||||
:title="asMine ? $t('import.titleAsMine') : $t('import.title')"
|
||||
wide
|
||||
@close="$emit('close')"
|
||||
>
|
||||
<!-- Loading -->
|
||||
<div v-if="step === 'loading'" class="import-loading" data-testid="import-loading">
|
||||
<i class="bi bi-arrow-repeat spin"></i>
|
||||
<p>{{ $t('app.loading') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Suffix prompt -->
|
||||
<div v-else-if="step === 'suffix'" class="import-suffix" data-testid="import-suffix-step">
|
||||
<p>{{ $t('import.suffixExplanation', { name: summary.exporterName }) }}</p>
|
||||
<div class="form-group">
|
||||
<label for="suffix-input">{{ $t('import.suffixLabel') }}</label>
|
||||
<input
|
||||
id="suffix-input"
|
||||
v-model="suffixInput"
|
||||
type="text"
|
||||
data-testid="import-suffix-input"
|
||||
:placeholder="$t('import.suffixPlaceholder')"
|
||||
@keyup.enter="onConfirmSuffix"
|
||||
/>
|
||||
<p v-if="suffixError" class="field-error" data-testid="import-suffix-error">
|
||||
{{ suffixError }}
|
||||
</p>
|
||||
</div>
|
||||
<p class="text-muted import-suffix-preview">
|
||||
{{ $t('import.suffixPreview', { title: 'Push-ups', suffix: suffixInput.trim() || '...' }) }}
|
||||
</p>
|
||||
<div class="import-actions">
|
||||
<button class="btn" data-testid="import-cancel" @click="$emit('close')">
|
||||
{{ $t('import.cancel') }}
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
data-testid="import-suffix-confirm"
|
||||
@click="onConfirmSuffix"
|
||||
>
|
||||
{{ $t('import.continue') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Preview -->
|
||||
<div v-else-if="step === 'preview'" class="import-preview" data-testid="import-preview-step">
|
||||
<div class="import-summary">
|
||||
<p>
|
||||
<strong>{{ totalEntityCount }}</strong>
|
||||
{{ $t('import.totalCount', { count: totalEntityCount }) }}
|
||||
</p>
|
||||
<p class="text-muted">
|
||||
{{
|
||||
$t('import.from', { name: summary.exporterName, date: formatDate(summary.exportDate) })
|
||||
}}
|
||||
</p>
|
||||
<p
|
||||
v-if="!summary.isSameUser && (analysis.suffixInfo?.existingSuffix || suffixInput.trim())"
|
||||
class="text-muted"
|
||||
>
|
||||
{{
|
||||
$t('import.usingSuffix', {
|
||||
suffix: analysis.suffixInfo?.existingSuffix || suffixInput.trim(),
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
<!-- Entity breakdown -->
|
||||
<ul class="entity-breakdown">
|
||||
<li v-if="analysis.exercises">
|
||||
{{ analysis.exercises.total }} {{ $t('nav.exercises') }}
|
||||
</li>
|
||||
<li v-if="analysis.combos">{{ analysis.combos.total }} {{ $t('nav.combos') }}</li>
|
||||
<li v-if="analysis.sessions">{{ analysis.sessions.total }} {{ $t('nav.sessions') }}</li>
|
||||
<li v-if="analysis.collections">
|
||||
{{ analysis.collections.total }} {{ $t('nav.collections') }}
|
||||
</li>
|
||||
<li v-if="analysis.executionLogs">
|
||||
{{ analysis.executionLogs.total }} {{ $t('import.executionLogs') }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="import-actions">
|
||||
<button class="btn" data-testid="import-cancel" @click="$emit('close')">
|
||||
{{ $t('import.cancel') }}
|
||||
</button>
|
||||
<button class="btn btn-primary" data-testid="import-apply" @click="onApply">
|
||||
{{ $t('import.apply') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Same-user collisions -->
|
||||
<div
|
||||
v-else-if="step === 'collisions'"
|
||||
class="import-collisions"
|
||||
data-testid="import-collisions-step"
|
||||
>
|
||||
<p>{{ $t('import.collisionExplanation', { count: totalCollisions }) }}</p>
|
||||
|
||||
<!-- Exercises collisions -->
|
||||
<div
|
||||
v-if="exerciseResolutions.length > 0"
|
||||
class="collision-group"
|
||||
data-testid="collision-group-exercises"
|
||||
>
|
||||
<h4>{{ $t('nav.exercises') }}</h4>
|
||||
<div class="collision-batch">
|
||||
<button
|
||||
class="btn btn-sm"
|
||||
data-testid="exercise-replace-all"
|
||||
@click="setAllCollisions('exercise', COLLISION_ACTION.REPLACE)"
|
||||
>
|
||||
{{ $t('import.replaceAll') }}
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-sm"
|
||||
data-testid="exercise-skip-all"
|
||||
@click="setAllCollisions('exercise', COLLISION_ACTION.SKIP)"
|
||||
>
|
||||
{{ $t('import.skipAll') }}
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
v-for="(collision, index) in exerciseResolutions"
|
||||
:key="collision.imported.id"
|
||||
class="collision-item"
|
||||
:data-testid="'collision-item-exercise-' + index"
|
||||
>
|
||||
<div class="collision-info">
|
||||
<strong>{{ collision.imported.title }}</strong>
|
||||
</div>
|
||||
<div class="collision-actions">
|
||||
<button
|
||||
class="btn btn-sm"
|
||||
:class="{ 'btn-primary': collision.action === COLLISION_ACTION.REPLACE }"
|
||||
:data-testid="'collision-replace-exercise-' + index"
|
||||
@click="setCollisionAction('exercise', index, COLLISION_ACTION.REPLACE)"
|
||||
>
|
||||
{{ $t('import.replace') }}
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-sm"
|
||||
:class="{ 'btn-primary': collision.action === COLLISION_ACTION.SKIP }"
|
||||
:data-testid="'collision-skip-exercise-' + index"
|
||||
@click="setCollisionAction('exercise', index, COLLISION_ACTION.SKIP)"
|
||||
>
|
||||
{{ $t('import.skip') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Combos collisions -->
|
||||
<div
|
||||
v-if="comboResolutions.length > 0"
|
||||
class="collision-group"
|
||||
data-testid="collision-group-combos"
|
||||
>
|
||||
<h4>{{ $t('nav.combos') }}</h4>
|
||||
<div class="collision-batch">
|
||||
<button
|
||||
class="btn btn-sm"
|
||||
data-testid="combo-replace-all"
|
||||
@click="setAllCollisions('combo', COLLISION_ACTION.REPLACE)"
|
||||
>
|
||||
{{ $t('import.replaceAll') }}
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-sm"
|
||||
data-testid="combo-skip-all"
|
||||
@click="setAllCollisions('combo', COLLISION_ACTION.SKIP)"
|
||||
>
|
||||
{{ $t('import.skipAll') }}
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
v-for="(collision, index) in comboResolutions"
|
||||
:key="collision.imported.id"
|
||||
class="collision-item"
|
||||
:data-testid="'collision-item-combo-' + index"
|
||||
>
|
||||
<div class="collision-info">
|
||||
<strong>{{ collision.imported.title }}</strong>
|
||||
</div>
|
||||
<div class="collision-actions">
|
||||
<button
|
||||
class="btn btn-sm"
|
||||
:class="{ 'btn-primary': collision.action === COLLISION_ACTION.REPLACE }"
|
||||
:data-testid="'collision-replace-combo-' + index"
|
||||
@click="setCollisionAction('combo', index, COLLISION_ACTION.REPLACE)"
|
||||
>
|
||||
{{ $t('import.replace') }}
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-sm"
|
||||
:class="{ 'btn-primary': collision.action === COLLISION_ACTION.SKIP }"
|
||||
:data-testid="'collision-skip-combo-' + index"
|
||||
@click="setCollisionAction('combo', index, COLLISION_ACTION.SKIP)"
|
||||
>
|
||||
{{ $t('import.skip') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sessions collisions -->
|
||||
<div
|
||||
v-if="sessionResolutions.length > 0"
|
||||
class="collision-group"
|
||||
data-testid="collision-group-sessions"
|
||||
>
|
||||
<h4>{{ $t('nav.sessions') }}</h4>
|
||||
<div class="collision-batch">
|
||||
<button
|
||||
class="btn btn-sm"
|
||||
data-testid="session-replace-all"
|
||||
@click="setAllCollisions('session', COLLISION_ACTION.REPLACE)"
|
||||
>
|
||||
{{ $t('import.replaceAll') }}
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-sm"
|
||||
data-testid="session-skip-all"
|
||||
@click="setAllCollisions('session', COLLISION_ACTION.SKIP)"
|
||||
>
|
||||
{{ $t('import.skipAll') }}
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
v-for="(collision, index) in sessionResolutions"
|
||||
:key="collision.imported.id"
|
||||
class="collision-item"
|
||||
:data-testid="'collision-item-session-' + index"
|
||||
>
|
||||
<div class="collision-info">
|
||||
<strong>{{ collision.imported.title }}</strong>
|
||||
</div>
|
||||
<div class="collision-actions">
|
||||
<button
|
||||
class="btn btn-sm"
|
||||
:class="{ 'btn-primary': collision.action === COLLISION_ACTION.REPLACE }"
|
||||
:data-testid="'collision-replace-session-' + index"
|
||||
@click="setCollisionAction('session', index, COLLISION_ACTION.REPLACE)"
|
||||
>
|
||||
{{ $t('import.replace') }}
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-sm"
|
||||
:class="{ 'btn-primary': collision.action === COLLISION_ACTION.SKIP }"
|
||||
:data-testid="'collision-skip-session-' + index"
|
||||
@click="setCollisionAction('session', index, COLLISION_ACTION.SKIP)"
|
||||
>
|
||||
{{ $t('import.skip') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Collections collisions -->
|
||||
<div
|
||||
v-if="collectionResolutions.length > 0"
|
||||
class="collision-group"
|
||||
data-testid="collision-group-collections"
|
||||
>
|
||||
<h4>{{ $t('nav.collections') }}</h4>
|
||||
<div class="collision-batch">
|
||||
<button
|
||||
class="btn btn-sm"
|
||||
data-testid="collection-replace-all"
|
||||
@click="setAllCollisions('collection', COLLISION_ACTION.REPLACE)"
|
||||
>
|
||||
{{ $t('import.replaceAll') }}
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-sm"
|
||||
data-testid="collection-skip-all"
|
||||
@click="setAllCollisions('collection', COLLISION_ACTION.SKIP)"
|
||||
>
|
||||
{{ $t('import.skipAll') }}
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
v-for="(collision, index) in collectionResolutions"
|
||||
:key="collision.imported.id"
|
||||
class="collision-item"
|
||||
:data-testid="'collision-item-collection-' + index"
|
||||
>
|
||||
<div class="collision-info">
|
||||
<strong>{{ collision.imported.label }}</strong>
|
||||
</div>
|
||||
<div class="collision-actions">
|
||||
<button
|
||||
class="btn btn-sm"
|
||||
:class="{ 'btn-primary': collision.action === COLLISION_ACTION.REPLACE }"
|
||||
:data-testid="'collision-replace-collection-' + index"
|
||||
@click="setCollisionAction('collection', index, COLLISION_ACTION.REPLACE)"
|
||||
>
|
||||
{{ $t('import.replace') }}
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-sm"
|
||||
:class="{ 'btn-primary': collision.action === COLLISION_ACTION.SKIP }"
|
||||
:data-testid="'collision-skip-collection-' + index"
|
||||
@click="setCollisionAction('collection', index, COLLISION_ACTION.SKIP)"
|
||||
>
|
||||
{{ $t('import.skip') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="import-actions">
|
||||
<button class="btn" data-testid="import-cancel" @click="$emit('close')">
|
||||
{{ $t('import.cancel') }}
|
||||
</button>
|
||||
<button class="btn btn-primary" data-testid="import-apply" @click="onApply">
|
||||
{{ $t('import.apply') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Applying -->
|
||||
<div v-else-if="step === 'applying'" class="import-loading" data-testid="import-applying">
|
||||
<i class="bi bi-arrow-repeat spin"></i>
|
||||
<p>{{ $t('import.importing') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Report -->
|
||||
<div v-else-if="step === 'report'" class="import-report" data-testid="import-report-step">
|
||||
<div
|
||||
class="report-summary"
|
||||
:class="report.errors?.length ? 'report-has-errors' : 'report-ok'"
|
||||
>
|
||||
<i :class="report.errors?.length ? 'bi bi-exclamation-triangle' : 'bi bi-check-circle'"></i>
|
||||
<strong>{{ $t('import.reportTitle') }}</strong>
|
||||
</div>
|
||||
<ul class="report-details">
|
||||
<li v-if="totalImported" data-testid="import-report-imported">
|
||||
{{ $t('import.reportTotalImported', { count: totalImported }) }}
|
||||
</li>
|
||||
<li v-if="totalSkipped" data-testid="import-report-skipped">
|
||||
{{ $t('import.reportSkipped', { count: totalSkipped }) }}
|
||||
</li>
|
||||
<!-- Per-entity breakdown -->
|
||||
<li v-if="report.exercises?.imported" data-testid="import-report-exercises-imported">
|
||||
{{ $t('import.reportImported', { count: report.exercises.imported }) }}
|
||||
{{ $t('nav.exercises') }}
|
||||
</li>
|
||||
<li v-if="report.exercises?.replaced" data-testid="import-report-exercises-replaced">
|
||||
{{ $t('import.reportReplaced', { count: report.exercises.replaced }) }}
|
||||
{{ $t('nav.exercises') }}
|
||||
</li>
|
||||
<li v-if="report.exercises?.updated" data-testid="import-report-exercises-updated">
|
||||
{{ $t('import.reportUpdated', { count: report.exercises.updated }) }}
|
||||
{{ $t('nav.exercises') }}
|
||||
</li>
|
||||
<li v-if="report.combos?.imported" data-testid="import-report-combos-imported">
|
||||
{{ $t('import.reportImported', { count: report.combos.imported }) }}
|
||||
{{ $t('nav.combos') }}
|
||||
</li>
|
||||
<li v-if="report.combos?.replaced" data-testid="import-report-combos-replaced">
|
||||
{{ $t('import.reportReplaced', { count: report.combos.replaced }) }}
|
||||
{{ $t('nav.combos') }}
|
||||
</li>
|
||||
<li v-if="report.combos?.updated" data-testid="import-report-combos-updated">
|
||||
{{ $t('import.reportUpdated', { count: report.combos.updated }) }}
|
||||
{{ $t('nav.combos') }}
|
||||
</li>
|
||||
<li v-if="report.sessions?.imported" data-testid="import-report-sessions-imported">
|
||||
{{ $t('import.reportImported', { count: report.sessions.imported }) }}
|
||||
{{ $t('nav.sessions') }}
|
||||
</li>
|
||||
<li v-if="report.sessions?.replaced" data-testid="import-report-sessions-replaced">
|
||||
{{ $t('import.reportReplaced', { count: report.sessions.replaced }) }}
|
||||
{{ $t('nav.sessions') }}
|
||||
</li>
|
||||
<li v-if="report.sessions?.updated" data-testid="import-report-sessions-updated">
|
||||
{{ $t('import.reportUpdated', { count: report.sessions.updated }) }}
|
||||
{{ $t('nav.sessions') }}
|
||||
</li>
|
||||
<li v-if="report.collections?.imported" data-testid="import-report-collections-imported">
|
||||
{{ $t('import.reportImported', { count: report.collections.imported }) }}
|
||||
{{ $t('nav.collections') }}
|
||||
</li>
|
||||
<li v-if="report.collections?.replaced" data-testid="import-report-collections-replaced">
|
||||
{{ $t('import.reportReplaced', { count: report.collections.replaced }) }}
|
||||
{{ $t('nav.collections') }}
|
||||
</li>
|
||||
<li v-if="report.collections?.updated" data-testid="import-report-collections-updated">
|
||||
{{ $t('import.reportUpdated', { count: report.collections.updated }) }}
|
||||
{{ $t('nav.collections') }}
|
||||
</li>
|
||||
<li v-if="report.executionLogs?.imported" data-testid="import-report-logs-imported">
|
||||
{{ $t('import.reportImported', { count: report.executionLogs.imported }) }}
|
||||
{{ $t('import.executionLogs') }}
|
||||
</li>
|
||||
<li v-if="report.settings?.imported" data-testid="import-report-settings-imported">
|
||||
{{ $t('import.reportImported', { count: report.settings.imported }) }}
|
||||
{{ $t('nav.settings') }}
|
||||
</li>
|
||||
<li
|
||||
v-for="(err, i) in report.errors"
|
||||
:key="i"
|
||||
class="report-error-item"
|
||||
:data-testid="'import-report-error-' + i"
|
||||
>
|
||||
{{ err }}
|
||||
</li>
|
||||
</ul>
|
||||
<div class="import-actions">
|
||||
<button class="btn btn-primary" data-testid="import-done" @click="onDone">
|
||||
{{ $t('import.done') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</ModalDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.import-loading {
|
||||
text-align: center;
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
.import-loading .spin {
|
||||
font-size: 32px;
|
||||
display: inline-block;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.import-summary {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.import-summary p {
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
.entity-breakdown {
|
||||
list-style: disc;
|
||||
padding-left: 20px;
|
||||
margin: 8px 0 0;
|
||||
font-size: 14px;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.entity-breakdown li {
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.import-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin: 12px 0;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.field-error {
|
||||
color: var(--btn-danger-bg);
|
||||
font-size: 13px;
|
||||
margin: 4px 0 0;
|
||||
}
|
||||
|
||||
.import-suffix-preview {
|
||||
font-size: 13px;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.collision-group {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.collision-group h4 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.collision-batch {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.collision-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.collision-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.collision-info strong {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.collision-info .text-muted {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.collision-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
font-size: 12px;
|
||||
padding: 4px 10px;
|
||||
}
|
||||
|
||||
.report-summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 16px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.report-ok {
|
||||
color: #28a745;
|
||||
}
|
||||
|
||||
.report-has-errors {
|
||||
color: var(--btn-danger-bg);
|
||||
}
|
||||
|
||||
.report-details {
|
||||
list-style: disc;
|
||||
padding-left: 20px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.report-details li {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.report-error-item {
|
||||
color: var(--btn-danger-bg);
|
||||
}
|
||||
</style>
|
||||
65
src/components/InstallPrompt.vue
Normal file
65
src/components/InstallPrompt.vue
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { useInstallPrompt } from '../composables/useInstallPrompt.js'
|
||||
|
||||
const { canInstall, install } = useInstallPrompt()
|
||||
const dismissed = ref(false)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport v-if="canInstall && !dismissed" to="#actionbar-actions" defer>
|
||||
<button
|
||||
class="install-btn"
|
||||
data-testid="install-prompt-btn"
|
||||
:aria-label="$t('install.button')"
|
||||
:title="$t('install.button')"
|
||||
@click="install"
|
||||
>
|
||||
<i class="bi bi-download"></i>
|
||||
</button>
|
||||
<button
|
||||
class="install-dismiss-btn"
|
||||
data-testid="install-prompt-dismiss"
|
||||
:aria-label="$t('install.dismiss')"
|
||||
:title="$t('install.dismiss')"
|
||||
@click="dismissed = true"
|
||||
>
|
||||
<i class="bi bi-x-lg"></i>
|
||||
</button>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.install-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 10px;
|
||||
border: 1px solid var(--btn-primary-bg);
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: var(--actionbar-font-color);
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.install-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.install-dismiss-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--actionbar-font-color);
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
font-size: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.install-dismiss-btn:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
</style>
|
||||
23
src/components/LicenseDialog.vue
Normal file
23
src/components/LicenseDialog.vue
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
<script setup>
|
||||
import licenseText from '../assets/license.md?raw'
|
||||
import { renderMarkdown } from '../utils/markdown.js'
|
||||
import ModalDialog from './ModalDialog.vue'
|
||||
|
||||
defineEmits(['close'])
|
||||
|
||||
const licenseHtml = renderMarkdown(licenseText)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ModalDialog :title="$t('about.licenseBtn')" wide @close="$emit('close')">
|
||||
<div class="license-body" data-testid="license-dialog-content" v-html="licenseHtml"></div>
|
||||
</ModalDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.license-body {
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
color: var(--front-color1);
|
||||
}
|
||||
</style>
|
||||
116
src/components/ModalDialog.vue
Normal file
116
src/components/ModalDialog.vue
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
<script setup>
|
||||
import { useId, onMounted, ref } from 'vue'
|
||||
|
||||
defineProps({
|
||||
title: { type: String, default: '' },
|
||||
wide: { type: Boolean, default: false },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close'])
|
||||
|
||||
const uid = useId()
|
||||
const cardRef = ref(null)
|
||||
|
||||
onMounted(() => {
|
||||
cardRef.value?.focus()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
class="modal-overlay"
|
||||
data-testid="modal-overlay"
|
||||
@click.self="emit('close')"
|
||||
@keydown.esc="emit('close')"
|
||||
>
|
||||
<div
|
||||
ref="cardRef"
|
||||
class="modal-card"
|
||||
:class="{ 'modal-wide': wide }"
|
||||
data-testid="modal-card"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
:aria-labelledby="title ? `modal-title-${uid}` : undefined"
|
||||
tabindex="-1"
|
||||
>
|
||||
<div v-if="title" class="modal-header">
|
||||
<h3 :id="`modal-title-${uid}`" data-testid="modal-title">{{ title }}</h3>
|
||||
<button
|
||||
class="modal-close-btn"
|
||||
data-testid="modal-close"
|
||||
:aria-label="$t('modal.close')"
|
||||
@click="emit('close')"
|
||||
>
|
||||
<i class="bi bi-x-lg"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 10000;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.modal-card {
|
||||
background: var(--bg-color1);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
max-height: 85vh;
|
||||
overflow-y: auto;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.modal-wide {
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.modal-header h3 {
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.modal-close-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--front-color1);
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
line-height: 1;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.modal-close-btn:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 20px;
|
||||
}
|
||||
</style>
|
||||
84
src/components/SaveFileDialog.vue
Normal file
84
src/components/SaveFileDialog.vue
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
<script setup>
|
||||
import { nextTick, ref, watch } from 'vue'
|
||||
import ModalDialog from './ModalDialog.vue'
|
||||
import { useSaveFilePrompt } from '../composables/useSaveFilePrompt.js'
|
||||
|
||||
const { pendingSave, confirmFilename, cancelFilename } = useSaveFilePrompt()
|
||||
|
||||
const stem = ref('')
|
||||
const inputRef = ref(null)
|
||||
|
||||
watch(pendingSave, async (save) => {
|
||||
if (!save) return
|
||||
stem.value = save.defaultStem
|
||||
await nextTick()
|
||||
inputRef.value?.focus()
|
||||
inputRef.value?.select()
|
||||
})
|
||||
|
||||
function onSave() {
|
||||
confirmFilename(stem.value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ModalDialog v-if="pendingSave" :title="$t('saveFile.title')" @close="cancelFilename">
|
||||
<label class="save-file-label" for="save-file-input">{{ $t('saveFile.label') }}</label>
|
||||
<div class="save-file-input-row">
|
||||
<input
|
||||
id="save-file-input"
|
||||
ref="inputRef"
|
||||
v-model="stem"
|
||||
type="text"
|
||||
class="save-file-input"
|
||||
data-testid="save-file-input"
|
||||
@keydown.enter="onSave"
|
||||
/>
|
||||
<span class="save-file-extension" data-testid="save-file-extension">{{
|
||||
pendingSave.extension
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="save-file-actions">
|
||||
<button class="btn" data-testid="save-file-cancel" @click="cancelFilename">
|
||||
{{ $t('saveFile.cancel') }}
|
||||
</button>
|
||||
<button class="btn btn-primary" data-testid="save-file-confirm" @click="onSave">
|
||||
{{ $t('saveFile.save') }}
|
||||
</button>
|
||||
</div>
|
||||
</ModalDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.save-file-label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
color: var(--front-color2);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.save-file-input-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.save-file-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.save-file-extension {
|
||||
color: var(--front-color2);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.save-file-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
</style>
|
||||
75
src/components/TimerDisplay.vue
Normal file
75
src/components/TimerDisplay.vue
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
remaining: { type: Number, required: true },
|
||||
isPaused: { type: Boolean, default: false },
|
||||
pauseLabel: { type: String, default: 'Pause' },
|
||||
resumeLabel: { type: String, default: 'Resume' },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['toggle-pause'])
|
||||
|
||||
const formatted = computed(() => {
|
||||
const s = Math.max(0, props.remaining)
|
||||
const m = Math.floor(s / 60)
|
||||
const sec = s % 60
|
||||
return `${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')}`
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="timer-display">
|
||||
<span class="timer-countdown" :class="{ 'timer-low': remaining > 0 && remaining < 10 }">
|
||||
{{ formatted }}
|
||||
</span>
|
||||
<button
|
||||
class="timer-pause-btn btn-primary"
|
||||
:aria-label="isPaused ? resumeLabel : pauseLabel"
|
||||
@click="emit('toggle-pause')"
|
||||
>
|
||||
<i :class="isPaused ? 'bi bi-play-fill' : 'bi bi-pause-fill'"></i>
|
||||
{{ isPaused ? resumeLabel : pauseLabel }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.timer-display {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin: 12px 0;
|
||||
}
|
||||
|
||||
.timer-countdown {
|
||||
font-size: 48px;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
letter-spacing: 0.02em;
|
||||
color: var(--color-text);
|
||||
transition: color 0.3s;
|
||||
}
|
||||
|
||||
.timer-low {
|
||||
color: var(--btn-danger-bg, #dc3545);
|
||||
}
|
||||
|
||||
.timer-pause-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 16px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.timer-pause-btn:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
</style>
|
||||
33
src/composables/useAudioCues.js
Normal file
33
src/composables/useAudioCues.js
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
let audioCtx = null
|
||||
|
||||
function getCtx() {
|
||||
if (!audioCtx) audioCtx = new AudioContext()
|
||||
return audioCtx
|
||||
}
|
||||
|
||||
function _beep(freq, duration, gain) {
|
||||
try {
|
||||
const ctx = getCtx()
|
||||
if (ctx.state === 'suspended') ctx.resume()
|
||||
const osc = ctx.createOscillator()
|
||||
const gainNode = ctx.createGain()
|
||||
osc.connect(gainNode)
|
||||
gainNode.connect(ctx.destination)
|
||||
osc.frequency.value = freq
|
||||
gainNode.gain.value = gain
|
||||
osc.start()
|
||||
osc.stop(ctx.currentTime + duration)
|
||||
} catch (_e) {
|
||||
// Ignore audio errors — beep is best-effort
|
||||
}
|
||||
}
|
||||
|
||||
export function useAudioCues() {
|
||||
function playBip(volume = 1.0) {
|
||||
_beep(440, 0.1, 0.6 * volume)
|
||||
}
|
||||
function playBIP(volume = 1.0) {
|
||||
_beep(880, 0.3, 1.0 * volume)
|
||||
}
|
||||
return { playBip, playBIP }
|
||||
}
|
||||
31
src/composables/useBackupDownload.js
Normal file
31
src/composables/useBackupDownload.js
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { db } from '../db/database.js'
|
||||
import { formatBackupFilename } from '../utils/backupFilename.js'
|
||||
import { useBackupStatus } from './useBackupStatus.js'
|
||||
import { useSaveFilePrompt } from './useSaveFilePrompt.js'
|
||||
|
||||
export function useBackupDownload() {
|
||||
const { markBackedUp } = useBackupStatus()
|
||||
const { promptFilename } = useSaveFilePrompt()
|
||||
|
||||
/**
|
||||
* Prompts for a filename, exports the database, triggers a file download,
|
||||
* and records last_backup_at. Returns false without side effects if cancelled.
|
||||
*/
|
||||
async function downloadBackup() {
|
||||
const filename = await promptFilename(formatBackupFilename(__APP_VERSION__))
|
||||
if (!filename) return false
|
||||
|
||||
const bytes = await db.exportDatabase()
|
||||
const blob = new Blob([bytes], { type: 'application/x-sqlite3' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = filename
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
await markBackedUp()
|
||||
return true
|
||||
}
|
||||
|
||||
return { downloadBackup }
|
||||
}
|
||||
81
src/composables/useBackupStatus.js
Normal file
81
src/composables/useBackupStatus.js
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
import { ref, computed } from 'vue'
|
||||
import { useSettings } from './useSettings.js'
|
||||
|
||||
const DEFAULT_REMINDER_DAYS = 7
|
||||
|
||||
const lastBackupAt = ref(null)
|
||||
const dbCreatedAt = ref(null)
|
||||
const backupReminderDays = ref(DEFAULT_REMINDER_DAYS)
|
||||
const sessionReminderShown = ref(false)
|
||||
|
||||
export function useBackupStatus() {
|
||||
const { get, set } = useSettings()
|
||||
|
||||
async function load() {
|
||||
const lastBackupAtStr = await get('last_backup_at')
|
||||
if (lastBackupAtStr) {
|
||||
lastBackupAt.value = new Date(lastBackupAtStr)
|
||||
} else {
|
||||
lastBackupAt.value = null
|
||||
}
|
||||
|
||||
const dbCreatedAtStr = await get('db_created_at')
|
||||
if (dbCreatedAtStr) {
|
||||
dbCreatedAt.value = new Date(dbCreatedAtStr)
|
||||
} else {
|
||||
dbCreatedAt.value = null
|
||||
}
|
||||
|
||||
const daysStr = await get('backup_reminder_days')
|
||||
const parsed = parseInt(daysStr, 10)
|
||||
if (!isNaN(parsed) && parsed >= 1 && parsed <= 365) {
|
||||
backupReminderDays.value = parsed
|
||||
} else {
|
||||
if (daysStr !== null && daysStr !== undefined) {
|
||||
console.warn(
|
||||
`[useBackupStatus] Invalid backup_reminder_days value: "${daysStr}", falling back to ${DEFAULT_REMINDER_DAYS}`,
|
||||
)
|
||||
}
|
||||
backupReminderDays.value = DEFAULT_REMINDER_DAYS
|
||||
}
|
||||
}
|
||||
|
||||
const daysSinceBackup = computed(() => {
|
||||
const effectiveDate = lastBackupAt.value || dbCreatedAt.value
|
||||
if (!effectiveDate) return null
|
||||
const now = new Date()
|
||||
const diffMs = now.getTime() - effectiveDate.getTime()
|
||||
return Math.floor(diffMs / (1000 * 60 * 60 * 24))
|
||||
})
|
||||
|
||||
const isStale = computed(() => {
|
||||
const effectiveDate = lastBackupAt.value || dbCreatedAt.value
|
||||
if (!effectiveDate) return true
|
||||
const now = new Date()
|
||||
const diffMs = now.getTime() - effectiveDate.getTime()
|
||||
const thresholdMs = backupReminderDays.value * 86_400_000
|
||||
return diffMs > thresholdMs
|
||||
})
|
||||
|
||||
const visible = computed(() => {
|
||||
return isStale.value
|
||||
})
|
||||
|
||||
async function markBackedUp() {
|
||||
const now = new Date().toISOString()
|
||||
await set('last_backup_at', now)
|
||||
lastBackupAt.value = new Date(now)
|
||||
}
|
||||
|
||||
return {
|
||||
lastBackupAt,
|
||||
dbCreatedAt,
|
||||
daysSinceBackup,
|
||||
isStale,
|
||||
visible,
|
||||
load,
|
||||
markBackedUp,
|
||||
backupReminderDays,
|
||||
sessionReminderShown,
|
||||
}
|
||||
}
|
||||
54
src/composables/useCollections.js
Normal file
54
src/composables/useCollections.js
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import { ref } from 'vue'
|
||||
import { collectionRepository } from '../db/repositories/collectionRepository.js'
|
||||
|
||||
export function useCollections() {
|
||||
const collections = ref([])
|
||||
const loading = ref(false)
|
||||
const error = ref(null)
|
||||
|
||||
async function fetchAll() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
collections.value = await collectionRepository.getAll()
|
||||
} catch (e) {
|
||||
error.value = e.message
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function create(data) {
|
||||
const id = await collectionRepository.create(data)
|
||||
await fetchAll()
|
||||
return id
|
||||
}
|
||||
|
||||
async function update(id, data) {
|
||||
await collectionRepository.update(id, data)
|
||||
await fetchAll()
|
||||
}
|
||||
|
||||
async function remove(id) {
|
||||
await collectionRepository.delete(id)
|
||||
await fetchAll()
|
||||
}
|
||||
|
||||
async function search(query) {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
collections.value = await collectionRepository.search(query)
|
||||
} catch (e) {
|
||||
error.value = e.message
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function getById(id) {
|
||||
return collectionRepository.getById(id)
|
||||
}
|
||||
|
||||
return { collections, loading, error, fetchAll, create, update, remove, search, getById }
|
||||
}
|
||||
54
src/composables/useCombos.js
Normal file
54
src/composables/useCombos.js
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import { ref } from 'vue'
|
||||
import { comboRepository } from '../db/repositories/comboRepository.js'
|
||||
|
||||
export function useCombos() {
|
||||
const combos = ref([])
|
||||
const loading = ref(false)
|
||||
const error = ref(null)
|
||||
|
||||
async function fetchAll() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
combos.value = await comboRepository.getAll()
|
||||
} catch (e) {
|
||||
error.value = e.message
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function create(data) {
|
||||
const id = await comboRepository.create(data)
|
||||
await fetchAll()
|
||||
return id
|
||||
}
|
||||
|
||||
async function update(id, data) {
|
||||
await comboRepository.update(id, data)
|
||||
await fetchAll()
|
||||
}
|
||||
|
||||
async function remove(id) {
|
||||
await comboRepository.delete(id)
|
||||
await fetchAll()
|
||||
}
|
||||
|
||||
async function search(query) {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
combos.value = await comboRepository.search(query)
|
||||
} catch (e) {
|
||||
error.value = e.message
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function getById(id) {
|
||||
return comboRepository.getById(id)
|
||||
}
|
||||
|
||||
return { combos, loading, error, fetchAll, create, update, remove, search, getById }
|
||||
}
|
||||
42
src/composables/useDatabase.js
Normal file
42
src/composables/useDatabase.js
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import { ref, readonly } from 'vue'
|
||||
import { db } from '../db/database.js'
|
||||
|
||||
const initialized = ref(false)
|
||||
const initializing = ref(false)
|
||||
const dbInfo = ref(null)
|
||||
const error = ref(null)
|
||||
const migrationPending = ref(null)
|
||||
|
||||
export function useDatabase() {
|
||||
async function init() {
|
||||
if (initialized.value || initializing.value) return dbInfo.value
|
||||
initializing.value = true
|
||||
try {
|
||||
dbInfo.value = await db.init()
|
||||
migrationPending.value = db.migrationPending
|
||||
initialized.value = !db.migrationPending
|
||||
return dbInfo.value
|
||||
} catch (e) {
|
||||
error.value = e.message
|
||||
throw e
|
||||
} finally {
|
||||
initializing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function runPendingMigrations() {
|
||||
await db.runPendingMigrations()
|
||||
migrationPending.value = null
|
||||
initialized.value = true
|
||||
}
|
||||
|
||||
return {
|
||||
initialized: readonly(initialized),
|
||||
initializing: readonly(initializing),
|
||||
dbInfo: readonly(dbInfo),
|
||||
error: readonly(error),
|
||||
migrationPending: readonly(migrationPending),
|
||||
init,
|
||||
runPendingMigrations,
|
||||
}
|
||||
}
|
||||
69
src/composables/useExecutionLogs.js
Normal file
69
src/composables/useExecutionLogs.js
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
import { ref } from 'vue'
|
||||
import { executionLogRepository } from '../db/repositories/executionLogRepository.js'
|
||||
|
||||
export function useExecutionLogs() {
|
||||
const logs = ref([])
|
||||
const loading = ref(false)
|
||||
const error = ref(null)
|
||||
|
||||
async function fetchAll() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
logs.value = await executionLogRepository.getAll()
|
||||
} catch (e) {
|
||||
error.value = e.message
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchBySession(sessionId) {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
logs.value = await executionLogRepository.getBySessionId(sessionId)
|
||||
} catch (e) {
|
||||
error.value = e.message
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function create(data) {
|
||||
const id = await executionLogRepository.create(data)
|
||||
await fetchAll()
|
||||
return id
|
||||
}
|
||||
|
||||
async function remove(id) {
|
||||
await executionLogRepository.delete(id)
|
||||
await fetchAll()
|
||||
}
|
||||
|
||||
async function getById(id) {
|
||||
return executionLogRepository.getById(id)
|
||||
}
|
||||
|
||||
async function getCountByDateRange(from, to) {
|
||||
return executionLogRepository.getCountByDateRange(from, to)
|
||||
}
|
||||
|
||||
async function removeByDateRange(from, to) {
|
||||
await executionLogRepository.deleteByDateRange(from, to)
|
||||
await fetchAll()
|
||||
}
|
||||
|
||||
return {
|
||||
logs,
|
||||
loading,
|
||||
error,
|
||||
fetchAll,
|
||||
fetchBySession,
|
||||
create,
|
||||
remove,
|
||||
getById,
|
||||
getCountByDateRange,
|
||||
removeByDateRange,
|
||||
}
|
||||
}
|
||||
54
src/composables/useExercises.js
Normal file
54
src/composables/useExercises.js
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import { ref } from 'vue'
|
||||
import { exerciseRepository } from '../db/repositories/exerciseRepository.js'
|
||||
|
||||
export function useExercises() {
|
||||
const exercises = ref([])
|
||||
const loading = ref(false)
|
||||
const error = ref(null)
|
||||
|
||||
async function fetchAll() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
exercises.value = await exerciseRepository.getAll()
|
||||
} catch (e) {
|
||||
error.value = e.message
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function create(data) {
|
||||
const id = await exerciseRepository.create(data)
|
||||
await fetchAll()
|
||||
return id
|
||||
}
|
||||
|
||||
async function update(id, data) {
|
||||
await exerciseRepository.update(id, data)
|
||||
await fetchAll()
|
||||
}
|
||||
|
||||
async function remove(id) {
|
||||
await exerciseRepository.delete(id)
|
||||
await fetchAll()
|
||||
}
|
||||
|
||||
async function search(query) {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
exercises.value = await exerciseRepository.search(query)
|
||||
} catch (e) {
|
||||
error.value = e.message
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function getById(id) {
|
||||
return exerciseRepository.getById(id)
|
||||
}
|
||||
|
||||
return { exercises, loading, error, fetchAll, create, update, remove, search, getById }
|
||||
}
|
||||
268
src/composables/useHtmlExport.js
Normal file
268
src/composables/useHtmlExport.js
Normal file
|
|
@ -0,0 +1,268 @@
|
|||
import i18next from 'i18next'
|
||||
import { formatDate, formatDuration, formatItemDuration } from '../utils/dateFormatter.js'
|
||||
import { useSaveFilePrompt } from './useSaveFilePrompt.js'
|
||||
|
||||
function escHtml(str) {
|
||||
return String(str ?? '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
}
|
||||
|
||||
function enrichItemsWithTiming(log) {
|
||||
const items = log.items || []
|
||||
return items.map((item, i) => {
|
||||
const curr = item.timestamp ? new Date(item.timestamp).getTime() : null
|
||||
const prev =
|
||||
i === 0
|
||||
? log.started_at
|
||||
? new Date(log.started_at).getTime()
|
||||
: null
|
||||
: items[i - 1].timestamp
|
||||
? new Date(items[i - 1].timestamp).getTime()
|
||||
: null
|
||||
return { ...item, duration_ms: curr && prev ? curr - prev : null }
|
||||
})
|
||||
}
|
||||
|
||||
function groupItems(items) {
|
||||
if (!items || items.length === 0) return []
|
||||
const result = []
|
||||
const comboMap = {}
|
||||
for (const item of items) {
|
||||
if (!item.combo_id) {
|
||||
result.push({ type: 'exercise', item })
|
||||
} else {
|
||||
if (!comboMap[item.combo_id]) {
|
||||
comboMap[item.combo_id] = {
|
||||
type: 'combo',
|
||||
combo_id: item.combo_id,
|
||||
combo_title: item.combo_title,
|
||||
exercises: [],
|
||||
}
|
||||
result.push(comboMap[item.combo_id])
|
||||
}
|
||||
comboMap[item.combo_id].exercises.push(item)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function comboRounds(group) {
|
||||
const hasRounds = group.exercises.some((ex) => ex.round_number != null)
|
||||
if (!hasRounds) return [group.exercises]
|
||||
const map = {}
|
||||
for (const ex of group.exercises) {
|
||||
const rn = ex.round_number ?? 0
|
||||
if (!map[rn]) map[rn] = []
|
||||
map[rn].push(ex)
|
||||
}
|
||||
return Object.entries(map)
|
||||
.sort(([a], [b]) => Number(a) - Number(b))
|
||||
.map(([, exes]) => exes)
|
||||
}
|
||||
|
||||
function itemParens(item) {
|
||||
const parts = [item.reps_done, item.weight_used]
|
||||
const dur = formatItemDuration(item.duration_ms)
|
||||
if (dur) parts.push(dur)
|
||||
return `(${parts.join(',')})`
|
||||
}
|
||||
|
||||
function renderSessionItemsHtml(log) {
|
||||
const groups = groupItems(enrichItemsWithTiming(log))
|
||||
if (!groups.length) return ''
|
||||
return groups
|
||||
.map((group, gi) => {
|
||||
const sep = gi > 0 ? ', ' : ''
|
||||
if (group.type === 'exercise') {
|
||||
return `${sep}${escHtml(group.item.exercise_title)} ${escHtml(itemParens(group.item))}`
|
||||
}
|
||||
const rounds = comboRounds(group)
|
||||
const roundsHtml = rounds
|
||||
.map((round, ri) => {
|
||||
const exsHtml = round
|
||||
.map((ex, ei) => {
|
||||
const sep2 = ri > 0 || ei > 0 ? ', ' : ''
|
||||
return `${sep2}${escHtml(ex.exercise_title)} ${escHtml(itemParens(ex))}`
|
||||
})
|
||||
.join('')
|
||||
return ri > 0 ? ` // ${exsHtml}` : exsHtml
|
||||
})
|
||||
.join('')
|
||||
return `${sep}${escHtml(group.combo_title)} ×${rounds.length} : <em>{${roundsHtml}}</em>`
|
||||
})
|
||||
.join('')
|
||||
}
|
||||
|
||||
async function fetchIconAsBase64() {
|
||||
try {
|
||||
const res = await fetch('/icon.svg')
|
||||
if (!res.ok) return null
|
||||
const text = await res.text()
|
||||
const bytes = new TextEncoder().encode(text)
|
||||
const binStr = Array.from(bytes, (b) => String.fromCharCode(b)).join('')
|
||||
return `data:image/svg+xml;base64,${btoa(binStr)}`
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadHtml(defaultFilename, html) {
|
||||
const { promptFilename } = useSaveFilePrompt()
|
||||
const filename = await promptFilename(defaultFilename)
|
||||
if (!filename) return
|
||||
|
||||
const blob = new Blob([html], { type: 'text/html' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = filename
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
export function useHtmlExport() {
|
||||
async function exportHomeAsHtml({
|
||||
count7Days,
|
||||
count30Days,
|
||||
recentSessions,
|
||||
historyByDay,
|
||||
historyFrom,
|
||||
historyTo,
|
||||
}) {
|
||||
const lang = i18next.language || 'en'
|
||||
const t = i18next.t.bind(i18next)
|
||||
const iconDataUrl = await fetchIconAsBase64()
|
||||
const exportDate = formatDate(new Date(), lang)
|
||||
|
||||
const iconHtml = iconDataUrl
|
||||
? `<img src="${iconDataUrl}" alt="TrainUs" class="app-icon" />`
|
||||
: ''
|
||||
|
||||
const recentHtml = recentSessions
|
||||
.map((log) => {
|
||||
const duration = formatDuration(log.started_at, log.finished_at)
|
||||
const date = formatDate(log.started_at, lang)
|
||||
const itemsLine = renderSessionItemsHtml(log)
|
||||
return `
|
||||
<div class="card">
|
||||
<div class="session-header">
|
||||
<span class="session-title">${escHtml(log.session_title)}</span>
|
||||
<span class="session-meta">
|
||||
<span class="session-date">${escHtml(date)}</span>
|
||||
${duration ? `<span class="session-duration">⏱ ${escHtml(duration)}</span>` : ''}
|
||||
</span>
|
||||
</div>
|
||||
${itemsLine ? `<div class="session-items">${itemsLine}</div>` : ''}
|
||||
</div>`
|
||||
})
|
||||
.join('')
|
||||
|
||||
const historyHtml = historyByDay
|
||||
.map(({ day, logs }) => {
|
||||
const dayLabel = formatDate(day, lang)
|
||||
const logsHtml = logs
|
||||
.map((log) => {
|
||||
const duration = formatDuration(log.started_at, log.finished_at)
|
||||
const itemsLine = renderSessionItemsHtml(log)
|
||||
return `
|
||||
<div class="card card--sm">
|
||||
<div class="session-header session-header--sm">
|
||||
<span class="history-title">${escHtml(log.session_title)}</span>
|
||||
${duration ? `<span class="session-duration">⏱ ${escHtml(duration)}</span>` : ''}
|
||||
</div>
|
||||
${itemsLine ? `<div class="session-items session-items--sm">${itemsLine}</div>` : ''}
|
||||
</div>`
|
||||
})
|
||||
.join('')
|
||||
return `<div class="history-day"><h4 class="history-day-label">${escHtml(dayLabel)}</h4>${logsHtml}</div>`
|
||||
})
|
||||
.join('')
|
||||
|
||||
const html = `<!DOCTYPE html>
|
||||
<html lang="${escHtml(lang)}">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>TrainUs</title>
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; }
|
||||
body { margin: 0 auto; padding: 24px 16px; font-family: Helvetica, sans-serif; color: #1a1a1a; background: #fff; max-width: 800px; }
|
||||
h1, h2, h3, h4 { margin: 0; padding: 0; }
|
||||
.app-header { display: flex; align-items: center; gap: 12px; margin-bottom: 4px; }
|
||||
.app-icon { width: 36px; height: 36px; }
|
||||
.app-name { font-size: 22px; font-weight: 700; }
|
||||
.export-meta { font-size: 12px; color: #555; margin-bottom: 24px; }
|
||||
.stats-cards { display: flex; gap: 16px; margin-bottom: 24px; }
|
||||
.stat-card { flex: 1; display: flex; flex-direction: column; align-items: center; padding: 16px; border-radius: 8px; background: #f5f5f5; border: 1px solid #ddd; }
|
||||
.stat-value { font-size: 32px; font-weight: 700; color: #2563eb; }
|
||||
.stat-label { font-size: 13px; color: #555; text-align: center; margin-top: 4px; }
|
||||
.section-title { font-size: 16px; font-weight: 600; margin-bottom: 10px; }
|
||||
.recent-section { margin-bottom: 28px; }
|
||||
.history-section { margin-bottom: 20px; }
|
||||
.history-header { display: flex; align-items: baseline; gap: 10px; margin-bottom: 10px; }
|
||||
.history-range { font-size: 12px; color: #555; }
|
||||
.card { background: #f5f5f5; border: 1px solid #ddd; border-radius: 8px; padding: 16px; margin-bottom: 8px; }
|
||||
.card--sm { padding: 10px 16px; }
|
||||
.session-header { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; }
|
||||
.session-header--sm { margin-bottom: 4px; }
|
||||
.session-title { flex: 1; font-size: 15px; font-weight: 600; }
|
||||
.history-title { flex: 1; font-size: 14px; font-weight: 600; }
|
||||
.session-meta { display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
|
||||
.session-date { font-size: 13px; color: #555; white-space: nowrap; }
|
||||
.session-duration { font-size: 12px; color: #555; white-space: nowrap; }
|
||||
.session-items { font-size: 13px; color: #1a1a1a; line-height: 1.6; }
|
||||
.session-items--sm { font-size: 12px; }
|
||||
.history-day { margin-bottom: 16px; }
|
||||
.history-day-label { font-size: 13px; font-weight: 600; color: #555; margin-bottom: 6px; text-transform: uppercase; letter-spacing: 0.04em; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-header">
|
||||
${iconHtml}
|
||||
<span class="app-name">TrainUs</span>
|
||||
</div>
|
||||
<p class="export-meta">${escHtml(t('home.exportedOn', { date: exportDate }))}</p>
|
||||
|
||||
<div class="stats-cards">
|
||||
<div class="stat-card">
|
||||
<span class="stat-value">${count7Days}</span>
|
||||
<span class="stat-label">${escHtml(t('home.sessionsLast7Days'))}</span>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span class="stat-value">${count30Days}</span>
|
||||
<span class="stat-label">${escHtml(t('home.sessionsLast30Days'))}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${
|
||||
recentSessions.length > 0
|
||||
? `<div class="recent-section">
|
||||
<h3 class="section-title">${escHtml(t('home.recentSessions'))}</h3>
|
||||
${recentHtml}
|
||||
</div>`
|
||||
: ''
|
||||
}
|
||||
|
||||
${
|
||||
historyByDay.length > 0
|
||||
? `<div class="history-section">
|
||||
<div class="history-header">
|
||||
<h3 class="section-title">${escHtml(t('home.history'))}</h3>
|
||||
<span class="history-range">${escHtml(historyFrom)} – ${escHtml(historyTo)}</span>
|
||||
</div>
|
||||
${historyHtml}
|
||||
</div>`
|
||||
: ''
|
||||
}
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
const date = new Date().toISOString().slice(0, 10)
|
||||
await downloadHtml(`trainus-home-${date}.html`, html)
|
||||
}
|
||||
|
||||
return { exportHomeAsHtml }
|
||||
}
|
||||
29
src/composables/useInstallPrompt.js
Normal file
29
src/composables/useInstallPrompt.js
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import { ref } from 'vue'
|
||||
|
||||
let deferredPrompt = null
|
||||
const canInstall = ref(false)
|
||||
|
||||
window.addEventListener('beforeinstallprompt', (e) => {
|
||||
e.preventDefault()
|
||||
deferredPrompt = e
|
||||
canInstall.value = true
|
||||
})
|
||||
|
||||
window.addEventListener('appinstalled', () => {
|
||||
deferredPrompt = null
|
||||
canInstall.value = false
|
||||
})
|
||||
|
||||
export function useInstallPrompt() {
|
||||
async function install() {
|
||||
if (!deferredPrompt) return
|
||||
deferredPrompt.prompt()
|
||||
const { outcome } = await deferredPrompt.userChoice
|
||||
if (outcome === 'accepted') {
|
||||
deferredPrompt = null
|
||||
canInstall.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return { canInstall, install }
|
||||
}
|
||||
293
src/composables/useJsonExport.js
Normal file
293
src/composables/useJsonExport.js
Normal file
|
|
@ -0,0 +1,293 @@
|
|||
import { settingsRepository } from '../db/repositories/settingsRepository.js'
|
||||
import { exerciseRepository } from '../db/repositories/exerciseRepository.js'
|
||||
import { comboRepository } from '../db/repositories/comboRepository.js'
|
||||
import { sessionRepository } from '../db/repositories/sessionRepository.js'
|
||||
import { collectionRepository } from '../db/repositories/collectionRepository.js'
|
||||
import { executionLogRepository } from '../db/repositories/executionLogRepository.js'
|
||||
import { buildEnvelope, downloadJson } from '../utils/jsonEnvelope.js'
|
||||
|
||||
const SETTINGS_EXPORT_KEYS = ['user_name', 'language', 'theme', 'backup_reminder_days']
|
||||
|
||||
function mergeById(existing = [], additional = []) {
|
||||
const map = new Map(existing.map((item) => [item.id, item]))
|
||||
for (const item of additional) {
|
||||
if (!map.has(item.id)) map.set(item.id, item)
|
||||
}
|
||||
return [...map.values()]
|
||||
}
|
||||
|
||||
/** Exercises referenced by a combo's exercise list, fetched as full entities. */
|
||||
async function collectComboDependencies(combos) {
|
||||
const exerciseIds = new Set()
|
||||
for (const combo of combos) {
|
||||
for (const ex of combo.exercises || []) {
|
||||
exerciseIds.add(ex.exercise_id)
|
||||
}
|
||||
}
|
||||
const exercises = (
|
||||
await Promise.all([...exerciseIds].map((id) => exerciseRepository.getById(id)))
|
||||
).filter(Boolean)
|
||||
return { exercises }
|
||||
}
|
||||
|
||||
/** Combos and exercises referenced by a session's items, including the combos' own exercises. */
|
||||
async function collectSessionDependencies(sessions) {
|
||||
const exerciseIds = new Set()
|
||||
const comboIds = new Set()
|
||||
for (const session of sessions) {
|
||||
for (const item of session.items || []) {
|
||||
if (item.item_type === 'exercise') exerciseIds.add(item.item_id)
|
||||
if (item.item_type === 'combo') comboIds.add(item.item_id)
|
||||
}
|
||||
}
|
||||
const combos = (await Promise.all([...comboIds].map((id) => comboRepository.getById(id)))).filter(
|
||||
Boolean,
|
||||
)
|
||||
const { exercises: comboExercises } = await collectComboDependencies(combos)
|
||||
for (const exercise of comboExercises) {
|
||||
exerciseIds.add(exercise.id)
|
||||
}
|
||||
const exercises = (
|
||||
await Promise.all([...exerciseIds].map((id) => exerciseRepository.getById(id)))
|
||||
).filter(Boolean)
|
||||
return { combos, exercises }
|
||||
}
|
||||
|
||||
/** Sessions referenced by a collection, plus their transitive combo/exercise dependencies. */
|
||||
async function collectCollectionDependencies(collections) {
|
||||
const sessionIds = new Set()
|
||||
for (const collection of collections) {
|
||||
for (const s of collection.sessions || []) {
|
||||
sessionIds.add(s.session_id)
|
||||
}
|
||||
}
|
||||
const sessions = (
|
||||
await Promise.all([...sessionIds].map((id) => sessionRepository.getById(id)))
|
||||
).filter(Boolean)
|
||||
const { combos, exercises } = await collectSessionDependencies(sessions)
|
||||
return { sessions, combos, exercises }
|
||||
}
|
||||
|
||||
/**
|
||||
* Composable for exporting all entity types as JSON.
|
||||
*/
|
||||
export function useJsonExport() {
|
||||
async function getExporter() {
|
||||
const name = await settingsRepository.get('user_name')
|
||||
const uuid = await settingsRepository.get('user_uuid')
|
||||
return { name: name || 'User', uuid }
|
||||
}
|
||||
|
||||
async function exportAllExercises(query) {
|
||||
const exercises = query
|
||||
? await exerciseRepository.search(query)
|
||||
: await exerciseRepository.getAll()
|
||||
const exporter = await getExporter()
|
||||
const envelope = buildEnvelope({ exercises }, exporter)
|
||||
const json = JSON.stringify(envelope, null, 2)
|
||||
const date = new Date().toISOString().slice(0, 10)
|
||||
await downloadJson(`trainus-exercises-${date}.json`, json)
|
||||
}
|
||||
|
||||
async function exportExercise(exerciseId) {
|
||||
const exercise = await exerciseRepository.getById(exerciseId)
|
||||
if (!exercise) throw new Error('Exercise not found')
|
||||
const exporter = await getExporter()
|
||||
const envelope = buildEnvelope({ exercises: [exercise] }, exporter)
|
||||
const json = JSON.stringify(envelope, null, 2)
|
||||
const date = new Date().toISOString().slice(0, 10)
|
||||
await downloadJson(`trainus-exercises-${date}.json`, json)
|
||||
}
|
||||
|
||||
async function exportAllCombos(query) {
|
||||
const combos = query
|
||||
? await comboRepository.searchWithExercises(query)
|
||||
: await comboRepository.getAllWithExercises()
|
||||
const { exercises } = await collectComboDependencies(combos)
|
||||
const exporter = await getExporter()
|
||||
const envelope = buildEnvelope({ combos, exercises }, exporter)
|
||||
const json = JSON.stringify(envelope, null, 2)
|
||||
const date = new Date().toISOString().slice(0, 10)
|
||||
await downloadJson(`trainus-combos-${date}.json`, json)
|
||||
}
|
||||
|
||||
async function exportCombo(comboId) {
|
||||
const combo = await comboRepository.getById(comboId)
|
||||
if (!combo) throw new Error('Combo not found')
|
||||
const { exercises } = await collectComboDependencies([combo])
|
||||
const exporter = await getExporter()
|
||||
const envelope = buildEnvelope({ combos: [combo], exercises }, exporter)
|
||||
const json = JSON.stringify(envelope, null, 2)
|
||||
const date = new Date().toISOString().slice(0, 10)
|
||||
await downloadJson(`trainus-combos-${date}.json`, json)
|
||||
}
|
||||
|
||||
async function exportAllSessions(query) {
|
||||
const sessions = query
|
||||
? await sessionRepository.searchWithItems(query)
|
||||
: await sessionRepository.getAllWithItems()
|
||||
const { combos, exercises } = await collectSessionDependencies(sessions)
|
||||
const exporter = await getExporter()
|
||||
const envelope = buildEnvelope({ sessions, combos, exercises }, exporter)
|
||||
const json = JSON.stringify(envelope, null, 2)
|
||||
const date = new Date().toISOString().slice(0, 10)
|
||||
await downloadJson(`trainus-sessions-${date}.json`, json)
|
||||
}
|
||||
|
||||
async function exportSession(sessionId) {
|
||||
const session = await sessionRepository.getById(sessionId)
|
||||
if (!session) throw new Error('Session not found')
|
||||
const { combos, exercises } = await collectSessionDependencies([session])
|
||||
const exporter = await getExporter()
|
||||
const envelope = buildEnvelope({ sessions: [session], combos, exercises }, exporter)
|
||||
const json = JSON.stringify(envelope, null, 2)
|
||||
const date = new Date().toISOString().slice(0, 10)
|
||||
await downloadJson(`trainus-sessions-${date}.json`, json)
|
||||
}
|
||||
|
||||
async function exportAllCollections(query) {
|
||||
const collections = query
|
||||
? await collectionRepository.searchWithSessions(query)
|
||||
: await collectionRepository.getAllWithSessions()
|
||||
const { sessions, combos, exercises } = await collectCollectionDependencies(collections)
|
||||
const exporter = await getExporter()
|
||||
const envelope = buildEnvelope({ collections, sessions, combos, exercises }, exporter)
|
||||
const json = JSON.stringify(envelope, null, 2)
|
||||
const date = new Date().toISOString().slice(0, 10)
|
||||
await downloadJson(`trainus-collections-${date}.json`, json)
|
||||
}
|
||||
|
||||
async function exportCollection(collectionId) {
|
||||
const collection = await collectionRepository.getById(collectionId)
|
||||
if (!collection) throw new Error('Collection not found')
|
||||
const { sessions, combos, exercises } = await collectCollectionDependencies([collection])
|
||||
const exporter = await getExporter()
|
||||
const envelope = buildEnvelope(
|
||||
{ collections: [collection], sessions, combos, exercises },
|
||||
exporter,
|
||||
)
|
||||
const json = JSON.stringify(envelope, null, 2)
|
||||
const date = new Date().toISOString().slice(0, 10)
|
||||
await downloadJson(`trainus-collections-${date}.json`, json)
|
||||
}
|
||||
|
||||
async function exportAllExecutionLogs() {
|
||||
const executionLogs = await executionLogRepository.getAllWithItems()
|
||||
const exporter = await getExporter()
|
||||
const envelope = buildEnvelope({ executionLogs }, exporter)
|
||||
const json = JSON.stringify(envelope, null, 2)
|
||||
const date = new Date().toISOString().slice(0, 10)
|
||||
await downloadJson(`trainus-logs-${date}.json`, json)
|
||||
}
|
||||
|
||||
async function exportSettings() {
|
||||
const allSettings = await settingsRepository.getAll()
|
||||
const data = {}
|
||||
for (const key of SETTINGS_EXPORT_KEYS) {
|
||||
if (allSettings[key] !== undefined) {
|
||||
data[key] = allSettings[key]
|
||||
}
|
||||
}
|
||||
const exporter = await getExporter()
|
||||
const envelope = buildEnvelope({ settings: data }, exporter)
|
||||
const json = JSON.stringify(envelope, null, 2)
|
||||
const date = new Date().toISOString().slice(0, 10)
|
||||
await downloadJson(`trainus-settings-${date}.json`, json)
|
||||
}
|
||||
|
||||
async function exportAll() {
|
||||
const [exercises, combos, sessions, collections, executionLogs] = await Promise.all([
|
||||
exerciseRepository.getAll(),
|
||||
comboRepository.getAllWithExercises(),
|
||||
sessionRepository.getAllWithItems(),
|
||||
collectionRepository.getAllWithSessions(),
|
||||
executionLogRepository.getAllWithItems(),
|
||||
])
|
||||
|
||||
const allSettings = await settingsRepository.getAll()
|
||||
const settingsData = {}
|
||||
for (const key of SETTINGS_EXPORT_KEYS) {
|
||||
if (allSettings[key] !== undefined) {
|
||||
settingsData[key] = allSettings[key]
|
||||
}
|
||||
}
|
||||
|
||||
const data = { exercises, combos, sessions, collections, executionLogs, settings: settingsData }
|
||||
const exporter = await getExporter()
|
||||
const envelope = buildEnvelope(data, exporter)
|
||||
const json = JSON.stringify(envelope, null, 2)
|
||||
const date = new Date().toISOString().slice(0, 10)
|
||||
await downloadJson(`trainus-full-${date}.json`, json)
|
||||
}
|
||||
|
||||
async function exportByFilter(options = {}) {
|
||||
const data = {}
|
||||
const exporter = await getExporter()
|
||||
|
||||
if (options.exercises) {
|
||||
data.exercises = await exerciseRepository.getAll()
|
||||
}
|
||||
if (options.combos) {
|
||||
data.combos = await comboRepository.getAllWithExercises()
|
||||
}
|
||||
if (options.sessions) {
|
||||
data.sessions = await sessionRepository.getAllWithItems()
|
||||
}
|
||||
if (options.collections) {
|
||||
data.collections = await collectionRepository.getAllWithSessions()
|
||||
}
|
||||
|
||||
// Bundle dependencies so a partial selection (e.g. sessions without combos/exercises)
|
||||
// still produces a self-contained, importable export.
|
||||
if (data.collections) {
|
||||
const deps = await collectCollectionDependencies(data.collections)
|
||||
data.sessions = mergeById(data.sessions, deps.sessions)
|
||||
data.combos = mergeById(data.combos, deps.combos)
|
||||
data.exercises = mergeById(data.exercises, deps.exercises)
|
||||
}
|
||||
if (data.sessions) {
|
||||
const deps = await collectSessionDependencies(data.sessions)
|
||||
data.combos = mergeById(data.combos, deps.combos)
|
||||
data.exercises = mergeById(data.exercises, deps.exercises)
|
||||
}
|
||||
if (data.combos) {
|
||||
const deps = await collectComboDependencies(data.combos)
|
||||
data.exercises = mergeById(data.exercises, deps.exercises)
|
||||
}
|
||||
|
||||
if (options.executionLogs) {
|
||||
data.executionLogs = await executionLogRepository.getAllWithItems()
|
||||
}
|
||||
if (options.settings) {
|
||||
const allSettings = await settingsRepository.getAll()
|
||||
const settingsData = {}
|
||||
for (const key of SETTINGS_EXPORT_KEYS) {
|
||||
if (allSettings[key] !== undefined) {
|
||||
settingsData[key] = allSettings[key]
|
||||
}
|
||||
}
|
||||
data.settings = settingsData
|
||||
}
|
||||
|
||||
const envelope = buildEnvelope(data, exporter)
|
||||
const json = JSON.stringify(envelope, null, 2)
|
||||
const date = new Date().toISOString().slice(0, 10)
|
||||
const filename = `trainus-data-${date}.json`
|
||||
await downloadJson(filename, json)
|
||||
}
|
||||
|
||||
return {
|
||||
exportAllExercises,
|
||||
exportExercise,
|
||||
exportAllCombos,
|
||||
exportCombo,
|
||||
exportAllSessions,
|
||||
exportSession,
|
||||
exportAllCollections,
|
||||
exportCollection,
|
||||
exportAllExecutionLogs,
|
||||
exportSettings,
|
||||
exportAll,
|
||||
exportByFilter,
|
||||
}
|
||||
}
|
||||
587
src/composables/useJsonImport.js
Normal file
587
src/composables/useJsonImport.js
Normal file
|
|
@ -0,0 +1,587 @@
|
|||
import { ref } from 'vue'
|
||||
import { settingsRepository } from '../db/repositories/settingsRepository.js'
|
||||
import { exerciseRepository } from '../db/repositories/exerciseRepository.js'
|
||||
import { comboRepository } from '../db/repositories/comboRepository.js'
|
||||
import { sessionRepository } from '../db/repositories/sessionRepository.js'
|
||||
import { collectionRepository } from '../db/repositories/collectionRepository.js'
|
||||
import { executionLogRepository } from '../db/repositories/executionLogRepository.js'
|
||||
import { suffixRepository } from '../db/repositories/suffixRepository.js'
|
||||
import { parseEnvelope, pickJsonFile } from '../utils/jsonEnvelope.js'
|
||||
|
||||
export const COLLISION_ACTION = {
|
||||
REPLACE: 'replace',
|
||||
SKIP: 'skip',
|
||||
}
|
||||
|
||||
const SETTINGS_SKIP_KEYS = ['user_uuid', 'app_version', 'db_created_at', 'last_backup_at']
|
||||
|
||||
/**
|
||||
* Composable for importing data from JSON files.
|
||||
*/
|
||||
export function useJsonImport() {
|
||||
const importing = ref(false)
|
||||
const error = ref(null)
|
||||
|
||||
async function loadFile() {
|
||||
const text = await pickJsonFile()
|
||||
if (!text) return { envelope: null, error: null, cancelled: true }
|
||||
|
||||
const result = parseEnvelope(text)
|
||||
return { envelope: result.envelope, error: result.error, cancelled: false }
|
||||
}
|
||||
|
||||
async function ensureSuffix(uuid, name, suffix) {
|
||||
const existing = await suffixRepository.getByUuid(uuid)
|
||||
if (existing) return existing.suffix
|
||||
|
||||
await suffixRepository.create({ user_uuid: uuid, user_name: name, suffix })
|
||||
return suffix
|
||||
}
|
||||
|
||||
// --- Full multi-entity import ---
|
||||
|
||||
async function analyzeAll(envelope, { asMine = false } = {}) {
|
||||
const localUuid = await settingsRepository.get('user_uuid')
|
||||
const exporterUuid = envelope.metadata.exportedBy.uuid
|
||||
const exporterName = envelope.metadata.exportedBy.name
|
||||
const exportDate = envelope.metadata.exportDate
|
||||
const isSameUser = asMine || exporterUuid === localUuid
|
||||
|
||||
const result = {
|
||||
summary: {
|
||||
exporterName,
|
||||
exporterUuid,
|
||||
exportDate,
|
||||
isSameUser,
|
||||
asMine,
|
||||
hasExercises: !!envelope.data.exercises?.length,
|
||||
hasCombos: !!envelope.data.combos?.length,
|
||||
hasSessions: !!envelope.data.sessions?.length,
|
||||
hasCollections: !!envelope.data.collections?.length,
|
||||
hasExecutionLogs: !!envelope.data.executionLogs?.length,
|
||||
hasSettings: !!envelope.data.settings,
|
||||
},
|
||||
exercises: null,
|
||||
combos: null,
|
||||
sessions: null,
|
||||
collections: null,
|
||||
executionLogs: null,
|
||||
settings: null,
|
||||
}
|
||||
|
||||
// Analyze exercises
|
||||
if (envelope.data.exercises?.length) {
|
||||
result.exercises = await _analyzeEntityList(
|
||||
envelope.data.exercises,
|
||||
exerciseRepository,
|
||||
'title',
|
||||
exporterUuid,
|
||||
isSameUser,
|
||||
)
|
||||
}
|
||||
|
||||
// Analyze combos
|
||||
if (envelope.data.combos?.length) {
|
||||
result.combos = await _analyzeEntityList(
|
||||
envelope.data.combos,
|
||||
comboRepository,
|
||||
'title',
|
||||
exporterUuid,
|
||||
isSameUser,
|
||||
)
|
||||
}
|
||||
|
||||
// Analyze sessions
|
||||
if (envelope.data.sessions?.length) {
|
||||
result.sessions = await _analyzeEntityList(
|
||||
envelope.data.sessions,
|
||||
sessionRepository,
|
||||
'title',
|
||||
exporterUuid,
|
||||
isSameUser,
|
||||
)
|
||||
}
|
||||
|
||||
// Analyze collections
|
||||
if (envelope.data.collections?.length) {
|
||||
result.collections = await _analyzeEntityList(
|
||||
envelope.data.collections,
|
||||
collectionRepository,
|
||||
'label',
|
||||
exporterUuid,
|
||||
isSameUser,
|
||||
)
|
||||
}
|
||||
|
||||
// Analyze execution logs (no title collision, just ID existence)
|
||||
if (envelope.data.executionLogs?.length) {
|
||||
result.executionLogs = await _analyzeEntityList(
|
||||
envelope.data.executionLogs,
|
||||
executionLogRepository,
|
||||
null,
|
||||
exporterUuid,
|
||||
isSameUser,
|
||||
)
|
||||
}
|
||||
|
||||
// Settings
|
||||
if (envelope.data.settings) {
|
||||
result.settings = envelope.data.settings
|
||||
}
|
||||
|
||||
// Suffix handling for different-user imports
|
||||
if (!isSameUser) {
|
||||
const suffixEntry = await suffixRepository.getByUuid(exporterUuid)
|
||||
result.suffixInfo = {
|
||||
needsSuffix: !suffixEntry,
|
||||
existingSuffix: suffixEntry?.suffix || null,
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
async function _analyzeEntityList(entities, repository, titleField, exporterUuid, isSameUser) {
|
||||
const result = {
|
||||
total: entities.length,
|
||||
noCollision: [],
|
||||
sameUserCollisions: [],
|
||||
differentUser: [],
|
||||
}
|
||||
|
||||
// Same-user title conflicts (different id, same title) are detected once up front
|
||||
// (O(n) instead of O(n²)) and folded into sameUserCollisions alongside id conflicts.
|
||||
let titleMap = null
|
||||
if (isSameUser && titleField) {
|
||||
const allRows = await repository.getAll()
|
||||
titleMap = new Map(allRows.map((row) => [row[titleField], row]))
|
||||
}
|
||||
|
||||
for (const entity of entities) {
|
||||
const existing = await repository.getById(entity.id)
|
||||
|
||||
if (isSameUser) {
|
||||
if (existing) {
|
||||
result.sameUserCollisions.push({ imported: entity, existing })
|
||||
} else {
|
||||
const titleMatch = titleMap?.get(entity[titleField])
|
||||
if (titleMatch) {
|
||||
result.sameUserCollisions.push({ imported: entity, existing: titleMatch })
|
||||
} else {
|
||||
result.noCollision.push(entity)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (existing && existing.created_by === exporterUuid) {
|
||||
result.differentUser.push({ imported: entity, existing, action: 'update' })
|
||||
} else if (existing) {
|
||||
result.differentUser.push({ imported: entity, existing, action: 'new_id' })
|
||||
} else {
|
||||
result.differentUser.push({ imported: entity, existing: null, action: 'insert' })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
async function applyAllImport(analysis, options = {}) {
|
||||
const {
|
||||
suffix,
|
||||
exerciseResolutions = [],
|
||||
comboResolutions = [],
|
||||
sessionResolutions = [],
|
||||
collectionResolutions = [],
|
||||
} = options
|
||||
|
||||
const localUuid = await settingsRepository.get('user_uuid')
|
||||
const exporterUuid = analysis.summary.exporterUuid
|
||||
const exporterName = analysis.summary.exporterName
|
||||
const isSameUser = analysis.summary.isSameUser
|
||||
|
||||
const idMap = { exercise: {}, combo: {}, session: {}, collection: {}, executionLog: {} }
|
||||
const report = {
|
||||
exercises: { imported: 0, skipped: 0, replaced: 0, updated: 0 },
|
||||
combos: { imported: 0, skipped: 0, replaced: 0, updated: 0 },
|
||||
sessions: { imported: 0, skipped: 0, replaced: 0, updated: 0 },
|
||||
collections: { imported: 0, skipped: 0, replaced: 0, updated: 0 },
|
||||
executionLogs: { imported: 0, skipped: 0 },
|
||||
settings: { imported: 0 },
|
||||
errors: [],
|
||||
}
|
||||
|
||||
// Register suffix if needed
|
||||
if (!isSameUser && analysis.suffixInfo?.needsSuffix && suffix) {
|
||||
await ensureSuffix(exporterUuid, exporterName, suffix)
|
||||
}
|
||||
|
||||
const effectiveSuffix = suffix || analysis.suffixInfo?.existingSuffix
|
||||
|
||||
// 1. Import exercises
|
||||
if (analysis.exercises) {
|
||||
await _importEntityList(
|
||||
analysis.exercises,
|
||||
exerciseRepository,
|
||||
'title',
|
||||
isSameUser,
|
||||
localUuid,
|
||||
exporterUuid,
|
||||
effectiveSuffix,
|
||||
exerciseResolutions,
|
||||
idMap.exercise,
|
||||
report.exercises,
|
||||
report.errors,
|
||||
)
|
||||
}
|
||||
|
||||
// 2. Import combos (remap exercise_id references)
|
||||
if (analysis.combos) {
|
||||
await _importEntityList(
|
||||
analysis.combos,
|
||||
comboRepository,
|
||||
'title',
|
||||
isSameUser,
|
||||
localUuid,
|
||||
exporterUuid,
|
||||
effectiveSuffix,
|
||||
comboResolutions,
|
||||
idMap.combo,
|
||||
report.combos,
|
||||
report.errors,
|
||||
{ remapExercises: idMap.exercise },
|
||||
async (comboId, combo) => {
|
||||
if (combo.exercises?.length) {
|
||||
const exercises = combo.exercises.map((ex) => ({
|
||||
exerciseId: ex.exercise_id,
|
||||
defaultReps: ex.default_reps ?? 1,
|
||||
defaultWeight: ex.default_weight ?? 0,
|
||||
}))
|
||||
await comboRepository.setExercises(comboId, exercises)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// 3. Import sessions (remap exercise_id and combo_id references)
|
||||
if (analysis.sessions) {
|
||||
await _importEntityList(
|
||||
analysis.sessions,
|
||||
sessionRepository,
|
||||
'title',
|
||||
isSameUser,
|
||||
localUuid,
|
||||
exporterUuid,
|
||||
effectiveSuffix,
|
||||
sessionResolutions,
|
||||
idMap.session,
|
||||
report.sessions,
|
||||
report.errors,
|
||||
{ remapExercises: idMap.exercise, remapCombos: idMap.combo },
|
||||
async (sessionId, session) => {
|
||||
if (session.items?.length) {
|
||||
const items = session.items.map((item) => ({
|
||||
item_id: item.item_id,
|
||||
item_type: item.item_type,
|
||||
repetitions: item.repetitions ?? 1,
|
||||
weight: item.weight ?? 0,
|
||||
}))
|
||||
await sessionRepository.setItems(sessionId, items)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// 4. Import collections (remap session_id references)
|
||||
if (analysis.collections) {
|
||||
await _importEntityList(
|
||||
analysis.collections,
|
||||
collectionRepository,
|
||||
'label',
|
||||
isSameUser,
|
||||
localUuid,
|
||||
exporterUuid,
|
||||
effectiveSuffix,
|
||||
collectionResolutions,
|
||||
idMap.collection,
|
||||
report.collections,
|
||||
report.errors,
|
||||
{ remapSessions: idMap.session },
|
||||
async (collectionId, collection) => {
|
||||
if (collection.sessions?.length) {
|
||||
const sessionIds = collection.sessions.map((s) => s.session_id)
|
||||
await collectionRepository.setSessions(collectionId, sessionIds)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// 5. Import execution logs (remap session_id and exercise_id)
|
||||
if (analysis.executionLogs) {
|
||||
await _importExecutionLogs(
|
||||
analysis.executionLogs,
|
||||
isSameUser,
|
||||
localUuid,
|
||||
exporterUuid,
|
||||
idMap,
|
||||
report.executionLogs,
|
||||
report.errors,
|
||||
)
|
||||
}
|
||||
|
||||
// 6. Import settings — only for same-user restores; skip entirely for cross-user imports
|
||||
if (analysis.settings && isSameUser) {
|
||||
for (const [key, value] of Object.entries(analysis.settings)) {
|
||||
if (!SETTINGS_SKIP_KEYS.includes(key)) {
|
||||
await settingsRepository.set(key, value)
|
||||
report.settings.imported++
|
||||
}
|
||||
}
|
||||
} else if (analysis.settings && !isSameUser) {
|
||||
report.settings = { skipped: true }
|
||||
}
|
||||
|
||||
return report
|
||||
}
|
||||
|
||||
async function _importEntityList(
|
||||
analysis,
|
||||
repository,
|
||||
titleField,
|
||||
isSameUser,
|
||||
localUuid,
|
||||
exporterUuid,
|
||||
suffix,
|
||||
resolutions,
|
||||
idMap,
|
||||
report,
|
||||
errors,
|
||||
remapOptions = {},
|
||||
setNestedData = null,
|
||||
) {
|
||||
const resolutionMap = {}
|
||||
for (const r of resolutions) {
|
||||
resolutionMap[r.imported.id] = r
|
||||
}
|
||||
|
||||
// Import no-collision entities (same-user path)
|
||||
for (const entity of analysis.noCollision) {
|
||||
try {
|
||||
const finalEntity = _prepareEntity(
|
||||
entity,
|
||||
titleField,
|
||||
isSameUser ? localUuid : exporterUuid,
|
||||
suffix,
|
||||
remapOptions,
|
||||
)
|
||||
await repository.createWithId(entity.id, finalEntity)
|
||||
idMap[entity.id] = entity.id
|
||||
report.imported++
|
||||
if (setNestedData) {
|
||||
try {
|
||||
await setNestedData(entity.id, finalEntity)
|
||||
} catch (ne) {
|
||||
errors.push(ne.message)
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
errors.push(e.message)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle same-user collisions (id match or title match — both resolve against the
|
||||
// existing row, so idMap correctly redirects same-batch references either way)
|
||||
for (const collision of analysis.sameUserCollisions) {
|
||||
const resolution = resolutionMap[collision.imported.id]
|
||||
if (!resolution || resolution.action === COLLISION_ACTION.SKIP) {
|
||||
report.skipped++
|
||||
idMap[collision.imported.id] = collision.existing.id
|
||||
continue
|
||||
}
|
||||
|
||||
if (resolution.action === COLLISION_ACTION.REPLACE) {
|
||||
try {
|
||||
const finalEntity = _prepareEntity(
|
||||
collision.imported,
|
||||
titleField,
|
||||
localUuid,
|
||||
null,
|
||||
remapOptions,
|
||||
)
|
||||
await repository.replaceAll(collision.existing.id, finalEntity)
|
||||
idMap[collision.imported.id] = collision.existing.id
|
||||
report.replaced++
|
||||
if (setNestedData) {
|
||||
try {
|
||||
await setNestedData(collision.existing.id, finalEntity)
|
||||
} catch (ne) {
|
||||
errors.push(ne.message)
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
errors.push(e.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle different-user entities. A resolution here means a title conflict was found
|
||||
// (on the suffixed title) against an existing row — redirect to it instead of the
|
||||
// default id-based action.
|
||||
for (const entry of analysis.differentUser) {
|
||||
const resolution = resolutionMap[entry.imported.id]
|
||||
try {
|
||||
if (resolution) {
|
||||
if (resolution.action === COLLISION_ACTION.SKIP) {
|
||||
report.skipped++
|
||||
idMap[entry.imported.id] = resolution.existing.id
|
||||
continue
|
||||
}
|
||||
|
||||
const finalEntity = _prepareEntity(
|
||||
entry.imported,
|
||||
titleField,
|
||||
exporterUuid,
|
||||
suffix,
|
||||
remapOptions,
|
||||
)
|
||||
await repository.replaceAll(resolution.existing.id, finalEntity)
|
||||
idMap[entry.imported.id] = resolution.existing.id
|
||||
report.replaced++
|
||||
if (setNestedData) {
|
||||
try {
|
||||
await setNestedData(resolution.existing.id, finalEntity)
|
||||
} catch (ne) {
|
||||
errors.push(ne.message)
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
const entityId = entry.action === 'new_id' ? crypto.randomUUID() : entry.imported.id
|
||||
const finalEntity = _prepareEntity(
|
||||
entry.imported,
|
||||
titleField,
|
||||
exporterUuid,
|
||||
suffix,
|
||||
remapOptions,
|
||||
)
|
||||
|
||||
if (entry.action === 'update') {
|
||||
await repository.replaceAll(entityId, finalEntity)
|
||||
idMap[entry.imported.id] = entityId
|
||||
report.updated++
|
||||
} else {
|
||||
await repository.createWithId(entityId, finalEntity)
|
||||
idMap[entry.imported.id] = entityId
|
||||
report.imported++
|
||||
}
|
||||
|
||||
if (setNestedData) {
|
||||
try {
|
||||
await setNestedData(entityId, finalEntity)
|
||||
} catch (ne) {
|
||||
errors.push(ne.message)
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
errors.push(e.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function _prepareEntity(entity, titleField, createdBy, suffix, remapOptions) {
|
||||
const cloned = { ...entity }
|
||||
cloned.created_by = createdBy
|
||||
|
||||
// Apply title/suffix
|
||||
if (titleField) {
|
||||
cloned[titleField] = suffix ? `${entity[titleField]} [${suffix}]` : entity[titleField]
|
||||
}
|
||||
|
||||
// Remap combo exercises
|
||||
if (remapOptions.remapExercises && cloned.exercises?.length) {
|
||||
cloned.exercises = cloned.exercises.map((ex) => ({
|
||||
...ex,
|
||||
exercise_id: remapOptions.remapExercises[ex.exercise_id] || ex.exercise_id,
|
||||
}))
|
||||
}
|
||||
|
||||
// Remap session items
|
||||
if ((remapOptions.remapExercises || remapOptions.remapCombos) && cloned.items?.length) {
|
||||
cloned.items = cloned.items.map((item) => {
|
||||
if (item.item_type === 'exercise' && remapOptions.remapExercises) {
|
||||
return { ...item, item_id: remapOptions.remapExercises[item.item_id] || item.item_id }
|
||||
}
|
||||
if (item.item_type === 'combo' && remapOptions.remapCombos) {
|
||||
return { ...item, item_id: remapOptions.remapCombos[item.item_id] || item.item_id }
|
||||
}
|
||||
return item
|
||||
})
|
||||
}
|
||||
|
||||
// Remap collection sessions
|
||||
if (remapOptions.remapSessions && cloned.sessions?.length) {
|
||||
cloned.sessions = cloned.sessions.map((s) => ({
|
||||
...s,
|
||||
session_id: remapOptions.remapSessions[s.session_id] || s.session_id,
|
||||
}))
|
||||
}
|
||||
|
||||
return cloned
|
||||
}
|
||||
|
||||
async function _importExecutionLogs(
|
||||
analysis,
|
||||
isSameUser,
|
||||
localUuid,
|
||||
exporterUuid,
|
||||
idMap,
|
||||
report,
|
||||
errors,
|
||||
) {
|
||||
// Collect all logs to import from the three analysis buckets
|
||||
const logsToImport = [
|
||||
...(analysis.noCollision || []).map((log) => ({ log, action: 'insert' })),
|
||||
...(analysis.sameUserCollisions || []).map((e) => ({ log: e.imported, action: 'insert' })),
|
||||
...(analysis.differentUser || []).map((e) => ({ log: e.imported, action: e.action })),
|
||||
]
|
||||
|
||||
for (const { log, action } of logsToImport) {
|
||||
try {
|
||||
const remappedSessionId = idMap.session[log.session_id] || log.session_id
|
||||
const sessionExists = await sessionRepository.getById(remappedSessionId)
|
||||
if (!sessionExists) {
|
||||
report.skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
const logId = action === 'new_id' ? crypto.randomUUID() : log.id
|
||||
await executionLogRepository.createWithId(logId, {
|
||||
...log,
|
||||
session_id: remappedSessionId,
|
||||
})
|
||||
|
||||
if (log.items?.length) {
|
||||
const remappedItems = log.items.map((item) => ({
|
||||
...item,
|
||||
...(action === 'new_id' ? { id: crypto.randomUUID(), log_id: logId } : {}),
|
||||
exercise_id: idMap.exercise[item.exercise_id] || item.exercise_id,
|
||||
}))
|
||||
await executionLogRepository.addItems(logId, remappedItems)
|
||||
}
|
||||
|
||||
report.imported++
|
||||
} catch (e) {
|
||||
errors.push(e.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
importing,
|
||||
error,
|
||||
loadFile,
|
||||
ensureSuffix,
|
||||
analyzeAll,
|
||||
applyAllImport,
|
||||
}
|
||||
}
|
||||
30
src/composables/useKeyboardShortcut.js
Normal file
30
src/composables/useKeyboardShortcut.js
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import { onMounted, onBeforeUnmount } from 'vue'
|
||||
|
||||
/**
|
||||
* Registers a keyboard shortcut listener for a single key.
|
||||
*
|
||||
* @param {string} key - The key to listen for (e.g. 'e', 'n', 'escape', 'enter')
|
||||
* @param {Function} handler - Callback to invoke when the key is pressed
|
||||
* @param {boolean|object} options - Legacy boolean (preventDefault) or options object:
|
||||
* - preventDefault {boolean} - Whether to call event.preventDefault() (default: true)
|
||||
* - ctrl {boolean} - Require Ctrl to be held (default: false)
|
||||
* - ignoreInputs {boolean} - Skip when focus is in input/textarea/select (default: true)
|
||||
*/
|
||||
export function useKeyboardShortcut(key, handler, options = {}) {
|
||||
if (typeof options === 'boolean') options = { preventDefault: options }
|
||||
const { preventDefault = true, ctrl = false, ignoreInputs = true } = options
|
||||
|
||||
function onKeydown(event) {
|
||||
if (event.key.toLowerCase() !== key.toLowerCase()) return
|
||||
if (ctrl && !event.ctrlKey) return
|
||||
|
||||
const tag = event.target.tagName.toLowerCase()
|
||||
if (ignoreInputs && (tag === 'input' || tag === 'textarea' || tag === 'select')) return
|
||||
|
||||
if (preventDefault) event.preventDefault()
|
||||
handler()
|
||||
}
|
||||
|
||||
onMounted(() => window.addEventListener('keydown', onKeydown))
|
||||
onBeforeUnmount(() => window.removeEventListener('keydown', onKeydown))
|
||||
}
|
||||
55
src/composables/useListNavigation.js
Normal file
55
src/composables/useListNavigation.js
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import { ref, onMounted, onBeforeUnmount, nextTick } from 'vue'
|
||||
|
||||
/**
|
||||
* Provides keyboard navigation for a list of items using arrow keys.
|
||||
*
|
||||
* @param {number} itemCount - The number of items in the list
|
||||
* @param {Function} onSelect - Callback when Enter is pressed on a focused item (receives index)
|
||||
* @returns {{ focusedIndex: Ref<number>, onKeydown: Function, focusItem: Function }}
|
||||
*/
|
||||
export function useListNavigation(itemCount, onSelect) {
|
||||
const focusedIndex = ref(-1)
|
||||
|
||||
function focusItem(index) {
|
||||
focusedIndex.value = Math.max(0, Math.min(index, itemCount.value - 1))
|
||||
nextTick(() => {
|
||||
const el = document.querySelector(`[data-list-index="${focusedIndex.value}"]`)
|
||||
el?.focus()
|
||||
})
|
||||
}
|
||||
|
||||
function onKeydown(event) {
|
||||
const tag = event.target.tagName.toLowerCase()
|
||||
if (tag === 'input' || tag === 'textarea' || tag === 'select') return
|
||||
|
||||
if (event.key === 'ArrowDown') {
|
||||
event.preventDefault()
|
||||
if (focusedIndex.value < 0) {
|
||||
focusItem(0)
|
||||
} else {
|
||||
focusItem(focusedIndex.value + 1)
|
||||
}
|
||||
} else if (event.key === 'ArrowUp') {
|
||||
event.preventDefault()
|
||||
if (focusedIndex.value > 0) {
|
||||
focusItem(focusedIndex.value - 1)
|
||||
}
|
||||
} else if (event.key === 'Enter' && focusedIndex.value >= 0) {
|
||||
event.preventDefault()
|
||||
onSelect(focusedIndex.value)
|
||||
} else if (event.key === 'Escape' && focusedIndex.value >= 0) {
|
||||
event.preventDefault()
|
||||
focusedIndex.value = -1
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('keydown', onKeydown)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('keydown', onKeydown)
|
||||
})
|
||||
|
||||
return { focusedIndex, onKeydown, focusItem }
|
||||
}
|
||||
21
src/composables/useOnlineStatus.js
Normal file
21
src/composables/useOnlineStatus.js
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
|
||||
export function useOnlineStatus() {
|
||||
const isOnline = ref(navigator.onLine)
|
||||
|
||||
function update() {
|
||||
isOnline.value = navigator.onLine
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('online', update)
|
||||
window.addEventListener('offline', update)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('online', update)
|
||||
window.removeEventListener('offline', update)
|
||||
})
|
||||
|
||||
return { isOnline }
|
||||
}
|
||||
37
src/composables/useSaveFilePrompt.js
Normal file
37
src/composables/useSaveFilePrompt.js
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import { ref } from 'vue'
|
||||
|
||||
const pendingSave = ref(null)
|
||||
|
||||
function splitFilename(filename) {
|
||||
const idx = filename.lastIndexOf('.')
|
||||
if (idx <= 0) return { stem: filename, extension: '' }
|
||||
return { stem: filename.slice(0, idx), extension: filename.slice(idx) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Singleton prompt service backing the global <SaveFileDialog>.
|
||||
* promptFilename() resolves to the chosen filename, or null if cancelled.
|
||||
*/
|
||||
export function useSaveFilePrompt() {
|
||||
function promptFilename(defaultFilename) {
|
||||
return new Promise((resolve) => {
|
||||
const { stem, extension } = splitFilename(defaultFilename)
|
||||
pendingSave.value = { defaultStem: stem, extension, resolve }
|
||||
})
|
||||
}
|
||||
|
||||
function confirmFilename(stem) {
|
||||
const { defaultStem, extension, resolve } = pendingSave.value
|
||||
const finalStem = stem.trim() || defaultStem
|
||||
pendingSave.value = null
|
||||
resolve(`${finalStem}${extension}`)
|
||||
}
|
||||
|
||||
function cancelFilename() {
|
||||
const { resolve } = pendingSave.value
|
||||
pendingSave.value = null
|
||||
resolve(null)
|
||||
}
|
||||
|
||||
return { pendingSave, promptFilename, confirmFilename, cancelFilename }
|
||||
}
|
||||
451
src/composables/useSessionExecution.js
Normal file
451
src/composables/useSessionExecution.js
Normal file
|
|
@ -0,0 +1,451 @@
|
|||
import { ref, computed } from 'vue'
|
||||
import { sessionRepository } from '../db/repositories/sessionRepository.js'
|
||||
import { comboRepository } from '../db/repositories/comboRepository.js'
|
||||
import { exerciseRepository } from '../db/repositories/exerciseRepository.js'
|
||||
import { executionLogRepository } from '../db/repositories/executionLogRepository.js'
|
||||
import { settingsRepository } from '../db/repositories/settingsRepository.js'
|
||||
import { useTimer } from './useTimer.js'
|
||||
import { useAudioCues } from './useAudioCues.js'
|
||||
|
||||
export function useSessionExecution(sessionId) {
|
||||
const sessionTitle = ref('')
|
||||
const queue = ref([])
|
||||
const currentIndex = ref(0)
|
||||
const logId = ref(null)
|
||||
const showFinalRound = ref(false)
|
||||
const sessionDone = ref(false)
|
||||
const loading = ref(true)
|
||||
const error = ref(null)
|
||||
|
||||
const currentReps = ref(0)
|
||||
const currentWeight = ref(0.0)
|
||||
const currentSide = ref('left')
|
||||
|
||||
// Timer state
|
||||
const timerPhase = ref(null) // 'work' | 'rest' | 'amrap' | null
|
||||
const isAmrapTimeUp = ref(false)
|
||||
const currentAmrapComboId = ref(null)
|
||||
|
||||
const {
|
||||
remaining: timerRemaining,
|
||||
isPaused: timerPaused,
|
||||
start: startTimer,
|
||||
pause: pauseTimer,
|
||||
resume: resumeTimer,
|
||||
stop: stopTimer,
|
||||
} = useTimer()
|
||||
const { playBip, playBIP } = useAudioCues()
|
||||
const audioVolume = ref(0.5)
|
||||
|
||||
const currentStep = computed(() => queue.value[currentIndex.value] || null)
|
||||
const nextStep = computed(() => queue.value[currentIndex.value + 1] || null)
|
||||
const totalSteps = computed(() => queue.value.length)
|
||||
const timerActive = computed(() => timerPhase.value !== null && !isAmrapTimeUp.value)
|
||||
|
||||
function loadStepState() {
|
||||
const step = currentStep.value
|
||||
if (!step) return
|
||||
currentReps.value = step.prefillReps
|
||||
currentWeight.value = step.prefillWeight
|
||||
currentSide.value = step.prefillSide
|
||||
_startStepTimer(step)
|
||||
}
|
||||
|
||||
function _emomSounds(remaining) {
|
||||
if (remaining === 30 || remaining === 2 || remaining === 1) playBip(audioVolume.value)
|
||||
}
|
||||
|
||||
function _amrapLastMinuteSounds(remaining) {
|
||||
if (remaining === 30 || remaining === 2 || remaining === 1) playBip(audioVolume.value)
|
||||
}
|
||||
|
||||
function _tabataCountdown(remaining, totalTime) {
|
||||
const half = Math.floor(totalTime / 2)
|
||||
if (remaining === half) playBip(audioVolume.value)
|
||||
if (remaining === 2 || remaining === 1) playBip(audioVolume.value)
|
||||
}
|
||||
|
||||
function _startStepTimer(step) {
|
||||
if (step.comboType === 'EMOM') {
|
||||
timerPhase.value = 'work'
|
||||
startTimer(60, {
|
||||
onTick: _emomSounds,
|
||||
onExpire: _onEmomExpire,
|
||||
})
|
||||
} else if (step.comboType === 'TABATA') {
|
||||
_startWorkPhase(step)
|
||||
} else if (step.comboType === 'AMRAP') {
|
||||
_maybeStartAmrapTimer(step)
|
||||
} else {
|
||||
timerPhase.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function _onEmomExpire() {
|
||||
playBIP(audioVolume.value)
|
||||
const step = currentStep.value
|
||||
if (!step) return
|
||||
timerPhase.value = null
|
||||
_saveStepToLog(step).then(() => _advance())
|
||||
}
|
||||
|
||||
function _startWorkPhase(step) {
|
||||
timerPhase.value = 'work'
|
||||
playBIP(audioVolume.value)
|
||||
startTimer(step.workTime, {
|
||||
onTick: (r) => _tabataCountdown(r, step.workTime),
|
||||
onExpire: () => _startRestPhase(step),
|
||||
})
|
||||
}
|
||||
|
||||
function _startRestPhase(step) {
|
||||
timerPhase.value = 'rest'
|
||||
playBIP(audioVolume.value)
|
||||
startTimer(step.restTime, {
|
||||
onTick: (r) => _tabataCountdown(r, step.restTime),
|
||||
onExpire: async () => {
|
||||
timerPhase.value = null
|
||||
if (step.isFinalComboStep) {
|
||||
showFinalRound.value = true
|
||||
} else {
|
||||
await _advance()
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function _maybeStartAmrapTimer(step) {
|
||||
if (step.comboId === currentAmrapComboId.value) return
|
||||
currentAmrapComboId.value = step.comboId
|
||||
isAmrapTimeUp.value = false
|
||||
timerPhase.value = 'amrap'
|
||||
|
||||
const durationSec = step.amrapMinutes * 60
|
||||
const halfDuration = Math.round(durationSec / 2)
|
||||
let lastElapsedMin = 0
|
||||
|
||||
startTimer(durationSec, {
|
||||
onTick: (r) => {
|
||||
if (r <= 60) {
|
||||
_amrapLastMinuteSounds(r)
|
||||
} else {
|
||||
const elapsed = Math.floor((durationSec - r) / 60)
|
||||
const isNewMinute = elapsed > lastElapsedMin
|
||||
if (isNewMinute) lastElapsedMin = elapsed
|
||||
if (r === halfDuration) {
|
||||
playBIP(audioVolume.value)
|
||||
} else if (isNewMinute) {
|
||||
playBip(audioVolume.value)
|
||||
}
|
||||
}
|
||||
},
|
||||
onExpire: () => {
|
||||
playBIP(audioVolume.value)
|
||||
timerPhase.value = null
|
||||
isAmrapTimeUp.value = true
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async function init() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const volumeStr = await settingsRepository.get('audio_volume')
|
||||
const parsed = parseInt(volumeStr, 10)
|
||||
audioVolume.value = isNaN(parsed) ? 0.5 : parsed / 100
|
||||
|
||||
const session = await sessionRepository.getById(sessionId)
|
||||
if (!session) throw new Error('Session not found')
|
||||
sessionTitle.value = session.title
|
||||
|
||||
const prefillMap = await executionLogRepository.getLastItemsBySession(sessionId)
|
||||
|
||||
const steps = []
|
||||
for (const item of session.items) {
|
||||
if (item.item_type === 'exercise') {
|
||||
const ex = await exerciseRepository.getById(item.item_id)
|
||||
const pf = prefillMap[item.item_id] || {}
|
||||
steps.push({
|
||||
exerciseId: item.item_id,
|
||||
exerciseTitle: item.title,
|
||||
prefillReps: pf.reps_done ?? item.repetitions,
|
||||
prefillWeight: pf.weight_used ?? item.weight,
|
||||
prefillSide: pf.side || 'left',
|
||||
comboId: null,
|
||||
comboTitle: null,
|
||||
comboType: null,
|
||||
workTime: null,
|
||||
restTime: null,
|
||||
amrapMinutes: null,
|
||||
roundNumber: null,
|
||||
totalRounds: null,
|
||||
asymmetric: ex?.asymmetric || false,
|
||||
alternate: ex?.alternate || false,
|
||||
isFinalComboStep: false,
|
||||
comboExercises: null,
|
||||
})
|
||||
} else if (item.item_type === 'combo') {
|
||||
const combo = await comboRepository.getById(item.item_id)
|
||||
if (!combo || !combo.exercises.length) continue
|
||||
|
||||
const comboType = combo.type || 'NONE'
|
||||
const timerCfg = combo.timer_config || {}
|
||||
const workTime = timerCfg.workTime ?? 20
|
||||
const restTime = timerCfg.restTime ?? 10
|
||||
const amrapMinutes = item.repetitions || 1
|
||||
|
||||
// AMRAP: treat repetitions as minutes, unroll just 1 round in queue
|
||||
// EMOM: repetitions = total duration in minutes; rounds = floor(minutes / exercises)
|
||||
// TABATA/NONE: repetitions = number of full combo rounds
|
||||
const totalRounds =
|
||||
comboType === 'AMRAP'
|
||||
? 1
|
||||
: comboType === 'EMOM' && combo.exercises.length > 0
|
||||
? Math.max(1, Math.floor((item.repetitions || 1) / combo.exercises.length))
|
||||
: item.repetitions || 1
|
||||
|
||||
for (let round = 1; round <= totalRounds; round++) {
|
||||
for (let ei = 0; ei < combo.exercises.length; ei++) {
|
||||
const ex = combo.exercises[ei]
|
||||
const pf = prefillMap[ex.exercise_id] || {}
|
||||
const isLast = ei === combo.exercises.length - 1 && round === totalRounds
|
||||
steps.push({
|
||||
exerciseId: ex.exercise_id,
|
||||
exerciseTitle: ex.title,
|
||||
prefillReps: pf.reps_done ?? ex.default_reps,
|
||||
prefillWeight: pf.weight_used ?? ex.default_weight,
|
||||
prefillSide: pf.side || 'left',
|
||||
comboId: item.item_id,
|
||||
comboTitle: combo.title,
|
||||
comboType,
|
||||
workTime,
|
||||
restTime,
|
||||
amrapMinutes,
|
||||
roundNumber: round,
|
||||
totalRounds,
|
||||
asymmetric: ex.asymmetric || false,
|
||||
alternate: ex.alternate || false,
|
||||
isFinalComboStep: isLast,
|
||||
comboExercises: combo.exercises,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
queue.value = steps
|
||||
|
||||
logId.value = await executionLogRepository.create({
|
||||
session_id: sessionId,
|
||||
started_at: new Date().toISOString(),
|
||||
finished_at: null,
|
||||
})
|
||||
|
||||
loadStepState()
|
||||
} catch (e) {
|
||||
error.value = e.message
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function _saveStepToLog(step) {
|
||||
await executionLogRepository.addItem({
|
||||
log_id: logId.value,
|
||||
exercise_id: step.exerciseId,
|
||||
combo_id: step.comboId || null,
|
||||
round_number: step.roundNumber || null,
|
||||
reps_done: currentReps.value,
|
||||
weight_used: currentWeight.value,
|
||||
side: step.asymmetric ? currentSide.value : null,
|
||||
completed: true,
|
||||
timestamp: new Date().toISOString(),
|
||||
})
|
||||
step.actualReps = currentReps.value
|
||||
step.actualWeight = currentWeight.value
|
||||
step.actualSide = currentSide.value
|
||||
}
|
||||
|
||||
async function markDone() {
|
||||
const step = currentStep.value
|
||||
if (!step || sessionDone.value) return
|
||||
|
||||
// Stop running step timer (EMOM or TABATA work phase)
|
||||
if (step.comboType === 'EMOM' || (step.comboType === 'TABATA' && timerPhase.value === 'work')) {
|
||||
stopTimer()
|
||||
timerPhase.value = null
|
||||
}
|
||||
|
||||
await _saveStepToLog(step)
|
||||
|
||||
if (step.comboType === 'TABATA') {
|
||||
_startRestPhase(step)
|
||||
} else if (step.comboType === 'EMOM') {
|
||||
// EMOM always auto-advances, no final-round screen
|
||||
await _advance()
|
||||
} else if (step.comboType === 'AMRAP') {
|
||||
if (step.isFinalComboStep) {
|
||||
if (isAmrapTimeUp.value) {
|
||||
await _advance()
|
||||
} else {
|
||||
// Auto-cycle: add one more round silently
|
||||
_addAmrapRound()
|
||||
}
|
||||
} else {
|
||||
await _advance()
|
||||
}
|
||||
} else {
|
||||
if (step.isFinalComboStep) {
|
||||
showFinalRound.value = true
|
||||
} else {
|
||||
await _advance()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function _appendRound({ resetFinalRound = false } = {}) {
|
||||
const step = currentStep.value
|
||||
if (!step?.comboId || !step?.comboExercises) return
|
||||
|
||||
const newRound = step.roundNumber + 1
|
||||
const exercises = step.comboExercises
|
||||
const lastRoundSteps = queue.value.filter(
|
||||
(s) => s.comboId === step.comboId && s.roundNumber === step.roundNumber,
|
||||
)
|
||||
|
||||
const newSteps = exercises.map((ex, i) => {
|
||||
const prev = lastRoundSteps.find((s) => s.exerciseId === ex.exercise_id)
|
||||
return {
|
||||
exerciseId: ex.exercise_id,
|
||||
exerciseTitle: ex.title,
|
||||
prefillReps: prev?.actualReps ?? prev?.prefillReps ?? ex.default_reps,
|
||||
prefillWeight: prev?.actualWeight ?? prev?.prefillWeight ?? ex.default_weight,
|
||||
prefillSide: prev?.actualSide || 'left',
|
||||
comboId: step.comboId,
|
||||
comboTitle: step.comboTitle,
|
||||
comboType: step.comboType,
|
||||
workTime: step.workTime,
|
||||
restTime: step.restTime,
|
||||
amrapMinutes: step.amrapMinutes,
|
||||
roundNumber: newRound,
|
||||
totalRounds: newRound,
|
||||
asymmetric: ex.asymmetric || false,
|
||||
alternate: ex.alternate || false,
|
||||
isFinalComboStep: i === exercises.length - 1,
|
||||
comboExercises: exercises,
|
||||
}
|
||||
})
|
||||
|
||||
queue.value.splice(currentIndex.value + 1, 0, ...newSteps)
|
||||
if (resetFinalRound) showFinalRound.value = false
|
||||
currentIndex.value++
|
||||
loadStepState()
|
||||
}
|
||||
|
||||
function _addAmrapRound() {
|
||||
_appendRound()
|
||||
}
|
||||
|
||||
async function continueAfterCombo() {
|
||||
showFinalRound.value = false
|
||||
await _advance()
|
||||
}
|
||||
|
||||
async function continueAfterAmrap() {
|
||||
isAmrapTimeUp.value = false
|
||||
currentAmrapComboId.value = null
|
||||
stopTimer()
|
||||
// Skip any remaining steps belonging to this AMRAP combo
|
||||
const comboId = currentStep.value?.comboId
|
||||
while (
|
||||
currentIndex.value < queue.value.length &&
|
||||
queue.value[currentIndex.value]?.comboId === comboId
|
||||
) {
|
||||
currentIndex.value++
|
||||
}
|
||||
if (currentIndex.value >= queue.value.length) {
|
||||
await _finish()
|
||||
} else {
|
||||
loadStepState()
|
||||
}
|
||||
}
|
||||
|
||||
function addOneMoreRound() {
|
||||
_appendRound({ resetFinalRound: true })
|
||||
}
|
||||
|
||||
function togglePause() {
|
||||
if (timerPaused.value) {
|
||||
resumeTimer()
|
||||
} else {
|
||||
pauseTimer()
|
||||
}
|
||||
}
|
||||
|
||||
async function _advance() {
|
||||
currentIndex.value++
|
||||
if (currentIndex.value >= queue.value.length) {
|
||||
stopTimer()
|
||||
timerPhase.value = null
|
||||
await _finish()
|
||||
} else {
|
||||
// If the new step is a different AMRAP combo, stop the current AMRAP timer
|
||||
const nextStep = currentStep.value
|
||||
if (nextStep?.comboType !== 'AMRAP' || nextStep?.comboId !== currentAmrapComboId.value) {
|
||||
if (currentAmrapComboId.value) {
|
||||
stopTimer()
|
||||
currentAmrapComboId.value = null
|
||||
timerPhase.value = null
|
||||
}
|
||||
}
|
||||
loadStepState()
|
||||
}
|
||||
}
|
||||
|
||||
async function _finish() {
|
||||
await executionLogRepository.update(logId.value, {
|
||||
finished_at: new Date().toISOString(),
|
||||
status: 'completed',
|
||||
})
|
||||
sessionDone.value = true
|
||||
}
|
||||
|
||||
async function exit() {
|
||||
if (sessionDone.value) return
|
||||
stopTimer()
|
||||
timerPhase.value = null
|
||||
await executionLogRepository.update(logId.value, {
|
||||
finished_at: new Date().toISOString(),
|
||||
status: 'aborted',
|
||||
})
|
||||
sessionDone.value = true
|
||||
}
|
||||
|
||||
return {
|
||||
sessionTitle,
|
||||
queue,
|
||||
currentIndex,
|
||||
currentStep,
|
||||
nextStep,
|
||||
totalSteps,
|
||||
showFinalRound,
|
||||
sessionDone,
|
||||
loading,
|
||||
error,
|
||||
currentReps,
|
||||
currentWeight,
|
||||
currentSide,
|
||||
timerPhase,
|
||||
timerRemaining,
|
||||
timerPaused,
|
||||
timerActive,
|
||||
isAmrapTimeUp,
|
||||
init,
|
||||
markDone,
|
||||
continueAfterCombo,
|
||||
continueAfterAmrap,
|
||||
addOneMoreRound,
|
||||
togglePause,
|
||||
exit,
|
||||
}
|
||||
}
|
||||
54
src/composables/useSessions.js
Normal file
54
src/composables/useSessions.js
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import { ref } from 'vue'
|
||||
import { sessionRepository } from '../db/repositories/sessionRepository.js'
|
||||
|
||||
export function useSessions() {
|
||||
const sessions = ref([])
|
||||
const loading = ref(false)
|
||||
const error = ref(null)
|
||||
|
||||
async function fetchAll() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
sessions.value = await sessionRepository.getAll()
|
||||
} catch (e) {
|
||||
error.value = e.message
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function create(data) {
|
||||
const id = await sessionRepository.create(data)
|
||||
await fetchAll()
|
||||
return id
|
||||
}
|
||||
|
||||
async function update(id, data) {
|
||||
await sessionRepository.update(id, data)
|
||||
await fetchAll()
|
||||
}
|
||||
|
||||
async function remove(id) {
|
||||
await sessionRepository.delete(id)
|
||||
await fetchAll()
|
||||
}
|
||||
|
||||
async function search(query) {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
sessions.value = await sessionRepository.search(query)
|
||||
} catch (e) {
|
||||
error.value = e.message
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function getById(id) {
|
||||
return sessionRepository.getById(id)
|
||||
}
|
||||
|
||||
return { sessions, loading, error, fetchAll, create, update, remove, search, getById }
|
||||
}
|
||||
27
src/composables/useSettings.js
Normal file
27
src/composables/useSettings.js
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { ref } from 'vue'
|
||||
import { settingsRepository } from '../db/repositories/settingsRepository.js'
|
||||
|
||||
export function useSettings() {
|
||||
const settings = ref({})
|
||||
const loading = ref(false)
|
||||
|
||||
async function fetchAll() {
|
||||
loading.value = true
|
||||
try {
|
||||
settings.value = await settingsRepository.getAll()
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function get(key) {
|
||||
return settingsRepository.get(key)
|
||||
}
|
||||
|
||||
async function set(key, value) {
|
||||
await settingsRepository.set(key, value)
|
||||
settings.value = { ...settings.value, [key]: value }
|
||||
}
|
||||
|
||||
return { settings, loading, fetchAll, get, set }
|
||||
}
|
||||
111
src/composables/useStats.js
Normal file
111
src/composables/useStats.js
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
import { ref, watch } from 'vue'
|
||||
import { executionLogRepository } from '../db/repositories/executionLogRepository.js'
|
||||
|
||||
export function useStats() {
|
||||
const dateRange = ref('30d')
|
||||
const frequencyGroupBy = ref('week')
|
||||
const selectedExerciseId = ref('')
|
||||
|
||||
const totalSessions = ref(0)
|
||||
const totalExercises = ref(0)
|
||||
const streak = ref(0)
|
||||
const loggedExercises = ref([])
|
||||
const frequencyRaw = ref([])
|
||||
const volumeRaw = ref([])
|
||||
const progressionRaw = ref([])
|
||||
const loading = ref(false)
|
||||
|
||||
function getSinceDate(range) {
|
||||
if (range === 'all') return null
|
||||
const d = new Date()
|
||||
d.setHours(0, 0, 0, 0)
|
||||
if (range === '7d') d.setDate(d.getDate() - 7)
|
||||
else if (range === '30d') d.setDate(d.getDate() - 30)
|
||||
else if (range === '3m') d.setMonth(d.getMonth() - 3)
|
||||
return d.toISOString()
|
||||
}
|
||||
|
||||
function computeStreak(days) {
|
||||
if (!days.length) return 0
|
||||
// Use UTC midnight to match SQLite DATE() which extracts UTC dates
|
||||
const daySet = new Set(days.map((r) => r.day))
|
||||
const cur = new Date()
|
||||
cur.setUTCHours(0, 0, 0, 0)
|
||||
let count = 0
|
||||
while (true) {
|
||||
const dateStr = cur.toISOString().slice(0, 10)
|
||||
if (daySet.has(dateStr)) {
|
||||
count++
|
||||
cur.setUTCDate(cur.getUTCDate() - 1)
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
async function loadFrequency() {
|
||||
const since = getSinceDate(dateRange.value)
|
||||
frequencyRaw.value = await executionLogRepository.getFrequencyData(
|
||||
since,
|
||||
frequencyGroupBy.value,
|
||||
)
|
||||
}
|
||||
|
||||
async function loadProgression() {
|
||||
if (!selectedExerciseId.value) {
|
||||
progressionRaw.value = []
|
||||
return
|
||||
}
|
||||
const since = getSinceDate(dateRange.value)
|
||||
progressionRaw.value = await executionLogRepository.getProgressionData(
|
||||
selectedExerciseId.value,
|
||||
since,
|
||||
)
|
||||
}
|
||||
|
||||
async function loadAll() {
|
||||
loading.value = true
|
||||
try {
|
||||
const since = getSinceDate(dateRange.value)
|
||||
const [sessions, exercises, days, frequency, volume, logged] = await Promise.all([
|
||||
executionLogRepository.getTotalSessionsSince(since),
|
||||
executionLogRepository.getTotalExercisesDone(since),
|
||||
executionLogRepository.getDistinctSessionDays(),
|
||||
executionLogRepository.getFrequencyData(since, frequencyGroupBy.value),
|
||||
executionLogRepository.getVolumeData(since),
|
||||
executionLogRepository.getLoggedExercises(),
|
||||
])
|
||||
totalSessions.value = sessions
|
||||
totalExercises.value = exercises
|
||||
streak.value = computeStreak(days)
|
||||
frequencyRaw.value = frequency
|
||||
volumeRaw.value = volume
|
||||
loggedExercises.value = logged
|
||||
await loadProgression()
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(dateRange, loadAll)
|
||||
watch(frequencyGroupBy, loadFrequency)
|
||||
watch(selectedExerciseId, loadProgression)
|
||||
|
||||
return {
|
||||
dateRange,
|
||||
frequencyGroupBy,
|
||||
selectedExerciseId,
|
||||
totalSessions,
|
||||
totalExercises,
|
||||
streak,
|
||||
loggedExercises,
|
||||
frequencyRaw,
|
||||
volumeRaw,
|
||||
progressionRaw,
|
||||
loading,
|
||||
loadAll,
|
||||
loadProgression,
|
||||
getSinceDate,
|
||||
}
|
||||
}
|
||||
92
src/composables/useTimer.js
Normal file
92
src/composables/useTimer.js
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
import { ref, onUnmounted } from 'vue'
|
||||
|
||||
export function useTimer() {
|
||||
const remaining = ref(0)
|
||||
const isPaused = ref(false)
|
||||
let intervalId = null
|
||||
let onTickFn = null
|
||||
let onExpireFn = null
|
||||
let endsAt = 0
|
||||
let pausedAt = 0
|
||||
let lastRemaining = 0
|
||||
|
||||
function start(seconds, { onTick, onExpire } = {}) {
|
||||
_clear()
|
||||
remaining.value = seconds
|
||||
lastRemaining = seconds
|
||||
isPaused.value = false
|
||||
onTickFn = onTick || null
|
||||
onExpireFn = onExpire || null
|
||||
endsAt = Date.now() + seconds * 1000
|
||||
intervalId = setInterval(_step, 250)
|
||||
}
|
||||
|
||||
function _step() {
|
||||
if (isPaused.value) return
|
||||
|
||||
const now = Date.now()
|
||||
const newRemaining = Math.max(0, Math.ceil((endsAt - now) / 1000))
|
||||
|
||||
if (newRemaining < lastRemaining) {
|
||||
// Fire onTick for each integer second that passed (catches skipped seconds)
|
||||
for (let s = lastRemaining - 1; s >= newRemaining; s--) {
|
||||
if (s > 0) onTickFn?.(s)
|
||||
}
|
||||
lastRemaining = newRemaining
|
||||
}
|
||||
|
||||
remaining.value = newRemaining
|
||||
|
||||
if (newRemaining <= 0) {
|
||||
_clear()
|
||||
onExpireFn?.()
|
||||
}
|
||||
}
|
||||
|
||||
function pause() {
|
||||
if (isPaused.value) return
|
||||
isPaused.value = true
|
||||
pausedAt = Date.now()
|
||||
}
|
||||
|
||||
function resume() {
|
||||
if (!isPaused.value) return
|
||||
// Shift endsAt forward by the paused duration
|
||||
endsAt += Date.now() - pausedAt
|
||||
isPaused.value = false
|
||||
}
|
||||
|
||||
function stop() {
|
||||
_clear()
|
||||
remaining.value = 0
|
||||
isPaused.value = false
|
||||
onTickFn = null
|
||||
onExpireFn = null
|
||||
endsAt = 0
|
||||
pausedAt = 0
|
||||
lastRemaining = 0
|
||||
}
|
||||
|
||||
function _clear() {
|
||||
if (intervalId) {
|
||||
clearInterval(intervalId)
|
||||
intervalId = null
|
||||
}
|
||||
}
|
||||
|
||||
// Recompute remaining immediately when the tab becomes visible
|
||||
function _onVisibilityChange() {
|
||||
if (!document.hidden && !isPaused.value && intervalId) {
|
||||
_step()
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('visibilitychange', _onVisibilityChange)
|
||||
|
||||
onUnmounted(() => {
|
||||
_clear()
|
||||
document.removeEventListener('visibilitychange', _onVisibilityChange)
|
||||
})
|
||||
|
||||
return { remaining, isPaused, start, pause, resume, stop }
|
||||
}
|
||||
160
src/db/database.js
Normal file
160
src/db/database.js
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
class Database {
|
||||
constructor() {
|
||||
this._worker = null
|
||||
this._messageId = 0
|
||||
this._pending = new Map()
|
||||
this._initPromise = null
|
||||
this._broken = false
|
||||
this.info = null
|
||||
// Set when the DB is older than the app: { from, to }. The app must show
|
||||
// the blocking migration screen and call runPendingMigrations().
|
||||
this.migrationPending = null
|
||||
}
|
||||
|
||||
async init() {
|
||||
if (this._initPromise) return this._initPromise
|
||||
this._initPromise = this._doInit()
|
||||
return this._initPromise
|
||||
}
|
||||
|
||||
async _doInit() {
|
||||
this._worker = new Worker(new URL('./worker.js', import.meta.url), { type: 'module' })
|
||||
this._worker.onmessage = (e) => this._handleMessage(e)
|
||||
this._worker.onerror = (e) => this._handleError(e)
|
||||
|
||||
// Initialize SQLite + OPFS
|
||||
// Compute the absolute base URL at runtime so the worker can locate
|
||||
// sqlite3.wasm correctly regardless of the deployment subdirectory.
|
||||
const wasmBaseUrl = new URL(import.meta.env.BASE_URL, window.location.href).href
|
||||
this.info = await this._send('init', {
|
||||
dbName: __APP_NAME__,
|
||||
wasmBaseUrl,
|
||||
})
|
||||
|
||||
// Detect the schema state (creates the schema on a fresh DB)
|
||||
const status = await this._send('getSchemaStatus')
|
||||
|
||||
if (status.status === 'incompatible') {
|
||||
throw new Error('DB_NEWER_THAN_APP')
|
||||
}
|
||||
|
||||
if (status.status === 'migration-pending') {
|
||||
// Pause: the UI must obtain a user backup, then call runPendingMigrations().
|
||||
// The DB is open (exportDatabase works) but defaults are not initialized yet.
|
||||
this.migrationPending = { from: status.from, to: status.to }
|
||||
return this.info
|
||||
}
|
||||
|
||||
// Initialize default settings
|
||||
await this._initDefaults()
|
||||
|
||||
return this.info
|
||||
}
|
||||
|
||||
async runPendingMigrations() {
|
||||
const result = await this._send('runMigrations')
|
||||
if (result.status === 'migration-failed') {
|
||||
console.error('DB migration failed:', result)
|
||||
throw new Error('DB_MIGRATION_FAILED')
|
||||
}
|
||||
this.migrationPending = null
|
||||
await this._initDefaults()
|
||||
return result
|
||||
}
|
||||
|
||||
async _initDefaults() {
|
||||
// Check if user_uuid exists (first launch detection)
|
||||
const existing = await this._send('selectOne', {
|
||||
sql: "SELECT value FROM settings WHERE key = 'user_uuid'",
|
||||
})
|
||||
|
||||
if (!existing) {
|
||||
const uuid = crypto.randomUUID()
|
||||
const defaults = [
|
||||
['user_uuid', uuid],
|
||||
['user_name', 'User'],
|
||||
['language', 'en'],
|
||||
['theme', 'light'],
|
||||
['db_created_at', new Date().toISOString()],
|
||||
]
|
||||
for (const [key, value] of defaults) {
|
||||
await this._send('exec', {
|
||||
sql: 'INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)',
|
||||
params: [key, value],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Always update app_version to current
|
||||
await this._send('exec', {
|
||||
sql: 'INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)',
|
||||
params: ['app_version', __APP_VERSION__],
|
||||
})
|
||||
}
|
||||
|
||||
_handleMessage(e) {
|
||||
const { id, result, error } = e.data
|
||||
const resolver = this._pending.get(id)
|
||||
if (resolver) {
|
||||
this._pending.delete(id)
|
||||
if (error) resolver.reject(new Error(error))
|
||||
else resolver.resolve(result)
|
||||
}
|
||||
}
|
||||
|
||||
_handleError(e) {
|
||||
console.error('DB Worker error:', e)
|
||||
this._broken = true
|
||||
for (const [, { reject }] of this._pending) {
|
||||
reject(new Error('Worker crashed'))
|
||||
}
|
||||
this._pending.clear()
|
||||
window.dispatchEvent(new CustomEvent('db-connection-lost'))
|
||||
}
|
||||
|
||||
_send(type, args = {}) {
|
||||
if (this._broken) {
|
||||
return Promise.reject(new Error('DB connection lost'))
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
const id = ++this._messageId
|
||||
this._pending.set(id, { resolve, reject })
|
||||
this._worker.postMessage({ id, type, ...args })
|
||||
})
|
||||
}
|
||||
|
||||
async batch(statements) {
|
||||
return this._send('batch', { statements })
|
||||
}
|
||||
|
||||
async exec(sql, params) {
|
||||
return this._send('exec', { sql, params })
|
||||
}
|
||||
|
||||
async run(sql, params) {
|
||||
return this._send('run', { sql, params })
|
||||
}
|
||||
|
||||
async selectAll(sql, params) {
|
||||
return this._send('selectAll', { sql, params })
|
||||
}
|
||||
|
||||
async selectOne(sql, params) {
|
||||
return this._send('selectOne', { sql, params })
|
||||
}
|
||||
|
||||
async exportDatabase() {
|
||||
return this._send('exportDb')
|
||||
}
|
||||
|
||||
async importDatabase(bytes) {
|
||||
return this._send('importDb', { bytes })
|
||||
}
|
||||
|
||||
async resetDatabase() {
|
||||
await this._send('resetDb')
|
||||
await this._initDefaults()
|
||||
}
|
||||
}
|
||||
|
||||
export const db = new Database()
|
||||
83
src/db/releases.js
Normal file
83
src/db/releases.js
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
export const RELEASES = [
|
||||
{
|
||||
schemaVersion: 1,
|
||||
appVersion: '1.0.0',
|
||||
dbBreak: false,
|
||||
migrationScript: null,
|
||||
},
|
||||
]
|
||||
|
||||
/**
|
||||
* Minimum tables a valid TrainUs DB must contain.
|
||||
*/
|
||||
export const REQUIRED_TABLES = ['schema_version', 'settings']
|
||||
|
||||
/**
|
||||
* Ordered migration scripts to bring a live DB from `fromVersion` up to `toVersion`.
|
||||
* Returns [{ schemaVersion, script }] for releases in (fromVersion, toVersion], ascending.
|
||||
* A release with `migrationScript: null` and `dbBreak: false` is a valid no-op migration
|
||||
* (only the schema_version row is stamped). Every schemaVersion bump must ship exactly
|
||||
* one RELEASES entry.
|
||||
* Throws if a release in range has `dbBreak: true` without a migration script.
|
||||
*/
|
||||
export function getMigrationScripts(fromVersion, toVersion) {
|
||||
const releasesInRange = RELEASES.filter(
|
||||
(r) => r.schemaVersion > fromVersion && r.schemaVersion <= toVersion,
|
||||
).sort((a, b) => a.schemaVersion - b.schemaVersion)
|
||||
|
||||
const blocked = releasesInRange.find((r) => r.dbBreak && !r.migrationScript)
|
||||
if (blocked) {
|
||||
throw new Error(`Missing migration script for breaking schema version ${blocked.schemaVersion}`)
|
||||
}
|
||||
|
||||
return releasesInRange.map((r) => ({
|
||||
schemaVersion: r.schemaVersion,
|
||||
script: r.migrationScript,
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if two schema versions are compatible.
|
||||
* Returns { compatible, needsAdapt, scripts } where:
|
||||
* - compatible: true if migration path exists (or same version)
|
||||
* - needsAdapt: true if migration scripts need to run
|
||||
* - scripts: array of SQL migration scripts to run sequentially
|
||||
*/
|
||||
export function checkCompatibility(importedVersion, currentVersion) {
|
||||
if (importedVersion === currentVersion) {
|
||||
return { compatible: true, needsAdapt: false, scripts: [] }
|
||||
}
|
||||
|
||||
// Determine direction: upgrading imported data to current schema
|
||||
const ascending = importedVersion < currentVersion
|
||||
const fromVersion = Math.min(importedVersion, currentVersion)
|
||||
const toVersion = Math.max(importedVersion, currentVersion)
|
||||
|
||||
// Collect releases between fromVersion (exclusive) and toVersion (inclusive)
|
||||
const releasesInRange = RELEASES.filter(
|
||||
(r) => r.schemaVersion > fromVersion && r.schemaVersion <= toVersion,
|
||||
).sort((a, b) => a.schemaVersion - b.schemaVersion)
|
||||
|
||||
const hasBreak = releasesInRange.some((r) => r.dbBreak)
|
||||
|
||||
if (!hasBreak) {
|
||||
// No breaking changes in the range — compatible without adaptation
|
||||
return { compatible: true, needsAdapt: false, scripts: [] }
|
||||
}
|
||||
|
||||
// There are breaking changes — collect migration scripts
|
||||
const scriptsNeeded = ascending
|
||||
? releasesInRange.filter((r) => r.dbBreak)
|
||||
: releasesInRange.filter((r) => r.dbBreak).reverse()
|
||||
|
||||
const missingScript = scriptsNeeded.some((r) => !r.migrationScript)
|
||||
if (missingScript) {
|
||||
return { compatible: false, needsAdapt: true, scripts: [] }
|
||||
}
|
||||
|
||||
return {
|
||||
compatible: true,
|
||||
needsAdapt: true,
|
||||
scripts: scriptsNeeded.map((r) => r.migrationScript),
|
||||
}
|
||||
}
|
||||
122
src/db/repositories/collectionRepository.js
Normal file
122
src/db/repositories/collectionRepository.js
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
import { db } from '../database.js'
|
||||
import { escapeLike } from '../../utils/escapeLike.js'
|
||||
|
||||
export const collectionRepository = {
|
||||
async create(collection) {
|
||||
const id = crypto.randomUUID()
|
||||
await db.run('INSERT INTO collection (id, label, created_by) VALUES (?, ?, ?)', [
|
||||
id,
|
||||
collection.label,
|
||||
collection.created_by,
|
||||
])
|
||||
return id
|
||||
},
|
||||
|
||||
async getById(id) {
|
||||
const row = await db.selectOne('SELECT * FROM collection WHERE id = ?', [id])
|
||||
if (!row) return null
|
||||
row.sessions = await this.getSessions(id)
|
||||
return row
|
||||
},
|
||||
|
||||
async getAll() {
|
||||
const collections = await db.selectAll('SELECT * FROM collection ORDER BY label')
|
||||
for (const c of collections) {
|
||||
const row = await db.selectOne(
|
||||
'SELECT COUNT(*) AS sessionCount FROM collection_session WHERE collection_id = ?',
|
||||
[c.id],
|
||||
)
|
||||
c.sessionCount = row ? row.sessionCount : 0
|
||||
}
|
||||
return collections
|
||||
},
|
||||
|
||||
async getAllWithSessions() {
|
||||
const collections = await db.selectAll('SELECT * FROM collection ORDER BY label')
|
||||
for (const c of collections) {
|
||||
c.sessions = await this.getSessions(c.id)
|
||||
const row = await db.selectOne(
|
||||
'SELECT COUNT(*) AS sessionCount FROM collection_session WHERE collection_id = ?',
|
||||
[c.id],
|
||||
)
|
||||
c.sessionCount = row ? row.sessionCount : 0
|
||||
}
|
||||
return collections
|
||||
},
|
||||
|
||||
async searchWithSessions(query) {
|
||||
const collections = await this.search(query)
|
||||
for (const c of collections) {
|
||||
c.sessions = await this.getSessions(c.id)
|
||||
const row = await db.selectOne(
|
||||
'SELECT COUNT(*) AS sessionCount FROM collection_session WHERE collection_id = ?',
|
||||
[c.id],
|
||||
)
|
||||
c.sessionCount = row ? row.sessionCount : 0
|
||||
}
|
||||
return collections
|
||||
},
|
||||
|
||||
async update(id, data) {
|
||||
return db.run('UPDATE collection SET label = ? WHERE id = ?', [data.label, id])
|
||||
},
|
||||
|
||||
async delete(id) {
|
||||
return db.run('DELETE FROM collection WHERE id = ?', [id])
|
||||
},
|
||||
|
||||
async createWithId(id, collection) {
|
||||
await db.run('INSERT INTO collection (id, label, created_by) VALUES (?, ?, ?)', [
|
||||
id,
|
||||
collection.label,
|
||||
collection.created_by,
|
||||
])
|
||||
return id
|
||||
},
|
||||
|
||||
async replaceAll(id, collection) {
|
||||
await db.run(
|
||||
`UPDATE collection SET label = ?, created_by = ?
|
||||
WHERE id = ?`,
|
||||
[collection.label, collection.created_by, id],
|
||||
)
|
||||
},
|
||||
|
||||
async search(query) {
|
||||
return db.selectAll("SELECT * FROM collection WHERE label LIKE ? ESCAPE '\\' ORDER BY label", [
|
||||
`%${escapeLike(query)}%`,
|
||||
])
|
||||
},
|
||||
|
||||
async getSessions(collectionId) {
|
||||
return db.selectAll(
|
||||
`SELECT cs.position, cs.session_id, s.title
|
||||
FROM collection_session cs
|
||||
JOIN session s ON s.id = cs.session_id
|
||||
WHERE cs.collection_id = ?
|
||||
ORDER BY cs.position`,
|
||||
[collectionId],
|
||||
)
|
||||
},
|
||||
|
||||
async getRawSessions(collectionId) {
|
||||
return db.selectAll(
|
||||
`SELECT cs.position, cs.session_id
|
||||
FROM collection_session cs
|
||||
WHERE cs.collection_id = ?
|
||||
ORDER BY cs.position`,
|
||||
[collectionId],
|
||||
)
|
||||
},
|
||||
|
||||
async setSessions(collectionId, sessionIds) {
|
||||
const statements = [
|
||||
{ sql: 'DELETE FROM collection_session WHERE collection_id = ?', params: [collectionId] },
|
||||
...sessionIds.map((sessionId, i) => ({
|
||||
sql: 'INSERT INTO collection_session (collection_id, session_id, position) VALUES (?, ?, ?)',
|
||||
params: [collectionId, sessionId, i],
|
||||
})),
|
||||
]
|
||||
await db.batch(statements)
|
||||
},
|
||||
}
|
||||
151
src/db/repositories/comboRepository.js
Normal file
151
src/db/repositories/comboRepository.js
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
import { db } from '../database.js'
|
||||
import { escapeLike } from '../../utils/escapeLike.js'
|
||||
|
||||
function deserialize(row) {
|
||||
return {
|
||||
...row,
|
||||
timer_config: JSON.parse(row.timer_config || '{}'),
|
||||
}
|
||||
}
|
||||
|
||||
export const comboRepository = {
|
||||
async create(combo) {
|
||||
const id = crypto.randomUUID()
|
||||
await db.run(
|
||||
`INSERT INTO combo (id, title, description, type, timer_config, created_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
id,
|
||||
combo.title,
|
||||
combo.description || '',
|
||||
combo.type,
|
||||
JSON.stringify(combo.timer_config || {}),
|
||||
combo.created_by,
|
||||
],
|
||||
)
|
||||
return id
|
||||
},
|
||||
|
||||
async getById(id) {
|
||||
const row = await db.selectOne('SELECT * FROM combo WHERE id = ?', [id])
|
||||
if (!row) return null
|
||||
const combo = deserialize(row)
|
||||
combo.exercises = await this.getExercises(id)
|
||||
return combo
|
||||
},
|
||||
|
||||
async getAll() {
|
||||
const rows = await db.selectAll('SELECT * FROM combo ORDER BY title')
|
||||
return rows.map(deserialize)
|
||||
},
|
||||
|
||||
async getAllWithExercises() {
|
||||
const combos = await this.getAll()
|
||||
for (const combo of combos) {
|
||||
combo.exercises = await this.getExercises(combo.id)
|
||||
}
|
||||
return combos
|
||||
},
|
||||
|
||||
async searchWithExercises(query) {
|
||||
const combos = await this.search(query)
|
||||
for (const combo of combos) {
|
||||
combo.exercises = await this.getExercises(combo.id)
|
||||
}
|
||||
return combos
|
||||
},
|
||||
|
||||
async update(id, data) {
|
||||
return db.run(
|
||||
`UPDATE combo SET title = ?, description = ?, type = ?, timer_config = ?
|
||||
WHERE id = ?`,
|
||||
[data.title, data.description || '', data.type, JSON.stringify(data.timer_config || {}), id],
|
||||
)
|
||||
},
|
||||
|
||||
async delete(id) {
|
||||
return db.run('DELETE FROM combo WHERE id = ?', [id])
|
||||
},
|
||||
|
||||
async createWithId(id, combo) {
|
||||
await db.run(
|
||||
`INSERT INTO combo (id, title, description, type, timer_config, created_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
id,
|
||||
combo.title,
|
||||
combo.description || '',
|
||||
combo.type,
|
||||
JSON.stringify(combo.timer_config || {}),
|
||||
combo.created_by,
|
||||
],
|
||||
)
|
||||
return id
|
||||
},
|
||||
|
||||
async replaceAll(id, combo) {
|
||||
await db.run(
|
||||
`UPDATE combo SET title = ?, description = ?, type = ?, timer_config = ?, created_by = ?
|
||||
WHERE id = ?`,
|
||||
[
|
||||
combo.title,
|
||||
combo.description || '',
|
||||
combo.type,
|
||||
JSON.stringify(combo.timer_config || {}),
|
||||
combo.created_by,
|
||||
id,
|
||||
],
|
||||
)
|
||||
},
|
||||
|
||||
async search(query) {
|
||||
const rows = await db.selectAll(
|
||||
"SELECT * FROM combo WHERE title LIKE ? ESCAPE '\\' ORDER BY title",
|
||||
[`%${escapeLike(query)}%`],
|
||||
)
|
||||
return rows.map(deserialize)
|
||||
},
|
||||
|
||||
async getExercises(comboId) {
|
||||
const rows = await db.selectAll(
|
||||
`SELECT ce.position, ce.exercise_id, ce.default_reps, ce.default_weight,
|
||||
e.title, e.asymmetric, e.alternate
|
||||
FROM combo_exercise ce
|
||||
JOIN exercise e ON e.id = ce.exercise_id
|
||||
WHERE ce.combo_id = ?
|
||||
ORDER BY ce.position`,
|
||||
[comboId],
|
||||
)
|
||||
return rows.map((r) => ({
|
||||
...r,
|
||||
asymmetric: Boolean(r.asymmetric),
|
||||
alternate: Boolean(r.alternate),
|
||||
}))
|
||||
},
|
||||
|
||||
async getRawExercises(comboId) {
|
||||
return db.selectAll(
|
||||
`SELECT ce.position, ce.exercise_id, ce.default_reps, ce.default_weight
|
||||
FROM combo_exercise ce
|
||||
WHERE ce.combo_id = ?
|
||||
ORDER BY ce.position`,
|
||||
[comboId],
|
||||
)
|
||||
},
|
||||
|
||||
async setExercises(comboId, exercises) {
|
||||
const statements = [
|
||||
{ sql: 'DELETE FROM combo_exercise WHERE combo_id = ?', params: [comboId] },
|
||||
...exercises.map((exercise, i) => {
|
||||
const exerciseId = typeof exercise === 'string' ? exercise : exercise.exerciseId
|
||||
const defaultReps = typeof exercise === 'object' ? exercise.defaultReps || 1 : 1
|
||||
const defaultWeight = typeof exercise === 'object' ? exercise.defaultWeight || 0.0 : 0.0
|
||||
return {
|
||||
sql: 'INSERT INTO combo_exercise (combo_id, exercise_id, position, default_reps, default_weight) VALUES (?, ?, ?, ?, ?)',
|
||||
params: [comboId, exerciseId, i, defaultReps, defaultWeight],
|
||||
}
|
||||
}),
|
||||
]
|
||||
await db.batch(statements)
|
||||
},
|
||||
}
|
||||
304
src/db/repositories/executionLogRepository.js
Normal file
304
src/db/repositories/executionLogRepository.js
Normal file
|
|
@ -0,0 +1,304 @@
|
|||
import { db } from '../database.js'
|
||||
import { escapeLike } from '../../utils/escapeLike.js'
|
||||
|
||||
export const executionLogRepository = {
|
||||
async create(log) {
|
||||
const id = crypto.randomUUID()
|
||||
await db.run(
|
||||
`INSERT INTO execution_log (id, session_id, started_at, finished_at, status)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
[id, log.session_id, log.started_at, log.finished_at || null, log.status || 'completed'],
|
||||
)
|
||||
return id
|
||||
},
|
||||
|
||||
async getById(id) {
|
||||
const row = await db.selectOne('SELECT * FROM execution_log WHERE id = ?', [id])
|
||||
if (!row) return null
|
||||
row.items = await this.getItems(id)
|
||||
return row
|
||||
},
|
||||
|
||||
async getAll() {
|
||||
return db.selectAll('SELECT * FROM execution_log ORDER BY started_at DESC')
|
||||
},
|
||||
|
||||
async getBySessionId(sessionId) {
|
||||
return db.selectAll(
|
||||
'SELECT * FROM execution_log WHERE session_id = ? ORDER BY started_at DESC',
|
||||
[sessionId],
|
||||
)
|
||||
},
|
||||
|
||||
async update(id, data) {
|
||||
if (data.status !== undefined) {
|
||||
return db.run('UPDATE execution_log SET finished_at = ?, status = ? WHERE id = ?', [
|
||||
data.finished_at,
|
||||
data.status,
|
||||
id,
|
||||
])
|
||||
}
|
||||
return db.run('UPDATE execution_log SET finished_at = ? WHERE id = ?', [data.finished_at, id])
|
||||
},
|
||||
|
||||
async delete(id) {
|
||||
return db.run('DELETE FROM execution_log WHERE id = ?', [id])
|
||||
},
|
||||
|
||||
async search(query) {
|
||||
return db.selectAll(
|
||||
`SELECT el.* FROM execution_log el
|
||||
JOIN session s ON s.id = el.session_id
|
||||
WHERE s.title LIKE ? ESCAPE '\\'
|
||||
ORDER BY el.started_at DESC`,
|
||||
[`%${escapeLike(query)}%`],
|
||||
)
|
||||
},
|
||||
|
||||
async getItems(logId) {
|
||||
return db.selectAll(`SELECT * FROM execution_log_item WHERE log_id = ? ORDER BY timestamp`, [
|
||||
logId,
|
||||
])
|
||||
},
|
||||
|
||||
async addItem(item) {
|
||||
const id = crypto.randomUUID()
|
||||
await db.run(
|
||||
`INSERT INTO execution_log_item (id, log_id, exercise_id, combo_id, round_number, reps_done, weight_used, side, completed, timestamp)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
id,
|
||||
item.log_id,
|
||||
item.exercise_id,
|
||||
item.combo_id || null,
|
||||
item.round_number || null,
|
||||
item.reps_done || 0,
|
||||
item.weight_used || 0,
|
||||
item.side || null,
|
||||
item.completed ? 1 : 0,
|
||||
item.timestamp || new Date().toISOString(),
|
||||
],
|
||||
)
|
||||
return id
|
||||
},
|
||||
|
||||
async createWithId(id, log) {
|
||||
await db.run(
|
||||
`INSERT INTO execution_log (id, session_id, started_at, finished_at, status)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
[id, log.session_id, log.started_at, log.finished_at || null, log.status || 'completed'],
|
||||
)
|
||||
return id
|
||||
},
|
||||
|
||||
async addItems(logId, items) {
|
||||
if (!items.length) return
|
||||
const statements = items.map((item) => ({
|
||||
sql: `INSERT INTO execution_log_item (id, log_id, exercise_id, combo_id, round_number, reps_done, weight_used, side, completed, timestamp)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
params: [
|
||||
item.id,
|
||||
logId,
|
||||
item.exercise_id,
|
||||
item.combo_id || null,
|
||||
item.round_number || null,
|
||||
item.reps_done || 0,
|
||||
item.weight_used || 0,
|
||||
item.side || null,
|
||||
item.completed ? 1 : 0,
|
||||
item.timestamp || new Date().toISOString(),
|
||||
],
|
||||
}))
|
||||
await db.batch(statements)
|
||||
},
|
||||
|
||||
async getLastItemsBySession(sessionId) {
|
||||
const log = await db.selectOne(
|
||||
'SELECT id FROM execution_log WHERE session_id = ? ORDER BY started_at DESC LIMIT 1',
|
||||
[sessionId],
|
||||
)
|
||||
if (!log) return {}
|
||||
const items = await db.selectAll(
|
||||
'SELECT exercise_id, reps_done, weight_used, side FROM execution_log_item WHERE log_id = ? ORDER BY timestamp',
|
||||
[log.id],
|
||||
)
|
||||
const map = {}
|
||||
for (const item of items) {
|
||||
map[item.exercise_id] = {
|
||||
reps_done: item.reps_done,
|
||||
weight_used: item.weight_used,
|
||||
side: item.side,
|
||||
}
|
||||
}
|
||||
return map
|
||||
},
|
||||
|
||||
async getTotalSessionsSince(since = null) {
|
||||
if (!since) {
|
||||
const row = await db.selectOne(
|
||||
"SELECT COUNT(*) AS count FROM execution_log WHERE status = 'completed'",
|
||||
)
|
||||
return row ? row.count : 0
|
||||
}
|
||||
return this.getCountSince(since)
|
||||
},
|
||||
|
||||
async getTotalExercisesDone(since = null) {
|
||||
let sql = `SELECT COUNT(*) AS count FROM execution_log_item eli
|
||||
JOIN execution_log el ON el.id = eli.log_id
|
||||
WHERE eli.completed = 1`
|
||||
const params = []
|
||||
if (since) {
|
||||
sql += ' AND el.started_at >= ?'
|
||||
params.push(since)
|
||||
}
|
||||
const row = await db.selectOne(sql, params)
|
||||
return row ? row.count : 0
|
||||
},
|
||||
|
||||
async getDistinctSessionDays() {
|
||||
return db.selectAll(
|
||||
`SELECT DATE(started_at) AS day FROM execution_log GROUP BY DATE(started_at) ORDER BY day DESC`,
|
||||
)
|
||||
},
|
||||
|
||||
async getFrequencyData(since = null, groupBy = 'week') {
|
||||
const fmt = groupBy === 'month' ? '%Y-%m' : '%Y-%W'
|
||||
let sql = `SELECT strftime('${fmt}', started_at) AS period, COUNT(*) AS count FROM execution_log WHERE status = 'completed'`
|
||||
const params = []
|
||||
if (since) {
|
||||
sql += ' AND started_at >= ?'
|
||||
params.push(since)
|
||||
}
|
||||
sql += ' GROUP BY period ORDER BY period'
|
||||
return db.selectAll(sql, params)
|
||||
},
|
||||
|
||||
async getVolumeData(since = null) {
|
||||
let sql = `SELECT el.id, DATE(el.started_at) AS day, el.started_at,
|
||||
SUM(eli.reps_done * eli.weight_used) AS volume
|
||||
FROM execution_log el
|
||||
JOIN execution_log_item eli ON eli.log_id = el.id
|
||||
WHERE eli.completed = 1`
|
||||
const params = []
|
||||
if (since) {
|
||||
sql += ' AND el.started_at >= ?'
|
||||
params.push(since)
|
||||
}
|
||||
sql += ' GROUP BY el.id ORDER BY el.started_at'
|
||||
return db.selectAll(sql, params)
|
||||
},
|
||||
|
||||
async getLoggedExercises() {
|
||||
return db.selectAll(
|
||||
`SELECT DISTINCT e.id, e.title
|
||||
FROM exercise e
|
||||
JOIN execution_log_item eli ON eli.exercise_id = e.id
|
||||
ORDER BY e.title`,
|
||||
)
|
||||
},
|
||||
|
||||
async getProgressionData(exerciseId, since = null) {
|
||||
let sql = `SELECT el.started_at,
|
||||
SUM(eli.reps_done) AS total_reps,
|
||||
MAX(eli.weight_used) AS max_weight
|
||||
FROM execution_log el
|
||||
JOIN execution_log_item eli ON eli.log_id = el.id
|
||||
WHERE eli.exercise_id = ? AND eli.completed = 1`
|
||||
const params = [exerciseId]
|
||||
if (since) {
|
||||
sql += ' AND el.started_at >= ?'
|
||||
params.push(since)
|
||||
}
|
||||
sql += ' GROUP BY el.id ORDER BY el.started_at'
|
||||
return db.selectAll(sql, params)
|
||||
},
|
||||
|
||||
async getRecent(limit = 3) {
|
||||
return db.selectAll(
|
||||
`SELECT el.*, s.title AS session_title
|
||||
FROM execution_log el
|
||||
JOIN session s ON s.id = el.session_id
|
||||
ORDER BY el.started_at DESC
|
||||
LIMIT ?`,
|
||||
[limit],
|
||||
)
|
||||
},
|
||||
|
||||
async getItemsWithTitles(logId) {
|
||||
return db.selectAll(
|
||||
`SELECT eli.*, e.title AS exercise_title,
|
||||
c.title AS combo_title, c.type AS combo_type
|
||||
FROM execution_log_item eli
|
||||
JOIN exercise e ON e.id = eli.exercise_id
|
||||
LEFT JOIN combo c ON c.id = eli.combo_id
|
||||
WHERE eli.log_id = ?
|
||||
ORDER BY eli.timestamp`,
|
||||
[logId],
|
||||
)
|
||||
},
|
||||
|
||||
async getRecentWithItems(limit = 4) {
|
||||
const logs = await db.selectAll(
|
||||
`SELECT el.*, s.title AS session_title
|
||||
FROM execution_log el
|
||||
JOIN session s ON s.id = el.session_id
|
||||
ORDER BY el.started_at DESC
|
||||
LIMIT ?`,
|
||||
[limit],
|
||||
)
|
||||
for (const log of logs) {
|
||||
log.items = await this.getItemsWithTitles(log.id)
|
||||
}
|
||||
return logs
|
||||
},
|
||||
|
||||
async getAllWithItems() {
|
||||
const logs = await db.selectAll(
|
||||
`SELECT el.*, s.title AS session_title
|
||||
FROM execution_log el
|
||||
JOIN session s ON s.id = el.session_id
|
||||
ORDER BY el.started_at DESC`,
|
||||
)
|
||||
for (const log of logs) {
|
||||
log.items = await this.getItemsWithTitles(log.id)
|
||||
}
|
||||
return logs
|
||||
},
|
||||
|
||||
async getCountSince(isoDate) {
|
||||
const row = await db.selectOne(
|
||||
`SELECT COUNT(*) AS count FROM execution_log WHERE status = 'completed' AND started_at >= ?`,
|
||||
[isoDate],
|
||||
)
|
||||
return row ? row.count : 0
|
||||
},
|
||||
|
||||
async countItemsByExercise(exerciseId) {
|
||||
const row = await db.selectOne(
|
||||
`SELECT COUNT(*) AS count FROM execution_log_item WHERE exercise_id = ?`,
|
||||
[exerciseId],
|
||||
)
|
||||
return row ? row.count : 0
|
||||
},
|
||||
|
||||
async countLogsBySession(sessionId) {
|
||||
const row = await db.selectOne(
|
||||
`SELECT COUNT(*) AS count FROM execution_log WHERE session_id = ?`,
|
||||
[sessionId],
|
||||
)
|
||||
return row ? row.count : 0
|
||||
},
|
||||
|
||||
async getCountByDateRange(from, to) {
|
||||
const row = await db.selectOne(
|
||||
`SELECT COUNT(*) AS count FROM execution_log WHERE DATE(started_at) BETWEEN ? AND ?`,
|
||||
[from, to],
|
||||
)
|
||||
return row ? row.count : 0
|
||||
},
|
||||
|
||||
async deleteByDateRange(from, to) {
|
||||
return db.run(`DELETE FROM execution_log WHERE DATE(started_at) BETWEEN ? AND ?`, [from, to])
|
||||
},
|
||||
}
|
||||
126
src/db/repositories/exerciseRepository.js
Normal file
126
src/db/repositories/exerciseRepository.js
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
import { db } from '../database.js'
|
||||
import { escapeLike } from '../../utils/escapeLike.js'
|
||||
|
||||
function deserialize(row) {
|
||||
return {
|
||||
...row,
|
||||
asymmetric: Boolean(row.asymmetric),
|
||||
alternate: Boolean(row.alternate),
|
||||
image_urls: JSON.parse(row.image_urls || '[]'),
|
||||
video_urls: JSON.parse(row.video_urls || '[]'),
|
||||
}
|
||||
}
|
||||
|
||||
export const exerciseRepository = {
|
||||
async create(exercise) {
|
||||
const id = crypto.randomUUID()
|
||||
await db.run(
|
||||
`INSERT INTO exercise (id, title, description, asymmetric, alternate,
|
||||
image_urls, video_urls, default_reps, default_weight, created_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
id,
|
||||
exercise.title,
|
||||
exercise.description || '',
|
||||
exercise.asymmetric ? 1 : 0,
|
||||
exercise.alternate ? 1 : 0,
|
||||
JSON.stringify(exercise.image_urls || []),
|
||||
JSON.stringify(exercise.video_urls || []),
|
||||
exercise.default_reps || 0,
|
||||
exercise.default_weight || 0,
|
||||
exercise.created_by,
|
||||
],
|
||||
)
|
||||
return id
|
||||
},
|
||||
|
||||
async getById(id) {
|
||||
const row = await db.selectOne('SELECT * FROM exercise WHERE id = ?', [id])
|
||||
return row ? deserialize(row) : null
|
||||
},
|
||||
|
||||
async getAll() {
|
||||
const rows = await db.selectAll('SELECT * FROM exercise ORDER BY title')
|
||||
return rows.map(deserialize)
|
||||
},
|
||||
|
||||
async update(id, data) {
|
||||
return db.run(
|
||||
`UPDATE exercise SET title = ?, description = ?, asymmetric = ?,
|
||||
alternate = ?, image_urls = ?, video_urls = ?,
|
||||
default_reps = ?, default_weight = ?
|
||||
WHERE id = ?`,
|
||||
[
|
||||
data.title,
|
||||
data.description || '',
|
||||
data.asymmetric ? 1 : 0,
|
||||
data.alternate ? 1 : 0,
|
||||
JSON.stringify(data.image_urls || []),
|
||||
JSON.stringify(data.video_urls || []),
|
||||
data.default_reps || 0,
|
||||
data.default_weight || 0,
|
||||
id,
|
||||
],
|
||||
)
|
||||
},
|
||||
|
||||
/**
|
||||
* Insert an exercise with a specific ID (for import scenarios).
|
||||
*/
|
||||
async createWithId(id, exercise) {
|
||||
await db.run(
|
||||
`INSERT INTO exercise (id, title, description, asymmetric, alternate,
|
||||
image_urls, video_urls, default_reps, default_weight, created_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
id,
|
||||
exercise.title,
|
||||
exercise.description || '',
|
||||
exercise.asymmetric ? 1 : 0,
|
||||
exercise.alternate ? 1 : 0,
|
||||
JSON.stringify(exercise.image_urls || []),
|
||||
JSON.stringify(exercise.video_urls || []),
|
||||
exercise.default_reps || 0,
|
||||
exercise.default_weight || 0,
|
||||
exercise.created_by,
|
||||
],
|
||||
)
|
||||
return id
|
||||
},
|
||||
|
||||
/**
|
||||
* Replace (update) all fields including created_by (for import overwrite).
|
||||
*/
|
||||
async replaceAll(id, data) {
|
||||
return db.run(
|
||||
`UPDATE exercise SET title = ?, description = ?, asymmetric = ?,
|
||||
alternate = ?, image_urls = ?, video_urls = ?,
|
||||
default_reps = ?, default_weight = ?, created_by = ?
|
||||
WHERE id = ?`,
|
||||
[
|
||||
data.title,
|
||||
data.description || '',
|
||||
data.asymmetric ? 1 : 0,
|
||||
data.alternate ? 1 : 0,
|
||||
JSON.stringify(data.image_urls || []),
|
||||
JSON.stringify(data.video_urls || []),
|
||||
data.default_reps || 0,
|
||||
data.default_weight || 0,
|
||||
data.created_by,
|
||||
id,
|
||||
],
|
||||
)
|
||||
},
|
||||
|
||||
async delete(id) {
|
||||
return db.run('DELETE FROM exercise WHERE id = ?', [id])
|
||||
},
|
||||
|
||||
async search(query) {
|
||||
const rows = await db.selectAll(
|
||||
"SELECT * FROM exercise WHERE title LIKE ? ESCAPE '\\' ORDER BY title",
|
||||
[`%${escapeLike(query)}%`],
|
||||
)
|
||||
return rows.map(deserialize)
|
||||
},
|
||||
}
|
||||
124
src/db/repositories/sessionRepository.js
Normal file
124
src/db/repositories/sessionRepository.js
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
import { db } from '../database.js'
|
||||
import { escapeLike } from '../../utils/escapeLike.js'
|
||||
|
||||
export const sessionRepository = {
|
||||
async create(session) {
|
||||
const id = crypto.randomUUID()
|
||||
await db.run(
|
||||
`INSERT INTO session (id, title, description, created_by)
|
||||
VALUES (?, ?, ?, ?)`,
|
||||
[id, session.title, session.description || '', session.created_by],
|
||||
)
|
||||
return id
|
||||
},
|
||||
|
||||
async getById(id) {
|
||||
const row = await db.selectOne('SELECT * FROM session WHERE id = ?', [id])
|
||||
if (!row) return null
|
||||
row.items = await this.getItems(id)
|
||||
return row
|
||||
},
|
||||
|
||||
async getAll() {
|
||||
return db.selectAll('SELECT * FROM session ORDER BY title')
|
||||
},
|
||||
|
||||
async getAllWithItems() {
|
||||
const sessions = await this.getAll()
|
||||
for (const session of sessions) {
|
||||
session.items = await this.getItems(session.id)
|
||||
}
|
||||
return sessions
|
||||
},
|
||||
|
||||
async searchWithItems(query) {
|
||||
const sessions = await this.search(query)
|
||||
for (const session of sessions) {
|
||||
session.items = await this.getItems(session.id)
|
||||
}
|
||||
return sessions
|
||||
},
|
||||
|
||||
async update(id, data) {
|
||||
return db.run('UPDATE session SET title = ?, description = ? WHERE id = ?', [
|
||||
data.title,
|
||||
data.description || '',
|
||||
id,
|
||||
])
|
||||
},
|
||||
|
||||
async delete(id) {
|
||||
return db.run('DELETE FROM session WHERE id = ?', [id])
|
||||
},
|
||||
|
||||
async createWithId(id, session) {
|
||||
await db.run(
|
||||
`INSERT INTO session (id, title, description, created_by)
|
||||
VALUES (?, ?, ?, ?)`,
|
||||
[id, session.title, session.description || '', session.created_by],
|
||||
)
|
||||
return id
|
||||
},
|
||||
|
||||
async replaceAll(id, session) {
|
||||
await db.run(
|
||||
`UPDATE session SET title = ?, description = ?, created_by = ?
|
||||
WHERE id = ?`,
|
||||
[session.title, session.description || '', session.created_by, id],
|
||||
)
|
||||
},
|
||||
|
||||
async search(query) {
|
||||
return db.selectAll("SELECT * FROM session WHERE title LIKE ? ESCAPE '\\' ORDER BY title", [
|
||||
`%${escapeLike(query)}%`,
|
||||
])
|
||||
},
|
||||
|
||||
async getItems(sessionId) {
|
||||
const exerciseRows = await db.selectAll(
|
||||
`SELECT si.position, si.item_id, si.item_type, si.repetitions, si.weight, e.title
|
||||
FROM session_item si
|
||||
JOIN exercise e ON e.id = si.item_id
|
||||
WHERE si.session_id = ? AND si.item_type = 'exercise'
|
||||
ORDER BY si.position`,
|
||||
[sessionId],
|
||||
)
|
||||
const comboRows = await db.selectAll(
|
||||
`SELECT si.position, si.item_id, si.item_type, si.repetitions, si.weight, c.title, c.type AS combo_type
|
||||
FROM session_item si
|
||||
JOIN combo c ON c.id = si.item_id
|
||||
WHERE si.session_id = ? AND si.item_type = 'combo'
|
||||
ORDER BY si.position`,
|
||||
[sessionId],
|
||||
)
|
||||
return [...exerciseRows, ...comboRows].sort((a, b) => a.position - b.position)
|
||||
},
|
||||
|
||||
async getRawItems(sessionId) {
|
||||
return db.selectAll(
|
||||
`SELECT si.position, si.item_id, si.item_type, si.repetitions, si.weight
|
||||
FROM session_item si
|
||||
WHERE si.session_id = ?
|
||||
ORDER BY si.position`,
|
||||
[sessionId],
|
||||
)
|
||||
},
|
||||
|
||||
async setItems(sessionId, items) {
|
||||
const statements = [
|
||||
{ sql: 'DELETE FROM session_item WHERE session_id = ?', params: [sessionId] },
|
||||
...items.map((item, i) => ({
|
||||
sql: 'INSERT INTO session_item (session_id, item_id, item_type, position, repetitions, weight) VALUES (?, ?, ?, ?, ?, ?)',
|
||||
params: [
|
||||
sessionId,
|
||||
item.item_id,
|
||||
item.item_type,
|
||||
i,
|
||||
item.repetitions || 1,
|
||||
item.weight || 0.0,
|
||||
],
|
||||
})),
|
||||
]
|
||||
await db.batch(statements)
|
||||
},
|
||||
}
|
||||
21
src/db/repositories/settingsRepository.js
Normal file
21
src/db/repositories/settingsRepository.js
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import { db } from '../database.js'
|
||||
|
||||
export const settingsRepository = {
|
||||
async get(key) {
|
||||
const row = await db.selectOne('SELECT value FROM settings WHERE key = ?', [key])
|
||||
return row ? row.value : null
|
||||
},
|
||||
|
||||
async set(key, value) {
|
||||
return db.run('INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)', [key, value])
|
||||
},
|
||||
|
||||
async getAll() {
|
||||
const rows = await db.selectAll('SELECT key, value FROM settings')
|
||||
const map = {}
|
||||
for (const row of rows) {
|
||||
map[row.key] = row.value
|
||||
}
|
||||
return map
|
||||
},
|
||||
}
|
||||
69
src/db/repositories/suffixRepository.js
Normal file
69
src/db/repositories/suffixRepository.js
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
import { db } from '../database.js'
|
||||
|
||||
export const suffixRepository = {
|
||||
async create(entry) {
|
||||
await db.run('INSERT INTO suffix (user_uuid, user_name, suffix) VALUES (?, ?, ?)', [
|
||||
entry.user_uuid,
|
||||
entry.user_name,
|
||||
entry.suffix,
|
||||
])
|
||||
},
|
||||
|
||||
async getByUuid(userUuid) {
|
||||
return db.selectOne('SELECT * FROM suffix WHERE user_uuid = ?', [userUuid])
|
||||
},
|
||||
|
||||
async getBySuffix(suffix) {
|
||||
return db.selectOne('SELECT * FROM suffix WHERE suffix = ?', [suffix])
|
||||
},
|
||||
|
||||
async getAll() {
|
||||
return db.selectAll('SELECT * FROM suffix ORDER BY user_name')
|
||||
},
|
||||
|
||||
async update(userUuid, data) {
|
||||
return db.run('UPDATE suffix SET user_name = ?, suffix = ? WHERE user_uuid = ?', [
|
||||
data.user_name,
|
||||
data.suffix,
|
||||
userUuid,
|
||||
])
|
||||
},
|
||||
|
||||
async delete(userUuid) {
|
||||
return db.run('DELETE FROM suffix WHERE user_uuid = ?', [userUuid])
|
||||
},
|
||||
|
||||
/**
|
||||
* Renames a suffix across all entity title/label columns.
|
||||
* Updates suffix table entry + all titles containing [OLD_SUFFIX] to [NEW_SUFFIX].
|
||||
*/
|
||||
async renameSuffix(userUuid, newSuffix) {
|
||||
const existing = await this.getByUuid(userUuid)
|
||||
if (!existing) throw new Error('Suffix entry not found')
|
||||
|
||||
const oldPattern = `[${existing.suffix}]`
|
||||
const newPattern = `[${newSuffix}]`
|
||||
|
||||
// Update suffix table
|
||||
await db.run('UPDATE suffix SET suffix = ? WHERE user_uuid = ?', [newSuffix, userUuid])
|
||||
|
||||
// Update titles across entity tables
|
||||
await db.run(
|
||||
"UPDATE exercise SET title = REPLACE(title, ?, ?) WHERE title LIKE '%' || ? || '%'",
|
||||
[oldPattern, newPattern, oldPattern],
|
||||
)
|
||||
await db.run("UPDATE combo SET title = REPLACE(title, ?, ?) WHERE title LIKE '%' || ? || '%'", [
|
||||
oldPattern,
|
||||
newPattern,
|
||||
oldPattern,
|
||||
])
|
||||
await db.run(
|
||||
"UPDATE session SET title = REPLACE(title, ?, ?) WHERE title LIKE '%' || ? || '%'",
|
||||
[oldPattern, newPattern, oldPattern],
|
||||
)
|
||||
await db.run(
|
||||
"UPDATE collection SET label = REPLACE(label, ?, ?) WHERE label LIKE '%' || ? || '%'",
|
||||
[oldPattern, newPattern, oldPattern],
|
||||
)
|
||||
},
|
||||
}
|
||||
109
src/db/schema.js
Normal file
109
src/db/schema.js
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
export const SCHEMA_VERSION = 1
|
||||
|
||||
export const SCHEMA_SQL = `
|
||||
CREATE TABLE IF NOT EXISTS schema_version (
|
||||
version INTEGER PRIMARY KEY,
|
||||
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS exercise (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL UNIQUE,
|
||||
description TEXT DEFAULT '',
|
||||
asymmetric INTEGER NOT NULL DEFAULT 0,
|
||||
alternate INTEGER NOT NULL DEFAULT 0,
|
||||
image_urls TEXT DEFAULT '[]',
|
||||
video_urls TEXT DEFAULT '[]',
|
||||
default_reps INTEGER NOT NULL DEFAULT 0,
|
||||
default_weight REAL NOT NULL DEFAULT 0.0,
|
||||
created_by TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS combo (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL UNIQUE,
|
||||
description TEXT DEFAULT '',
|
||||
type TEXT NOT NULL CHECK(type IN ('NONE', 'EMOM', 'AMRAP', 'TABATA')),
|
||||
timer_config TEXT DEFAULT '{}',
|
||||
created_by TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS combo_exercise (
|
||||
combo_id TEXT NOT NULL,
|
||||
exercise_id TEXT NOT NULL,
|
||||
position INTEGER NOT NULL,
|
||||
default_reps INTEGER NOT NULL DEFAULT 1,
|
||||
default_weight REAL NOT NULL DEFAULT 0.0,
|
||||
PRIMARY KEY (combo_id, position),
|
||||
FOREIGN KEY (combo_id) REFERENCES combo(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (exercise_id) REFERENCES exercise(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS session (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL UNIQUE,
|
||||
description TEXT DEFAULT '',
|
||||
created_by TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS session_item (
|
||||
session_id TEXT NOT NULL,
|
||||
item_id TEXT NOT NULL,
|
||||
item_type TEXT NOT NULL CHECK(item_type IN ('exercise', 'combo')),
|
||||
position INTEGER NOT NULL,
|
||||
repetitions INTEGER NOT NULL DEFAULT 1,
|
||||
weight REAL NOT NULL DEFAULT 0.0,
|
||||
PRIMARY KEY (session_id, position),
|
||||
FOREIGN KEY (session_id) REFERENCES session(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS collection (
|
||||
id TEXT PRIMARY KEY,
|
||||
label TEXT NOT NULL UNIQUE,
|
||||
created_by TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS collection_session (
|
||||
collection_id TEXT NOT NULL,
|
||||
session_id TEXT NOT NULL,
|
||||
position INTEGER NOT NULL,
|
||||
PRIMARY KEY (collection_id, position),
|
||||
FOREIGN KEY (collection_id) REFERENCES collection(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (session_id) REFERENCES session(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS execution_log (
|
||||
id TEXT PRIMARY KEY,
|
||||
session_id TEXT NOT NULL,
|
||||
started_at TEXT NOT NULL,
|
||||
finished_at TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'completed' CHECK(status IN ('completed','aborted')),
|
||||
FOREIGN KEY (session_id) REFERENCES session(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS execution_log_item (
|
||||
id TEXT PRIMARY KEY,
|
||||
log_id TEXT NOT NULL,
|
||||
exercise_id TEXT NOT NULL,
|
||||
combo_id TEXT DEFAULT NULL,
|
||||
round_number INTEGER DEFAULT NULL,
|
||||
reps_done INTEGER DEFAULT 0,
|
||||
weight_used REAL DEFAULT 0.0,
|
||||
side TEXT DEFAULT NULL,
|
||||
completed INTEGER NOT NULL DEFAULT 0,
|
||||
timestamp TEXT,
|
||||
FOREIGN KEY (log_id) REFERENCES execution_log(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (exercise_id) REFERENCES exercise(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS suffix (
|
||||
user_uuid TEXT PRIMARY KEY,
|
||||
user_name TEXT NOT NULL,
|
||||
suffix TEXT NOT NULL UNIQUE
|
||||
);
|
||||
`
|
||||
395
src/db/worker.js
Normal file
395
src/db/worker.js
Normal file
|
|
@ -0,0 +1,395 @@
|
|||
import sqlite3InitModule from '@sqlite.org/sqlite-wasm'
|
||||
import { REQUIRED_TABLES, checkCompatibility, getMigrationScripts } from './releases.js'
|
||||
import { SCHEMA_SQL, SCHEMA_VERSION } from './schema.js'
|
||||
|
||||
let db = null
|
||||
let sqlite3 = null
|
||||
|
||||
async function initDatabase(config) {
|
||||
const { dbName, wasmBaseUrl } = config
|
||||
|
||||
sqlite3 = await sqlite3InitModule({
|
||||
locateFile: (file) => new URL(file, wasmBaseUrl).href,
|
||||
})
|
||||
|
||||
const poolUtil = await sqlite3.installOpfsSAHPoolVfs({
|
||||
directory: `.${dbName}`,
|
||||
initialCapacity: 6,
|
||||
})
|
||||
db = new poolUtil.OpfsSAHPoolDb(`/${dbName}.db`)
|
||||
|
||||
db.exec('PRAGMA foreign_keys = ON')
|
||||
|
||||
return { vfs: 'opfs-sahpool', persistent: true }
|
||||
}
|
||||
|
||||
function _readDbVersion() {
|
||||
const tableRows = db.exec(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='schema_version'",
|
||||
{ returnValue: 'resultRows', rowMode: 'array' },
|
||||
)
|
||||
if (tableRows.length === 0) return null
|
||||
|
||||
const versionRows = db.exec('SELECT MAX(version) AS version FROM schema_version', {
|
||||
returnValue: 'resultRows',
|
||||
rowMode: 'object',
|
||||
})
|
||||
return versionRows.length > 0 ? versionRows[0].version : null
|
||||
}
|
||||
|
||||
function handleGetSchemaStatus() {
|
||||
const dbVersion = _readDbVersion()
|
||||
|
||||
if (dbVersion === null) {
|
||||
// Fresh DB: create the schema and stamp the current version
|
||||
db.exec(SCHEMA_SQL)
|
||||
db.exec('INSERT INTO schema_version (version) VALUES (?)', { bind: [SCHEMA_VERSION] })
|
||||
return { status: 'fresh', version: SCHEMA_VERSION }
|
||||
}
|
||||
|
||||
if (dbVersion === SCHEMA_VERSION) {
|
||||
return { status: 'up-to-date', version: dbVersion }
|
||||
}
|
||||
|
||||
if (dbVersion > SCHEMA_VERSION) {
|
||||
return { status: 'incompatible', dbVersion, appVersion: SCHEMA_VERSION }
|
||||
}
|
||||
|
||||
// Older DB — the app must obtain a user backup before calling runMigrations
|
||||
return { status: 'migration-pending', from: dbVersion, to: SCHEMA_VERSION }
|
||||
}
|
||||
|
||||
function handleRunMigrations() {
|
||||
const from = _readDbVersion()
|
||||
|
||||
if (from === null || from >= SCHEMA_VERSION) {
|
||||
return { status: 'up-to-date', version: from }
|
||||
}
|
||||
|
||||
let migrations
|
||||
try {
|
||||
migrations = getMigrationScripts(from, SCHEMA_VERSION)
|
||||
} catch (e) {
|
||||
return { status: 'migration-failed', from, to: SCHEMA_VERSION, message: e.message }
|
||||
}
|
||||
|
||||
for (const { schemaVersion, script } of migrations) {
|
||||
try {
|
||||
db.exec('BEGIN')
|
||||
if (script) db.exec(script)
|
||||
db.exec('INSERT INTO schema_version (version) VALUES (?)', { bind: [schemaVersion] })
|
||||
db.exec('COMMIT')
|
||||
} catch (e) {
|
||||
try {
|
||||
db.exec('ROLLBACK')
|
||||
} catch {
|
||||
// no open transaction left to roll back
|
||||
}
|
||||
return {
|
||||
status: 'migration-failed',
|
||||
from,
|
||||
to: SCHEMA_VERSION,
|
||||
failedAt: schemaVersion,
|
||||
message: e.message,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { status: 'migrated', from, to: SCHEMA_VERSION }
|
||||
}
|
||||
|
||||
function handleBatch(statements) {
|
||||
db.exec('BEGIN IMMEDIATE')
|
||||
try {
|
||||
for (const { sql, params } of statements) {
|
||||
db.exec(sql, { bind: params || undefined })
|
||||
}
|
||||
db.exec('COMMIT')
|
||||
return true
|
||||
} catch (e) {
|
||||
try {
|
||||
db.exec('ROLLBACK')
|
||||
} catch (_e) {
|
||||
// Ignore — the error we care about is `e`
|
||||
}
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
function handleExec(sql, params) {
|
||||
db.exec(sql, { bind: params || undefined })
|
||||
return true
|
||||
}
|
||||
|
||||
function handleRun(sql, params) {
|
||||
db.exec(sql, { bind: params || undefined })
|
||||
return { changes: sqlite3.capi.sqlite3_changes(db.pointer) }
|
||||
}
|
||||
|
||||
function handleSelectAll(sql, params) {
|
||||
return db.exec(sql, {
|
||||
bind: params || undefined,
|
||||
rowMode: 'object',
|
||||
returnValue: 'resultRows',
|
||||
})
|
||||
}
|
||||
|
||||
function handleSelectOne(sql, params) {
|
||||
const rows = db.exec(sql, {
|
||||
bind: params || undefined,
|
||||
rowMode: 'object',
|
||||
returnValue: 'resultRows',
|
||||
})
|
||||
return rows.length > 0 ? rows[0] : null
|
||||
}
|
||||
|
||||
function handleImportDb(bytes) {
|
||||
const uint8 = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes)
|
||||
|
||||
// 1. Try loading the file into an in-memory DB via sqlite3_deserialize
|
||||
let tempDb
|
||||
try {
|
||||
tempDb = new sqlite3.oo1.DB(':memory:')
|
||||
const n = uint8.byteLength
|
||||
const pBuf = sqlite3.wasm.alloc(n)
|
||||
sqlite3.wasm.heap8u().set(uint8, pBuf)
|
||||
const rc = sqlite3.capi.sqlite3_deserialize(
|
||||
tempDb.pointer,
|
||||
'main',
|
||||
pBuf,
|
||||
n,
|
||||
n,
|
||||
sqlite3.capi.SQLITE_DESERIALIZE_FREEONCLOSE | sqlite3.capi.SQLITE_DESERIALIZE_RESIZEABLE,
|
||||
)
|
||||
if (rc !== 0) {
|
||||
const msg = sqlite3.capi.sqlite3_errmsg(tempDb.pointer)
|
||||
tempDb.close()
|
||||
return { success: false, error: 'INVALID_FILE', message: msg || `deserialize rc=${rc}` }
|
||||
}
|
||||
// Verify the DB can be read
|
||||
tempDb.exec('SELECT count(*) FROM sqlite_master')
|
||||
} catch (e) {
|
||||
if (tempDb) tempDb.close()
|
||||
return { success: false, error: 'INVALID_FILE', message: e.message }
|
||||
}
|
||||
|
||||
// 2. Check required tables exist in the imported DB
|
||||
const importedTableRows = tempDb.exec(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'",
|
||||
{ returnValue: 'resultRows', rowMode: 'array' },
|
||||
)
|
||||
const importedTableNames = importedTableRows.map((r) => r[0])
|
||||
|
||||
for (const required of REQUIRED_TABLES) {
|
||||
if (!importedTableNames.includes(required)) {
|
||||
tempDb.close()
|
||||
return {
|
||||
success: false,
|
||||
error: 'INCOMPLETE_DB',
|
||||
message: `Missing required table: ${required}`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Read schema versions from both DBs
|
||||
const importedVersionRows = tempDb.exec(
|
||||
'SELECT version FROM schema_version ORDER BY version DESC LIMIT 1',
|
||||
{ returnValue: 'resultRows', rowMode: 'array' },
|
||||
)
|
||||
const importedVersion = importedVersionRows.length > 0 ? importedVersionRows[0][0] : null
|
||||
|
||||
if (importedVersion === null) {
|
||||
tempDb.close()
|
||||
return {
|
||||
success: false,
|
||||
error: 'INCOMPLETE_DB',
|
||||
message: 'No schema version found in imported DB',
|
||||
}
|
||||
}
|
||||
|
||||
const currentVersionRows = db.exec(
|
||||
'SELECT version FROM schema_version ORDER BY version DESC LIMIT 1',
|
||||
{ returnValue: 'resultRows', rowMode: 'array' },
|
||||
)
|
||||
const currentVersion = currentVersionRows.length > 0 ? currentVersionRows[0][0] : null
|
||||
|
||||
// 4. Check compatibility
|
||||
const compat = checkCompatibility(importedVersion, currentVersion)
|
||||
|
||||
if (!compat.compatible) {
|
||||
tempDb.close()
|
||||
return {
|
||||
success: false,
|
||||
error: 'INCOMPATIBLE_VERSION',
|
||||
message: `Schema version ${importedVersion} is incompatible with current version ${currentVersion}`,
|
||||
importedVersion,
|
||||
currentVersion,
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Run adapt scripts if needed
|
||||
if (compat.needsAdapt) {
|
||||
try {
|
||||
for (const script of compat.scripts) {
|
||||
tempDb.exec(script)
|
||||
}
|
||||
} catch (e) {
|
||||
tempDb.close()
|
||||
return { success: false, error: 'ADAPT_FAILED', message: e.message }
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Get current DB tables
|
||||
const currentTableRows = db.exec(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'",
|
||||
{ returnValue: 'resultRows', rowMode: 'array' },
|
||||
)
|
||||
const currentTableNames = currentTableRows.map((r) => r[0])
|
||||
|
||||
// Only restore tables that exist in both databases
|
||||
const tablesToRestore = importedTableNames.filter((t) => currentTableNames.includes(t))
|
||||
|
||||
// 7. Table-by-table copy inside a single transaction so a mid-restore
|
||||
// failure cannot leave the live DB partially wiped.
|
||||
const report = {
|
||||
success: true,
|
||||
tablesRestored: [],
|
||||
importedVersion,
|
||||
currentVersion,
|
||||
adapted: compat.needsAdapt,
|
||||
}
|
||||
|
||||
db.exec('PRAGMA foreign_keys = OFF')
|
||||
|
||||
let restoreError = null
|
||||
try {
|
||||
db.exec('BEGIN IMMEDIATE')
|
||||
for (const table of tablesToRestore) {
|
||||
db.exec(`DELETE FROM "${table}"`)
|
||||
|
||||
const rows = tempDb.exec(`SELECT * FROM "${table}"`, {
|
||||
returnValue: 'resultRows',
|
||||
rowMode: 'object',
|
||||
})
|
||||
|
||||
if (rows.length > 0) {
|
||||
const cols = Object.keys(rows[0])
|
||||
const currentColRows = db.exec(`PRAGMA table_info("${table}")`, {
|
||||
returnValue: 'resultRows',
|
||||
rowMode: 'object',
|
||||
})
|
||||
const currentCols = currentColRows.map((r) => r.name)
|
||||
const colsToInsert = cols.filter((c) => currentCols.includes(c))
|
||||
|
||||
if (colsToInsert.length > 0) {
|
||||
const quotedCols = colsToInsert.map((c) => `"${c}"`).join(', ')
|
||||
const placeholders = colsToInsert.map(() => '?').join(', ')
|
||||
const insertSql = `INSERT INTO "${table}" (${quotedCols}) VALUES (${placeholders})`
|
||||
const stmt = db.prepare(insertSql)
|
||||
try {
|
||||
for (const row of rows) {
|
||||
stmt.bind(colsToInsert.map((c) => row[c]))
|
||||
stmt.stepReset()
|
||||
}
|
||||
} finally {
|
||||
stmt.finalize()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
report.tablesRestored.push({ table, rows: rows.length })
|
||||
}
|
||||
db.exec('COMMIT')
|
||||
} catch (e) {
|
||||
try {
|
||||
db.exec('ROLLBACK')
|
||||
} catch (_e) {
|
||||
// Ignore — the error we care about is `e`
|
||||
}
|
||||
restoreError = e
|
||||
} finally {
|
||||
db.exec('PRAGMA foreign_keys = ON')
|
||||
tempDb.close()
|
||||
}
|
||||
|
||||
if (restoreError) {
|
||||
return { success: false, error: 'RESTORE_FAILED', message: restoreError.message }
|
||||
}
|
||||
return report
|
||||
}
|
||||
|
||||
function handleResetDb() {
|
||||
const tables = [
|
||||
'execution_log_item',
|
||||
'execution_log',
|
||||
'collection_session',
|
||||
'session_item',
|
||||
'combo_exercise',
|
||||
'suffix',
|
||||
'collection',
|
||||
'session',
|
||||
'combo',
|
||||
'exercise',
|
||||
'settings',
|
||||
'schema_version',
|
||||
]
|
||||
|
||||
db.exec('PRAGMA foreign_keys = OFF')
|
||||
try {
|
||||
for (const table of tables) {
|
||||
db.exec(`DROP TABLE IF EXISTS "${table}"`)
|
||||
}
|
||||
db.exec(SCHEMA_SQL)
|
||||
db.exec('INSERT INTO schema_version (version) VALUES (?)', { bind: [SCHEMA_VERSION] })
|
||||
} finally {
|
||||
db.exec('PRAGMA foreign_keys = ON')
|
||||
}
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
self.onmessage = async (e) => {
|
||||
const { id, type, ...args } = e.data
|
||||
try {
|
||||
let result
|
||||
switch (type) {
|
||||
case 'init':
|
||||
result = await initDatabase(args)
|
||||
break
|
||||
case 'getSchemaStatus':
|
||||
result = handleGetSchemaStatus()
|
||||
break
|
||||
case 'runMigrations':
|
||||
result = handleRunMigrations()
|
||||
break
|
||||
case 'batch':
|
||||
result = handleBatch(args.statements)
|
||||
break
|
||||
case 'exec':
|
||||
result = handleExec(args.sql, args.params)
|
||||
break
|
||||
case 'run':
|
||||
result = handleRun(args.sql, args.params)
|
||||
break
|
||||
case 'selectAll':
|
||||
result = handleSelectAll(args.sql, args.params)
|
||||
break
|
||||
case 'selectOne':
|
||||
result = handleSelectOne(args.sql, args.params)
|
||||
break
|
||||
case 'exportDb':
|
||||
result = sqlite3.capi.sqlite3_js_db_export(db)
|
||||
break
|
||||
case 'importDb':
|
||||
result = handleImportDb(args.bytes)
|
||||
break
|
||||
case 'resetDb':
|
||||
result = handleResetDb()
|
||||
break
|
||||
default:
|
||||
throw new Error(`Unknown message type: ${type}`)
|
||||
}
|
||||
self.postMessage({ id, result })
|
||||
} catch (err) {
|
||||
self.postMessage({ id, error: err.message })
|
||||
}
|
||||
}
|
||||
20
src/i18n/index.js
Normal file
20
src/i18n/index.js
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import i18next from 'i18next'
|
||||
import I18NextVue from 'i18next-vue'
|
||||
import en from './locales/en.json'
|
||||
import fr from './locales/fr.json'
|
||||
import da from './locales/da.json'
|
||||
|
||||
i18next.init({
|
||||
lng: 'en',
|
||||
fallbackLng: 'en',
|
||||
resources: {
|
||||
en: { translation: en },
|
||||
fr: { translation: fr },
|
||||
da: { translation: da },
|
||||
},
|
||||
interpolation: {
|
||||
escapeValue: false,
|
||||
},
|
||||
})
|
||||
|
||||
export { i18next, I18NextVue }
|
||||
460
src/i18n/locales/da.json
Normal file
460
src/i18n/locales/da.json
Normal file
|
|
@ -0,0 +1,460 @@
|
|||
{
|
||||
"app": {
|
||||
"name": "TrainUs",
|
||||
"loading": "Indlæser…"
|
||||
},
|
||||
"common": {
|
||||
"markdownHint": "Grundlæggende Markdown understøttes",
|
||||
"back": "Tilbage",
|
||||
"clearSearch": "Ryd søgning"
|
||||
},
|
||||
"error": {
|
||||
"dbInitFailed": "Kunne ikke initialisere databasen",
|
||||
"browserNotCompatible": "Din browser understøtter ikke de nødvendige lagringsfunktioner. Sørg for at bruge en nyere version af din browser og at du ikke er i privat browsing.",
|
||||
"dbNewerThanApp": "Dine data blev oprettet af en nyere version af appen. Dine data er ikke blevet ændret. Genindlæs siden for at få den nyeste version af appen.",
|
||||
"dbMigrationFailed": "Databaseopdateringen mislykkedes, og dine data blev ikke ændret. Genindlæs siden for at prøve igen, eller gendan den sikkerhedskopi, du lige har downloadet, hvis problemet fortsætter.",
|
||||
"dbConnectionLost": "Databaseforbindelsen gik tabt på grund af en uventet fejl. Genindlæs siden for at genoprette forbindelsen.",
|
||||
"reload": "Genindlæs"
|
||||
},
|
||||
"migration": {
|
||||
"title": "Databaseopdatering påkrævet",
|
||||
"body": "Appen er blevet opdateret, og din database skal migreres fra version {{from}} til version {{to}}.",
|
||||
"backupRequired": "Før opdateringen skal du downloade en sikkerhedskopi af dine data. Opdateringen kan først starte, når sikkerhedskopien er gemt.",
|
||||
"downloadBackup": "Download sikkerhedskopi",
|
||||
"update": "Opdater",
|
||||
"updating": "Opdaterer…"
|
||||
},
|
||||
"nav": {
|
||||
"mainNav": "Hovednavigation",
|
||||
"home": "Hjem",
|
||||
"exercises": "Øvelser",
|
||||
"combos": "Kombos",
|
||||
"sessions": "Sessioner",
|
||||
"collections": "Samlinger",
|
||||
"stats": "Statistik",
|
||||
"settings": "Indstillinger"
|
||||
},
|
||||
"home": {
|
||||
"title": "Hjem",
|
||||
"empty": "Ingen træningssessioner udført endnu. Start en session for at se din aktivitet her!",
|
||||
"recentSessions": "Seneste sessioner",
|
||||
"sessionsLast7Days": "Sessioner (de seneste 7 dage)",
|
||||
"sessionsLast30Days": "Sessioner (de seneste 30 dage)",
|
||||
"history": "Historik",
|
||||
"exportHtml": "Eksportér som HTML",
|
||||
"exportedOn": "Eksporteret den {{date}}"
|
||||
},
|
||||
"exercises": {
|
||||
"title": "Øvelser",
|
||||
"empty": "Ingen øvelser endnu. Opret din første øvelse for at komme i gang!",
|
||||
"searchPlaceholder": "Søg i øvelser…",
|
||||
"noResults": "Ingen øvelser matcher din søgning.",
|
||||
"new": "Ny øvelse",
|
||||
"edit": "Rediger øvelse",
|
||||
"detail": "Øvelsesdetaljer",
|
||||
"deleteConfirm": "Er du sikker på, at du vil slette \"{{title}}\"? Dette kan ikke fortrydes.",
|
||||
"deleteConfirmWithHistory": "Er du sikker på, at du vil slette \"{{title}}\"? Dette sletter også {{count}} journalpost(er). Dette kan ikke fortrydes.",
|
||||
"deleted": "Øvelse slettet.",
|
||||
"form": {
|
||||
"title": "Titel",
|
||||
"titlePlaceholder": "f.eks. Armstrækninger",
|
||||
"description": "Beskrivelse",
|
||||
"descriptionPlaceholder": "Valgfri beskrivelse…",
|
||||
"asymmetric": "Asymmetrisk øvelse",
|
||||
"alternate": "Skift side ved hver gentagelse",
|
||||
"alternateFalse": "Gennemfør alle gentagelser på én side, skift derefter",
|
||||
"imageUrls": "Billed-URL'er",
|
||||
"videoUrls": "Video-URL'er",
|
||||
"urlPlaceholder": "https://…",
|
||||
"addUrl": "Tilføj URL",
|
||||
"removeUrl": "Fjern",
|
||||
"defaultReps": "Standardgentagelser",
|
||||
"defaultWeight": "Standardvægt (kg)",
|
||||
"save": "Gem",
|
||||
"cancel": "Annuller"
|
||||
},
|
||||
"reps": "gent.",
|
||||
"weight": "kg",
|
||||
"asymmetricLabel": "Asymmetrisk",
|
||||
"alternateLabel": "Alternerende",
|
||||
"delete": "Slet øvelse",
|
||||
"videoOffline": "Videoer er ikke tilgængelige offline.",
|
||||
"evenRepsHint": "Antal gentagelser skal være lige for asymmetriske øvelser."
|
||||
},
|
||||
"combos": {
|
||||
"title": "Kombos",
|
||||
"empty": "Ingen kombos endnu. Opret din første kombo for at komme i gang!",
|
||||
"searchPlaceholder": "Søg i kombos…",
|
||||
"noResults": "Ingen kombos matcher din søgning.",
|
||||
"new": "Ny kombo",
|
||||
"edit": "Rediger kombo",
|
||||
"detail": "Kombodetailer",
|
||||
"deleteConfirm": "Er du sikker på, at du vil slette \"{{title}}\"? Dette kan ikke fortrydes.",
|
||||
"deleted": "Kombo slettet.",
|
||||
"exercises": "øvelser",
|
||||
"timerConfig": "Timer-konfiguration",
|
||||
"delete": "Slet kombo",
|
||||
"form": {
|
||||
"title": "Titel",
|
||||
"titlePlaceholder": "f.eks. Overkrop Blast",
|
||||
"description": "Beskrivelse",
|
||||
"descriptionPlaceholder": "Valgfri beskrivelse…",
|
||||
"type": "Type",
|
||||
"exercises": "Øvelser",
|
||||
"selectExercise": "Vælg en øvelse",
|
||||
"noExercises": "Ingen øvelser tilgængelige — opret øvelser først",
|
||||
"addExercise": "Tilføj øvelse",
|
||||
"reps": "Gent.",
|
||||
"weight": "Vægt (kg)",
|
||||
"save": "Gem",
|
||||
"cancel": "Annuller"
|
||||
},
|
||||
"types": {
|
||||
"none": "Ingen",
|
||||
"emom": "EMOM",
|
||||
"amrap": "AMRAP",
|
||||
"tabata": "TABATA"
|
||||
},
|
||||
"timer": {
|
||||
"emomDuration": "Varighed",
|
||||
"amrapDuration": "Varighed",
|
||||
"tabataWorkTime": "Arbejdstid (s)",
|
||||
"tabataRestTime": "Hviletid (s)",
|
||||
"tabataSeconds": "sekunder",
|
||||
"minutes": "minutter",
|
||||
"rounds": "runder"
|
||||
}
|
||||
},
|
||||
"collections": {
|
||||
"title": "Samlinger",
|
||||
"empty": "Ingen samlinger endnu. Opret din første samling for at organisere dine sessioner!",
|
||||
"searchPlaceholder": "Søg i samlinger…",
|
||||
"noResults": "Ingen samlinger matcher din søgning.",
|
||||
"new": "Ny samling",
|
||||
"edit": "Rediger samling",
|
||||
"detail": "Samlingsdetaljer",
|
||||
"deleteConfirm": "Er du sikker på, at du vil slette \"{{label}}\"? Dette kan ikke fortrydes.",
|
||||
"deleted": "Samling slettet.",
|
||||
"sessions": "sessioner",
|
||||
"delete": "Slet samling",
|
||||
"form": {
|
||||
"label": "Etiket",
|
||||
"labelPlaceholder": "f.eks. Ugentlig træning",
|
||||
"sessions": "Sessioner",
|
||||
"selectSession": "Vælg en session",
|
||||
"noSessions": "Ingen sessioner tilgængelige — opret sessioner først",
|
||||
"addSession": "Tilføj session",
|
||||
"save": "Gem",
|
||||
"cancel": "Annuller"
|
||||
}
|
||||
},
|
||||
"sessions": {
|
||||
"title": "Sessioner",
|
||||
"empty": "Ingen sessioner endnu.",
|
||||
"searchPlaceholder": "Søg i sessioner…",
|
||||
"noResults": "Ingen sessioner matcher din søgning.",
|
||||
"new": "Ny session",
|
||||
"edit": "Rediger session",
|
||||
"detail": "Sessionsdetaljer",
|
||||
"deleteConfirm": "Er du sikker på, at du vil slette \"{{title}}\"? Dette kan ikke fortrydes.",
|
||||
"deleteConfirmWithHistory": "Er du sikker på, at du vil slette \"{{title}}\"? Dette sletter også {{count}} udførelseslog(ge). Dette kan ikke fortrydes.",
|
||||
"deleted": "Session slettet.",
|
||||
"items": "elementer",
|
||||
"delete": "Slet session",
|
||||
"itemType": {
|
||||
"exercise": "Øvelse",
|
||||
"combo": "Kombo"
|
||||
},
|
||||
"form": {
|
||||
"title": "Titel",
|
||||
"titlePlaceholder": "f.eks. Overkrop dag",
|
||||
"description": "Beskrivelse",
|
||||
"descriptionPlaceholder": "Valgfri beskrivelse…",
|
||||
"items": "Elementer",
|
||||
"reps": "Gent.",
|
||||
"durationMin": "Varighed (min)",
|
||||
"weight": "Vægt (kg)",
|
||||
"selectExercise": "Vælg en øvelse",
|
||||
"selectCombo": "Vælg en kombo",
|
||||
"noExercises": "Ingen øvelser tilgængelige — opret øvelser først",
|
||||
"noCombos": "Ingen kombos tilgængelige — opret kombos først",
|
||||
"addItem": "Tilføj element",
|
||||
"save": "Gem",
|
||||
"cancel": "Annuller"
|
||||
}
|
||||
},
|
||||
"stats": {
|
||||
"title": "Statistik",
|
||||
"empty": "Ingen data endnu. Udfør en session for at se din statistik.",
|
||||
"usingSince": "Du har brugt TrainUs siden {{date}}",
|
||||
"dateRange": {
|
||||
"7d": "7 dage",
|
||||
"30d": "30 dage",
|
||||
"3m": "3 måneder",
|
||||
"all": "Altid"
|
||||
},
|
||||
"summary": {
|
||||
"sessions": "Sessioner",
|
||||
"exercises": "Udførte øvelser",
|
||||
"streak": "Dages streak"
|
||||
},
|
||||
"charts": {
|
||||
"frequency": {
|
||||
"title": "Frekvens",
|
||||
"week": "Per uge",
|
||||
"month": "Per måned",
|
||||
"yLabel": "Sessioner",
|
||||
"noData": "Ingen sessioner i denne periode."
|
||||
},
|
||||
"volume": {
|
||||
"title": "Volumen",
|
||||
"yLabel": "Volumen (kg·gent.)",
|
||||
"noData": "Ingen volumedata i denne periode."
|
||||
},
|
||||
"progression": {
|
||||
"title": "Progression",
|
||||
"selectExercise": "Vælg en øvelse…",
|
||||
"reps": "Gent.",
|
||||
"weight": "Vægt (kg)",
|
||||
"noData": "Ingen data for denne øvelse i denne periode."
|
||||
}
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"title": "Indstillinger",
|
||||
"language": "Sprog",
|
||||
"userName": "Brugernavn",
|
||||
"userNamePlaceholder": "Indtast dit navn",
|
||||
"nameRequired": "Navn må ikke være tomt",
|
||||
"userId": "Bruger-id",
|
||||
"theme": "Tema",
|
||||
"themeLight": "Lys",
|
||||
"themeDark": "Mørk",
|
||||
"themeTrainus": "TrainUs",
|
||||
"database": "Database",
|
||||
"downloadDb": "Download database",
|
||||
"downloadDbHint": "Download eller gendan den rå SQLite-databasefil.",
|
||||
"restoreDb": "Gendan database",
|
||||
"restoreDbWarning": "Dette vil erstatte ALLE nuværende data med indholdet af den valgte fil. Dette kan ikke fortrydes.\n\nNuværende appversion: {{version}}\n\nEr du sikker på, at du vil fortsætte?",
|
||||
"restoreInProgress": "Gendanner database…",
|
||||
"restoreSuccess": "Database gendannet",
|
||||
"restoreError": "Gendannelse af database mislykkedes",
|
||||
"restoreAdapted": "Skema tilpasset fra version {{from}} til {{to}}.",
|
||||
"restoreRows": "række(r)",
|
||||
"restoreDismiss": "OK",
|
||||
"restoreErrorCodes": {
|
||||
"INVALID_FILE": "Den valgte fil er ikke en gyldig SQLite-database eller er beskadiget.",
|
||||
"INCOMPLETE_DB": "Den valgte fil er ikke en gyldig TrainUs-database (manglende påkrævede tabeller).",
|
||||
"INCOMPATIBLE_VERSION": "Databaseversionen er inkompatibel, og ingen migreringsvej er tilgængelig.",
|
||||
"ADAPT_FAILED": "Tilpasning af databaseskema mislykkedes.",
|
||||
"RESTORE_FAILED": "Der opstod en fejl under gendannelse af data.",
|
||||
"UNEXPECTED": "Der opstod en uventet fejl."
|
||||
},
|
||||
"exportImport": "Eksporter / Importer",
|
||||
"exportAllData": "Eksporter alle data",
|
||||
"exportData": "Eksporter data",
|
||||
"importData": "Importer data",
|
||||
"importAsMine": "Importer som mine",
|
||||
"comingSoon": "Kommer snart i en fremtidig opdatering.",
|
||||
"suffixManagement": "Importsuffikser",
|
||||
"suffixHint": "Administrer suffikser til at skelne importerede øvelser fra andre brugere.",
|
||||
"suffixDeleteConfirm": "Slet suffikset \"{{suffix}}\" for {{name}}? Importerede øvelser slettes ikke.",
|
||||
"suffixDeleteTitle": "Slet suffiks",
|
||||
"backupReminderInvalid": "Skal være mellem 1 og 365",
|
||||
"initializeDb": "Initialiser database",
|
||||
"initializeDbHint": "Slet alle data og nulstil databasen til dens indledende tilstand. Denne handling kan ikke fortrydes.",
|
||||
"initializeDbTitle": "Initialiser database",
|
||||
"initializeDbConfirm": "Jeg forstår, at dette vil slette alle mine data permanent og ikke kan fortrydes",
|
||||
"backup": {
|
||||
"lastBackup": "Seneste sikkerhedskopi: {{when}}",
|
||||
"neverBackedUp": "Aldrig sikkerhedskopieret",
|
||||
"reminderEvery": "Påmind mig hver",
|
||||
"reminderEveryUnit": "dage"
|
||||
},
|
||||
"sound": "Lyd",
|
||||
"soundVolume": "Lydstyrke",
|
||||
"soundTest": "Test",
|
||||
"credits": "Kreditering",
|
||||
"manageJournal": "Administrer øvelseslogge",
|
||||
"manageJournalHint": "Slet øvelseslogposter inden for et datointerval.",
|
||||
"cleanJournalBtn": "Ryd journaldata"
|
||||
},
|
||||
"cleanJournal": {
|
||||
"title": "Ryd journaldata",
|
||||
"hint": "Vælg et datointerval for permanent at slette alle øvelseslogposter inden for det.",
|
||||
"from": "Fra",
|
||||
"to": "Til",
|
||||
"errorToBeforeFrom": "Slutdato skal være på eller efter startdato.",
|
||||
"matchCount_one": "{{count}} session vil blive slettet.",
|
||||
"matchCount_other": "{{count}} sessioner vil blive slettet.",
|
||||
"matchCount": "{{count}} session(er) vil blive slettet.",
|
||||
"confirmLabel": "Jeg forstår, at dette permanent vil slette de valgte øvelseslogposter og ikke kan fortrydes.",
|
||||
"deleteBtn": "Slet"
|
||||
},
|
||||
"credits": {
|
||||
"title": "Kreditering",
|
||||
"intro": "Open source-ikoner brugt i denne applikation:",
|
||||
"appIcon": {
|
||||
"name": "Mighty Force",
|
||||
"description": "App-ikon, af Delapouite",
|
||||
"author": "Delapouite"
|
||||
},
|
||||
"bootstrapIcons": {
|
||||
"name": "Bootstrap Icons",
|
||||
"description": "UI-ikoner brugt gennem hele applikationen"
|
||||
},
|
||||
"gameIcons": "game-icons.net vedligeholdes af Lorc, Delapouite & bidragydere",
|
||||
"thirdParties": {
|
||||
"title": "Tredjepartsbiblioteker"
|
||||
},
|
||||
"testingTools": {
|
||||
"title": "Testværktøjer"
|
||||
}
|
||||
},
|
||||
"export": {
|
||||
"exercises": "Eksporter alle øvelser",
|
||||
"exercise": "Eksporter denne øvelse",
|
||||
"combos": "Eksporter alle kombos",
|
||||
"combo": "Eksporter denne kombo",
|
||||
"sessions": "Eksporter alle sessioner",
|
||||
"session": "Eksporter denne session",
|
||||
"collections": "Eksporter alle samlinger",
|
||||
"collection": "Eksporter denne samling",
|
||||
"executionLogs": "Udførelseslogge",
|
||||
"settings": "Indstillinger",
|
||||
"filterTitle": "Eksporter data",
|
||||
"filterDescription": "Vælg hvilke datatyper der skal eksporteres."
|
||||
},
|
||||
"import": {
|
||||
"title": "Importer data",
|
||||
"titleAsMine": "Importer som mine",
|
||||
"exercises": "Importer øvelser",
|
||||
"importAsMine": "Importer som mine",
|
||||
"cancel": "Annuller",
|
||||
"continue": "Fortsæt",
|
||||
"apply": "Importer",
|
||||
"done": "Færdig",
|
||||
"importing": "Importerer…",
|
||||
"exerciseCount": "øvelse(r)",
|
||||
"totalCount": "element(er)",
|
||||
"executionLogs": "udførelseslog(ge)",
|
||||
"from": "Fra {{name}} den {{date}}",
|
||||
"usingSuffix": "Suffiks: [{{suffix}}]",
|
||||
"suffixExplanation": "Disse elementer er fra {{name}}. Vælg et kort suffiks for at skelne dem fra dine egne.",
|
||||
"suffixLabel": "Suffiks",
|
||||
"suffixPlaceholder": "f.eks. JD",
|
||||
"suffixPreview": "Titler vil se sådan ud: \"{{title}} [{{suffix}}]\"",
|
||||
"suffixRequired": "Suffiks er påkrævet.",
|
||||
"suffixTaken": "Dette suffiks bruges allerede af en anden bruger.",
|
||||
"collisionExplanation": "{{count}} element(er) er i konflikt med eksisterende data. Vælg hvad der skal gøres for hvert:",
|
||||
"replace": "Erstat",
|
||||
"skip": "Spring over",
|
||||
"replaceAll": "Erstat alle",
|
||||
"skipAll": "Spring alle over",
|
||||
"exportedOn": "Eksporteret den {{date}}",
|
||||
"reportTitle": "Import fuldført",
|
||||
"reportTotalImported": "{{count}} element(er) importeret",
|
||||
"reportImported": "{{count}} importeret",
|
||||
"reportReplaced": "{{count}} erstattet",
|
||||
"reportUpdated": "{{count}} opdateret",
|
||||
"reportSkipped": "{{count}} sprunget over",
|
||||
"errors": {
|
||||
"invalidJson": "Filen indeholder ikke gyldigt JSON.",
|
||||
"invalidFormat": "Filformatet genkendes ikke.",
|
||||
"missingMetadata": "Filen mangler metadata.",
|
||||
"wrongApp": "Denne fil blev ikke eksporteret fra TrainUs.",
|
||||
"missingSchema": "Filen mangler skemaversionsinformation.",
|
||||
"newerSchema": "Denne fil blev eksporteret fra en nyere version af TrainUs. Opdater venligst applikationen.",
|
||||
"missingData": "Filen indeholder ingen data.",
|
||||
"missingExporter": "Filen mangler eksportørinformation."
|
||||
}
|
||||
},
|
||||
"validation": {
|
||||
"required": "Dette felt er påkrævet.",
|
||||
"minValue": "Værdien skal være ≥ {{min}}.",
|
||||
"invalidUrl": "Indtast en gyldig URL (https://…).",
|
||||
"titleTaken": "En øvelse med denne titel findes allerede.",
|
||||
"comboTitleTaken": "En kombo med denne titel findes allerede.",
|
||||
"collectionLabelTaken": "En samling med denne etiket findes allerede."
|
||||
},
|
||||
"shortcuts": {
|
||||
"edit": "— tryk E",
|
||||
"new": "— tryk N",
|
||||
"back": "— tryk Esc",
|
||||
"save": "— Ctrl+Enter"
|
||||
},
|
||||
"execution": {
|
||||
"start": "Start session",
|
||||
"exit": "Afslut session",
|
||||
"steps": "trin",
|
||||
"now": "NU",
|
||||
"next": "NÆSTE",
|
||||
"round": "Runde",
|
||||
"reps": "Gent.",
|
||||
"weight": "Vægt",
|
||||
"side": "Side",
|
||||
"sideLeft": "VENSTRE",
|
||||
"sideRight": "HØJRE",
|
||||
"asymAlternate": "(1x{{left}} ↔ 1x{{right}}) × {{count}}",
|
||||
"asymSequential": "{{count}}x {{left}} → {{count}}x {{right}}",
|
||||
"done": "Færdig",
|
||||
"continue": "Fortsæt",
|
||||
"oneMoreRound": "En runde til",
|
||||
"comboContext": "Kombo: {{title}} — Runde {{round}} af {{total}}",
|
||||
"comboComplete": "{{title}} — {{rounds}} runde(r) fuldført",
|
||||
"complete": "Session fuldført!",
|
||||
"backToSession": "Tilbage til session",
|
||||
"timer": {
|
||||
"pause": "Pause",
|
||||
"resume": "Genoptag",
|
||||
"rest": "HVILE",
|
||||
"nextExercise": "Næste:",
|
||||
"timeUp": "Tiden er gået",
|
||||
"timeUpTitle": "⏱ Tiden er gået — {{title}}"
|
||||
},
|
||||
"showDetails": "Vis øvelsesdetaljer",
|
||||
"hideDetails": "Skjul øvelsesdetaljer",
|
||||
"videoOffline": "Videoer er ikke tilgængelige offline.",
|
||||
"leaveConfirm": "Du er midt i en session. Forlad alligevel? Sessionen vil blive markeret som afbrudt."
|
||||
},
|
||||
"backup": {
|
||||
"reminder": {
|
||||
"tooltip": "Seneste sikkerhedskopi for {{days}} dag(e) siden — klik for at sikkerhedskopiere",
|
||||
"tooltipNever": "Ingen sikkerhedskopi endnu — klik for at oprette en"
|
||||
},
|
||||
"modal": {
|
||||
"title": "Påmindelse om sikkerhedskopi",
|
||||
"body": "Din seneste sikkerhedskopi var for {{days}} dag(e) siden. Vi anbefaler at sikkerhedskopiere dine data regelmæssigt.",
|
||||
"bodyNever": "Du har aldrig sikkerhedskopieret dine data. Vi anbefaler at oprette en sikkerhedskopi.",
|
||||
"dismiss": "Afvis",
|
||||
"goToSettings": "Gå til indstillinger"
|
||||
}
|
||||
},
|
||||
"install": {
|
||||
"button": "Installer app",
|
||||
"dismiss": "Afvis installationsprompt"
|
||||
},
|
||||
"modal": {
|
||||
"close": "Luk dialog"
|
||||
},
|
||||
"saveFile": {
|
||||
"title": "Gem fil",
|
||||
"label": "Filnavn",
|
||||
"cancel": "Annuller",
|
||||
"save": "Gem"
|
||||
},
|
||||
"about": {
|
||||
"title": "Om TrainUs",
|
||||
"appVersion": "Applikationsversion",
|
||||
"dbVersion": "Databaseversion",
|
||||
"author": "Oprettet af",
|
||||
"links": "Links",
|
||||
"repository": "Repository",
|
||||
"website": "Websted",
|
||||
"license": "Licens",
|
||||
"licenseBtn": "Se licens"
|
||||
},
|
||||
"license": {
|
||||
"title": "Licens"
|
||||
}
|
||||
}
|
||||
460
src/i18n/locales/en.json
Normal file
460
src/i18n/locales/en.json
Normal file
|
|
@ -0,0 +1,460 @@
|
|||
{
|
||||
"app": {
|
||||
"name": "TrainUs",
|
||||
"loading": "Loading…"
|
||||
},
|
||||
"common": {
|
||||
"markdownHint": "Basic Markdown supported",
|
||||
"back": "Back",
|
||||
"clearSearch": "Clear search"
|
||||
},
|
||||
"error": {
|
||||
"dbInitFailed": "Unable to initialize the database",
|
||||
"browserNotCompatible": "Your browser does not support the required storage features. Please make sure you are using a recent version of your browser and that you are not in private browsing mode.",
|
||||
"dbNewerThanApp": "Your data was created by a newer version of the app. Your data has not been modified. Please reload the page to get the latest version of the app.",
|
||||
"dbMigrationFailed": "The database update failed and your data was left unchanged. Reload the page to try again, or restore the backup you just downloaded if the problem persists.",
|
||||
"dbConnectionLost": "The database connection was lost due to an unexpected error. Reload the page to reconnect.",
|
||||
"reload": "Reload"
|
||||
},
|
||||
"migration": {
|
||||
"title": "Database update required",
|
||||
"body": "The app has been updated and your database needs to be migrated from version {{from}} to version {{to}}.",
|
||||
"backupRequired": "Before updating, please download a backup of your data. The update can only start once the backup has been saved.",
|
||||
"downloadBackup": "Download backup",
|
||||
"update": "Update",
|
||||
"updating": "Updating…"
|
||||
},
|
||||
"nav": {
|
||||
"mainNav": "Main navigation",
|
||||
"home": "Home",
|
||||
"exercises": "Exercises",
|
||||
"combos": "Combos",
|
||||
"sessions": "Sessions",
|
||||
"collections": "Collections",
|
||||
"stats": "Statistics",
|
||||
"settings": "Settings"
|
||||
},
|
||||
"home": {
|
||||
"title": "Home",
|
||||
"empty": "No training sessions executed yet. Start a session to see your activity here!",
|
||||
"recentSessions": "Recent Sessions",
|
||||
"sessionsLast7Days": "Sessions (last 7 days)",
|
||||
"sessionsLast30Days": "Sessions (last 30 days)",
|
||||
"history": "History",
|
||||
"exportHtml": "Export as HTML",
|
||||
"exportedOn": "Exported on {{date}}"
|
||||
},
|
||||
"exercises": {
|
||||
"title": "Exercises",
|
||||
"empty": "No exercises yet. Create your first exercise to get started!",
|
||||
"searchPlaceholder": "Search exercises…",
|
||||
"noResults": "No exercises match your search.",
|
||||
"new": "New Exercise",
|
||||
"edit": "Edit Exercise",
|
||||
"detail": "Exercise Details",
|
||||
"deleteConfirm": "Are you sure you want to delete \"{{title}}\"? This cannot be undone.",
|
||||
"deleteConfirmWithHistory": "Are you sure you want to delete \"{{title}}\"? This will also delete {{count}} journal entry/entries. This cannot be undone.",
|
||||
"deleted": "Exercise deleted.",
|
||||
"form": {
|
||||
"title": "Title",
|
||||
"titlePlaceholder": "e.g. Push-ups",
|
||||
"description": "Description",
|
||||
"descriptionPlaceholder": "Optional description…",
|
||||
"asymmetric": "Asymmetric exercise",
|
||||
"alternate": "Alternate sides each repetition",
|
||||
"alternateFalse": "Complete all reps on one side, then switch",
|
||||
"imageUrls": "Image URLs",
|
||||
"videoUrls": "Video URLs",
|
||||
"urlPlaceholder": "https://…",
|
||||
"addUrl": "Add URL",
|
||||
"removeUrl": "Remove",
|
||||
"defaultReps": "Default repetitions",
|
||||
"defaultWeight": "Default weight (kg)",
|
||||
"save": "Save",
|
||||
"cancel": "Cancel"
|
||||
},
|
||||
"reps": "reps",
|
||||
"weight": "kg",
|
||||
"asymmetricLabel": "Asymmetric",
|
||||
"alternateLabel": "Alternating",
|
||||
"delete": "Delete exercise",
|
||||
"videoOffline": "Videos are not available offline.",
|
||||
"evenRepsHint": "Repetitions must be even for asymmetric exercises."
|
||||
},
|
||||
"combos": {
|
||||
"title": "Combos",
|
||||
"empty": "No combos yet. Create your first combo to get started!",
|
||||
"searchPlaceholder": "Search combos…",
|
||||
"noResults": "No combos match your search.",
|
||||
"new": "New Combo",
|
||||
"edit": "Edit Combo",
|
||||
"detail": "Combo Details",
|
||||
"deleteConfirm": "Are you sure you want to delete \"{{title}}\"? This cannot be undone.",
|
||||
"deleted": "Combo deleted.",
|
||||
"exercises": "exercises",
|
||||
"timerConfig": "Timer Configuration",
|
||||
"delete": "Delete combo",
|
||||
"form": {
|
||||
"title": "Title",
|
||||
"titlePlaceholder": "e.g. Upper Body Blast",
|
||||
"description": "Description",
|
||||
"descriptionPlaceholder": "Optional description…",
|
||||
"type": "Type",
|
||||
"exercises": "Exercises",
|
||||
"selectExercise": "Select an exercise",
|
||||
"noExercises": "No exercises available — create exercises first",
|
||||
"addExercise": "Add Exercise",
|
||||
"reps": "Reps",
|
||||
"weight": "Weight (kg)",
|
||||
"save": "Save",
|
||||
"cancel": "Cancel"
|
||||
},
|
||||
"types": {
|
||||
"none": "None",
|
||||
"emom": "EMOM",
|
||||
"amrap": "AMRAP",
|
||||
"tabata": "TABATA"
|
||||
},
|
||||
"timer": {
|
||||
"emomDuration": "Duration",
|
||||
"amrapDuration": "Duration",
|
||||
"tabataWorkTime": "Work time (s)",
|
||||
"tabataRestTime": "Rest time (s)",
|
||||
"tabataSeconds": "seconds",
|
||||
"minutes": "minutes",
|
||||
"rounds": "rounds"
|
||||
}
|
||||
},
|
||||
"collections": {
|
||||
"title": "Collections",
|
||||
"empty": "No collections yet. Create your first collection to organize your sessions!",
|
||||
"searchPlaceholder": "Search collections…",
|
||||
"noResults": "No collections match your search.",
|
||||
"new": "New Collection",
|
||||
"edit": "Edit Collection",
|
||||
"detail": "Collection Details",
|
||||
"deleteConfirm": "Are you sure you want to delete \"{{label}}\"? This cannot be undone.",
|
||||
"deleted": "Collection deleted.",
|
||||
"sessions": "sessions",
|
||||
"delete": "Delete collection",
|
||||
"form": {
|
||||
"label": "Label",
|
||||
"labelPlaceholder": "e.g. Weekly Training",
|
||||
"sessions": "Sessions",
|
||||
"selectSession": "Select a session",
|
||||
"noSessions": "No sessions available — create sessions first",
|
||||
"addSession": "Add Session",
|
||||
"save": "Save",
|
||||
"cancel": "Cancel"
|
||||
}
|
||||
},
|
||||
"sessions": {
|
||||
"title": "Sessions",
|
||||
"empty": "No sessions yet.",
|
||||
"searchPlaceholder": "Search sessions…",
|
||||
"noResults": "No sessions match your search.",
|
||||
"new": "New Session",
|
||||
"edit": "Edit Session",
|
||||
"detail": "Session Details",
|
||||
"deleteConfirm": "Are you sure you want to delete \"{{title}}\"? This cannot be undone.",
|
||||
"deleteConfirmWithHistory": "Are you sure you want to delete \"{{title}}\"? This will also delete {{count}} journal log(s). This cannot be undone.",
|
||||
"deleted": "Session deleted.",
|
||||
"items": "items",
|
||||
"delete": "Delete session",
|
||||
"itemType": {
|
||||
"exercise": "Exercise",
|
||||
"combo": "Combo"
|
||||
},
|
||||
"form": {
|
||||
"title": "Title",
|
||||
"titlePlaceholder": "e.g. Upper Body Day",
|
||||
"description": "Description",
|
||||
"descriptionPlaceholder": "Optional description…",
|
||||
"items": "Items",
|
||||
"reps": "Reps",
|
||||
"durationMin": "Duration (min)",
|
||||
"weight": "Weight (kg)",
|
||||
"selectExercise": "Select an exercise",
|
||||
"selectCombo": "Select a combo",
|
||||
"noExercises": "No exercises available — create exercises first",
|
||||
"noCombos": "No combos available — create combos first",
|
||||
"addItem": "Add Item",
|
||||
"save": "Save",
|
||||
"cancel": "Cancel"
|
||||
}
|
||||
},
|
||||
"stats": {
|
||||
"title": "Statistics",
|
||||
"empty": "No data yet. Execute a session to see your statistics.",
|
||||
"usingSince": "You are using TrainUs since {{date}}",
|
||||
"dateRange": {
|
||||
"7d": "7 days",
|
||||
"30d": "30 days",
|
||||
"3m": "3 months",
|
||||
"all": "All time"
|
||||
},
|
||||
"summary": {
|
||||
"sessions": "Sessions",
|
||||
"exercises": "Exercises done",
|
||||
"streak": "Day streak"
|
||||
},
|
||||
"charts": {
|
||||
"frequency": {
|
||||
"title": "Frequency",
|
||||
"week": "Per week",
|
||||
"month": "Per month",
|
||||
"yLabel": "Sessions",
|
||||
"noData": "No sessions in this period."
|
||||
},
|
||||
"volume": {
|
||||
"title": "Volume",
|
||||
"yLabel": "Volume (kg·reps)",
|
||||
"noData": "No volume data in this period."
|
||||
},
|
||||
"progression": {
|
||||
"title": "Progression",
|
||||
"selectExercise": "Select an exercise…",
|
||||
"reps": "Reps",
|
||||
"weight": "Weight (kg)",
|
||||
"noData": "No data for this exercise in this period."
|
||||
}
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"title": "Settings",
|
||||
"language": "Language",
|
||||
"userName": "User Name",
|
||||
"userNamePlaceholder": "Enter your name",
|
||||
"nameRequired": "Name cannot be empty",
|
||||
"userId": "User ID",
|
||||
"theme": "Theme",
|
||||
"themeLight": "Light",
|
||||
"themeDark": "Dark",
|
||||
"themeTrainus": "TrainUs",
|
||||
"database": "Database",
|
||||
"downloadDb": "Download Database",
|
||||
"downloadDbHint": "Download or restore the raw SQLite database file.",
|
||||
"restoreDb": "Restore Database",
|
||||
"restoreDbWarning": "This will replace ALL current data with the contents of the selected file. This cannot be undone.\n\nCurrent app version: {{version}}\n\nAre you sure you want to continue?",
|
||||
"restoreInProgress": "Restoring database…",
|
||||
"restoreSuccess": "Database restored successfully",
|
||||
"restoreError": "Database restore failed",
|
||||
"restoreAdapted": "Schema adapted from version {{from}} to {{to}}.",
|
||||
"restoreRows": "row(s)",
|
||||
"restoreDismiss": "OK",
|
||||
"restoreErrorCodes": {
|
||||
"INVALID_FILE": "The selected file is not a valid SQLite database or is corrupted.",
|
||||
"INCOMPLETE_DB": "The selected file is not a valid TrainUs database (missing required tables).",
|
||||
"INCOMPATIBLE_VERSION": "The database version is incompatible and no migration path is available.",
|
||||
"ADAPT_FAILED": "Failed to adapt the database schema to the current version.",
|
||||
"RESTORE_FAILED": "An error occurred while restoring the data.",
|
||||
"UNEXPECTED": "An unexpected error occurred."
|
||||
},
|
||||
"exportImport": "Export / Import",
|
||||
"exportAllData": "Export All Data",
|
||||
"exportData": "Export Data",
|
||||
"importData": "Import Data",
|
||||
"importAsMine": "Import as Mine",
|
||||
"comingSoon": "Coming soon in a future update.",
|
||||
"suffixManagement": "Import Suffixes",
|
||||
"suffixHint": "Manage suffixes used to distinguish imported exercises from other users.",
|
||||
"suffixDeleteConfirm": "Delete suffix \"{{suffix}}\" for {{name}}? This will not remove imported exercises.",
|
||||
"suffixDeleteTitle": "Delete Suffix",
|
||||
"backupReminderInvalid": "Must be between 1 and 365",
|
||||
"initializeDb": "Initialize Database",
|
||||
"initializeDbHint": "Delete all data and reset the database to its initial state. This action cannot be undone.",
|
||||
"initializeDbTitle": "Initialize Database",
|
||||
"initializeDbConfirm": "I understand that this will permanently delete all my data and cannot be reversed",
|
||||
"backup": {
|
||||
"lastBackup": "Last backup: {{when}}",
|
||||
"neverBackedUp": "Never backed up",
|
||||
"reminderEvery": "Remind me every",
|
||||
"reminderEveryUnit": "days"
|
||||
},
|
||||
"sound": "Sound",
|
||||
"soundVolume": "Volume",
|
||||
"soundTest": "Test",
|
||||
"credits": "Credits",
|
||||
"manageJournal": "Manage Exercise Logs",
|
||||
"manageJournalHint": "Delete exercise log entries within a date range.",
|
||||
"cleanJournalBtn": "Clean Journal Data"
|
||||
},
|
||||
"cleanJournal": {
|
||||
"title": "Clean Journal Data",
|
||||
"hint": "Select a date range to permanently delete all exercise log entries within it.",
|
||||
"from": "From",
|
||||
"to": "To",
|
||||
"errorToBeforeFrom": "End date must be on or after the start date.",
|
||||
"matchCount_one": "{{count}} session will be deleted.",
|
||||
"matchCount_other": "{{count}} sessions will be deleted.",
|
||||
"matchCount": "{{count}} session(s) will be deleted.",
|
||||
"confirmLabel": "I understand this will permanently delete the selected exercise log entries and cannot be undone.",
|
||||
"deleteBtn": "Delete"
|
||||
},
|
||||
"credits": {
|
||||
"title": "Credits",
|
||||
"intro": "Open-source icons used in this application:",
|
||||
"appIcon": {
|
||||
"name": "Mighty Force",
|
||||
"description": "App icon, by Delapouite",
|
||||
"author": "Delapouite"
|
||||
},
|
||||
"bootstrapIcons": {
|
||||
"name": "Bootstrap Icons",
|
||||
"description": "UI icons used throughout the application"
|
||||
},
|
||||
"gameIcons": "game-icons.net is maintained by Lorc, Delapouite & contributors",
|
||||
"thirdParties": {
|
||||
"title": "Third-party libraries"
|
||||
},
|
||||
"testingTools": {
|
||||
"title": "Testing Tools"
|
||||
}
|
||||
},
|
||||
"export": {
|
||||
"exercises": "Export all exercises",
|
||||
"exercise": "Export this exercise",
|
||||
"combos": "Export all combos",
|
||||
"combo": "Export this combo",
|
||||
"sessions": "Export all sessions",
|
||||
"session": "Export this session",
|
||||
"collections": "Export all collections",
|
||||
"collection": "Export this collection",
|
||||
"executionLogs": "Execution Logs",
|
||||
"settings": "Settings",
|
||||
"filterTitle": "Export Data",
|
||||
"filterDescription": "Select which data types to export."
|
||||
},
|
||||
"import": {
|
||||
"title": "Import Data",
|
||||
"titleAsMine": "Import as Mine",
|
||||
"exercises": "Import exercises",
|
||||
"importAsMine": "Import as mine",
|
||||
"cancel": "Cancel",
|
||||
"continue": "Continue",
|
||||
"apply": "Import",
|
||||
"done": "Done",
|
||||
"importing": "Importing…",
|
||||
"exerciseCount": "exercise(s)",
|
||||
"totalCount": "item(s)",
|
||||
"executionLogs": "execution log(s)",
|
||||
"from": "From {{name}} on {{date}}",
|
||||
"usingSuffix": "Suffix: [{{suffix}}]",
|
||||
"suffixExplanation": "These items are from {{name}}. Choose a short suffix to distinguish them from your own.",
|
||||
"suffixLabel": "Suffix",
|
||||
"suffixPlaceholder": "e.g. JD",
|
||||
"suffixPreview": "Titles will look like: \"{{title}} [{{suffix}}]\"",
|
||||
"suffixRequired": "Suffix is required.",
|
||||
"suffixTaken": "This suffix is already used by another user.",
|
||||
"collisionExplanation": "{{count}} item(s) conflict with existing data. Choose what to do for each:",
|
||||
"replace": "Replace",
|
||||
"skip": "Skip",
|
||||
"replaceAll": "Replace All",
|
||||
"skipAll": "Skip All",
|
||||
"exportedOn": "Exported on {{date}}",
|
||||
"reportTitle": "Import Complete",
|
||||
"reportTotalImported": "{{count}} item(s) imported",
|
||||
"reportImported": "{{count}} imported",
|
||||
"reportReplaced": "{{count}} replaced",
|
||||
"reportUpdated": "{{count}} updated",
|
||||
"reportSkipped": "{{count}} skipped",
|
||||
"errors": {
|
||||
"invalidJson": "The file does not contain valid JSON.",
|
||||
"invalidFormat": "The file format is not recognized.",
|
||||
"missingMetadata": "The file is missing metadata.",
|
||||
"wrongApp": "This file was not exported from TrainUs.",
|
||||
"missingSchema": "The file is missing schema version information.",
|
||||
"newerSchema": "This file was exported from a newer version of TrainUs. Please update the application.",
|
||||
"missingData": "The file does not contain any data.",
|
||||
"missingExporter": "The file is missing exporter information."
|
||||
}
|
||||
},
|
||||
"validation": {
|
||||
"required": "This field is required.",
|
||||
"minValue": "Value must be ≥ {{min}}.",
|
||||
"invalidUrl": "Please enter a valid URL (https://…).",
|
||||
"titleTaken": "An exercise with this title already exists.",
|
||||
"comboTitleTaken": "A combo with this title already exists.",
|
||||
"collectionLabelTaken": "A collection with this label already exists."
|
||||
},
|
||||
"shortcuts": {
|
||||
"edit": "— press E",
|
||||
"new": "— press N",
|
||||
"back": "— press Esc",
|
||||
"save": "— Ctrl+Enter"
|
||||
},
|
||||
"execution": {
|
||||
"start": "Start session",
|
||||
"exit": "Exit session",
|
||||
"steps": "steps",
|
||||
"now": "NOW",
|
||||
"next": "NEXT",
|
||||
"round": "Round",
|
||||
"reps": "Reps",
|
||||
"weight": "Weight",
|
||||
"side": "Side",
|
||||
"sideLeft": "LEFT",
|
||||
"sideRight": "RIGHT",
|
||||
"asymAlternate": "(1x{{left}} ↔ 1x{{right}}) × {{count}}",
|
||||
"asymSequential": "{{count}}x {{left}} → {{count}}x {{right}}",
|
||||
"done": "Done",
|
||||
"continue": "Continue",
|
||||
"oneMoreRound": "One More Round",
|
||||
"comboContext": "Combo: {{title}} — Round {{round}} of {{total}}",
|
||||
"comboComplete": "{{title}} — {{rounds}} round(s) complete",
|
||||
"complete": "Session complete!",
|
||||
"backToSession": "Back to session",
|
||||
"timer": {
|
||||
"pause": "Pause",
|
||||
"resume": "Resume",
|
||||
"rest": "REST",
|
||||
"nextExercise": "Next:",
|
||||
"timeUp": "Time's up",
|
||||
"timeUpTitle": "⏱ Time's up — {{title}}"
|
||||
},
|
||||
"showDetails": "Show exercise details",
|
||||
"hideDetails": "Hide exercise details",
|
||||
"videoOffline": "Videos are not available offline.",
|
||||
"leaveConfirm": "You are in the middle of a session. Leave anyway? The session will be marked as aborted."
|
||||
},
|
||||
"backup": {
|
||||
"reminder": {
|
||||
"tooltip": "Last backup {{days}} day(s) ago — click to back up",
|
||||
"tooltipNever": "No backup yet — click to create one"
|
||||
},
|
||||
"modal": {
|
||||
"title": "Backup reminder",
|
||||
"body": "Your last backup was {{days}} day(s) ago. We recommend backing up your data regularly.",
|
||||
"bodyNever": "You have never backed up your data. We recommend creating a backup.",
|
||||
"dismiss": "Dismiss",
|
||||
"goToSettings": "Go to Settings"
|
||||
}
|
||||
},
|
||||
"install": {
|
||||
"button": "Install app",
|
||||
"dismiss": "Dismiss install prompt"
|
||||
},
|
||||
"modal": {
|
||||
"close": "Close dialog"
|
||||
},
|
||||
"saveFile": {
|
||||
"title": "Save file",
|
||||
"label": "File name",
|
||||
"cancel": "Cancel",
|
||||
"save": "Save"
|
||||
},
|
||||
"about": {
|
||||
"title": "About TrainUs",
|
||||
"appVersion": "Application Version",
|
||||
"dbVersion": "Database Version",
|
||||
"author": "Created by",
|
||||
"links": "Links",
|
||||
"repository": "Repository",
|
||||
"website": "Website",
|
||||
"license": "License",
|
||||
"licenseBtn": "View License"
|
||||
},
|
||||
"license": {
|
||||
"title": "License"
|
||||
}
|
||||
}
|
||||
460
src/i18n/locales/fr.json
Normal file
460
src/i18n/locales/fr.json
Normal file
|
|
@ -0,0 +1,460 @@
|
|||
{
|
||||
"app": {
|
||||
"name": "TrainUs",
|
||||
"loading": "Chargement…"
|
||||
},
|
||||
"common": {
|
||||
"markdownHint": "Markdown basique supporté",
|
||||
"back": "Retour",
|
||||
"clearSearch": "Effacer la recherche"
|
||||
},
|
||||
"error": {
|
||||
"dbInitFailed": "Impossible d'initialiser la base de données",
|
||||
"browserNotCompatible": "Votre navigateur ne prend pas en charge les fonctionnalités de stockage requises. Veuillez vous assurer d'utiliser une version récente de votre navigateur et de ne pas être en navigation privée.",
|
||||
"dbNewerThanApp": "Vos données ont été créées par une version plus récente de l'application. Vos données n'ont pas été modifiées. Veuillez recharger la page pour obtenir la dernière version de l'application.",
|
||||
"dbMigrationFailed": "La mise à jour de la base de données a échoué et vos données n'ont pas été modifiées. Rechargez la page pour réessayer, ou restaurez la sauvegarde que vous venez de télécharger si le problème persiste.",
|
||||
"dbConnectionLost": "La connexion à la base de données a été perdue suite à une erreur inattendue. Rechargez la page pour vous reconnecter.",
|
||||
"reload": "Recharger"
|
||||
},
|
||||
"migration": {
|
||||
"title": "Mise à jour de la base de données requise",
|
||||
"body": "L'application a été mise à jour et votre base de données doit être migrée de la version {{from}} vers la version {{to}}.",
|
||||
"backupRequired": "Avant de mettre à jour, veuillez télécharger une sauvegarde de vos données. La mise à jour ne pourra démarrer qu'une fois la sauvegarde enregistrée.",
|
||||
"downloadBackup": "Télécharger la sauvegarde",
|
||||
"update": "Mettre à jour",
|
||||
"updating": "Mise à jour…"
|
||||
},
|
||||
"nav": {
|
||||
"mainNav": "Navigation principale",
|
||||
"home": "Accueil",
|
||||
"exercises": "Exercices",
|
||||
"combos": "Combos",
|
||||
"sessions": "Séances",
|
||||
"collections": "Collections",
|
||||
"stats": "Statistiques",
|
||||
"settings": "Réglages"
|
||||
},
|
||||
"home": {
|
||||
"title": "Accueil",
|
||||
"empty": "Aucune séance exécutée pour le moment. Lancez une séance pour voir votre activité ici !",
|
||||
"recentSessions": "Séances récentes",
|
||||
"sessionsLast7Days": "Séances (7 derniers jours)",
|
||||
"sessionsLast30Days": "Séances (30 derniers jours)",
|
||||
"history": "Historique",
|
||||
"exportHtml": "Exporter en HTML",
|
||||
"exportedOn": "Exporté le {{date}}"
|
||||
},
|
||||
"exercises": {
|
||||
"title": "Exercices",
|
||||
"empty": "Aucun exercice pour le moment. Créez votre premier exercice pour commencer !",
|
||||
"searchPlaceholder": "Rechercher des exercices…",
|
||||
"noResults": "Aucun exercice ne correspond à votre recherche.",
|
||||
"new": "Nouvel exercice",
|
||||
"edit": "Modifier l'exercice",
|
||||
"detail": "Détails de l'exercice",
|
||||
"deleteConfirm": "Êtes-vous sûr de vouloir supprimer « {{title}} » ? Cette action est irréversible.",
|
||||
"deleteConfirmWithHistory": "Êtes-vous sûr de vouloir supprimer « {{title}} » ? Cela supprimera également {{count}} entrée(s) du journal. Cette action est irréversible.",
|
||||
"deleted": "Exercice supprimé.",
|
||||
"form": {
|
||||
"title": "Titre",
|
||||
"titlePlaceholder": "ex. Pompes",
|
||||
"description": "Description",
|
||||
"descriptionPlaceholder": "Description facultative…",
|
||||
"asymmetric": "Exercice asymétrique",
|
||||
"alternate": "Alterner les côtés à chaque répétition",
|
||||
"alternateFalse": "Compléter toutes les répétitions d'un côté, puis changer",
|
||||
"imageUrls": "URLs des images",
|
||||
"videoUrls": "URLs des vidéos",
|
||||
"urlPlaceholder": "https://…",
|
||||
"addUrl": "Ajouter une URL",
|
||||
"removeUrl": "Supprimer",
|
||||
"defaultReps": "Répétitions par défaut",
|
||||
"defaultWeight": "Poids par défaut (kg)",
|
||||
"save": "Enregistrer",
|
||||
"cancel": "Annuler"
|
||||
},
|
||||
"reps": "reps",
|
||||
"weight": "kg",
|
||||
"asymmetricLabel": "Asymétrique",
|
||||
"alternateLabel": "Alternance",
|
||||
"delete": "Supprimer l'exercice",
|
||||
"videoOffline": "Les vidéos ne sont pas disponibles hors ligne.",
|
||||
"evenRepsHint": "Le nombre de répétitions doit être pair pour les exercices asymétriques."
|
||||
},
|
||||
"combos": {
|
||||
"title": "Combos",
|
||||
"empty": "Aucun combo pour le moment. Créez votre premier combo pour commencer !",
|
||||
"searchPlaceholder": "Rechercher des combos…",
|
||||
"noResults": "Aucun combo ne correspond à votre recherche.",
|
||||
"new": "Nouveau Combo",
|
||||
"edit": "Modifier le Combo",
|
||||
"detail": "Détails du Combo",
|
||||
"deleteConfirm": "Êtes-vous sûr de vouloir supprimer « {{title}} » ? Cette action est irréversible.",
|
||||
"deleted": "Combo supprimé.",
|
||||
"exercises": "exercices",
|
||||
"timerConfig": "Configuration du minuteur",
|
||||
"delete": "Supprimer le combo",
|
||||
"form": {
|
||||
"title": "Titre",
|
||||
"titlePlaceholder": "ex. Upper Body Blast",
|
||||
"description": "Description",
|
||||
"descriptionPlaceholder": "Description facultative…",
|
||||
"type": "Type",
|
||||
"exercises": "Exercices",
|
||||
"selectExercise": "Sélectionner un exercice",
|
||||
"noExercises": "Aucun exercice disponible — créez d'abord des exercices",
|
||||
"addExercise": "Ajouter Exercice",
|
||||
"reps": "Reps",
|
||||
"weight": "Poids (kg)",
|
||||
"save": "Enregistrer",
|
||||
"cancel": "Annuler"
|
||||
},
|
||||
"types": {
|
||||
"none": "Aucun",
|
||||
"emom": "EMOM",
|
||||
"amrap": "AMRAP",
|
||||
"tabata": "TABATA"
|
||||
},
|
||||
"timer": {
|
||||
"emomDuration": "Durée",
|
||||
"amrapDuration": "Durée",
|
||||
"tabataWorkTime": "Temps de travail (s)",
|
||||
"tabataRestTime": "Temps de repos (s)",
|
||||
"tabataSeconds": "secondes",
|
||||
"minutes": "minutes",
|
||||
"rounds": "tours"
|
||||
}
|
||||
},
|
||||
"collections": {
|
||||
"title": "Collections",
|
||||
"empty": "Aucune collection pour le moment. Créez votre première collection pour organiser vos séances !",
|
||||
"searchPlaceholder": "Rechercher des collections…",
|
||||
"noResults": "Aucune collection ne correspond à votre recherche.",
|
||||
"new": "Nouvelle Collection",
|
||||
"edit": "Modifier la Collection",
|
||||
"detail": "Détails de la Collection",
|
||||
"deleteConfirm": "Êtes-vous sûr de vouloir supprimer « {{label}} » ? Cette action est irréversible.",
|
||||
"deleted": "Collection supprimée.",
|
||||
"sessions": "séances",
|
||||
"delete": "Supprimer la collection",
|
||||
"form": {
|
||||
"label": "Libellé",
|
||||
"labelPlaceholder": "ex. Entraînement Hebdomadaire",
|
||||
"sessions": "Séances",
|
||||
"selectSession": "Sélectionner une séance",
|
||||
"noSessions": "Aucune séance disponible — créez d'abord des séances",
|
||||
"addSession": "Ajouter Séance",
|
||||
"save": "Enregistrer",
|
||||
"cancel": "Annuler"
|
||||
}
|
||||
},
|
||||
"sessions": {
|
||||
"title": "Séances",
|
||||
"empty": "Aucune séance pour le moment.",
|
||||
"searchPlaceholder": "Rechercher des séances…",
|
||||
"noResults": "Aucune séance ne correspond à votre recherche.",
|
||||
"new": "Nouvelle séance",
|
||||
"edit": "Modifier la séance",
|
||||
"detail": "Détails de la séance",
|
||||
"deleteConfirm": "Êtes-vous sûr de vouloir supprimer « {{title}} » ? Cette action est irréversible.",
|
||||
"deleteConfirmWithHistory": "Êtes-vous sûr de vouloir supprimer « {{title}} » ? Cela supprimera également {{count}} journal(aux) d'exécution. Cette action est irréversible.",
|
||||
"deleted": "Séance supprimée.",
|
||||
"items": "éléments",
|
||||
"delete": "Supprimer la séance",
|
||||
"itemType": {
|
||||
"exercise": "Exercice",
|
||||
"combo": "Combo"
|
||||
},
|
||||
"form": {
|
||||
"title": "Titre",
|
||||
"titlePlaceholder": "ex. Upper Body Day",
|
||||
"description": "Description",
|
||||
"descriptionPlaceholder": "Description facultative…",
|
||||
"items": "Éléments",
|
||||
"reps": "Reps",
|
||||
"durationMin": "Durée (min)",
|
||||
"weight": "Poids (kg)",
|
||||
"selectExercise": "Sélectionner un exercice",
|
||||
"selectCombo": "Sélectionner un combo",
|
||||
"noExercises": "Aucun exercice disponible — créez d'abord des exercices",
|
||||
"noCombos": "Aucun combo disponible — créez d'abord des combos",
|
||||
"addItem": "Ajouter élément",
|
||||
"save": "Enregistrer",
|
||||
"cancel": "Annuler"
|
||||
}
|
||||
},
|
||||
"stats": {
|
||||
"title": "Statistiques",
|
||||
"empty": "Pas encore de données. Exécutez une séance pour voir vos statistiques.",
|
||||
"usingSince": "Vous utilisez TrainUs depuis le {{date}}",
|
||||
"dateRange": {
|
||||
"7d": "7 jours",
|
||||
"30d": "30 jours",
|
||||
"3m": "3 mois",
|
||||
"all": "Tout"
|
||||
},
|
||||
"summary": {
|
||||
"sessions": "Séances",
|
||||
"exercises": "Exercices faits",
|
||||
"streak": "Jours consécutifs"
|
||||
},
|
||||
"charts": {
|
||||
"frequency": {
|
||||
"title": "Fréquence",
|
||||
"week": "Par semaine",
|
||||
"month": "Par mois",
|
||||
"yLabel": "Séances",
|
||||
"noData": "Aucune séance dans cette période."
|
||||
},
|
||||
"volume": {
|
||||
"title": "Volume",
|
||||
"yLabel": "Volume (kg·rép.)",
|
||||
"noData": "Aucune donnée de volume dans cette période."
|
||||
},
|
||||
"progression": {
|
||||
"title": "Progression",
|
||||
"selectExercise": "Sélectionner un exercice…",
|
||||
"reps": "Rép.",
|
||||
"weight": "Poids (kg)",
|
||||
"noData": "Aucune donnée pour cet exercice dans cette période."
|
||||
}
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"title": "Réglages",
|
||||
"language": "Langue",
|
||||
"userName": "Nom d'utilisateur",
|
||||
"userNamePlaceholder": "Entrez votre nom",
|
||||
"nameRequired": "Le nom ne peut pas être vide",
|
||||
"userId": "Identifiant",
|
||||
"theme": "Thème",
|
||||
"themeLight": "Clair",
|
||||
"themeDark": "Sombre",
|
||||
"themeTrainus": "TrainUs",
|
||||
"database": "Base de données",
|
||||
"downloadDb": "Télécharger la base de données",
|
||||
"downloadDbHint": "Télécharger ou restaurer le fichier SQLite brut.",
|
||||
"restoreDb": "Restaurer la base de données",
|
||||
"restoreDbWarning": "Ceci va remplacer TOUTES les données actuelles par le contenu du fichier sélectionné. Cette action est irréversible.\n\nVersion actuelle de l'application : {{version}}\n\nÊtes-vous sûr de vouloir continuer ?",
|
||||
"restoreInProgress": "Restauration en cours…",
|
||||
"restoreSuccess": "Base de données restaurée avec succès",
|
||||
"restoreError": "Échec de la restauration",
|
||||
"restoreAdapted": "Schéma adapté de la version {{from}} à la version {{to}}.",
|
||||
"restoreRows": "ligne(s)",
|
||||
"restoreDismiss": "OK",
|
||||
"restoreErrorCodes": {
|
||||
"INVALID_FILE": "Le fichier sélectionné n'est pas une base de données SQLite valide ou est corrompu.",
|
||||
"INCOMPLETE_DB": "Le fichier sélectionné n'est pas une base de données TrainUs valide (tables requises manquantes).",
|
||||
"INCOMPATIBLE_VERSION": "La version de la base de données est incompatible et aucun chemin de migration n'est disponible.",
|
||||
"ADAPT_FAILED": "Échec de l'adaptation du schéma de la base de données.",
|
||||
"RESTORE_FAILED": "Une erreur est survenue lors de la restauration des données.",
|
||||
"UNEXPECTED": "Une erreur inattendue est survenue."
|
||||
},
|
||||
"exportImport": "Exporter / Importer",
|
||||
"exportAllData": "Exporter toutes les données",
|
||||
"exportData": "Exporter les données",
|
||||
"importData": "Importer les données",
|
||||
"importAsMine": "Importer comme les miens",
|
||||
"comingSoon": "Disponible dans une prochaine mise à jour.",
|
||||
"suffixManagement": "Suffixes d'importation",
|
||||
"suffixHint": "Gérez les suffixes utilisés pour distinguer les exercices importés d'autres utilisateurs.",
|
||||
"suffixDeleteConfirm": "Supprimer le suffixe « {{suffix}} » pour {{name}} ? Les exercices importés ne seront pas supprimés.",
|
||||
"suffixDeleteTitle": "Supprimer le suffixe",
|
||||
"backupReminderInvalid": "Doit être entre 1 et 365",
|
||||
"initializeDb": "Initialiser la base de données",
|
||||
"initializeDbHint": "Supprimer toutes les données et réinitialiser la base de données à son état initial. Cette action est irréversible.",
|
||||
"initializeDbTitle": "Initialiser la base de données",
|
||||
"initializeDbConfirm": "Je comprends que cela supprimera définitivement toutes mes données et ne peut pas être annulé",
|
||||
"backup": {
|
||||
"lastBackup": "Dernière sauvegarde : {{when}}",
|
||||
"neverBackedUp": "Jamais sauvegardé",
|
||||
"reminderEvery": "Me rappeler tous les",
|
||||
"reminderEveryUnit": "jours"
|
||||
},
|
||||
"sound": "Son",
|
||||
"soundVolume": "Volume",
|
||||
"soundTest": "Tester",
|
||||
"credits": "Crédits",
|
||||
"manageJournal": "Gérer les journaux d'exercices",
|
||||
"manageJournalHint": "Supprimez les entrées du journal d'exercices sur une plage de dates.",
|
||||
"cleanJournalBtn": "Nettoyer le journal"
|
||||
},
|
||||
"cleanJournal": {
|
||||
"title": "Nettoyer le journal",
|
||||
"hint": "Sélectionnez une plage de dates pour supprimer définitivement toutes les entrées du journal d'exercices.",
|
||||
"from": "Du",
|
||||
"to": "Au",
|
||||
"errorToBeforeFrom": "La date de fin doit être égale ou postérieure à la date de début.",
|
||||
"matchCount_one": "{{count}} séance sera supprimée.",
|
||||
"matchCount_other": "{{count}} séances seront supprimées.",
|
||||
"matchCount": "{{count}} séance(s) seront supprimée(s).",
|
||||
"confirmLabel": "Je comprends que cette action supprimera définitivement les entrées du journal sélectionnées et ne peut pas être annulée.",
|
||||
"deleteBtn": "Supprimer"
|
||||
},
|
||||
"credits": {
|
||||
"title": "Crédits",
|
||||
"intro": "Icônes open source utilisées dans cette application:",
|
||||
"appIcon": {
|
||||
"name": "Mighty Force",
|
||||
"description": "Icône de l'application, par Delapouite",
|
||||
"author": "Delapouite"
|
||||
},
|
||||
"bootstrapIcons": {
|
||||
"name": "Bootstrap Icons",
|
||||
"description": "Icônes d'interface utilisées dans toute l'application"
|
||||
},
|
||||
"gameIcons": "game-icons.net est maintenu par Lorc, Delapouite & contributeurs",
|
||||
"thirdParties": {
|
||||
"title": "Bibliothèques tierces"
|
||||
},
|
||||
"testingTools": {
|
||||
"title": "Outils de test"
|
||||
}
|
||||
},
|
||||
"export": {
|
||||
"exercises": "Exporter tous les exercices",
|
||||
"exercise": "Exporter cet exercice",
|
||||
"combos": "Exporter tous les combos",
|
||||
"combo": "Exporter ce combo",
|
||||
"sessions": "Exporter toutes les séances",
|
||||
"session": "Exporter cette séance",
|
||||
"collections": "Exporter toutes les collections",
|
||||
"collection": "Exporter cette collection",
|
||||
"executionLogs": "Journaux d'exécution",
|
||||
"settings": "Paramètres",
|
||||
"filterTitle": "Exporter les données",
|
||||
"filterDescription": "Sélectionnez les types de données à exporter."
|
||||
},
|
||||
"import": {
|
||||
"title": "Importer des données",
|
||||
"titleAsMine": "Importer comme les miens",
|
||||
"exercises": "Importer des exercices",
|
||||
"importAsMine": "Importer comme les miens",
|
||||
"cancel": "Annuler",
|
||||
"continue": "Continuer",
|
||||
"apply": "Importer",
|
||||
"done": "Terminé",
|
||||
"importing": "Importation en cours…",
|
||||
"exerciseCount": "exercice(s)",
|
||||
"totalCount": "élément(s)",
|
||||
"executionLogs": "journal(aux) d'exécution",
|
||||
"from": "De {{name}} le {{date}}",
|
||||
"usingSuffix": "Suffixe : [{{suffix}}]",
|
||||
"suffixExplanation": "Ces éléments proviennent de {{name}}. Choisissez un court suffixe pour les distinguer des vôtres.",
|
||||
"suffixLabel": "Suffixe",
|
||||
"suffixPlaceholder": "ex. JD",
|
||||
"suffixPreview": "Les titres ressembleront à : « {{title}} [{{suffix}}] »",
|
||||
"suffixRequired": "Le suffixe est requis.",
|
||||
"suffixTaken": "Ce suffixe est déjà utilisé par un autre utilisateur.",
|
||||
"collisionExplanation": "{{count}} élément(s) sont en conflit avec des données existantes. Choisissez quoi faire pour chacun :",
|
||||
"replace": "Remplacer",
|
||||
"skip": "Ignorer",
|
||||
"replaceAll": "Tout remplacer",
|
||||
"skipAll": "Tout ignorer",
|
||||
"exportedOn": "Exporté le {{date}}",
|
||||
"reportTitle": "Importation terminée",
|
||||
"reportTotalImported": "{{count}} élément(s) importé(s)",
|
||||
"reportImported": "{{count}} importé(s)",
|
||||
"reportReplaced": "{{count}} remplacé(s)",
|
||||
"reportUpdated": "{{count}} mis à jour",
|
||||
"reportSkipped": "{{count}} ignoré(s)",
|
||||
"errors": {
|
||||
"invalidJson": "Le fichier ne contient pas de JSON valide.",
|
||||
"invalidFormat": "Le format du fichier n'est pas reconnu.",
|
||||
"missingMetadata": "Le fichier ne contient pas de métadonnées.",
|
||||
"wrongApp": "Ce fichier n'a pas été exporté depuis TrainUs.",
|
||||
"missingSchema": "Le fichier ne contient pas d'information de version du schéma.",
|
||||
"newerSchema": "Ce fichier a été exporté depuis une version plus récente de TrainUs. Veuillez mettre à jour l'application.",
|
||||
"missingData": "Le fichier ne contient aucune donnée.",
|
||||
"missingExporter": "Le fichier ne contient pas d'informations sur l'exportateur."
|
||||
}
|
||||
},
|
||||
"validation": {
|
||||
"required": "Ce champ est requis.",
|
||||
"minValue": "La valeur doit être ≥ {{min}}.",
|
||||
"invalidUrl": "Veuillez entrer une URL valide (https://…).",
|
||||
"titleTaken": "Un exercice avec ce titre existe déjà.",
|
||||
"comboTitleTaken": "Un combo avec ce titre existe déjà.",
|
||||
"collectionLabelTaken": "Une collection avec ce libellé existe déjà."
|
||||
},
|
||||
"shortcuts": {
|
||||
"edit": "— appuyez sur E",
|
||||
"new": "— appuyez sur N",
|
||||
"back": "— appuyez sur Échap",
|
||||
"save": "— Ctrl+Entrée"
|
||||
},
|
||||
"execution": {
|
||||
"start": "Démarrer la séance",
|
||||
"exit": "Quitter la séance",
|
||||
"steps": "étapes",
|
||||
"now": "MAINTENANT",
|
||||
"next": "SUIVANT",
|
||||
"round": "Tour",
|
||||
"reps": "Reps",
|
||||
"weight": "Poids",
|
||||
"side": "Côté",
|
||||
"sideLeft": "GAUCHE",
|
||||
"sideRight": "DROIT",
|
||||
"asymAlternate": "(1x{{left}} ↔ 1x{{right}}) × {{count}}",
|
||||
"asymSequential": "{{count}}x {{left}} → {{count}}x {{right}}",
|
||||
"done": "Terminé",
|
||||
"continue": "Continuer",
|
||||
"oneMoreRound": "Un tour de plus",
|
||||
"comboContext": "Combo : {{title}} — Tour {{round}} sur {{total}}",
|
||||
"comboComplete": "{{title}} — {{rounds}} tour(s) terminé(s)",
|
||||
"complete": "Séance terminée !",
|
||||
"backToSession": "Retour à la séance",
|
||||
"timer": {
|
||||
"pause": "Pause",
|
||||
"resume": "Reprendre",
|
||||
"rest": "REPOS",
|
||||
"nextExercise": "Suivant :",
|
||||
"timeUp": "Temps écoulé",
|
||||
"timeUpTitle": "⏱ Temps écoulé — {{title}}"
|
||||
},
|
||||
"showDetails": "Afficher les détails de l'exercice",
|
||||
"hideDetails": "Masquer les détails de l'exercice",
|
||||
"videoOffline": "Les vidéos ne sont pas disponibles hors ligne.",
|
||||
"leaveConfirm": "Vous êtes en pleine séance. Partir quand même ? La séance sera marquée comme interrompue."
|
||||
},
|
||||
"backup": {
|
||||
"reminder": {
|
||||
"tooltip": "Dernière sauvegarde il y a {{days}} jour(s) — cliquez pour sauvegarder",
|
||||
"tooltipNever": "Pas encore de sauvegarde — cliquez pour en créer une"
|
||||
},
|
||||
"modal": {
|
||||
"title": "Rappel de sauvegarde",
|
||||
"body": "Votre dernière sauvegarde remonte à {{days}} jour(s). Nous vous recommandons de sauvegarder régulièrement.",
|
||||
"bodyNever": "Vous n'avez jamais effectué de sauvegarde. Nous vous recommandons d'en créer une.",
|
||||
"dismiss": "Ignorer",
|
||||
"goToSettings": "Aller aux paramètres"
|
||||
}
|
||||
},
|
||||
"install": {
|
||||
"button": "Installer l'application",
|
||||
"dismiss": "Ignorer l'invitation à installer"
|
||||
},
|
||||
"modal": {
|
||||
"close": "Fermer la boîte de dialogue"
|
||||
},
|
||||
"saveFile": {
|
||||
"title": "Enregistrer le fichier",
|
||||
"label": "Nom du fichier",
|
||||
"cancel": "Annuler",
|
||||
"save": "Enregistrer"
|
||||
},
|
||||
"about": {
|
||||
"title": "À propos de TrainUs",
|
||||
"appVersion": "Version de l'application",
|
||||
"dbVersion": "Version de la base de données",
|
||||
"author": "Créé par",
|
||||
"links": "Liens",
|
||||
"repository": "Dépôt",
|
||||
"website": "Site web",
|
||||
"license": "Licence",
|
||||
"licenseBtn": "Voir la licence"
|
||||
},
|
||||
"license": {
|
||||
"title": "Licence"
|
||||
}
|
||||
}
|
||||
65
src/main.js
Normal file
65
src/main.js
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import { createApp } from 'vue'
|
||||
import { i18next, I18NextVue } from './i18n'
|
||||
import router from './router'
|
||||
import { db } from './db/database.js'
|
||||
import { settingsRepository } from './db/repositories/settingsRepository.js'
|
||||
import { exerciseRepository } from './db/repositories/exerciseRepository.js'
|
||||
import { comboRepository } from './db/repositories/comboRepository.js'
|
||||
import { sessionRepository } from './db/repositories/sessionRepository.js'
|
||||
import { collectionRepository } from './db/repositories/collectionRepository.js'
|
||||
import { executionLogRepository } from './db/repositories/executionLogRepository.js'
|
||||
import { suffixRepository } from './db/repositories/suffixRepository.js'
|
||||
import 'bootstrap-icons/font/bootstrap-icons.css'
|
||||
import './styles/variables.css'
|
||||
import './styles/layout.css'
|
||||
import App from './App.vue'
|
||||
|
||||
const app = createApp(App)
|
||||
app.use(I18NextVue, { i18next })
|
||||
app.use(router)
|
||||
|
||||
// Initialize database (before mount so App.vue can subscribe to the promise)
|
||||
const dbReady = db
|
||||
.init()
|
||||
.then(async (info) => {
|
||||
console.log('Database initialized:', info)
|
||||
|
||||
// Apply persisted settings before first render
|
||||
const lang = await settingsRepository.get('language')
|
||||
if (lang) {
|
||||
i18next.changeLanguage(lang)
|
||||
}
|
||||
const theme = await settingsRepository.get('theme')
|
||||
if (theme) {
|
||||
document.documentElement.setAttribute('data-theme', theme)
|
||||
}
|
||||
|
||||
if (db.migrationPending) {
|
||||
// App stays paused: App.vue shows the blocking migration screen
|
||||
document.documentElement.setAttribute('data-db-migration', 'pending')
|
||||
return { migrationPending: db.migrationPending }
|
||||
}
|
||||
|
||||
document.documentElement.setAttribute('data-db-ready', 'true')
|
||||
return {}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('Database initialization failed:', err)
|
||||
document.documentElement.setAttribute('data-db-error', err.message)
|
||||
throw err
|
||||
})
|
||||
|
||||
// Expose for testing
|
||||
window.__db = db
|
||||
window.__dbReady = dbReady
|
||||
window.__repos = {
|
||||
settings: settingsRepository,
|
||||
exercises: exerciseRepository,
|
||||
combos: comboRepository,
|
||||
sessions: sessionRepository,
|
||||
collections: collectionRepository,
|
||||
executionLogs: executionLogRepository,
|
||||
suffixes: suffixRepository,
|
||||
}
|
||||
|
||||
app.mount('#app')
|
||||
119
src/router/index.js
Normal file
119
src/router/index.js
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
import { createRouter, createWebHashHistory } from 'vue-router'
|
||||
|
||||
const routes = [
|
||||
{
|
||||
path: '/',
|
||||
redirect: '/home',
|
||||
},
|
||||
{
|
||||
path: '/home',
|
||||
name: 'home',
|
||||
component: () => import('../views/HomeView.vue'),
|
||||
},
|
||||
{
|
||||
path: '/exercises',
|
||||
name: 'exercises',
|
||||
component: () => import('../views/ExerciseListView.vue'),
|
||||
},
|
||||
{
|
||||
path: '/exercises/new',
|
||||
name: 'exercise-new',
|
||||
component: () => import('../views/ExerciseFormView.vue'),
|
||||
},
|
||||
{
|
||||
path: '/exercises/:id',
|
||||
name: 'exercise-detail',
|
||||
component: () => import('../views/ExerciseDetailView.vue'),
|
||||
},
|
||||
{
|
||||
path: '/exercises/:id/edit',
|
||||
name: 'exercise-edit',
|
||||
component: () => import('../views/ExerciseFormView.vue'),
|
||||
},
|
||||
{
|
||||
path: '/combos',
|
||||
name: 'combos',
|
||||
component: () => import('../views/ComboListView.vue'),
|
||||
},
|
||||
{
|
||||
path: '/combos/new',
|
||||
name: 'combo-new',
|
||||
component: () => import('../views/ComboFormView.vue'),
|
||||
},
|
||||
{
|
||||
path: '/combos/:id',
|
||||
name: 'combo-detail',
|
||||
component: () => import('../views/ComboDetailView.vue'),
|
||||
},
|
||||
{
|
||||
path: '/combos/:id/edit',
|
||||
name: 'combo-edit',
|
||||
component: () => import('../views/ComboFormView.vue'),
|
||||
},
|
||||
{
|
||||
path: '/sessions',
|
||||
name: 'sessions',
|
||||
component: () => import('../views/SessionListView.vue'),
|
||||
},
|
||||
{
|
||||
path: '/sessions/new',
|
||||
name: 'session-new',
|
||||
component: () => import('../views/SessionFormView.vue'),
|
||||
},
|
||||
{
|
||||
path: '/sessions/:id',
|
||||
name: 'session-detail',
|
||||
component: () => import('../views/SessionDetailView.vue'),
|
||||
},
|
||||
{
|
||||
path: '/sessions/:id/edit',
|
||||
name: 'session-edit',
|
||||
component: () => import('../views/SessionFormView.vue'),
|
||||
},
|
||||
{
|
||||
path: '/sessions/:id/execute',
|
||||
name: 'session-execute',
|
||||
component: () => import('../views/SessionExecuteView.vue'),
|
||||
},
|
||||
{
|
||||
path: '/collections',
|
||||
name: 'collections',
|
||||
component: () => import('../views/CollectionsView.vue'),
|
||||
},
|
||||
{
|
||||
path: '/collections/new',
|
||||
name: 'collection-new',
|
||||
component: () => import('../views/CollectionFormView.vue'),
|
||||
},
|
||||
{
|
||||
path: '/collections/:id',
|
||||
name: 'collection-detail',
|
||||
component: () => import('../views/CollectionDetailView.vue'),
|
||||
},
|
||||
{
|
||||
path: '/collections/:id/edit',
|
||||
name: 'collection-edit',
|
||||
component: () => import('../views/CollectionFormView.vue'),
|
||||
},
|
||||
{
|
||||
path: '/stats',
|
||||
name: 'stats',
|
||||
component: () => import('../views/StatsView.vue'),
|
||||
},
|
||||
{
|
||||
path: '/settings',
|
||||
name: 'settings',
|
||||
component: () => import('../views/SettingsView.vue'),
|
||||
},
|
||||
{
|
||||
path: '/:pathMatch(.*)*',
|
||||
redirect: '/home',
|
||||
},
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHashHistory(),
|
||||
routes,
|
||||
})
|
||||
|
||||
export default router
|
||||
276
src/styles/layout.css
Normal file
276
src/styles/layout.css
Normal file
|
|
@ -0,0 +1,276 @@
|
|||
/* Reset & base */
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font: var(--font-stack);
|
||||
color: var(--front-color1);
|
||||
background-color: var(--bg-color1);
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-size: var(--h1-size);
|
||||
}
|
||||
|
||||
h2,
|
||||
h3 {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--btn-primary-bg);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* App layout */
|
||||
#app {
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* CONTENT */
|
||||
.content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.content-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
/* ACTIONBAR */
|
||||
.actionbar {
|
||||
display: flex;
|
||||
background-color: var(--actionbar-bg-color);
|
||||
color: var(--actionbar-font-color);
|
||||
padding: 0 8px;
|
||||
align-items: center;
|
||||
height: var(--actionbar-height);
|
||||
flex-shrink: 0;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.actionbar h1 {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 20px;
|
||||
padding-left: 8px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
#actionbar-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.actionbar button {
|
||||
width: 40px;
|
||||
min-width: 40px;
|
||||
height: 36px;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
background-color: rgba(255, 255, 255, 0.12);
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
color: var(--actionbar-font-color);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.actionbar button:hover {
|
||||
background-color: rgba(255, 255, 255, 0.28);
|
||||
border-color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
/* NAVBAR */
|
||||
.navbar {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: var(--navbar-width);
|
||||
flex-shrink: 0;
|
||||
background-color: var(--navbar-bg-color);
|
||||
overflow-y: auto;
|
||||
z-index: 20;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.navbar-brand {
|
||||
text-align: center;
|
||||
padding: 12px 8px;
|
||||
font-size: var(--h1-size);
|
||||
font-weight: bold;
|
||||
color: var(--navbar-front-color);
|
||||
}
|
||||
|
||||
.navbar a {
|
||||
display: flex;
|
||||
color: var(--navbar-front-color);
|
||||
padding: 10px 12px;
|
||||
text-decoration: none;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.navbar a i {
|
||||
font-size: 20px;
|
||||
width: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.navbar a.router-link-active {
|
||||
background-color: var(--bg-color1);
|
||||
color: var(--front-color1);
|
||||
}
|
||||
|
||||
.navbar a:hover:not(.router-link-active) {
|
||||
background-color: var(--navbar-hover-bg-color);
|
||||
color: var(--navbar-hover-front-color);
|
||||
}
|
||||
|
||||
.navbar a:focus-visible {
|
||||
outline: 2px solid var(--btn-primary-bg);
|
||||
outline-offset: -2px;
|
||||
background-color: var(--navbar-hover-bg-color);
|
||||
color: var(--navbar-hover-front-color);
|
||||
}
|
||||
|
||||
.navbar-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* RESPONSIVE — Tablet / Mobile */
|
||||
@media screen and (max-width: 700px) {
|
||||
#app {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.navbar {
|
||||
order: 2;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
flex-direction: row;
|
||||
justify-content: space-around;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.navbar-brand {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.navbar-spacer {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.navbar a {
|
||||
flex-direction: column;
|
||||
padding: 6px 8px;
|
||||
font-size: 11px;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.navbar a i {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.content {
|
||||
order: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 600px) {
|
||||
.navbar a span {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Utility classes */
|
||||
.text-muted {
|
||||
color: var(--front-color2);
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: var(--btn-primary-bg);
|
||||
color: var(--btn-primary-color);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background-color: var(--btn-danger-bg);
|
||||
color: var(--btn-danger-color);
|
||||
}
|
||||
|
||||
.card {
|
||||
background-color: var(--bg-color2);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
background-color: var(--input-bg);
|
||||
color: var(--front-color1);
|
||||
border: 1px solid var(--input-border);
|
||||
border-radius: 6px;
|
||||
padding: 8px 12px;
|
||||
font-size: 14px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
input:focus,
|
||||
select:focus,
|
||||
textarea:focus {
|
||||
outline: 2px solid var(--btn-primary-bg);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
76
src/styles/variables.css
Normal file
76
src/styles/variables.css
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
:root {
|
||||
/* FONTS */
|
||||
--font-stack: 100%, Helvetica, sans-serif;
|
||||
--h1-size: 32px;
|
||||
|
||||
/* COLORS — Light theme (default) */
|
||||
--bg-color1: #ffffff;
|
||||
--bg-color2: #f5f5f5;
|
||||
--front-color1: #1a1a1a;
|
||||
--front-color2: #555555;
|
||||
--navbar-bg-color: #e0e0e0;
|
||||
--navbar-front-color: #1a1a1a;
|
||||
--navbar-hover-bg-color: #b0b0b0;
|
||||
--navbar-hover-front-color: #ffffff;
|
||||
--actionbar-bg-color: #1a1a1a;
|
||||
--actionbar-font-color: #ffffff;
|
||||
--border-color: #ddd;
|
||||
--input-bg: #ffffff;
|
||||
--input-border: #ccc;
|
||||
--btn-primary-bg: #2563eb;
|
||||
--btn-primary-color: #ffffff;
|
||||
--btn-danger-bg: #dc2626;
|
||||
--btn-danger-color: #ffffff;
|
||||
--success-color: #16a34a;
|
||||
--warning-color: #d97706;
|
||||
|
||||
/* SIZES */
|
||||
--navbar-width: 190px;
|
||||
--actionbar-height: 50px;
|
||||
}
|
||||
|
||||
/* TrainUs theme — logo colours (#1E7FC0 blue, #12A892 teal) */
|
||||
[data-theme='trainus'] {
|
||||
--bg-color1: #f0f8ff;
|
||||
--bg-color2: #dceefb;
|
||||
--front-color1: #0d1b2a;
|
||||
--front-color2: #2c4a63;
|
||||
--navbar-bg-color: #1e7fc0;
|
||||
--navbar-front-color: #ffffff;
|
||||
--navbar-hover-bg-color: #12a892;
|
||||
--navbar-hover-front-color: #ffffff;
|
||||
--actionbar-bg-color: #12a892;
|
||||
--actionbar-font-color: #ffffff;
|
||||
--border-color: #a8d5f0;
|
||||
--input-bg: #ffffff;
|
||||
--input-border: #1e7fc0;
|
||||
--btn-primary-bg: #1e7fc0;
|
||||
--btn-primary-color: #ffffff;
|
||||
--btn-danger-bg: #dc2626;
|
||||
--btn-danger-color: #ffffff;
|
||||
--success-color: #12a892;
|
||||
--warning-color: #d97706;
|
||||
}
|
||||
|
||||
/* Dark theme */
|
||||
[data-theme='dark'] {
|
||||
--bg-color1: #1a1a1a;
|
||||
--bg-color2: #2a2a2a;
|
||||
--front-color1: #e0e0e0;
|
||||
--front-color2: #aaaaaa;
|
||||
--navbar-bg-color: #111111;
|
||||
--navbar-front-color: #e0e0e0;
|
||||
--navbar-hover-bg-color: #333333;
|
||||
--navbar-hover-front-color: #ffffff;
|
||||
--actionbar-bg-color: #111111;
|
||||
--actionbar-font-color: #e0e0e0;
|
||||
--border-color: #444;
|
||||
--input-bg: #2a2a2a;
|
||||
--input-border: #555;
|
||||
--btn-primary-bg: #3b82f6;
|
||||
--btn-primary-color: #ffffff;
|
||||
--btn-danger-bg: #ef4444;
|
||||
--btn-danger-color: #ffffff;
|
||||
--success-color: #22c55e;
|
||||
--warning-color: #f59e0b;
|
||||
}
|
||||
10
src/utils/backupFilename.js
Normal file
10
src/utils/backupFilename.js
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
export function formatBackupFilename(version, date = new Date()) {
|
||||
const pad = (n) => String(n).padStart(2, '0')
|
||||
const y = date.getFullYear()
|
||||
const mo = pad(date.getMonth() + 1)
|
||||
const d = pad(date.getDate())
|
||||
const h = pad(date.getHours())
|
||||
const mi = pad(date.getMinutes())
|
||||
const s = pad(date.getSeconds())
|
||||
return `trainus-${version}-backup-${y}${mo}${d}-${h}h${mi}m${s}s.sqlite3`
|
||||
}
|
||||
62
src/utils/dateFormatter.js
Normal file
62
src/utils/dateFormatter.js
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import i18next from 'i18next'
|
||||
|
||||
export function formatDate(date, lang) {
|
||||
if (!date) return ''
|
||||
const d = date instanceof Date ? date : new Date(date)
|
||||
if (isNaN(d.getTime())) return ''
|
||||
|
||||
lang = lang || i18next.language || 'en'
|
||||
const pad = (n) => String(n).padStart(2, '0')
|
||||
const dd = pad(d.getDate())
|
||||
const mm = pad(d.getMonth() + 1)
|
||||
const yyyy = d.getFullYear()
|
||||
|
||||
if (lang === 'fr') {
|
||||
return `${dd}/${mm}/${yyyy}`
|
||||
}
|
||||
return `${mm}/${dd}/${yyyy}`
|
||||
}
|
||||
|
||||
export function formatItemDuration(ms) {
|
||||
if (!ms || ms <= 0) return null
|
||||
const totalSecs = Math.round(ms / 1000)
|
||||
if (totalSecs <= 0) return null
|
||||
const mins = Math.floor(totalSecs / 60)
|
||||
const secs = totalSecs % 60
|
||||
if (mins === 0) return `${secs}s`
|
||||
if (secs === 0) return `${mins}min`
|
||||
return `${mins}min${secs}s`
|
||||
}
|
||||
|
||||
export function formatDuration(start, end) {
|
||||
if (!start || !end) return null
|
||||
const s = start instanceof Date ? start : new Date(start)
|
||||
const e = end instanceof Date ? end : new Date(end)
|
||||
if (isNaN(s.getTime()) || isNaN(e.getTime())) return null
|
||||
const mins = Math.round((e - s) / 60000)
|
||||
if (mins <= 0) return null
|
||||
if (mins < 60) return `${mins} min`
|
||||
const h = Math.floor(mins / 60)
|
||||
const m = mins % 60
|
||||
return m === 0 ? `${h}h` : `${h}h ${m}min`
|
||||
}
|
||||
|
||||
export function formatDateTime(date, lang) {
|
||||
if (!date) return ''
|
||||
const d = date instanceof Date ? date : new Date(date)
|
||||
if (isNaN(d.getTime())) return ''
|
||||
|
||||
lang = lang || i18next.language || 'en'
|
||||
const pad = (n) => String(n).padStart(2, '0')
|
||||
const dd = pad(d.getDate())
|
||||
const mm = pad(d.getMonth() + 1)
|
||||
const yyyy = d.getFullYear()
|
||||
const HH = pad(d.getHours())
|
||||
const min = pad(d.getMinutes())
|
||||
const ss = pad(d.getSeconds())
|
||||
|
||||
if (lang === 'fr') {
|
||||
return `${dd}/${mm}/${yyyy}, ${HH}:${min}:${ss}`
|
||||
}
|
||||
return d.toLocaleString()
|
||||
}
|
||||
7
src/utils/escapeLike.js
Normal file
7
src/utils/escapeLike.js
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
/**
|
||||
* Escape backslash, % and _ so they are treated as literals in a LIKE pattern.
|
||||
* Use with LIKE ? ESCAPE '\' in SQL.
|
||||
*/
|
||||
export function escapeLike(s) {
|
||||
return s.replace(/\\/g, '\\\\').replace(/%/g, '\\%').replace(/_/g, '\\_')
|
||||
}
|
||||
113
src/utils/jsonEnvelope.js
Normal file
113
src/utils/jsonEnvelope.js
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
import { SCHEMA_VERSION } from '../db/schema.js'
|
||||
import { useSaveFilePrompt } from '../composables/useSaveFilePrompt.js'
|
||||
|
||||
/**
|
||||
* Builds a JSON export envelope wrapping entity data.
|
||||
* @param {object} data — e.g. { exercises: [...] }
|
||||
* @param {{ name: string, uuid: string }} exportedBy — current user info
|
||||
* @returns {object} The complete envelope
|
||||
*/
|
||||
export function buildEnvelope(data, exportedBy) {
|
||||
return {
|
||||
metadata: {
|
||||
appName: __APP_NAME__,
|
||||
appVersion: __APP_VERSION__,
|
||||
schemaVersion: SCHEMA_VERSION,
|
||||
exportDate: new Date().toISOString(),
|
||||
exportedBy: {
|
||||
name: exportedBy.name,
|
||||
uuid: exportedBy.uuid,
|
||||
},
|
||||
},
|
||||
data,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses and validates a JSON envelope string.
|
||||
* Returns { valid, envelope, error } where error is an i18n key.
|
||||
*/
|
||||
export function parseEnvelope(jsonString) {
|
||||
let parsed
|
||||
try {
|
||||
parsed = JSON.parse(jsonString)
|
||||
} catch {
|
||||
return { valid: false, envelope: null, error: 'import.errors.invalidJson' }
|
||||
}
|
||||
|
||||
if (!parsed || typeof parsed !== 'object') {
|
||||
return { valid: false, envelope: null, error: 'import.errors.invalidFormat' }
|
||||
}
|
||||
|
||||
const { metadata, data } = parsed
|
||||
|
||||
if (!metadata || typeof metadata !== 'object') {
|
||||
return { valid: false, envelope: null, error: 'import.errors.missingMetadata' }
|
||||
}
|
||||
|
||||
if (metadata.appName !== __APP_NAME__) {
|
||||
return { valid: false, envelope: null, error: 'import.errors.wrongApp' }
|
||||
}
|
||||
|
||||
if (typeof metadata.schemaVersion !== 'number') {
|
||||
return { valid: false, envelope: null, error: 'import.errors.missingSchema' }
|
||||
}
|
||||
|
||||
if (metadata.schemaVersion > SCHEMA_VERSION) {
|
||||
return { valid: false, envelope: null, error: 'import.errors.newerSchema' }
|
||||
}
|
||||
|
||||
if (!data || typeof data !== 'object') {
|
||||
return { valid: false, envelope: null, error: 'import.errors.missingData' }
|
||||
}
|
||||
|
||||
if (!metadata.exportedBy || !metadata.exportedBy.uuid || !metadata.exportedBy.name) {
|
||||
return { valid: false, envelope: null, error: 'import.errors.missingExporter' }
|
||||
}
|
||||
|
||||
return { valid: true, envelope: parsed, error: null }
|
||||
}
|
||||
|
||||
/**
|
||||
* Prompts for a filename (prefilled with `defaultFilename`), then triggers a
|
||||
* browser file download. No-op if the user cancels.
|
||||
* @param {string} defaultFilename
|
||||
* @param {string} jsonString
|
||||
*/
|
||||
export async function downloadJson(defaultFilename, jsonString) {
|
||||
const { promptFilename } = useSaveFilePrompt()
|
||||
const filename = await promptFilename(defaultFilename)
|
||||
if (!filename) return
|
||||
|
||||
const blob = new Blob([jsonString], { type: 'application/json' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = filename
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a file picker and returns file contents as string.
|
||||
* Returns null if user cancels.
|
||||
*/
|
||||
export function pickJsonFile() {
|
||||
return new Promise((resolve) => {
|
||||
const input = document.createElement('input')
|
||||
input.type = 'file'
|
||||
input.accept = '.json'
|
||||
input.onchange = async (e) => {
|
||||
const file = e.target.files[0]
|
||||
if (!file) {
|
||||
resolve(null)
|
||||
return
|
||||
}
|
||||
const text = await file.text()
|
||||
resolve(text)
|
||||
}
|
||||
// Handle cancel (focus returns without change)
|
||||
input.addEventListener('cancel', () => resolve(null))
|
||||
input.click()
|
||||
})
|
||||
}
|
||||
7
src/utils/markdown.js
Normal file
7
src/utils/markdown.js
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { marked } from 'marked'
|
||||
import DOMPurify from 'dompurify'
|
||||
|
||||
export function renderMarkdown(text) {
|
||||
if (!text) return ''
|
||||
return DOMPurify.sanitize(marked.parse(text))
|
||||
}
|
||||
50
src/utils/validation.js
Normal file
50
src/utils/validation.js
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
/**
|
||||
* Reusable validation helpers for form fields.
|
||||
* Each function returns an error i18n key string if invalid, or '' if valid.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Validates that a string is non-empty after trimming.
|
||||
* @param {string} value
|
||||
* @returns {string} error key or ''
|
||||
*/
|
||||
export function validateRequired(value) {
|
||||
if (!value || !value.trim()) {
|
||||
return 'validation.required'
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that a number is >= min.
|
||||
* @param {number|string} value
|
||||
* @param {number} min
|
||||
* @returns {{ key: string, params: object } | ''} error descriptor or ''
|
||||
*/
|
||||
export function validateMin(value, min = 0) {
|
||||
const num = Number(value)
|
||||
if (isNaN(num) || num < min) {
|
||||
return { key: 'validation.minValue', params: { min } }
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a URL format. Empty strings are considered valid (optional field).
|
||||
* @param {string} value
|
||||
* @returns {string} error key or ''
|
||||
*/
|
||||
export function validateUrl(value) {
|
||||
if (!value || !value.trim()) {
|
||||
return ''
|
||||
}
|
||||
try {
|
||||
const url = new URL(value.trim())
|
||||
if (!['http:', 'https:'].includes(url.protocol)) {
|
||||
return 'validation.invalidUrl'
|
||||
}
|
||||
return ''
|
||||
} catch {
|
||||
return 'validation.invalidUrl'
|
||||
}
|
||||
}
|
||||
14
src/utils/youtube.js
Normal file
14
src/utils/youtube.js
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
export function getYouTubeId(url) {
|
||||
try {
|
||||
const u = new URL(url)
|
||||
if (u.hostname === 'youtu.be') {
|
||||
return u.pathname.slice(1)
|
||||
}
|
||||
if (u.hostname.includes('youtube.com')) {
|
||||
return u.searchParams.get('v')
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return null
|
||||
}
|
||||
184
src/views/CollectionDetailView.vue
Normal file
184
src/views/CollectionDetailView.vue
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useTranslation } from 'i18next-vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useCollections } from '../composables/useCollections.js'
|
||||
import { useJsonExport } from '../composables/useJsonExport.js'
|
||||
import { useKeyboardShortcut } from '../composables/useKeyboardShortcut.js'
|
||||
|
||||
const { t } = useTranslation()
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const { getById, remove } = useCollections()
|
||||
const { exportCollection } = useJsonExport()
|
||||
|
||||
const collection = ref(null)
|
||||
const loading = ref(true)
|
||||
|
||||
const collectionId = computed(() => route.params.id)
|
||||
|
||||
onMounted(async () => {
|
||||
const data = await getById(collectionId.value)
|
||||
if (!data) {
|
||||
router.replace({ name: 'collections' })
|
||||
return
|
||||
}
|
||||
collection.value = data
|
||||
loading.value = false
|
||||
})
|
||||
|
||||
function goToEdit() {
|
||||
router.push({ name: 'collection-edit', params: { id: collectionId.value } })
|
||||
}
|
||||
|
||||
useKeyboardShortcut('e', goToEdit)
|
||||
useKeyboardShortcut('escape', goBack, false)
|
||||
|
||||
function goBack() {
|
||||
router.push({ name: 'collections' })
|
||||
}
|
||||
|
||||
function goToSession(sessionId) {
|
||||
router.push({ name: 'session-detail', params: { id: sessionId } })
|
||||
}
|
||||
|
||||
async function onDelete() {
|
||||
if (!collection.value) return
|
||||
const confirmed = window.confirm(
|
||||
t('collections.deleteConfirm', { label: collection.value.label }),
|
||||
)
|
||||
if (!confirmed) return
|
||||
await remove(collectionId.value)
|
||||
router.push({ name: 'collections' })
|
||||
}
|
||||
|
||||
async function onExport() {
|
||||
await exportCollection(collectionId.value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="#actionbar-actions" defer>
|
||||
<button
|
||||
data-testid="collection-back-btn"
|
||||
:title="$t('collections.title') + ' ' + $t('shortcuts.back')"
|
||||
:aria-label="$t('collections.title')"
|
||||
@click="goBack"
|
||||
>
|
||||
<i class="bi bi-arrow-left"></i>
|
||||
</button>
|
||||
<button
|
||||
data-testid="collection-export-single-btn"
|
||||
:title="$t('export.collection')"
|
||||
:aria-label="$t('export.collection')"
|
||||
@click="onExport"
|
||||
>
|
||||
<i class="bi bi-download"></i>
|
||||
</button>
|
||||
<button
|
||||
data-testid="collection-edit-btn"
|
||||
:title="$t('collections.edit') + ' ' + $t('shortcuts.edit')"
|
||||
:aria-label="$t('collections.edit')"
|
||||
@click="goToEdit"
|
||||
>
|
||||
<i class="bi bi-pencil"></i>
|
||||
</button>
|
||||
<button
|
||||
data-testid="collection-delete-btn"
|
||||
:title="$t('collections.delete')"
|
||||
:aria-label="$t('collections.delete')"
|
||||
@click="onDelete"
|
||||
>
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</Teleport>
|
||||
|
||||
<div class="collection-detail-view">
|
||||
<p v-if="loading" class="text-muted">{{ $t('app.loading') }}</p>
|
||||
|
||||
<template v-else-if="collection">
|
||||
<h2 data-testid="collection-detail-title">{{ collection.label }}</h2>
|
||||
|
||||
<div v-if="collection.sessions && collection.sessions.length" class="sessions-section">
|
||||
<h3>{{ $t('collections.form.sessions') }}</h3>
|
||||
<ol class="collection-session-list">
|
||||
<li
|
||||
v-for="(session, index) in collection.sessions"
|
||||
:key="'session-' + index"
|
||||
class="collection-session-item"
|
||||
:data-testid="'collection-detail-session-' + index"
|
||||
>
|
||||
<span class="collection-session-number">{{ index + 1 }}</span>
|
||||
<a
|
||||
class="collection-session-title"
|
||||
:data-testid="'collection-detail-session-link-' + index"
|
||||
@click="goToSession(session.session_id)"
|
||||
>
|
||||
{{ session.title }}
|
||||
</a>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.collection-detail-view {
|
||||
max-width: 800px;
|
||||
}
|
||||
|
||||
.collection-detail-view h2 {
|
||||
font-size: 22px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.sessions-section {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.sessions-section h3 {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.collection-session-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.collection-session-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 4px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.collection-session-number {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
background-color: var(--btn-primary-bg);
|
||||
color: var(--btn-primary-color);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.collection-session-title {
|
||||
font-size: 14px;
|
||||
color: var(--btn-primary-bg);
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.collection-session-title:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
</style>
|
||||
428
src/views/CollectionFormView.vue
Normal file
428
src/views/CollectionFormView.vue
Normal file
|
|
@ -0,0 +1,428 @@
|
|||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useCollections } from '../composables/useCollections.js'
|
||||
import { useSessions } from '../composables/useSessions.js'
|
||||
import { useSettings } from '../composables/useSettings.js'
|
||||
import { validateRequired } from '../utils/validation.js'
|
||||
import { collectionRepository } from '../db/repositories/collectionRepository.js'
|
||||
import { useKeyboardShortcut } from '../composables/useKeyboardShortcut.js'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const { create, update, getById } = useCollections()
|
||||
const { sessions: allSessions, fetchAll: fetchSessions } = useSessions()
|
||||
const { get: getSetting } = useSettings()
|
||||
|
||||
const isEdit = computed(() => route.name === 'collection-edit')
|
||||
const collectionId = computed(() => route.params.id)
|
||||
|
||||
const label = ref('')
|
||||
const selectedSessions = ref([])
|
||||
|
||||
const errors = ref({})
|
||||
const saving = ref(false)
|
||||
|
||||
const newSessionId = ref('')
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchSessions()
|
||||
|
||||
if (isEdit.value && collectionId.value) {
|
||||
const collection = await getById(collectionId.value)
|
||||
if (!collection) {
|
||||
router.replace({ name: 'collections' })
|
||||
return
|
||||
}
|
||||
label.value = collection.label
|
||||
selectedSessions.value = (collection.sessions || []).map((s) => s.session_id)
|
||||
}
|
||||
})
|
||||
|
||||
function validate() {
|
||||
const e = {}
|
||||
const labelErr = validateRequired(label.value)
|
||||
if (labelErr) e.label = labelErr
|
||||
errors.value = e
|
||||
return Object.keys(e).length === 0
|
||||
}
|
||||
|
||||
function addSession() {
|
||||
if (!newSessionId.value) return
|
||||
if (selectedSessions.value.includes(newSessionId.value)) return
|
||||
selectedSessions.value.push(newSessionId.value)
|
||||
newSessionId.value = ''
|
||||
}
|
||||
|
||||
function removeSession(index) {
|
||||
selectedSessions.value.splice(index, 1)
|
||||
}
|
||||
|
||||
function moveSessionUp(index) {
|
||||
if (index === 0) return
|
||||
const arr = selectedSessions.value
|
||||
;[arr[index - 1], arr[index]] = [arr[index], arr[index - 1]]
|
||||
}
|
||||
|
||||
function moveSessionDown(index) {
|
||||
if (index === selectedSessions.value.length - 1) return
|
||||
const arr = selectedSessions.value
|
||||
;[arr[index], arr[index + 1]] = [arr[index + 1], arr[index]]
|
||||
}
|
||||
|
||||
function getSessionTitle(sessionId) {
|
||||
const session = allSessions.value.find((s) => s.id === sessionId)
|
||||
return session ? session.title : sessionId
|
||||
}
|
||||
|
||||
async function onSave() {
|
||||
if (!validate()) return
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
const userUuid = await getSetting('user_uuid')
|
||||
const data = {
|
||||
label: label.value.trim(),
|
||||
created_by: userUuid,
|
||||
}
|
||||
|
||||
let id
|
||||
if (isEdit.value) {
|
||||
await update(collectionId.value, data)
|
||||
id = collectionId.value
|
||||
} else {
|
||||
id = await create(data)
|
||||
}
|
||||
|
||||
await collectionRepository.setSessions(id, selectedSessions.value)
|
||||
|
||||
router.push({ name: 'collection-detail', params: { id } })
|
||||
} catch (e) {
|
||||
if (e.message && e.message.includes('UNIQUE')) {
|
||||
errors.value = { ...errors.value, label: 'validation.collectionLabelTaken' }
|
||||
} else {
|
||||
errors.value = { ...errors.value, save: e.message }
|
||||
}
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
useKeyboardShortcut('enter', onSave, { ctrl: true, ignoreInputs: false })
|
||||
useKeyboardShortcut('escape', onCancel, { ignoreInputs: false })
|
||||
|
||||
function onCancel() {
|
||||
if (isEdit.value) {
|
||||
router.push({ name: 'collection-detail', params: { id: collectionId.value } })
|
||||
} else {
|
||||
router.push({ name: 'collections' })
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="#actionbar-actions" defer>
|
||||
<button
|
||||
data-testid="collection-form-cancel"
|
||||
:title="$t('collections.form.cancel')"
|
||||
:aria-label="$t('collections.form.cancel')"
|
||||
@click="onCancel"
|
||||
>
|
||||
<i class="bi bi-x-lg"></i>
|
||||
</button>
|
||||
</Teleport>
|
||||
|
||||
<div class="collection-form-view">
|
||||
<h2 data-testid="collection-form-heading">
|
||||
{{ isEdit ? $t('collections.edit') : $t('collections.new') }}
|
||||
</h2>
|
||||
|
||||
<form novalidate @submit.prevent="onSave">
|
||||
<div class="form-group">
|
||||
<label for="collection-label">{{ $t('collections.form.label') }} *</label>
|
||||
<input
|
||||
id="collection-label"
|
||||
v-model="label"
|
||||
data-testid="collection-label"
|
||||
type="text"
|
||||
:placeholder="$t('collections.form.labelPlaceholder')"
|
||||
:class="{ 'input-error': errors.label }"
|
||||
required
|
||||
aria-required="true"
|
||||
:aria-describedby="errors.label ? 'collection-label-error' : undefined"
|
||||
/>
|
||||
<p
|
||||
v-if="errors.label"
|
||||
id="collection-label-error"
|
||||
role="alert"
|
||||
class="field-error"
|
||||
data-testid="collection-label-error"
|
||||
>
|
||||
{{ $t(errors.label) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>{{ $t('collections.form.sessions') }}</label>
|
||||
|
||||
<ol v-if="selectedSessions.length" class="session-order-list">
|
||||
<li
|
||||
v-for="(sessionId, index) in selectedSessions"
|
||||
:key="'session-' + index"
|
||||
class="session-order-item"
|
||||
:data-testid="'collection-session-' + index"
|
||||
>
|
||||
<span class="session-order-number">{{ index + 1 }}</span>
|
||||
<span class="session-order-title">{{ getSessionTitle(sessionId) }}</span>
|
||||
<div class="session-order-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="btn-move"
|
||||
:data-testid="'collection-session-move-up-' + index"
|
||||
:disabled="index === 0"
|
||||
@click="moveSessionUp(index)"
|
||||
>
|
||||
<i class="bi bi-chevron-up"></i>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-move"
|
||||
:data-testid="'collection-session-move-down-' + index"
|
||||
:disabled="index === selectedSessions.length - 1"
|
||||
@click="moveSessionDown(index)"
|
||||
>
|
||||
<i class="bi bi-chevron-down"></i>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-remove"
|
||||
:data-testid="'collection-session-remove-' + index"
|
||||
@click="removeSession(index)"
|
||||
>
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<div class="session-add">
|
||||
<select
|
||||
v-model="newSessionId"
|
||||
data-testid="collection-session-select"
|
||||
:disabled="allSessions.length === 0"
|
||||
>
|
||||
<option value="" disabled>
|
||||
{{
|
||||
allSessions.length === 0
|
||||
? $t('collections.form.noSessions')
|
||||
: $t('collections.form.selectSession')
|
||||
}}
|
||||
</option>
|
||||
<option v-for="session in allSessions" :key="session.id" :value="session.id">
|
||||
{{ session.title }}
|
||||
</option>
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary btn-sm"
|
||||
data-testid="collection-session-add"
|
||||
:disabled="!newSessionId"
|
||||
@click="addSession"
|
||||
>
|
||||
<i class="bi bi-plus"></i> {{ $t('collections.form.addSession') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="errors.save" class="field-error" data-testid="collection-save-error">
|
||||
{{ errors.save }}
|
||||
</p>
|
||||
|
||||
<div class="form-actions">
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-primary"
|
||||
data-testid="collection-save"
|
||||
:disabled="saving"
|
||||
:title="$t('collections.form.save') + ' ' + $t('shortcuts.save')"
|
||||
>
|
||||
<i class="bi bi-check-lg"></i>
|
||||
{{ $t('collections.form.save') }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn"
|
||||
data-testid="collection-form-cancel-btn"
|
||||
@click="onCancel"
|
||||
>
|
||||
{{ $t('collections.form.cancel') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.collection-form-view {
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
.collection-form-view h2 {
|
||||
margin-bottom: 16px;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.input-error {
|
||||
border-color: var(--btn-danger-bg) !important;
|
||||
}
|
||||
|
||||
.field-error {
|
||||
color: var(--btn-danger-bg);
|
||||
font-size: 13px;
|
||||
margin: 4px 0 0;
|
||||
}
|
||||
|
||||
select {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
background-color: var(--color-background);
|
||||
color: var(--color-text);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.session-order-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.session-order-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 4px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.session-order-number {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
background-color: var(--btn-primary-bg);
|
||||
color: var(--btn-primary-color);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.session-order-title {
|
||||
flex: 1;
|
||||
font-size: 14px;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.session-order-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.btn-move {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
font-size: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.btn-move:disabled {
|
||||
opacity: 0.3;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-move:hover:not(:disabled) {
|
||||
color: var(--btn-primary-bg);
|
||||
}
|
||||
|
||||
.btn-remove {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--btn-danger-bg);
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
font-size: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.btn-remove:hover {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.session-add {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.session-add select {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 6px 12px;
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 400px) {
|
||||
.session-order-item {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.session-order-number {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.session-order-actions {
|
||||
order: 2;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.form-actions .btn {
|
||||
flex: 1 1 auto;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
309
src/views/CollectionsView.vue
Normal file
309
src/views/CollectionsView.vue
Normal file
|
|
@ -0,0 +1,309 @@
|
|||
<script setup>
|
||||
import { ref, nextTick, onMounted, watch, computed } from 'vue'
|
||||
import { useTranslation } from 'i18next-vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useCollections } from '../composables/useCollections.js'
|
||||
import { useJsonExport } from '../composables/useJsonExport.js'
|
||||
import { useJsonImport } from '../composables/useJsonImport.js'
|
||||
import { useSettings } from '../composables/useSettings.js'
|
||||
import { useKeyboardShortcut } from '../composables/useKeyboardShortcut.js'
|
||||
import { useListNavigation } from '../composables/useListNavigation.js'
|
||||
import ImportDialog from '../components/ImportDialog.vue'
|
||||
|
||||
const { t } = useTranslation()
|
||||
const router = useRouter()
|
||||
const { collections, loading, fetchAll, search } = useCollections()
|
||||
const { exportAllCollections } = useJsonExport()
|
||||
const { loadFile } = useJsonImport()
|
||||
const { settings, fetchAll: fetchSettings } = useSettings()
|
||||
const localUuid = ref('')
|
||||
|
||||
// Import dialog state
|
||||
const showImportDialog = ref(false)
|
||||
const importEnvelope = ref(null)
|
||||
const importAsMine = ref(false)
|
||||
|
||||
const searchQuery = ref('')
|
||||
const searching = ref(false)
|
||||
const searchInput = ref(null)
|
||||
|
||||
onMounted(async () => {
|
||||
fetchAll()
|
||||
await fetchSettings()
|
||||
localUuid.value = settings.value.user_uuid || ''
|
||||
})
|
||||
|
||||
let searchTimeout = null
|
||||
watch(searchQuery, (query) => {
|
||||
clearTimeout(searchTimeout)
|
||||
const trimmed = query.trim()
|
||||
if (!trimmed) {
|
||||
fetchAll()
|
||||
return
|
||||
}
|
||||
searchTimeout = setTimeout(() => {
|
||||
search(trimmed)
|
||||
}, 250)
|
||||
})
|
||||
|
||||
function goToNew() {
|
||||
router.push({ name: 'collection-new' })
|
||||
}
|
||||
|
||||
useKeyboardShortcut('n', goToNew)
|
||||
|
||||
const collectionCount = computed(() => collections.value.length)
|
||||
const { focusedIndex: focusedCollectionIndex } = useListNavigation(collectionCount, (index) => {
|
||||
goToDetail(collections.value[index].id)
|
||||
})
|
||||
|
||||
function goToDetail(id) {
|
||||
router.push({ name: 'collection-detail', params: { id } })
|
||||
}
|
||||
|
||||
function toggleSearch() {
|
||||
searching.value = !searching.value
|
||||
if (searching.value) {
|
||||
nextTick(() => {
|
||||
searchInput.value?.focus()
|
||||
})
|
||||
} else {
|
||||
searchQuery.value = ''
|
||||
fetchAll()
|
||||
}
|
||||
}
|
||||
|
||||
function onSearchBlur() {
|
||||
if (!searchQuery.value.trim()) {
|
||||
searching.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function clearSearch() {
|
||||
searchQuery.value = ''
|
||||
searchInput.value?.focus()
|
||||
}
|
||||
|
||||
async function onExport() {
|
||||
await exportAllCollections(searchQuery.value.trim())
|
||||
}
|
||||
|
||||
async function onImport(asMine = false) {
|
||||
const result = await loadFile()
|
||||
if (result.cancelled) return
|
||||
if (result.error) {
|
||||
window.alert(t(result.error))
|
||||
return
|
||||
}
|
||||
importEnvelope.value = result.envelope
|
||||
importAsMine.value = asMine
|
||||
showImportDialog.value = true
|
||||
}
|
||||
|
||||
function onImportDone() {
|
||||
showImportDialog.value = false
|
||||
importEnvelope.value = null
|
||||
importAsMine.value = false
|
||||
fetchAll()
|
||||
}
|
||||
|
||||
function onImportClose() {
|
||||
showImportDialog.value = false
|
||||
importEnvelope.value = null
|
||||
importAsMine.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="#actionbar-actions" defer>
|
||||
<button
|
||||
data-testid="collection-search-toggle"
|
||||
:title="$t('collections.searchPlaceholder')"
|
||||
:aria-label="$t('collections.searchPlaceholder')"
|
||||
@click="toggleSearch"
|
||||
>
|
||||
<i class="bi bi-search"></i>
|
||||
</button>
|
||||
<button
|
||||
data-testid="collection-export-btn"
|
||||
:title="$t('export.collections')"
|
||||
:aria-label="$t('export.collections')"
|
||||
@click="onExport"
|
||||
>
|
||||
<i class="bi bi-download"></i>
|
||||
</button>
|
||||
<button
|
||||
data-testid="collection-import-btn"
|
||||
:title="$t('import.exercises')"
|
||||
:aria-label="$t('import.exercises')"
|
||||
@click="onImport(false)"
|
||||
>
|
||||
<i class="bi bi-upload"></i>
|
||||
</button>
|
||||
<button
|
||||
data-testid="collection-import-as-mine-btn"
|
||||
:title="$t('import.importAsMine')"
|
||||
:aria-label="$t('import.importAsMine')"
|
||||
@click="onImport(true)"
|
||||
>
|
||||
<i class="bi bi-person-check"></i>
|
||||
</button>
|
||||
<button
|
||||
data-testid="collection-new-btn"
|
||||
:title="$t('collections.new') + ' ' + $t('shortcuts.new')"
|
||||
:aria-label="$t('collections.new')"
|
||||
@click="goToNew"
|
||||
>
|
||||
<i class="bi bi-plus-lg"></i>
|
||||
</button>
|
||||
</Teleport>
|
||||
|
||||
<div class="collection-list-view">
|
||||
<div v-if="searching" class="search-bar" data-testid="collection-search-bar">
|
||||
<input
|
||||
ref="searchInput"
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
:placeholder="$t('collections.searchPlaceholder')"
|
||||
data-testid="collection-search-input"
|
||||
@blur="onSearchBlur"
|
||||
/>
|
||||
<button
|
||||
v-if="searchQuery"
|
||||
type="button"
|
||||
class="search-clear-btn"
|
||||
data-testid="collection-search-clear"
|
||||
:title="$t('common.clearSearch')"
|
||||
:aria-label="$t('common.clearSearch')"
|
||||
@mousedown.prevent="clearSearch"
|
||||
>
|
||||
<i class="bi bi-x-lg"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p v-if="loading" class="text-muted">{{ $t('app.loading') }}</p>
|
||||
|
||||
<p
|
||||
v-else-if="collections.length === 0 && !searchQuery.trim()"
|
||||
class="text-muted"
|
||||
data-testid="collection-empty"
|
||||
>
|
||||
{{ $t('collections.empty') }}
|
||||
</p>
|
||||
|
||||
<p
|
||||
v-else-if="collections.length === 0 && searchQuery.trim()"
|
||||
class="text-muted"
|
||||
data-testid="collection-no-results"
|
||||
>
|
||||
{{ $t('collections.noResults') }}
|
||||
</p>
|
||||
|
||||
<div
|
||||
v-for="(collection, index) in collections"
|
||||
:key="collection.id"
|
||||
class="card collection-card"
|
||||
:class="{ 'card-focused': focusedCollectionIndex === index }"
|
||||
:data-testid="'collection-card-' + collection.id"
|
||||
:data-list-index="index"
|
||||
tabindex="0"
|
||||
@click="goToDetail(collection.id)"
|
||||
>
|
||||
<div class="collection-card-header">
|
||||
<h3 data-testid="collection-card-title">{{ collection.label }}</h3>
|
||||
<span
|
||||
v-if="collection.sessionCount"
|
||||
class="badge badge-info"
|
||||
data-testid="collection-card-session-count"
|
||||
>
|
||||
{{ collection.sessionCount }} {{ $t('collections.sessions') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ImportDialog
|
||||
v-if="showImportDialog && importEnvelope"
|
||||
:envelope="importEnvelope"
|
||||
:local-uuid="localUuid"
|
||||
:as-mine="importAsMine"
|
||||
@done="onImportDone"
|
||||
@close="onImportClose"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.collection-list-view {
|
||||
max-width: 800px;
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
position: relative;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.search-bar input {
|
||||
padding-right: 36px;
|
||||
}
|
||||
|
||||
.search-clear-btn {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 8px;
|
||||
transform: translateY(-50%);
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 4px;
|
||||
line-height: 1;
|
||||
color: var(--front-color2);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.collection-card {
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.collection-card:hover {
|
||||
border-color: var(--btn-primary-bg);
|
||||
}
|
||||
|
||||
.collection-card:focus-visible {
|
||||
border-color: var(--btn-primary-bg);
|
||||
box-shadow: 0 0 0 2px var(--btn-primary-bg);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.collection-card.card-focused {
|
||||
border-color: var(--btn-primary-bg);
|
||||
box-shadow: 0 0 0 2px var(--btn-primary-bg);
|
||||
}
|
||||
|
||||
.collection-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.collection-card-header h3 {
|
||||
flex: 1;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
background-color: var(--btn-primary-bg);
|
||||
color: var(--btn-primary-color);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.badge-info {
|
||||
background-color: var(--front-color2);
|
||||
}
|
||||
</style>
|
||||
341
src/views/ComboDetailView.vue
Normal file
341
src/views/ComboDetailView.vue
Normal file
|
|
@ -0,0 +1,341 @@
|
|||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useTranslation } from 'i18next-vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useCombos } from '../composables/useCombos.js'
|
||||
import { useJsonExport } from '../composables/useJsonExport.js'
|
||||
import { useKeyboardShortcut } from '../composables/useKeyboardShortcut.js'
|
||||
import { renderMarkdown } from '../utils/markdown.js'
|
||||
|
||||
const { t } = useTranslation()
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const { getById, remove } = useCombos()
|
||||
const { exportCombo } = useJsonExport()
|
||||
|
||||
const combo = ref(null)
|
||||
const loading = ref(true)
|
||||
|
||||
const comboId = computed(() => route.params.id)
|
||||
|
||||
onMounted(async () => {
|
||||
const data = await getById(comboId.value)
|
||||
if (!data) {
|
||||
router.replace({ name: 'combos' })
|
||||
return
|
||||
}
|
||||
combo.value = data
|
||||
loading.value = false
|
||||
})
|
||||
|
||||
function goToEdit() {
|
||||
router.push({ name: 'combo-edit', params: { id: comboId.value } })
|
||||
}
|
||||
|
||||
useKeyboardShortcut('e', goToEdit)
|
||||
useKeyboardShortcut('escape', goBack, false)
|
||||
|
||||
function goBack() {
|
||||
router.push({ name: 'combos' })
|
||||
}
|
||||
|
||||
function goToExercise(exerciseId) {
|
||||
router.push({ name: 'exercise-detail', params: { id: exerciseId } })
|
||||
}
|
||||
|
||||
async function onDelete() {
|
||||
if (!combo.value) return
|
||||
const confirmed = window.confirm(t('combos.deleteConfirm', { title: combo.value.title }))
|
||||
if (!confirmed) return
|
||||
await remove(comboId.value)
|
||||
router.push({ name: 'combos' })
|
||||
}
|
||||
|
||||
async function onExport() {
|
||||
await exportCombo(comboId.value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="#actionbar-actions" defer>
|
||||
<button
|
||||
data-testid="combo-back-btn"
|
||||
:title="$t('combos.title') + ' ' + $t('shortcuts.back')"
|
||||
:aria-label="$t('combos.title')"
|
||||
@click="goBack"
|
||||
>
|
||||
<i class="bi bi-arrow-left"></i>
|
||||
</button>
|
||||
<button
|
||||
data-testid="combo-export-single-btn"
|
||||
:title="$t('export.combo')"
|
||||
:aria-label="$t('export.combo')"
|
||||
@click="onExport"
|
||||
>
|
||||
<i class="bi bi-download"></i>
|
||||
</button>
|
||||
<button
|
||||
data-testid="combo-edit-btn"
|
||||
:title="$t('combos.edit') + ' ' + $t('shortcuts.edit')"
|
||||
:aria-label="$t('combos.edit')"
|
||||
@click="goToEdit"
|
||||
>
|
||||
<i class="bi bi-pencil"></i>
|
||||
</button>
|
||||
<button
|
||||
data-testid="combo-delete-btn"
|
||||
:title="$t('combos.delete')"
|
||||
:aria-label="$t('combos.delete')"
|
||||
@click="onDelete"
|
||||
>
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</Teleport>
|
||||
|
||||
<div class="combo-detail-view">
|
||||
<p v-if="loading" class="text-muted">{{ $t('app.loading') }}</p>
|
||||
|
||||
<template v-else-if="combo">
|
||||
<!-- Title -->
|
||||
<h2 data-testid="combo-detail-title">{{ combo.title }}</h2>
|
||||
|
||||
<!-- Description -->
|
||||
<div
|
||||
v-if="combo.description"
|
||||
class="combo-description"
|
||||
data-testid="combo-detail-description"
|
||||
v-html="renderMarkdown(combo.description)"
|
||||
/>
|
||||
|
||||
<!-- Meta -->
|
||||
<div class="combo-meta">
|
||||
<span class="badge badge-type" data-testid="combo-detail-type">
|
||||
{{ $t(`combos.types.${combo.type.toLowerCase()}`) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Timer config -->
|
||||
<div
|
||||
v-if="combo.type !== 'NONE' && combo.timer_config && Object.keys(combo.timer_config).length"
|
||||
class="timer-config-section"
|
||||
>
|
||||
<h3>{{ $t('combos.timerConfig') }}</h3>
|
||||
<dl class="timer-config-list">
|
||||
<template v-if="combo.type === 'EMOM'">
|
||||
<dt>{{ $t('combos.timer.emomDuration') }}</dt>
|
||||
<dd data-testid="combo-detail-emom-duration">
|
||||
{{ combo.timer_config.duration || '—' }} {{ $t('combos.timer.minutes') }}
|
||||
</dd>
|
||||
</template>
|
||||
<template v-else-if="combo.type === 'AMRAP'">
|
||||
<dt>{{ $t('combos.timer.amrapDuration') }}</dt>
|
||||
<dd data-testid="combo-detail-amrap-duration">
|
||||
{{ combo.timer_config.duration || '—' }} {{ $t('combos.timer.minutes') }}
|
||||
</dd>
|
||||
</template>
|
||||
<template v-else-if="combo.type === 'TABATA'">
|
||||
<dt>{{ $t('combos.timer.tabataRounds') }}</dt>
|
||||
<dd data-testid="combo-detail-tabata-rounds">
|
||||
{{ combo.timer_config.rounds || '—' }} {{ $t('combos.timer.rounds') }}
|
||||
</dd>
|
||||
</template>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<!-- Exercises -->
|
||||
<div v-if="combo.exercises && combo.exercises.length" class="exercises-section">
|
||||
<h3>{{ $t('combos.form.exercises') }}</h3>
|
||||
<ol class="combo-exercise-list">
|
||||
<li
|
||||
v-for="(ex, index) in combo.exercises"
|
||||
:key="'ex-' + index"
|
||||
class="combo-exercise-item"
|
||||
:data-testid="'combo-detail-exercise-' + index"
|
||||
>
|
||||
<span class="combo-exercise-number">{{ index + 1 }}</span>
|
||||
<a
|
||||
class="combo-exercise-title"
|
||||
:data-testid="'combo-detail-exercise-link-' + index"
|
||||
@click="goToExercise(ex.exercise_id)"
|
||||
>
|
||||
{{ ex.title }}
|
||||
</a>
|
||||
<span
|
||||
class="combo-exercise-details"
|
||||
:data-testid="'combo-detail-exercise-details-' + index"
|
||||
>
|
||||
<span>{{ ex.default_reps }} {{ $t('exercises.reps') }}</span>
|
||||
<span>{{ ex.default_weight }} {{ $t('exercises.weight') }}</span>
|
||||
</span>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.combo-detail-view {
|
||||
max-width: 800px;
|
||||
}
|
||||
|
||||
.combo-detail-view h2 {
|
||||
font-size: 22px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.combo-description {
|
||||
font-size: 15px;
|
||||
line-height: 1.5;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.combo-description :deep(p) {
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.combo-description :deep(p:last-child) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.combo-description :deep(ul),
|
||||
.combo-description :deep(ol) {
|
||||
margin: 0 0 8px;
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.combo-description :deep(code) {
|
||||
font-family: monospace;
|
||||
font-size: 13px;
|
||||
background: var(--color-surface, #f4f4f4);
|
||||
padding: 1px 4px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.combo-description :deep(pre) {
|
||||
background: var(--color-surface, #f4f4f4);
|
||||
padding: 8px;
|
||||
border-radius: 4px;
|
||||
overflow-x: auto;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.combo-description :deep(strong) {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.combo-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 12px;
|
||||
font-size: 13px;
|
||||
background-color: var(--btn-primary-bg);
|
||||
color: var(--btn-primary-color);
|
||||
}
|
||||
|
||||
.badge-type {
|
||||
background-color: var(--front-color2);
|
||||
}
|
||||
|
||||
.timer-config-section {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.timer-config-section h3 {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.timer-config-list {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 4px 16px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.timer-config-list dt {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.timer-config-list dd {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.exercises-section {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.exercises-section h3 {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.combo-exercise-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.combo-exercise-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 4px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.combo-exercise-number {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
background-color: var(--btn-primary-bg);
|
||||
color: var(--btn-primary-color);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.combo-exercise-title {
|
||||
font-size: 14px;
|
||||
color: var(--btn-primary-bg);
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.combo-exercise-title:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.combo-exercise-details {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
color: var(--color-text-muted);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.combo-exercise-details span {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.combo-exercise-title:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
</style>
|
||||
711
src/views/ComboFormView.vue
Normal file
711
src/views/ComboFormView.vue
Normal file
|
|
@ -0,0 +1,711 @@
|
|||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useCombos } from '../composables/useCombos.js'
|
||||
import { useExercises } from '../composables/useExercises.js'
|
||||
import { useSettings } from '../composables/useSettings.js'
|
||||
import { validateRequired } from '../utils/validation.js'
|
||||
import { comboRepository } from '../db/repositories/comboRepository.js'
|
||||
import { useKeyboardShortcut } from '../composables/useKeyboardShortcut.js'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const { create, update, getById } = useCombos()
|
||||
const { exercises: allExercises, fetchAll: fetchExercises } = useExercises()
|
||||
const { get: getSetting } = useSettings()
|
||||
|
||||
const isEdit = computed(() => route.name === 'combo-edit')
|
||||
const comboId = computed(() => route.params.id)
|
||||
|
||||
// Form fields
|
||||
const title = ref('')
|
||||
const description = ref('')
|
||||
const type = ref('NONE')
|
||||
const selectedExercises = ref([])
|
||||
const timerConfig = ref({})
|
||||
|
||||
// Validation errors
|
||||
const errors = ref({})
|
||||
const saving = ref(false)
|
||||
|
||||
const newExerciseId = ref('')
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchExercises()
|
||||
|
||||
if (isEdit.value && comboId.value) {
|
||||
const combo = await getById(comboId.value)
|
||||
if (!combo) {
|
||||
router.replace({ name: 'combos' })
|
||||
return
|
||||
}
|
||||
title.value = combo.title
|
||||
description.value = combo.description || ''
|
||||
type.value = combo.type
|
||||
selectedExercises.value = (combo.exercises || []).map((e) => {
|
||||
const ex = allExercises.value.find((a) => a.id === e.exercise_id)
|
||||
return {
|
||||
exerciseId: e.exercise_id,
|
||||
defaultReps: e.default_reps || 1,
|
||||
defaultWeight: e.default_weight || 0.0,
|
||||
asymmetric: ex?.asymmetric || false,
|
||||
}
|
||||
})
|
||||
timerConfig.value = { ...combo.timer_config }
|
||||
}
|
||||
})
|
||||
|
||||
function validate() {
|
||||
const e = {}
|
||||
const titleErr = validateRequired(title.value)
|
||||
if (titleErr) e.title = titleErr
|
||||
errors.value = e
|
||||
return Object.keys(e).length === 0
|
||||
}
|
||||
|
||||
function addExercise() {
|
||||
if (!newExerciseId.value) return
|
||||
const ex = allExercises.value.find((e) => e.id === newExerciseId.value)
|
||||
const asymmetric = ex?.asymmetric || false
|
||||
let defaultReps = ex?.default_reps || 1
|
||||
if (asymmetric && defaultReps % 2 !== 0) defaultReps++
|
||||
selectedExercises.value.push({
|
||||
exerciseId: newExerciseId.value,
|
||||
defaultReps,
|
||||
defaultWeight: 0.0,
|
||||
asymmetric,
|
||||
})
|
||||
newExerciseId.value = ''
|
||||
}
|
||||
|
||||
function enforceEvenReps(exercise) {
|
||||
if (exercise.asymmetric && exercise.defaultReps > 0 && exercise.defaultReps % 2 !== 0) {
|
||||
exercise.defaultReps++
|
||||
}
|
||||
}
|
||||
|
||||
function removeExercise(index) {
|
||||
selectedExercises.value.splice(index, 1)
|
||||
}
|
||||
|
||||
function moveExerciseUp(index) {
|
||||
if (index === 0) return
|
||||
const arr = selectedExercises.value
|
||||
;[arr[index - 1], arr[index]] = [arr[index], arr[index - 1]]
|
||||
}
|
||||
|
||||
function moveExerciseDown(index) {
|
||||
if (index === selectedExercises.value.length - 1) return
|
||||
const arr = selectedExercises.value
|
||||
;[arr[index], arr[index + 1]] = [arr[index + 1], arr[index]]
|
||||
}
|
||||
|
||||
function getExerciseTitle(exerciseId) {
|
||||
const ex = allExercises.value.find((e) => e.id === exerciseId)
|
||||
return ex ? ex.title : exerciseId
|
||||
}
|
||||
|
||||
async function onSave() {
|
||||
if (!validate()) return
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
const userUuid = await getSetting('user_uuid')
|
||||
const data = {
|
||||
title: title.value.trim(),
|
||||
description: description.value.trim(),
|
||||
type: type.value,
|
||||
timer_config: timerConfig.value,
|
||||
created_by: userUuid,
|
||||
}
|
||||
|
||||
let id
|
||||
if (isEdit.value) {
|
||||
await update(comboId.value, data)
|
||||
id = comboId.value
|
||||
} else {
|
||||
id = await create(data)
|
||||
}
|
||||
|
||||
// Save exercises
|
||||
await comboRepository.setExercises(id, selectedExercises.value)
|
||||
|
||||
router.push({ name: 'combo-detail', params: { id } })
|
||||
} catch (e) {
|
||||
if (e.message && e.message.includes('UNIQUE')) {
|
||||
errors.value = { ...errors.value, title: 'validation.comboTitleTaken' }
|
||||
} else {
|
||||
errors.value = { ...errors.value, save: e.message }
|
||||
}
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
useKeyboardShortcut('enter', onSave, { ctrl: true, ignoreInputs: false })
|
||||
useKeyboardShortcut('escape', onCancel, { ignoreInputs: false })
|
||||
|
||||
function onCancel() {
|
||||
if (isEdit.value) {
|
||||
router.push({ name: 'combo-detail', params: { id: comboId.value } })
|
||||
} else {
|
||||
router.push({ name: 'combos' })
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="#actionbar-actions" defer>
|
||||
<button
|
||||
data-testid="combo-form-cancel"
|
||||
:title="$t('combos.form.cancel')"
|
||||
:aria-label="$t('combos.form.cancel')"
|
||||
@click="onCancel"
|
||||
>
|
||||
<i class="bi bi-x-lg"></i>
|
||||
</button>
|
||||
</Teleport>
|
||||
|
||||
<div class="combo-form-view">
|
||||
<h2 data-testid="combo-form-heading">
|
||||
{{ isEdit ? $t('combos.edit') : $t('combos.new') }}
|
||||
</h2>
|
||||
|
||||
<form novalidate @submit.prevent="onSave">
|
||||
<!-- Title -->
|
||||
<div class="form-group">
|
||||
<label for="combo-title">{{ $t('combos.form.title') }} *</label>
|
||||
<input
|
||||
id="combo-title"
|
||||
v-model="title"
|
||||
data-testid="combo-title"
|
||||
type="text"
|
||||
:placeholder="$t('combos.form.titlePlaceholder')"
|
||||
:class="{ 'input-error': errors.title }"
|
||||
required
|
||||
aria-required="true"
|
||||
:aria-describedby="errors.title ? 'combo-title-error' : undefined"
|
||||
/>
|
||||
<p
|
||||
v-if="errors.title"
|
||||
id="combo-title-error"
|
||||
role="alert"
|
||||
class="field-error"
|
||||
data-testid="combo-title-error"
|
||||
>
|
||||
{{ $t(errors.title) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div class="form-group">
|
||||
<label for="combo-description">
|
||||
{{ $t('combos.form.description') }}
|
||||
<span class="field-hint">{{ $t('common.markdownHint') }}</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="combo-description"
|
||||
v-model="description"
|
||||
data-testid="combo-description"
|
||||
:placeholder="$t('combos.form.descriptionPlaceholder')"
|
||||
rows="3"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Type -->
|
||||
<div class="form-group">
|
||||
<label for="combo-type">{{ $t('combos.form.type') }}</label>
|
||||
<select id="combo-type" v-model="type" data-testid="combo-type">
|
||||
<option value="NONE">{{ $t('combos.types.none') }}</option>
|
||||
<option value="EMOM">{{ $t('combos.types.emom') }}</option>
|
||||
<option value="AMRAP">{{ $t('combos.types.amrap') }}</option>
|
||||
<option value="TABATA">{{ $t('combos.types.tabata') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Timer config for EMOM -->
|
||||
<div v-if="type === 'EMOM'" class="form-group form-sub-group">
|
||||
<label for="combo-emom-duration">{{ $t('combos.timer.emomDuration') }}</label>
|
||||
<input
|
||||
id="combo-emom-duration"
|
||||
v-model.number="timerConfig.duration"
|
||||
data-testid="combo-emom-duration"
|
||||
type="number"
|
||||
min="1"
|
||||
:placeholder="$t('combos.timer.minutes')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Timer config for AMRAP -->
|
||||
<div v-if="type === 'AMRAP'" class="form-group form-sub-group">
|
||||
<label for="combo-amrap-duration">{{ $t('combos.timer.amrapDuration') }}</label>
|
||||
<input
|
||||
id="combo-amrap-duration"
|
||||
v-model.number="timerConfig.duration"
|
||||
data-testid="combo-amrap-duration"
|
||||
type="number"
|
||||
min="1"
|
||||
:placeholder="$t('combos.timer.minutes')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Timer config for TABATA -->
|
||||
<div v-if="type === 'TABATA'" class="form-group form-sub-group">
|
||||
<label for="combo-tabata-work-time">{{ $t('combos.timer.tabataWorkTime') }}</label>
|
||||
<input
|
||||
id="combo-tabata-work-time"
|
||||
v-model.number="timerConfig.workTime"
|
||||
data-testid="combo-tabata-work-time"
|
||||
type="number"
|
||||
min="1"
|
||||
:placeholder="$t('combos.timer.tabataSeconds')"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="type === 'TABATA'" class="form-group form-sub-group">
|
||||
<label for="combo-tabata-rest-time">{{ $t('combos.timer.tabataRestTime') }}</label>
|
||||
<input
|
||||
id="combo-tabata-rest-time"
|
||||
v-model.number="timerConfig.restTime"
|
||||
data-testid="combo-tabata-rest-time"
|
||||
type="number"
|
||||
min="0"
|
||||
:placeholder="$t('combos.timer.tabataSeconds')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Exercise selector -->
|
||||
<div class="form-group">
|
||||
<label>{{ $t('combos.form.exercises') }}</label>
|
||||
|
||||
<!-- Selected exercises list -->
|
||||
<ol v-if="selectedExercises.length" class="exercise-order-list">
|
||||
<li
|
||||
v-for="(exercise, index) in selectedExercises"
|
||||
:key="'ex-' + index"
|
||||
class="exercise-order-item"
|
||||
:data-testid="'combo-exercise-item-' + index"
|
||||
>
|
||||
<span class="exercise-order-number">{{ index + 1 }}</span>
|
||||
<span class="exercise-order-title">{{ getExerciseTitle(exercise.exerciseId) }}</span>
|
||||
<div class="exercise-order-fields">
|
||||
<div class="exercise-field">
|
||||
<label :for="'combo-exercise-reps-' + index">{{ $t('combos.form.reps') }}</label>
|
||||
<div class="stepper">
|
||||
<button
|
||||
type="button"
|
||||
class="stepper-btn btn-primary"
|
||||
@click="
|
||||
exercise.defaultReps = Math.max(
|
||||
exercise.asymmetric ? 2 : 1,
|
||||
exercise.defaultReps - (exercise.asymmetric ? 2 : 1),
|
||||
)
|
||||
"
|
||||
>
|
||||
<i class="bi bi-dash-circle-fill"></i>
|
||||
</button>
|
||||
<input
|
||||
:id="'combo-exercise-reps-' + index"
|
||||
v-model.number="exercise.defaultReps"
|
||||
:data-testid="'combo-exercise-reps-' + index"
|
||||
type="number"
|
||||
:min="exercise.asymmetric ? 2 : 1"
|
||||
:step="exercise.asymmetric ? 2 : 1"
|
||||
class="step-input"
|
||||
@change="enforceEvenReps(exercise)"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="stepper-btn btn-primary"
|
||||
@click="exercise.defaultReps += exercise.asymmetric ? 2 : 1"
|
||||
>
|
||||
<i class="bi bi-plus-circle-fill"></i>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="stepper-btn stepper-btn-fast"
|
||||
@click="exercise.defaultReps += exercise.asymmetric ? 10 : 5"
|
||||
>
|
||||
<i class="bi bi-forward-fill"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="exercise-field">
|
||||
<label :for="'combo-exercise-weight-' + index">{{
|
||||
$t('combos.form.weight')
|
||||
}}</label>
|
||||
<div class="stepper">
|
||||
<button
|
||||
type="button"
|
||||
class="stepper-btn btn-primary"
|
||||
@click="
|
||||
exercise.defaultWeight = Math.max(
|
||||
0,
|
||||
+(exercise.defaultWeight - 0.5).toFixed(1),
|
||||
)
|
||||
"
|
||||
>
|
||||
<i class="bi bi-dash-circle-fill"></i>
|
||||
</button>
|
||||
<input
|
||||
:id="'combo-exercise-weight-' + index"
|
||||
v-model.number="exercise.defaultWeight"
|
||||
:data-testid="'combo-exercise-weight-' + index"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.5"
|
||||
class="step-input"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="stepper-btn btn-primary"
|
||||
@click="exercise.defaultWeight = +(exercise.defaultWeight + 0.5).toFixed(1)"
|
||||
>
|
||||
<i class="bi bi-plus-circle-fill"></i>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="stepper-btn stepper-btn-fast"
|
||||
@click="exercise.defaultWeight = +(exercise.defaultWeight + 1).toFixed(1)"
|
||||
>
|
||||
<i class="bi bi-forward-fill"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="exercise-order-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="btn-move"
|
||||
:data-testid="'combo-exercise-move-up-' + index"
|
||||
:disabled="index === 0"
|
||||
@click="moveExerciseUp(index)"
|
||||
>
|
||||
<i class="bi bi-chevron-up"></i>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-move"
|
||||
:data-testid="'combo-exercise-move-down-' + index"
|
||||
:disabled="index === selectedExercises.length - 1"
|
||||
@click="moveExerciseDown(index)"
|
||||
>
|
||||
<i class="bi bi-chevron-down"></i>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-remove"
|
||||
:data-testid="'combo-exercise-remove-' + index"
|
||||
@click="removeExercise(index)"
|
||||
>
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
<p v-if="selectedExercises.some((e) => e.asymmetric)" class="field-hint">
|
||||
{{ $t('exercises.evenRepsHint') }}
|
||||
</p>
|
||||
|
||||
<!-- Add exercise -->
|
||||
<div class="exercise-add">
|
||||
<select
|
||||
v-model="newExerciseId"
|
||||
data-testid="combo-exercise-select"
|
||||
:disabled="allExercises.length === 0"
|
||||
>
|
||||
<option value="" disabled>
|
||||
{{
|
||||
allExercises.length === 0
|
||||
? $t('combos.form.noExercises')
|
||||
: $t('combos.form.selectExercise')
|
||||
}}
|
||||
</option>
|
||||
<option v-for="ex in allExercises" :key="ex.id" :value="ex.id">
|
||||
{{ ex.title }}
|
||||
</option>
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary btn-sm"
|
||||
data-testid="combo-exercise-add"
|
||||
:disabled="!newExerciseId"
|
||||
@click="addExercise"
|
||||
>
|
||||
<i class="bi bi-plus"></i> {{ $t('combos.form.addExercise') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Save error -->
|
||||
<p v-if="errors.save" class="field-error" data-testid="combo-save-error">
|
||||
{{ errors.save }}
|
||||
</p>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="form-actions">
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-primary"
|
||||
data-testid="combo-save"
|
||||
:disabled="saving"
|
||||
:title="$t('combos.form.save') + ' ' + $t('shortcuts.save')"
|
||||
>
|
||||
<i class="bi bi-check-lg"></i>
|
||||
{{ $t('combos.form.save') }}
|
||||
</button>
|
||||
<button type="button" class="btn" data-testid="combo-cancel" @click="onCancel">
|
||||
{{ $t('combos.form.cancel') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.combo-form-view {
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
.combo-form-view h2 {
|
||||
margin-bottom: 16px;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 6px;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.field-hint {
|
||||
font-weight: 400;
|
||||
font-size: 11px;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.form-sub-group {
|
||||
padding-left: 24px;
|
||||
}
|
||||
|
||||
.input-error {
|
||||
border-color: var(--btn-danger-bg) !important;
|
||||
}
|
||||
|
||||
.field-error {
|
||||
color: var(--btn-danger-bg);
|
||||
font-size: 13px;
|
||||
margin: 4px 0 0;
|
||||
}
|
||||
|
||||
.field-hint {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 12px;
|
||||
margin: 4px 0 0;
|
||||
}
|
||||
|
||||
select {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
background-color: var(--color-background);
|
||||
color: var(--color-text);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.exercise-order-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.exercise-order-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
padding: 8px 4px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.exercise-order-number {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
background-color: var(--btn-primary-bg);
|
||||
color: var(--btn-primary-color);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.exercise-order-title {
|
||||
flex: 1;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.exercise-order-fields {
|
||||
order: 3;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.exercise-field {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.exercise-field label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-muted);
|
||||
width: 90px;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.stepper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.stepper-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 18px;
|
||||
flex-shrink: 0;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.stepper-btn:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.stepper-btn:active {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.stepper-btn-fast {
|
||||
background: var(--btn-primary-bg);
|
||||
color: var(--btn-primary-color);
|
||||
}
|
||||
|
||||
.step-input {
|
||||
width: 56px;
|
||||
padding: 4px 2px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
background: var(--color-background);
|
||||
color: var(--color-text);
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.exercise-order-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.btn-move {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
font-size: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.btn-move:disabled {
|
||||
opacity: 0.3;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-move:hover:not(:disabled) {
|
||||
color: var(--btn-primary-bg);
|
||||
}
|
||||
|
||||
.btn-remove {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--btn-danger-bg);
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
font-size: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.btn-remove:hover {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.exercise-add {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.exercise-add select {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 6px 12px;
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 400px) {
|
||||
.exercise-order-number {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.exercise-order-title {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.exercise-order-actions {
|
||||
order: 2;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.form-actions .btn {
|
||||
flex: 1 1 auto;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
329
src/views/ComboListView.vue
Normal file
329
src/views/ComboListView.vue
Normal file
|
|
@ -0,0 +1,329 @@
|
|||
<script setup>
|
||||
import { ref, nextTick, onMounted, watch, computed } from 'vue'
|
||||
import { useTranslation } from 'i18next-vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useCombos } from '../composables/useCombos.js'
|
||||
import { useJsonExport } from '../composables/useJsonExport.js'
|
||||
import { useJsonImport } from '../composables/useJsonImport.js'
|
||||
import { useSettings } from '../composables/useSettings.js'
|
||||
import { useKeyboardShortcut } from '../composables/useKeyboardShortcut.js'
|
||||
import { useListNavigation } from '../composables/useListNavigation.js'
|
||||
import ImportDialog from '../components/ImportDialog.vue'
|
||||
|
||||
const { t } = useTranslation()
|
||||
const router = useRouter()
|
||||
const { combos, loading, fetchAll, search } = useCombos()
|
||||
const { exportAllCombos } = useJsonExport()
|
||||
const { loadFile } = useJsonImport()
|
||||
const { settings, fetchAll: fetchSettings } = useSettings()
|
||||
const localUuid = ref('')
|
||||
|
||||
// Import dialog state
|
||||
const showImportDialog = ref(false)
|
||||
const importEnvelope = ref(null)
|
||||
const importAsMine = ref(false)
|
||||
|
||||
const searchQuery = ref('')
|
||||
const searching = ref(false)
|
||||
const searchInput = ref(null)
|
||||
|
||||
onMounted(async () => {
|
||||
fetchAll()
|
||||
await fetchSettings()
|
||||
localUuid.value = settings.value.user_uuid || ''
|
||||
})
|
||||
|
||||
let searchTimeout = null
|
||||
watch(searchQuery, (query) => {
|
||||
clearTimeout(searchTimeout)
|
||||
const trimmed = query.trim()
|
||||
if (!trimmed) {
|
||||
fetchAll()
|
||||
return
|
||||
}
|
||||
searchTimeout = setTimeout(() => {
|
||||
search(trimmed)
|
||||
}, 250)
|
||||
})
|
||||
|
||||
function goToNew() {
|
||||
router.push({ name: 'combo-new' })
|
||||
}
|
||||
|
||||
useKeyboardShortcut('n', goToNew)
|
||||
|
||||
const comboCount = computed(() => combos.value.length)
|
||||
const { focusedIndex: focusedComboIndex } = useListNavigation(comboCount, (index) => {
|
||||
goToDetail(combos.value[index].id)
|
||||
})
|
||||
|
||||
function goToDetail(id) {
|
||||
router.push({ name: 'combo-detail', params: { id } })
|
||||
}
|
||||
|
||||
function toggleSearch() {
|
||||
searching.value = !searching.value
|
||||
if (searching.value) {
|
||||
nextTick(() => {
|
||||
searchInput.value?.focus()
|
||||
})
|
||||
} else {
|
||||
searchQuery.value = ''
|
||||
fetchAll()
|
||||
}
|
||||
}
|
||||
|
||||
function onSearchBlur() {
|
||||
if (!searchQuery.value.trim()) {
|
||||
searching.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function clearSearch() {
|
||||
searchQuery.value = ''
|
||||
searchInput.value?.focus()
|
||||
}
|
||||
|
||||
async function onExport() {
|
||||
await exportAllCombos(searchQuery.value.trim())
|
||||
}
|
||||
|
||||
async function onImport(asMine = false) {
|
||||
const result = await loadFile()
|
||||
if (result.cancelled) return
|
||||
if (result.error) {
|
||||
window.alert(t(result.error))
|
||||
return
|
||||
}
|
||||
importEnvelope.value = result.envelope
|
||||
importAsMine.value = asMine
|
||||
showImportDialog.value = true
|
||||
}
|
||||
|
||||
function onImportDone() {
|
||||
showImportDialog.value = false
|
||||
importEnvelope.value = null
|
||||
importAsMine.value = false
|
||||
fetchAll()
|
||||
}
|
||||
|
||||
function onImportClose() {
|
||||
showImportDialog.value = false
|
||||
importEnvelope.value = null
|
||||
importAsMine.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="#actionbar-actions" defer>
|
||||
<button
|
||||
data-testid="combo-search-toggle"
|
||||
:title="$t('combos.searchPlaceholder')"
|
||||
:aria-label="$t('combos.searchPlaceholder')"
|
||||
@click="toggleSearch"
|
||||
>
|
||||
<i class="bi bi-search"></i>
|
||||
</button>
|
||||
<button
|
||||
data-testid="combo-export-btn"
|
||||
:title="$t('export.combos')"
|
||||
:aria-label="$t('export.combos')"
|
||||
@click="onExport"
|
||||
>
|
||||
<i class="bi bi-download"></i>
|
||||
</button>
|
||||
<button
|
||||
data-testid="combo-import-btn"
|
||||
:title="$t('import.exercises')"
|
||||
:aria-label="$t('import.exercises')"
|
||||
@click="onImport(false)"
|
||||
>
|
||||
<i class="bi bi-upload"></i>
|
||||
</button>
|
||||
<button
|
||||
data-testid="combo-import-as-mine-btn"
|
||||
:title="$t('import.importAsMine')"
|
||||
:aria-label="$t('import.importAsMine')"
|
||||
@click="onImport(true)"
|
||||
>
|
||||
<i class="bi bi-person-check"></i>
|
||||
</button>
|
||||
<button
|
||||
data-testid="combo-new-btn"
|
||||
:title="$t('combos.new') + ' ' + $t('shortcuts.new')"
|
||||
:aria-label="$t('combos.new')"
|
||||
@click="goToNew"
|
||||
>
|
||||
<i class="bi bi-plus-lg"></i>
|
||||
</button>
|
||||
</Teleport>
|
||||
|
||||
<div class="combo-list-view">
|
||||
<!-- Search bar -->
|
||||
<div v-if="searching" class="search-bar" data-testid="combo-search-bar">
|
||||
<input
|
||||
ref="searchInput"
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
:placeholder="$t('combos.searchPlaceholder')"
|
||||
data-testid="combo-search-input"
|
||||
@blur="onSearchBlur"
|
||||
/>
|
||||
<button
|
||||
v-if="searchQuery"
|
||||
type="button"
|
||||
class="search-clear-btn"
|
||||
data-testid="combo-search-clear"
|
||||
:title="$t('common.clearSearch')"
|
||||
:aria-label="$t('common.clearSearch')"
|
||||
@mousedown.prevent="clearSearch"
|
||||
>
|
||||
<i class="bi bi-x-lg"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Loading state -->
|
||||
<p v-if="loading" class="text-muted">{{ $t('app.loading') }}</p>
|
||||
|
||||
<!-- Empty state -->
|
||||
<p
|
||||
v-else-if="combos.length === 0 && !searchQuery.trim()"
|
||||
class="text-muted"
|
||||
data-testid="combo-empty"
|
||||
>
|
||||
{{ $t('combos.empty') }}
|
||||
</p>
|
||||
|
||||
<!-- No search results -->
|
||||
<p
|
||||
v-else-if="combos.length === 0 && searchQuery.trim()"
|
||||
class="text-muted"
|
||||
data-testid="combo-no-results"
|
||||
>
|
||||
{{ $t('combos.noResults') }}
|
||||
</p>
|
||||
|
||||
<!-- Combo cards -->
|
||||
<div
|
||||
v-for="(combo, index) in combos"
|
||||
:key="combo.id"
|
||||
class="card combo-card"
|
||||
:class="{ 'card-focused': focusedComboIndex === index }"
|
||||
:data-testid="'combo-card-' + combo.id"
|
||||
:data-list-index="index"
|
||||
tabindex="0"
|
||||
@click="goToDetail(combo.id)"
|
||||
>
|
||||
<div class="combo-card-header">
|
||||
<h3 data-testid="combo-card-title">{{ combo.title }}</h3>
|
||||
<div class="combo-card-badges">
|
||||
<span class="badge badge-type" data-testid="combo-card-type">
|
||||
{{ $t(`combos.types.${combo.type.toLowerCase()}`) }}
|
||||
</span>
|
||||
<span
|
||||
v-if="combo.exercises && combo.exercises.length"
|
||||
class="badge badge-info"
|
||||
data-testid="combo-card-exercise-count"
|
||||
>
|
||||
{{ combo.exercises.length }} {{ $t('combos.exercises') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ImportDialog
|
||||
v-if="showImportDialog && importEnvelope"
|
||||
:envelope="importEnvelope"
|
||||
:local-uuid="localUuid"
|
||||
:as-mine="importAsMine"
|
||||
@done="onImportDone"
|
||||
@close="onImportClose"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.combo-list-view {
|
||||
max-width: 800px;
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
position: relative;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.search-bar input {
|
||||
padding-right: 36px;
|
||||
}
|
||||
|
||||
.search-clear-btn {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 8px;
|
||||
transform: translateY(-50%);
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 4px;
|
||||
line-height: 1;
|
||||
color: var(--front-color2);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.combo-card {
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.combo-card:hover {
|
||||
border-color: var(--btn-primary-bg);
|
||||
}
|
||||
|
||||
.combo-card:focus-visible {
|
||||
border-color: var(--btn-primary-bg);
|
||||
box-shadow: 0 0 0 2px var(--btn-primary-bg);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.combo-card.card-focused {
|
||||
border-color: var(--btn-primary-bg);
|
||||
box-shadow: 0 0 0 2px var(--btn-primary-bg);
|
||||
}
|
||||
|
||||
.combo-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.combo-card-header h3 {
|
||||
flex: 1;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.combo-card-badges {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
background-color: var(--btn-primary-bg);
|
||||
color: var(--btn-primary-color);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.badge-type {
|
||||
background-color: var(--front-color2);
|
||||
}
|
||||
|
||||
.badge-info {
|
||||
background-color: var(--front-color2);
|
||||
}
|
||||
</style>
|
||||
6
src/views/CombosView.vue
Normal file
6
src/views/CombosView.vue
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
<template>
|
||||
<div class="placeholder-view">
|
||||
<h2>{{ $t('combos.title') }}</h2>
|
||||
<p class="text-muted">{{ $t('combos.empty') }}</p>
|
||||
</div>
|
||||
</template>
|
||||
350
src/views/ExerciseDetailView.vue
Normal file
350
src/views/ExerciseDetailView.vue
Normal file
|
|
@ -0,0 +1,350 @@
|
|||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useTranslation } from 'i18next-vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useExercises } from '../composables/useExercises.js'
|
||||
import { useJsonExport } from '../composables/useJsonExport.js'
|
||||
import { useKeyboardShortcut } from '../composables/useKeyboardShortcut.js'
|
||||
import { useOnlineStatus } from '../composables/useOnlineStatus.js'
|
||||
import { renderMarkdown } from '../utils/markdown.js'
|
||||
import { getYouTubeId } from '../utils/youtube.js'
|
||||
import { executionLogRepository } from '../db/repositories/executionLogRepository.js'
|
||||
|
||||
const { t } = useTranslation()
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const { getById, remove } = useExercises()
|
||||
const { exportExercise } = useJsonExport()
|
||||
const { isOnline } = useOnlineStatus()
|
||||
|
||||
const exercise = ref(null)
|
||||
const loading = ref(true)
|
||||
|
||||
const exerciseId = computed(() => route.params.id)
|
||||
|
||||
onMounted(async () => {
|
||||
const data = await getById(exerciseId.value)
|
||||
if (!data) {
|
||||
router.replace({ name: 'exercises' })
|
||||
return
|
||||
}
|
||||
exercise.value = data
|
||||
loading.value = false
|
||||
})
|
||||
|
||||
function goToEdit() {
|
||||
router.push({ name: 'exercise-edit', params: { id: exerciseId.value } })
|
||||
}
|
||||
|
||||
useKeyboardShortcut('e', goToEdit)
|
||||
useKeyboardShortcut('escape', goBack, false)
|
||||
|
||||
function goBack() {
|
||||
router.push({ name: 'exercises' })
|
||||
}
|
||||
|
||||
async function onDelete() {
|
||||
if (!exercise.value) return
|
||||
const count = await executionLogRepository.countItemsByExercise(exerciseId.value)
|
||||
const msg =
|
||||
count > 0
|
||||
? t('exercises.deleteConfirmWithHistory', { title: exercise.value.title, count })
|
||||
: t('exercises.deleteConfirm', { title: exercise.value.title })
|
||||
const confirmed = window.confirm(msg)
|
||||
if (!confirmed) return
|
||||
await remove(exerciseId.value)
|
||||
router.push({ name: 'exercises' })
|
||||
}
|
||||
|
||||
async function onExport() {
|
||||
await exportExercise(exerciseId.value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="#actionbar-actions" defer>
|
||||
<button
|
||||
data-testid="exercise-back-btn"
|
||||
:title="$t('exercises.title') + ' ' + $t('shortcuts.back')"
|
||||
:aria-label="$t('exercises.title')"
|
||||
@click="goBack"
|
||||
>
|
||||
<i class="bi bi-arrow-left"></i>
|
||||
</button>
|
||||
<button
|
||||
data-testid="exercise-export-single-btn"
|
||||
:title="$t('export.exercise')"
|
||||
:aria-label="$t('export.exercise')"
|
||||
@click="onExport"
|
||||
>
|
||||
<i class="bi bi-download"></i>
|
||||
</button>
|
||||
<button
|
||||
data-testid="exercise-edit-btn"
|
||||
:title="$t('exercises.edit') + ' ' + $t('shortcuts.edit')"
|
||||
:aria-label="$t('exercises.edit')"
|
||||
@click="goToEdit"
|
||||
>
|
||||
<i class="bi bi-pencil"></i>
|
||||
</button>
|
||||
<button
|
||||
data-testid="exercise-delete-btn"
|
||||
:title="$t('exercises.delete')"
|
||||
:aria-label="$t('exercises.delete')"
|
||||
@click="onDelete"
|
||||
>
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</Teleport>
|
||||
|
||||
<div class="exercise-detail-view">
|
||||
<p v-if="loading" class="text-muted">{{ $t('app.loading') }}</p>
|
||||
|
||||
<template v-else-if="exercise">
|
||||
<!-- Title -->
|
||||
<h2 data-testid="exercise-detail-title">{{ exercise.title }}</h2>
|
||||
|
||||
<!-- Description -->
|
||||
<div
|
||||
v-if="exercise.description"
|
||||
class="exercise-description"
|
||||
data-testid="exercise-detail-description"
|
||||
v-html="renderMarkdown(exercise.description)"
|
||||
/>
|
||||
|
||||
<!-- Badges -->
|
||||
<div class="exercise-meta">
|
||||
<span v-if="exercise.default_reps" class="badge" data-testid="exercise-detail-reps">
|
||||
{{ exercise.default_reps }} {{ $t('exercises.reps') }}
|
||||
</span>
|
||||
<span v-if="exercise.default_weight" class="badge" data-testid="exercise-detail-weight">
|
||||
{{ exercise.default_weight }} {{ $t('exercises.weight') }}
|
||||
</span>
|
||||
<span
|
||||
v-if="exercise.asymmetric"
|
||||
class="badge badge-info"
|
||||
data-testid="exercise-detail-asymmetric"
|
||||
>
|
||||
<i class="bi bi-arrow-left-right"></i> {{ $t('exercises.asymmetricLabel') }}
|
||||
</span>
|
||||
<span
|
||||
v-if="exercise.asymmetric"
|
||||
class="badge badge-info"
|
||||
data-testid="exercise-detail-alternate"
|
||||
>
|
||||
{{ exercise.alternate ? $t('exercises.alternateLabel') : '' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Images -->
|
||||
<div v-if="exercise.image_urls && exercise.image_urls.length" class="media-section">
|
||||
<h3>Images</h3>
|
||||
<hr />
|
||||
<div class="image-gallery">
|
||||
<div v-for="(url, i) in exercise.image_urls" :key="'img-' + i" class="image-item">
|
||||
<img
|
||||
:src="url"
|
||||
:alt="exercise.title + ' image ' + (i + 1)"
|
||||
:data-testid="'exercise-detail-image-' + i"
|
||||
loading="lazy"
|
||||
@error="($event) => ($event.target.style.display = 'none')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Videos -->
|
||||
<div v-if="exercise.video_urls && exercise.video_urls.length" class="media-section">
|
||||
<h3>Videos</h3>
|
||||
<hr />
|
||||
<p v-if="!isOnline" class="video-offline">
|
||||
<i class="bi bi-wifi-off"></i> {{ $t('exercises.videoOffline') }}
|
||||
</p>
|
||||
<div v-else class="video-gallery">
|
||||
<div v-for="(url, i) in exercise.video_urls" :key="'vid-' + i" class="video-item">
|
||||
<!-- YouTube thumbnail link -->
|
||||
<a
|
||||
v-if="getYouTubeId(url)"
|
||||
:href="url"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
:data-testid="'exercise-detail-video-' + i"
|
||||
class="youtube-thumb"
|
||||
>
|
||||
<img
|
||||
:src="'https://img.youtube.com/vi/' + getYouTubeId(url) + '/mqdefault.jpg'"
|
||||
:alt="exercise.title + ' video ' + (i + 1)"
|
||||
loading="lazy"
|
||||
/>
|
||||
<span class="youtube-play"><i class="bi bi-play-circle-fill"></i></span>
|
||||
</a>
|
||||
<!-- Generic link fallback -->
|
||||
<a
|
||||
v-else
|
||||
:href="url"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
:data-testid="'exercise-detail-video-' + i"
|
||||
class="video-link"
|
||||
>
|
||||
<i class="bi bi-play-circle"></i> {{ url }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.exercise-detail-view {
|
||||
max-width: 800px;
|
||||
}
|
||||
|
||||
.exercise-detail-view h2 {
|
||||
font-size: 22px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.exercise-description {
|
||||
font-size: 15px;
|
||||
line-height: 1.5;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.exercise-description :deep(p) {
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.exercise-description :deep(p:last-child) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.exercise-description :deep(ul),
|
||||
.exercise-description :deep(ol) {
|
||||
margin: 0 0 8px;
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.exercise-description :deep(code) {
|
||||
font-family: monospace;
|
||||
font-size: 13px;
|
||||
background: var(--color-surface, #f4f4f4);
|
||||
padding: 1px 4px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.exercise-description :deep(pre) {
|
||||
background: var(--color-surface, #f4f4f4);
|
||||
padding: 8px;
|
||||
border-radius: 4px;
|
||||
overflow-x: auto;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.exercise-description :deep(strong) {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.exercise-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 12px;
|
||||
font-size: 13px;
|
||||
background-color: var(--btn-primary-bg);
|
||||
color: var(--btn-primary-color);
|
||||
}
|
||||
|
||||
.badge-info {
|
||||
background-color: var(--front-color2);
|
||||
}
|
||||
|
||||
.media-section {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.media-section h3 {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.image-gallery {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.image-item img {
|
||||
width: 100%;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.video-gallery {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.youtube-thumb {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.youtube-thumb img {
|
||||
display: block;
|
||||
width: 320px;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.youtube-play {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 48px;
|
||||
color: white;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.youtube-thumb:hover .youtube-play {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.video-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.video-offline {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
color: var(--text-muted, #888);
|
||||
}
|
||||
</style>
|
||||
678
src/views/ExerciseFormView.vue
Normal file
678
src/views/ExerciseFormView.vue
Normal file
|
|
@ -0,0 +1,678 @@
|
|||
<script setup>
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { useTranslation } from 'i18next-vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useExercises } from '../composables/useExercises.js'
|
||||
import { useSettings } from '../composables/useSettings.js'
|
||||
import { validateRequired, validateMin, validateUrl } from '../utils/validation.js'
|
||||
import { useKeyboardShortcut } from '../composables/useKeyboardShortcut.js'
|
||||
|
||||
const { t } = useTranslation()
|
||||
|
||||
function te(err) {
|
||||
if (!err) return ''
|
||||
if (typeof err === 'object') return t(err.key, err.params)
|
||||
return t(err)
|
||||
}
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const { create, update, getById } = useExercises()
|
||||
const { get: getSetting } = useSettings()
|
||||
|
||||
const isEdit = computed(() => route.name === 'exercise-edit')
|
||||
const exerciseId = computed(() => route.params.id)
|
||||
|
||||
// Form fields
|
||||
const title = ref('')
|
||||
const description = ref('')
|
||||
const asymmetric = ref(false)
|
||||
const alternate = ref(false)
|
||||
const imageUrls = ref([])
|
||||
const videoUrls = ref([])
|
||||
const defaultReps = ref(0)
|
||||
const defaultWeight = ref(0)
|
||||
|
||||
// Validation errors
|
||||
const errors = ref({})
|
||||
const saving = ref(false)
|
||||
|
||||
// New URL inputs
|
||||
const newImageUrl = ref('')
|
||||
const newVideoUrl = ref('')
|
||||
|
||||
onMounted(async () => {
|
||||
if (isEdit.value && exerciseId.value) {
|
||||
const exercise = await getById(exerciseId.value)
|
||||
if (!exercise) {
|
||||
router.replace({ name: 'exercises' })
|
||||
return
|
||||
}
|
||||
title.value = exercise.title
|
||||
description.value = exercise.description || ''
|
||||
asymmetric.value = exercise.asymmetric
|
||||
alternate.value = exercise.alternate
|
||||
imageUrls.value = [...(exercise.image_urls || [])]
|
||||
videoUrls.value = [...(exercise.video_urls || [])]
|
||||
defaultReps.value = exercise.default_reps || 0
|
||||
defaultWeight.value = exercise.default_weight || 0
|
||||
}
|
||||
})
|
||||
|
||||
watch(asymmetric, (val) => {
|
||||
if (val && defaultReps.value > 0 && defaultReps.value % 2 !== 0) {
|
||||
defaultReps.value++
|
||||
}
|
||||
})
|
||||
|
||||
function onDefaultRepsChange() {
|
||||
if (asymmetric.value && defaultReps.value > 0 && defaultReps.value % 2 !== 0) {
|
||||
defaultReps.value++
|
||||
}
|
||||
}
|
||||
|
||||
function validate() {
|
||||
const e = {}
|
||||
const titleErr = validateRequired(title.value)
|
||||
if (titleErr) e.title = titleErr
|
||||
|
||||
const repsErr = validateMin(defaultReps.value, 0)
|
||||
if (repsErr) e.defaultReps = repsErr
|
||||
|
||||
const weightErr = validateMin(defaultWeight.value, 0)
|
||||
if (weightErr) e.defaultWeight = weightErr
|
||||
|
||||
// Validate existing URLs in lists
|
||||
for (let i = 0; i < imageUrls.value.length; i++) {
|
||||
const urlErr = validateUrl(imageUrls.value[i])
|
||||
if (urlErr) {
|
||||
e[`imageUrl_${i}`] = urlErr
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < videoUrls.value.length; i++) {
|
||||
const urlErr = validateUrl(videoUrls.value[i])
|
||||
if (urlErr) {
|
||||
e[`videoUrl_${i}`] = urlErr
|
||||
}
|
||||
}
|
||||
|
||||
errors.value = e
|
||||
return Object.keys(e).length === 0
|
||||
}
|
||||
|
||||
function addImageUrl() {
|
||||
const urlErr = validateUrl(newImageUrl.value)
|
||||
if (urlErr) {
|
||||
errors.value = { ...errors.value, newImageUrl: urlErr }
|
||||
return
|
||||
}
|
||||
if (!newImageUrl.value.trim()) return
|
||||
imageUrls.value.push(newImageUrl.value.trim())
|
||||
newImageUrl.value = ''
|
||||
// Clear any previous error on this field
|
||||
const { newImageUrl: _, ...rest } = errors.value
|
||||
errors.value = rest
|
||||
}
|
||||
|
||||
function removeImageUrl(index) {
|
||||
imageUrls.value.splice(index, 1)
|
||||
}
|
||||
|
||||
function addVideoUrl() {
|
||||
const urlErr = validateUrl(newVideoUrl.value)
|
||||
if (urlErr) {
|
||||
errors.value = { ...errors.value, newVideoUrl: urlErr }
|
||||
return
|
||||
}
|
||||
if (!newVideoUrl.value.trim()) return
|
||||
videoUrls.value.push(newVideoUrl.value.trim())
|
||||
newVideoUrl.value = ''
|
||||
const { newVideoUrl: _, ...rest } = errors.value
|
||||
errors.value = rest
|
||||
}
|
||||
|
||||
function removeVideoUrl(index) {
|
||||
videoUrls.value.splice(index, 1)
|
||||
}
|
||||
|
||||
async function onSave() {
|
||||
if (!validate()) return
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
const userUuid = await getSetting('user_uuid')
|
||||
const data = {
|
||||
title: title.value.trim(),
|
||||
description: description.value.trim(),
|
||||
asymmetric: asymmetric.value,
|
||||
alternate: asymmetric.value ? alternate.value : false,
|
||||
image_urls: imageUrls.value,
|
||||
video_urls: videoUrls.value,
|
||||
default_reps: Number(defaultReps.value) || 0,
|
||||
default_weight: Number(defaultWeight.value) || 0,
|
||||
created_by: userUuid,
|
||||
}
|
||||
|
||||
if (isEdit.value) {
|
||||
await update(exerciseId.value, data)
|
||||
router.push({ name: 'exercise-detail', params: { id: exerciseId.value } })
|
||||
} else {
|
||||
const id = await create(data)
|
||||
router.push({ name: 'exercise-detail', params: { id } })
|
||||
}
|
||||
} catch (e) {
|
||||
// Handle unique constraint violation on title
|
||||
if (e.message && e.message.includes('UNIQUE')) {
|
||||
errors.value = { ...errors.value, title: 'validation.titleTaken' }
|
||||
} else {
|
||||
errors.value = { ...errors.value, save: e.message }
|
||||
}
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
useKeyboardShortcut('enter', onSave, { ctrl: true, ignoreInputs: false })
|
||||
useKeyboardShortcut('escape', onCancel, { ignoreInputs: false })
|
||||
|
||||
function onCancel() {
|
||||
if (isEdit.value) {
|
||||
router.push({ name: 'exercise-detail', params: { id: exerciseId.value } })
|
||||
} else {
|
||||
router.push({ name: 'exercises' })
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="#actionbar-actions" defer>
|
||||
<button
|
||||
data-testid="exercise-form-cancel"
|
||||
:title="$t('exercises.form.cancel')"
|
||||
:aria-label="$t('exercises.form.cancel')"
|
||||
@click="onCancel"
|
||||
>
|
||||
<i class="bi bi-x-lg"></i>
|
||||
</button>
|
||||
</Teleport>
|
||||
|
||||
<div class="exercise-form-view">
|
||||
<h2 data-testid="exercise-form-heading">
|
||||
{{ isEdit ? $t('exercises.edit') : $t('exercises.new') }}
|
||||
</h2>
|
||||
|
||||
<form novalidate @submit.prevent="onSave">
|
||||
<!-- Title -->
|
||||
<div class="form-group">
|
||||
<label for="exercise-title">{{ $t('exercises.form.title') }} *</label>
|
||||
<input
|
||||
id="exercise-title"
|
||||
v-model="title"
|
||||
data-testid="exercise-title"
|
||||
type="text"
|
||||
:placeholder="$t('exercises.form.titlePlaceholder')"
|
||||
:class="{ 'input-error': errors.title }"
|
||||
required
|
||||
aria-required="true"
|
||||
:aria-describedby="errors.title ? 'exercise-title-error' : undefined"
|
||||
/>
|
||||
<p
|
||||
v-if="errors.title"
|
||||
id="exercise-title-error"
|
||||
role="alert"
|
||||
class="field-error"
|
||||
data-testid="exercise-title-error"
|
||||
>
|
||||
{{ $t(errors.title) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div class="form-group">
|
||||
<label for="exercise-description">
|
||||
{{ $t('exercises.form.description') }}
|
||||
<span class="field-hint">{{ $t('common.markdownHint') }}</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="exercise-description"
|
||||
v-model="description"
|
||||
data-testid="exercise-description"
|
||||
:placeholder="$t('exercises.form.descriptionPlaceholder')"
|
||||
rows="3"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Asymmetric -->
|
||||
<div class="form-group">
|
||||
<label class="checkbox-label">
|
||||
<input v-model="asymmetric" type="checkbox" data-testid="exercise-asymmetric" />
|
||||
{{ $t('exercises.form.asymmetric') }}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Alternate (only visible when asymmetric) -->
|
||||
<div v-if="asymmetric" class="form-group form-sub-group">
|
||||
<label class="radio-label">
|
||||
<input
|
||||
v-model="alternate"
|
||||
type="radio"
|
||||
name="alternate"
|
||||
:value="true"
|
||||
data-testid="exercise-alternate-true"
|
||||
/>
|
||||
{{ $t('exercises.form.alternate') }}
|
||||
</label>
|
||||
<label class="radio-label">
|
||||
<input
|
||||
v-model="alternate"
|
||||
type="radio"
|
||||
name="alternate"
|
||||
:value="false"
|
||||
data-testid="exercise-alternate-false"
|
||||
/>
|
||||
{{ $t('exercises.form.alternateFalse') }}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Default Reps -->
|
||||
<div class="form-group">
|
||||
<label for="exercise-reps">{{ $t('exercises.form.defaultReps') }}</label>
|
||||
<div class="stepper">
|
||||
<button
|
||||
type="button"
|
||||
class="stepper-btn btn-primary"
|
||||
:aria-label="'-1 ' + $t('exercises.form.defaultReps')"
|
||||
@click="defaultReps = Math.max(0, defaultReps - (asymmetric ? 2 : 1))"
|
||||
>
|
||||
<i class="bi bi-dash-circle-fill"></i>
|
||||
</button>
|
||||
<input
|
||||
id="exercise-reps"
|
||||
v-model.number="defaultReps"
|
||||
data-testid="exercise-reps"
|
||||
type="number"
|
||||
min="0"
|
||||
:step="asymmetric ? 2 : 1"
|
||||
class="step-input"
|
||||
:class="{ 'input-error': errors.defaultReps }"
|
||||
:aria-describedby="errors.defaultReps ? 'exercise-reps-error' : undefined"
|
||||
@change="onDefaultRepsChange"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="stepper-btn btn-primary"
|
||||
:aria-label="'+1 ' + $t('exercises.form.defaultReps')"
|
||||
@click="defaultReps += asymmetric ? 2 : 1"
|
||||
>
|
||||
<i class="bi bi-plus-circle-fill"></i>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="stepper-btn stepper-btn-fast"
|
||||
:aria-label="'+5 ' + $t('exercises.form.defaultReps')"
|
||||
@click="defaultReps += asymmetric ? 10 : 5"
|
||||
>
|
||||
<i class="bi bi-forward-fill"></i>
|
||||
</button>
|
||||
</div>
|
||||
<p
|
||||
v-if="errors.defaultReps"
|
||||
id="exercise-reps-error"
|
||||
role="alert"
|
||||
class="field-error"
|
||||
data-testid="exercise-reps-error"
|
||||
>
|
||||
{{ te(errors.defaultReps) }}
|
||||
</p>
|
||||
<p v-if="asymmetric" class="field-hint">{{ $t('exercises.evenRepsHint') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Default Weight -->
|
||||
<div class="form-group">
|
||||
<label for="exercise-weight">{{ $t('exercises.form.defaultWeight') }}</label>
|
||||
<div class="stepper">
|
||||
<button
|
||||
type="button"
|
||||
class="stepper-btn btn-primary"
|
||||
:aria-label="'-0.5 ' + $t('exercises.form.defaultWeight')"
|
||||
@click="defaultWeight = Math.max(0, +(defaultWeight - 0.5).toFixed(1))"
|
||||
>
|
||||
<i class="bi bi-dash-circle-fill"></i>
|
||||
</button>
|
||||
<input
|
||||
id="exercise-weight"
|
||||
v-model.number="defaultWeight"
|
||||
data-testid="exercise-weight"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.5"
|
||||
class="step-input"
|
||||
:class="{ 'input-error': errors.defaultWeight }"
|
||||
:aria-describedby="errors.defaultWeight ? 'exercise-weight-error' : undefined"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="stepper-btn btn-primary"
|
||||
:aria-label="'+0.5 ' + $t('exercises.form.defaultWeight')"
|
||||
@click="defaultWeight = +(defaultWeight + 0.5).toFixed(1)"
|
||||
>
|
||||
<i class="bi bi-plus-circle-fill"></i>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="stepper-btn stepper-btn-fast"
|
||||
:aria-label="'+1 ' + $t('exercises.form.defaultWeight')"
|
||||
@click="defaultWeight = +(defaultWeight + 1).toFixed(1)"
|
||||
>
|
||||
<i class="bi bi-forward-fill"></i>
|
||||
</button>
|
||||
</div>
|
||||
<p
|
||||
v-if="errors.defaultWeight"
|
||||
id="exercise-weight-error"
|
||||
role="alert"
|
||||
class="field-error"
|
||||
data-testid="exercise-weight-error"
|
||||
>
|
||||
{{ te(errors.defaultWeight) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Image URLs -->
|
||||
<div class="form-group">
|
||||
<label>{{ $t('exercises.form.imageUrls') }}</label>
|
||||
<ul v-if="imageUrls.length" class="url-list">
|
||||
<li v-for="(url, i) in imageUrls" :key="'img-' + i" class="url-item">
|
||||
<span class="url-text">{{ url }}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-remove"
|
||||
:data-testid="'exercise-remove-image-' + i"
|
||||
@click="removeImageUrl(i)"
|
||||
>
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
<p v-if="errors['imageUrl_' + i]" class="field-error">
|
||||
{{ $t(errors['imageUrl_' + i]) }}
|
||||
</p>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="url-add">
|
||||
<input
|
||||
v-model="newImageUrl"
|
||||
type="url"
|
||||
:placeholder="$t('exercises.form.urlPlaceholder')"
|
||||
data-testid="exercise-new-image-url"
|
||||
:class="{ 'input-error': errors.newImageUrl }"
|
||||
@keyup.enter.prevent="addImageUrl"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary btn-sm"
|
||||
data-testid="exercise-add-image-url"
|
||||
@click="addImageUrl"
|
||||
>
|
||||
<i class="bi bi-plus"></i> {{ $t('exercises.form.addUrl') }}
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="errors.newImageUrl" class="field-error" data-testid="exercise-image-url-error">
|
||||
{{ $t(errors.newImageUrl) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Video URLs -->
|
||||
<div class="form-group">
|
||||
<label>{{ $t('exercises.form.videoUrls') }}</label>
|
||||
<ul v-if="videoUrls.length" class="url-list">
|
||||
<li v-for="(url, i) in videoUrls" :key="'vid-' + i" class="url-item">
|
||||
<span class="url-text">{{ url }}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-remove"
|
||||
:data-testid="'exercise-remove-video-' + i"
|
||||
@click="removeVideoUrl(i)"
|
||||
>
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
<p v-if="errors['videoUrl_' + i]" class="field-error">
|
||||
{{ $t(errors['videoUrl_' + i]) }}
|
||||
</p>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="url-add">
|
||||
<input
|
||||
v-model="newVideoUrl"
|
||||
type="url"
|
||||
:placeholder="$t('exercises.form.urlPlaceholder')"
|
||||
data-testid="exercise-new-video-url"
|
||||
:class="{ 'input-error': errors.newVideoUrl }"
|
||||
@keyup.enter.prevent="addVideoUrl"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary btn-sm"
|
||||
data-testid="exercise-add-video-url"
|
||||
@click="addVideoUrl"
|
||||
>
|
||||
<i class="bi bi-plus"></i> {{ $t('exercises.form.addUrl') }}
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="errors.newVideoUrl" class="field-error" data-testid="exercise-video-url-error">
|
||||
{{ $t(errors.newVideoUrl) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Save error -->
|
||||
<p v-if="errors.save" class="field-error" data-testid="exercise-save-error">
|
||||
{{ errors.save }}
|
||||
</p>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="form-actions">
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-primary"
|
||||
data-testid="exercise-save"
|
||||
:disabled="saving"
|
||||
:title="$t('exercises.form.save') + ' ' + $t('shortcuts.save')"
|
||||
>
|
||||
<i class="bi bi-check-lg"></i>
|
||||
{{ $t('exercises.form.save') }}
|
||||
</button>
|
||||
<button type="button" class="btn" data-testid="exercise-cancel" @click="onCancel">
|
||||
{{ $t('exercises.form.cancel') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.exercise-form-view {
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
.exercise-form-view h2 {
|
||||
margin-bottom: 16px;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 6px;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.field-hint {
|
||||
font-weight: 400;
|
||||
font-size: 11px;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.form-sub-group {
|
||||
padding-left: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.checkbox-label,
|
||||
.radio-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-weight: 400;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.checkbox-label input,
|
||||
.radio-label input {
|
||||
width: auto;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.input-error {
|
||||
border-color: var(--btn-danger-bg) !important;
|
||||
}
|
||||
|
||||
.stepper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.stepper-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 22px;
|
||||
flex-shrink: 0;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.stepper-btn:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.stepper-btn:active {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.stepper-btn-fast {
|
||||
background: var(--btn-primary-bg);
|
||||
color: var(--btn-primary-color);
|
||||
}
|
||||
|
||||
.step-input {
|
||||
width: 64px;
|
||||
padding: 6px 4px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
background: var(--color-background);
|
||||
color: var(--color-text);
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.field-error {
|
||||
color: var(--btn-danger-bg);
|
||||
font-size: 13px;
|
||||
margin: 4px 0 0;
|
||||
}
|
||||
|
||||
.field-hint {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 12px;
|
||||
margin: 4px 0 0;
|
||||
}
|
||||
|
||||
.url-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.url-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 4px 0;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.url-text {
|
||||
flex: 1;
|
||||
font-size: 13px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btn-remove {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--btn-danger-bg);
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
font-size: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.btn-remove:hover {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.url-add {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.url-add input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 6px 12px;
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 400px) {
|
||||
.url-add {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.url-add input {
|
||||
flex: 1 1 100%;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.form-actions .btn {
|
||||
flex: 1 1 auto;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue