trainUs/docs/decisions-log.md
David 352a3d5e88
Some checks are pending
CI / Lint & Format (push) Waiting to run
CI / Build (push) Waiting to run
CI / Tests (${{ matrix.browser }}) (chromium) (push) Waiting to run
CI / Tests (${{ matrix.browser }}) (firefox) (push) Waiting to run
Initial commit
2026-06-18 13:06:57 +02:00

75 KiB
Raw Blame History

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 grpcollection and group_sessioncollection_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.
  • 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 :keys. 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.