diff --git a/.claude/commands/adr.md b/.claude/commands/adr.md index db7ca4e9..ca886efb 100755 --- a/.claude/commands/adr.md +++ b/.claude/commands/adr.md @@ -1,6 +1,7 @@ --- description: Create or review Architecture Decision Records model: sonnet +effort: high --- # Architecture Decision Record diff --git a/.claude/commands/backlog.md b/.claude/commands/backlog.md index 47bf8287..571e4d50 100755 --- a/.claude/commands/backlog.md +++ b/.claude/commands/backlog.md @@ -1,8 +1,9 @@ ---- -description: Add to or prioritize the backlog -model: sonnet ---- - +--- +description: Add to or prioritize the backlog +model: sonnet +effort: low +--- + # Backlog Management Add ideas or bugs to the backlog, or prioritize existing items. Keeps track of future work and known issues. diff --git a/.claude/commands/blueprint.md b/.claude/commands/blueprint.md index 56f99f37..d4161d09 100755 --- a/.claude/commands/blueprint.md +++ b/.claude/commands/blueprint.md @@ -1,6 +1,7 @@ --- description: Full 4-phase planning: Spec, Tech, Steps with team orchestration model: sonnet +effort: high --- # Full Planning Workflow diff --git a/.claude/commands/commit.md b/.claude/commands/commit.md index 107f6cc3..2de4f375 100755 --- a/.claude/commands/commit.md +++ b/.claude/commands/commit.md @@ -1,8 +1,9 @@ ---- -description: Git commit with conventional format and task references -model: sonnet ---- - +--- +description: Git commit with conventional format and task references +model: sonnet +effort: low +--- + # Commit Changes Prepare a commit message following conventional commits format. References tasks and ADRs from planning artifacts. @@ -56,3 +57,10 @@ After commit is created: - Start testing - Update documentation - Review and verify + +### Step 4: Archive completed QuickPlans +After a successful commit, check for active QuickPlans: +1. Scan `Context/QuickPlans/` for `.md` files (ignore `Done/` subdirectory) +2. If none found, skip this step silently +3. If plans are found, list them and ask: "Archive these completed QuickPlans?" +4. If confirmed, move each plan to `Context/QuickPlans/Done/` (create the directory if needed) diff --git a/.claude/commands/diagnose.md b/.claude/commands/diagnose.md index 8cbaa325..94fd3d8a 100755 --- a/.claude/commands/diagnose.md +++ b/.claude/commands/diagnose.md @@ -1,6 +1,7 @@ --- description: Check project structure and Cortex health model: sonnet +effort: low --- # Diagnose: Cortex Project Health Check diff --git a/.claude/commands/impl.md b/.claude/commands/impl.md index 29bbc15d..ae3c4750 100755 --- a/.claude/commands/impl.md +++ b/.claude/commands/impl.md @@ -1,6 +1,7 @@ --- description: Execute Steps.md via hub-and-spoke sub-agent orchestration model: sonnet +effort: medium --- # Implementation Orchestrator @@ -21,14 +22,14 @@ Execute a planned feature by reading Steps.md and delegating tasks to specialist 3. Once identified, load Steps.md ### Step 2: Load planning context -1. Read `Context/Features/NNN-FeatureName/Steps.md` -- tasks, team, milestones -2. Read `Context/Features/NNN-FeatureName/Spec.md` -- requirements and assertions -3. Read `Context/Features/NNN-FeatureName/Tech.md` -- architecture decisions -4. Read `CLAUDE.md` for project conventions -5. Parse Steps.md to extract: +1. Read `Context/Features/NNN-FeatureName/Steps.md` -- this is the ONLY planning file the orchestrator reads +2. Parse Steps.md to extract: - Team Members (names, agent types, resume behavior) - Task list with assignments, dependencies, parallel flags - Milestones with testable assertion references and contract declarations + - File ownership boundaries per agent + +**Context budget rule:** Do NOT read Spec.md, Tech.md, CLAUDE.md, or any source code into the orchestrator's context. Agents read these files themselves. The orchestrator's job is coordination, not comprehension -- if it compacts before wave 1 starts, the entire execution plan is lost. ### Step 3: Initialize execution session 1. Create `.cortex/session.md` with plan reference and team roster @@ -147,14 +148,16 @@ After all waves complete: ## Agent Prompt Template -When rendering each agent's prompt file, use this template: +When rendering each agent's prompt file, use this template. The orchestrator fills in task IDs, file paths, and milestone references from Steps.md -- it does NOT read Spec.md/Tech.md to paste content. Agents read those files themselves. ``` You are [NAME], the [ROLE] on this team. FEATURE CONTEXT: -[Paste relevant Spec.md sections -- requirements and testable assertions] -[Paste relevant Tech.md sections -- architecture decisions for this domain] +Read these files before starting work (focus on sections relevant to your tasks): +- Context/Features/NNN-FeatureName/Spec.md -- requirements and testable assertions +- Context/Features/NNN-FeatureName/Tech.md -- architecture decisions +- CLAUDE.md -- project conventions YOUR TASKS: [List specific tasks from Steps.md assigned to this agent] @@ -182,7 +185,6 @@ COORDINATION (event log): FILE OWNERSHIP RULES: - Mark your tasks complete via TaskUpdate when done - If you change an interface another agent depends on, note it clearly in `.cortex/session.md` -- Read all relevant files and load skills before starting work Think hard and provide thorough implementation. ``` @@ -301,3 +303,5 @@ When to resume vs start fresh: - Contract files must exist before downstream agents are spawned - The quality-engineer always runs last in validation mode (read-only) - Advisor-cli exit code 2 means fall back to in-thread reasoning -- never fail the wave on advisor unavailability +- **The orchestrator must NOT read source code, explore the codebase, or load files beyond Steps.md.** Codebase comprehension is the agents' job. The orchestrator reads Steps.md, builds prompt files, spawns agents, and monitors events. Any additional file reads risk compaction before wave 1. +- **No pre-flight advisor consult.** The advisor is called at milestone boundaries (Step 5e), not before wave 1. Pre-flight exploration and advisor calls are the two largest sources of unnecessary orchestrator context. diff --git a/.claude/commands/qa.md b/.claude/commands/qa.md index b568a55e..151e4737 100755 --- a/.claude/commands/qa.md +++ b/.claude/commands/qa.md @@ -1,8 +1,9 @@ ---- -description: Run stack-specific QA agents in parallel -model: sonnet ---- - +--- +description: Run stack-specific QA agents in parallel +model: sonnet +effort: medium +--- + # Parallel Quality Assurance Run stack-specific QA agents in parallel across all detected stacks in the project. diff --git a/.claude/commands/quick.md b/.claude/commands/quick.md index 42a7b4f7..9fd70d3f 100755 --- a/.claude/commands/quick.md +++ b/.claude/commands/quick.md @@ -1,6 +1,7 @@ --- description: Lightweight single-file plan for small tasks model: sonnet +effort: medium --- # Quick Plan @@ -53,5 +54,10 @@ ALWAYS end by recommending the most appropriate execution command: Quick plans typically use `/impl` since they are smaller in scope. -### Step 5: Clean up -When task is complete, move quick plan to `Context/Backlog/Done/` for reference, or delete if not needed. +### Step 5: Archive the QuickPlan +When the task is complete, archive the plan so it does not accumulate in the active folder: +1. Create `Context/QuickPlans/Done/` if it does not exist +2. Move the plan file from `Context/QuickPlans/` to `Context/QuickPlans/Done/` +3. Confirm the move to the user + +Do NOT skip this step or leave it as a suggestion. Perform the move directly. diff --git a/.claude/commands/review-capture.md b/.claude/commands/review-capture.md index 2ce3acb8..16b16995 100755 --- a/.claude/commands/review-capture.md +++ b/.claude/commands/review-capture.md @@ -1,8 +1,9 @@ ---- -description: Capture review findings with 4-category triage -model: sonnet ---- - +--- +description: Capture review findings with 4-category triage +model: sonnet +effort: medium +--- + # Review Capture Capture review feedback and link it to the feature or PR being reviewed. Documents what was reviewed, feedback provided, and any decisions made. diff --git a/.claude/commands/review-resolve.md b/.claude/commands/review-resolve.md index 2fa63586..76d6d028 100755 --- a/.claude/commands/review-resolve.md +++ b/.claude/commands/review-resolve.md @@ -1,6 +1,7 @@ --- description: Work through captured review findings with interactive triage model: sonnet +effort: high --- # Review Resolution diff --git a/.claude/commands/review-verify.md b/.claude/commands/review-verify.md index 16a4031e..37d3fcfb 100755 --- a/.claude/commands/review-verify.md +++ b/.claude/commands/review-verify.md @@ -1,6 +1,7 @@ --- description: Verify all review findings have been addressed model: sonnet +effort: medium --- # Review Verification diff --git a/.claude/commands/security-scan.md b/.claude/commands/security-scan.md index df9c9e8f..898aae0e 100644 --- a/.claude/commands/security-scan.md +++ b/.claude/commands/security-scan.md @@ -1,6 +1,7 @@ --- description: Scan Cortex and Claude Code configuration for security issues model: sonnet +effort: medium --- # Security Scan: Configuration Audit diff --git a/.claude/commands/setup.md b/.claude/commands/setup.md index 2774e3d5..7fa88905 100644 --- a/.claude/commands/setup.md +++ b/.claude/commands/setup.md @@ -1,6 +1,7 @@ --- description: Initialize project for Cortex workflow model: sonnet +effort: low --- # Setup Cortex diff --git a/.claude/commands/spec-init.md b/.claude/commands/spec-init.md new file mode 100644 index 00000000..5caeb100 --- /dev/null +++ b/.claude/commands/spec-init.md @@ -0,0 +1,117 @@ +--- +description: Initialize spec-driven design templates for the project +model: sonnet +effort: low +--- + +# Initialize Spec Templates + +Generate the Spec-Driven Design template set for this project. Scans existing code and documentation, then produces structured spec docs in `docs/specs/` that integrate with the Cortex planning workflow. + +## When to use +- First time setting up spec-driven development in a project +- Converting existing docs (PRDs, architecture docs, Cortex specs) into the template format +- User says "init specs", "create spec templates", "set up specs", "convert my docs" + +## Workflow + +**CRITICAL:** This command reads project context before generating anything. Do NOT write files until Step 4. + +### Step 1: Gather project context +1. Read `CLAUDE.md` for project name, tech stack, architecture, conventions +2. Scan project structure for existing documentation: + - `docs/` -- any existing spec or design docs + - `README.md` -- project description + - `Context/Features/` -- existing Cortex feature specs (Spec.md, Tech.md, Steps.md) + - `Context/Decisions/` -- existing ADRs +3. Scan source code structure for domain hints: + - Model/entity class definitions + - Enum/sealed class definitions + - Database migration files + - Route/endpoint definitions +4. Check if `docs/specs/` already exists + +### Step 2: Present findings and choose mode +Present what was found and offer three modes: + +``` +Project: {name} +Stacks: {detected stacks} +Existing docs found: {list} +Cortex specs found: {count} features in Context/Features/ + +Modes: + [G] Greenfield -- blank templates pre-filled from project context + [B] Backfill -- convert existing docs into template format + [S] Selective -- pick which templates to generate + +Which mode? [G/B/S]: +``` + +Wait for user choice before proceeding. + +### Step 3: Determine template set +For greenfield and backfill, confirm which templates to generate: + +**Always generate (singletons):** +- `00-project-overview.md` +- `02-domain-model.md` +- `03-invariants.md` +- `04-architecture.md` +- `05-erd.md` +- `06-state-machines.md` +- `07-user-flows.md` + +**Ask the user (multi-instance):** +- How many PRDs? What are they named? (suggest based on existing docs/features) +- Any subsystem deep-dives needed? (suggest: notifications, auth, sync if detected) +- Any secondary platform docs? (suggest based on detected platforms) +- How many implementation plans? (suggest: one per major feature area) + +### Step 4: Generate templates +Use the `spec-init` skill (see `core/skills/spec-init/SKILL.md`) to generate each template file. + +For each file: +1. Create the file in `docs/specs/` with correct naming convention +2. In greenfield mode: fill project-specific placeholders, keep guidance comments +3. In backfill mode: map existing doc content to template sections, flag inferred content +4. Show progress as files are generated + +### Step 5: Validate and report +1. Run cross-reference validation (assertions, invariants, entities, states) +2. Generate `docs/specs/GAPS.md` with sections needing attention +3. Present summary: + +``` +Spec Init Complete + Mode: {greenfield/backfill/selective} + Files generated: {count} + - 00-project-overview.md + - 01-PRD-001-core.md + - 01-PRD-002-sync.md + - 02-domain-model.md + - ... + + Cross-references: + [PASS] 12/12 P0 requirements have assertions + [WARN] 2 invariants need assertion mappings + + Gaps: docs/specs/GAPS.md (3 sections need attention) + +Next steps: + 1. Review GAPS.md and fill empty sections + 2. Run /blueprint for your first feature + 3. plan-from-specs will auto-detect these docs +``` + +## Rules +- Always run Step 1 (context gathering) before generating anything +- Never overwrite existing files in `docs/specs/` without asking +- In backfill mode, preserve original docs -- copy content, don't move/delete +- Show progress as files are generated (don't go silent for 30 seconds) +- If the project has no CLAUDE.md, suggest running `/setup` first +- Cross-reference validation is not optional -- always run it +- GAPS.md is always generated, even when no gaps are found +- For backfill mode, prefer doc content over source code inference +- Flag all inferred content with `` comments +- If `docs/specs/` already has files, ask before proceeding (merge vs overwrite vs abort) diff --git a/.claude/commands/team-impl.md b/.claude/commands/team-impl.md index b91bbb21..9a39f993 100755 --- a/.claude/commands/team-impl.md +++ b/.claude/commands/team-impl.md @@ -1,6 +1,7 @@ --- description: Execute Steps.md via Agent Teams with peer-to-peer coordination model: sonnet +effort: medium --- # Team Implementation (Agent Teams) diff --git a/CLAUDE.md b/CLAUDE.md index 4ee8c63d..6813e2da 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -235,6 +235,70 @@ Request → Load Skills → Assess Complexity → Route → Execute → Commit **Success = Skills → Complexity Assessment → `/team-plan` → Approval → `/build` or `/team-build` → Improvement** +## Coding Guidelines + +Guidelines to reduce common LLM coding mistakes. Biased toward caution over speed. For trivial tasks, use judgment. + +### 1. Think Before Coding + +Don't assume. Don't hide confusion. Surface tradeoffs. + +- State assumptions explicitly. If uncertain, ask. +- If multiple interpretations exist, present them -- don't pick silently. +- If a simpler approach exists, say so. Push back when warranted. +- If something is unclear, stop. Name what's confusing. Ask. + +### 2. Read Before You Write + +Don't guess what a file contains. Don't rewrite what you haven't read. + +- Read the full file (or relevant sections) before modifying it. +- Check for existing implementations before writing new ones. +- Look for patterns the codebase already uses -- follow them. +- If a file is long, read the specific function/section you're changing plus its callers. +- Never assume file contents from the filename alone. + +### 3. Simplicity First + +Minimum code that solves the problem. Nothing speculative. + +- No features beyond what was asked. +- No abstractions for single-use code. +- No "flexibility" or "configurability" that wasn't requested. +- No error handling for impossible scenarios. +- If you write 200 lines and it could be 50, rewrite it. + +Ask: "Would a senior engineer say this is overcomplicated?" If yes, simplify. + +### 4. Surgical Changes + +Touch only what you must. Clean up only your own mess. + +When editing existing code: +- Don't "improve" adjacent code, comments, or formatting. +- Don't refactor things that aren't broken. +- Match existing style, even if you'd do it differently. +- If you notice unrelated issues, mention them -- don't fix them. + +When your changes create orphans: +- Remove imports/variables/functions that YOUR changes made unused. +- Don't remove pre-existing dead code unless asked. + +The test: every changed line should trace directly to the request. + +### 5. Goal-Driven Execution + +Define success criteria. Loop until verified. + +Transform tasks into verifiable goals: +- "Add validation" -- write tests for invalid inputs, then make them pass +- "Fix the bug" -- write a test that reproduces it, then make it pass +- "Refactor X" -- ensure tests pass before and after + +For multi-step tasks, state a brief plan with verification at each step. Strong success criteria let you loop independently. Weak criteria ("make it work") require constant clarification. + +--- + ## Design Context ### Users diff --git a/Context/Backlog/Bugs.md b/Context/Backlog/Bugs.md index 52e0893f..68e9bd8d 100644 --- a/Context/Backlog/Bugs.md +++ b/Context/Backlog/Bugs.md @@ -35,3 +35,14 @@ High-priority bugs should be addressed before starting new features. **Severity:** Medium **Resolution:** Fixed in `5d2335c` -- disable delete-week button when block has only one week. + +### B008: Gym selector shown every time a workout is started +**Added:** 2026-04-12 +**Context:** The "select gym" modal/selector appears on every workout start, even when the user has already chosen a gym in a previous session. The last-selected gym should be persisted and pre-selected so the user only needs to intervene when changing gyms. +**Related:** `src/stores/active-workout-store.ts`, gym-picker-storage, workout start flow +**Priority:** Medium + +### ~~B007: Active workout only shows one exercise despite multiple being added~~ (Resolved 2026-04-13) + +**Added:** 2026-04-12 +**Resolution:** Fixed in `11ca616` -- show all exercises simultaneously in active workout view. diff --git a/Context/Backlog/Ideas.md b/Context/Backlog/Ideas.md index 73f56da3..6fde1a20 100644 --- a/Context/Backlog/Ideas.md +++ b/Context/Backlog/Ideas.md @@ -92,6 +92,13 @@ Two related UX gaps in the workout logger: **Related:** `src/routes/_authenticated/log.$workoutId.tsx`, workout logger state, set auto-population logic **Area:** Workout logger UX +### "Add exercise" pre-populated with frequently used exercises + +**Added:** 2026-04-12 +**Context:** When opening the "Add exercise" picker, the search field starts empty with no suggestions. Pre-populating it with the user's most frequently logged exercises (derived from workout history) would reduce search friction, especially on the gym floor where speed matters. +**Related:** Exercise picker/search component, workout history data, active workout flow +**Priority:** Medium + ### Split log.$workoutId route into three components **Added:** 2026-04-06 **Context:** `src/routes/_authenticated/log.$workoutId.tsx` is ~800 lines with three distinct rendering paths (event workout, strength workout, post-workout summary) toggled by conditional branches. Splitting into separate route components would clarify ownership, shrink the file, and isolate state hoisting per path. diff --git a/Context/Features/018-Workout-Session-UX/Steps.md b/Context/Features/018-Workout-Session-UX/Steps.md index 7c3b8919..0896fca9 100644 --- a/Context/Features/018-Workout-Session-UX/Steps.md +++ b/Context/Features/018-Workout-Session-UX/Steps.md @@ -402,11 +402,68 @@ Follow-up tasks captured from PR #92 review (`Context/Reviews/0014-pr92-workout- **Acceptance:** Pause/resume works on Tauri with full persistence parity; interim UI gate removed; round-trip tests pass. **Review finding:** ADR-013 +## Wave 7: PR #109 Review Follow-ups + +Follow-up tasks captured from PR #109 review (`Context/Reviews/0018-pr109-workout-set-ux-2026-04-13.md`). + +### S051 -- Tests for new store actions (P18-010, High) + +**Owner:** `quality-engineer` +**Files:** `src/stores/__tests__/active-workout-store.test.ts` +**Deliverable:** Tests for four new store actions added in PR #109: `deleteSet`, `unconfirmSet`, `removeActivity` (undo-clearing side effect), `skipActivity` (lifecycle reset). Must cover: + +- `deleteSet` and `removeActivity` null out `undoAction` when targeted set matches +- `skipActivity` with `initialState` spread does not share `skippedActivityIds` Set reference + +**Acceptance:** All four actions covered; undo-clearing side effect and Set reference isolation tested. +**Review finding:** P18-010 + +### S052 -- Component test for swipe-to-delete DEL button (P18-011, High) + +**Owner:** `quality-engineer` +**Files:** `src/components/workout/__tests__/set-row.test.tsx` +**Deliverable:** Tests for DEL button path (swipe/touch simulation not required -- tap path only): + +1. DEL button renders when `onDelete` prop provided +2. DEL button absent when `onDelete` is absent +3. Clicking DEL calls `onDelete` and resets `swipeX` to 0 + +**Acceptance:** All three cases pass. +**Review finding:** P18-011 + +### S053 -- Regression test for B008 gym picker fix (P18-012, High) + +**Owner:** `quality-engineer` +**Files:** `src/routes/_authenticated/__tests__/` (new test file or existing) +**Deliverable:** Two test cases for B008 behavioral contract: + +1. Mock `readLastGymChoice` to return a saved gym -- start workout -- assert `openGymPicker` is NOT called +2. Mock `readLastGymChoice` to return null -- start workout -- assert `openGymPicker` IS called + +**Acceptance:** Both cases pass; regression is guarded. +**Review finding:** P18-012 + +### S054 -- Tests for `RestTimerBanner` component (P18-015, Medium) + +**Owner:** `quality-engineer` +**Files:** New test file alongside `src/components/workout/rest-timer-banner.tsx` +**Deliverable:** Unit tests covering: + +- Progress bar clamping (`Math.max(0, Math.min(100, ...))`) -- verify `remaining > total` does not produce >100% +- `total > 0` guard -- verify nothing renders (or renders safely) when `total === 0` +- Expand button calls `onExpand` callback +- Skip button calls `onSkip` callback +- Countdown text renders from `formatCountdown(remaining)` + +**Acceptance:** All cases covered and passing. +**Review finding:** P18-015 + --- ## Revision History -| Date | Change | ADR | -| ---------- | ----------------------------------------------- | ---------------- | -| 2026-04-06 | Initial draft | -- | -| 2026-04-07 | Add Wave 6 PR #92 review follow-ups (S041-S050) | ADR-012, ADR-013 | +| Date | Change | ADR | +| ---------- | ------------------------------------------------ | ---------------- | +| 2026-04-06 | Initial draft | -- | +| 2026-04-07 | Add Wave 6 PR #92 review follow-ups (S041-S050) | ADR-012, ADR-013 | +| 2026-04-13 | Add Wave 7 PR #109 review follow-ups (S051-S054) | -- | diff --git a/Context/QuickPlans/Done/2026-04-12-b007-multi-exercise-display.md b/Context/QuickPlans/Done/2026-04-12-b007-multi-exercise-display.md new file mode 100644 index 00000000..9f38300d --- /dev/null +++ b/Context/QuickPlans/Done/2026-04-12-b007-multi-exercise-display.md @@ -0,0 +1,63 @@ +# Quick Plan: B007 — Active Workout Shows Only One Exercise + +**Task:** Fix active workout showing only one exercise at a time; add support for repeated exercises and per-exercise deletion. + +**Goal:** All exercises in the workout are visible simultaneously. The active exercise is visually foregrounded (already handled by `isActive` styling in `ExerciseBlock`). Completed exercises are shown in a dimmed read-only state. Repeated instances of the same exercise work naturally. Any exercise can be deleted at any time. + +--- + +## Root Cause + +`src/routes/_authenticated/log.$workoutId.tsx:607` — hard focus filter inside the `loggedGroups.map` render loop: + +```tsx +// Hard focus: only render the active activity in SET mode. +if (activity.id !== activeFocusId) return null +``` + +This deliberately hides all non-active exercises. The `ExerciseBlock` component already has `isActive` prop support with visual differentiation (bg-surface-iron vs bg-surface-pit, ember vs ember/40), so the component is ready — the page is just not using it. + +## Approach + +**Three surgical edits in `log.$workoutId.tsx` only. No store changes needed.** + +### Edit 1 — Remove hard focus filter (line 607) +```diff +- // Hard focus: only render the active activity in SET mode. +- if (activity.id !== activeFocusId) return null +``` + +### Edit 2 — Pass `isActive` correctly (currently hardcoded `true`) +```diff +- isActive={true} ++ isActive={activity.id === activeFocusId} +``` + +### Edit 3 — Gate pending input row to active exercise only +Currently adds a pending row whenever `confirmedSets.length === 0`. Without the filter, every future exercise (which has 0 confirmed sets) would get a pending input row. + +```diff +- if (confirmedSets.length === 0 || pendingInputs[activity.id]) { ++ if (activity.id === activeFocusId && (confirmedSets.length === 0 || pendingInputs[activity.id])) { +``` + +## Out of scope + +- **CIRCUIT groups**: The circuit path has a separate focus filter (`if (group.id !== activeFocusId) return null`). Leave that untouched — B007 describes strength blocks only. +- **Store changes**: `addExerciseToWorkout` already creates distinct groups per add-call, so repeated exercises already get unique IDs. The display fix alone is sufficient. +- **`onRemoveExercise`**: Already passed to `ExerciseBlock` for all activities. Once the filter is removed, delete is accessible for any exercise automatically. +- **`onAddSet` / `onSkipExercise`**: `ExerciseBlock` already gates these inside `{isActive && ...}`, so they auto-hide for non-active blocks. No change needed. +- **`onConfirmSet`**: Safe to pass to all blocks; non-active blocks have no pending row so confirm never fires for them. + +## Verification + +1. Start a free-form workout, add 3 different exercises → all 3 visible, first one active (ember title, bg-surface-iron) +2. Confirm all sets on exercise 1 → exercise 2 becomes active; exercise 1 remains visible in dimmed state (bg-surface-pit) +3. Add the same exercise twice → both instances visible, stacked one after the other +4. Delete button (trash icon) visible and functional on any exercise, not just the active one +5. Pending input row appears only under the active exercise +6. Add Set / Done buttons appear only under the active exercise + +## Risks + +- None material. The `ExerciseBlock` component was already designed for multi-state display; `isActive` styling was already wired. The hard focus filter was the only thing blocking it. diff --git a/Context/QuickPlans/Done/2026-04-12-exercise-completion-signal.md b/Context/QuickPlans/Done/2026-04-12-exercise-completion-signal.md new file mode 100644 index 00000000..dbf6f61e --- /dev/null +++ b/Context/QuickPlans/Done/2026-04-12-exercise-completion-signal.md @@ -0,0 +1,150 @@ +# Quick Plan: Exercise Completion Signal + +**Branch:** `feat/exercise-completion-signal` +**Date:** 2026-04-12 +**Source:** Backlog -- Ideas.md "Exercise completion signal during active workout" + +--- + +## Task + +Two related UX gaps in the workout logger (strength path only): + +1. No way to explicitly mark an exercise block as done when stopping short of planned sets. +2. The trailing auto-populated set row looks identical to a confirmed set, so users can't tell it won't be saved until they act. + +--- + +## Goal + +- Athlete can tap "DONE" on an exercise block to skip remaining sets and advance focus to the next exercise. +- Trailing unconfirmed set row is visually distinct (dimmed/ghosted) so its "not yet saved" state is unambiguous. + +--- + +## Approach + +### 1. Store: `skippedActivityIds` set + +**File:** `src/stores/active-workout-store.ts` + +Add `skippedActivityIds: Set` to store state (default: empty). +Add `skipActivity(activityId: string): void` action that adds the id to the set. +Reset `skippedActivityIds` on `clearWorkout` / `discardWorkout`. + +> Keep this purely local (no persistence to Supabase). A skipped exercise +> advances focus; the sets that were logged before skipping are still saved. + +### 2. `activeFocusId` respects skipped activities + +**File:** `src/routes/_authenticated/log.$workoutId.tsx` + +Pull `skippedActivityIds` from the store alongside `loggedGroups`. + +In the `activeFocusId` useMemo (line 250), treat a skipped activity the same as +one with all sets completed: + +```ts +const skipped = useActiveWorkoutStore((s) => s.skippedActivityIds) +// In activeFocusId memo: +if (skippedActivityIds.has(activity.id)) continue // treat as done +``` + +Also guard ExerciseBlock rendering: if `skippedActivityIds.has(activity.id)` +return null (don't render the block at all). + +### 3. `ExerciseBlock` -- "DONE" footer button + +**File:** `src/components/workout/exercise-block.tsx` + +Add optional prop: `onSkipExercise?: () => void`. + +When provided (and `isActive` is true), render a secondary action row below the +set list: + +```tsx +{onSkipExercise && ( +
+ +
+)} +``` + +Iron & Ember rules: no border, no rounding, small secondary text -- does not +compete with the confirm-set CTA. + +### 4. Wire `onSkipExercise` in route + +**File:** `src/routes/_authenticated/log.$workoutId.tsx` + +```tsx +const { skipActivity } = useActiveWorkoutStore() + + 0 ? () => skipActivity(activity.id) : undefined + } +/> +``` + +Guard with `confirmedSets.length > 0` so the button only appears after at least +one set is confirmed. This prevents a fat-finger skip before any work begins. + +### 5. Trailing row visual treatment + +**File:** `src/components/workout/set-row.tsx` + +The route passes `id: 'pending-{activityId}-{n}'` for the trailing row. Add a +`isPending?: boolean` prop to `SetRow` and `SetRowData`. + +When `isPending` is true: +- Dim the entire row: `opacity-40` +- No status icon in the STATUS column (or a faint dashed placeholder) + +In `exercise-block.tsx`, mark the last row as pending when `!set.confirmed`: +```tsx +isPending={!set.confirmed && set.id.startsWith('pending-')} +``` + +This makes the intent obvious: the row exists as input affordance, not a logged set. + +--- + +## Files Touched + +| File | Change | +|------|--------| +| `src/stores/active-workout-store.ts` | Add `skippedActivityIds` + `skipActivity` + reset on clear | +| `src/components/workout/exercise-block.tsx` | Add `onSkipExercise` prop + DONE button | +| `src/components/workout/set-row.tsx` | Add `isPending` prop + opacity treatment | +| `src/routes/_authenticated/log.$workoutId.tsx` | Wire `onSkipExercise`, pull `skippedActivityIds`, update `activeFocusId` memo | + +--- + +## Verification + +- [ ] Tapping "DONE" on a strength block with >= 1 confirmed set advances focus to the next exercise. +- [ ] "DONE" button does not appear before any sets are confirmed on the block. +- [ ] Skipped activities do not re-render after being skipped. +- [ ] Trailing empty set row is visually dimmed vs. a confirmed row. +- [ ] Finishing the workout after a skip still saves all confirmed sets correctly. +- [ ] Cardio, ruck, and circuit blocks are unaffected (no `onSkipExercise` wired to them). +- [ ] Store resets on discard/finish -- no stale `skippedActivityIds` on next workout. +- [ ] `bun run build` passes with no TypeScript errors. + +--- + +## Risks + +- **Accidental skip guard**: The `confirmedSets.length > 0` guard mitigates fat-finger skips, but a user who logs one bad set and skips is harder to recover from. The undo affordance (already present) covers single-set rollback. +- **Programmed workouts**: For programmed workouts, skipping an exercise means the prescribed sets won't be logged. This is intentional -- athletes should be able to cut sets for injury/RPE reasons. No advancement-failure side effect because advancement is triggered by `finishWorkout`, not per-exercise completion. +- **`skippedActivityIds` type**: Using `Set` in Zustand state requires care with persistence/devtools serialization. Since we are not persisting this to storage, a plain `Set` is fine. Alternatively use `string[]` + `.includes()` for simpler serialization. diff --git a/Context/QuickPlans/Done/2026-04-12-minimize-rest-timer.md b/Context/QuickPlans/Done/2026-04-12-minimize-rest-timer.md new file mode 100644 index 00000000..04bd9023 --- /dev/null +++ b/Context/QuickPlans/Done/2026-04-12-minimize-rest-timer.md @@ -0,0 +1,92 @@ +# Quick Plan: Minimize Rest Timer + +**Task:** Add a way to minimize the rest timer so it doesn't always own the full screen. + +**Goal:** Allow athletes to collapse the rest timer into a compact sticky banner while resting, letting them review the exercise list, check prescribed weights, or pre-populate sets -- then expand back to full-screen when desired. + +--- + +## Current Behavior + +In `log.$workoutId.tsx`, when `restTimer` is truthy the entire exercise view is replaced by `` (line 531). The exercise view is hidden via `{!restTimer && (...)}` (line 542). There is no way to access the workout screen during rest without skipping the timer. + +--- + +## Approach + +### 1. Add `restMinimized` local state to the route + +In `log.$workoutId.tsx`, add `const [restMinimized, setRestMinimized] = useState(false)`. + +Reset it to `false` whenever `restTimer` becomes `null` (rest ends) using an effect or by driving it off a `useEffect` dependency on `restTimer`. + +### 2. Add minimize button to `RestPanel` + +Add an optional `onMinimize?: () => void` prop to `RestPanelProps` in `rest-panel.tsx`. When provided, render a small minimize/collapse icon button (top-right corner of the panel, or below the Skip button). Label: accessible aria-label "Minimize rest timer". + +### 3. Thread `onMinimize` through `RestView` + +Add `onMinimize?: () => void` to `RestViewProps` and pass it down to ``. + +### 4. New `RestTimerBanner` component + +Create `src/components/workout/rest-timer-banner.tsx` -- a compact sticky strip: + +``` +[ REST 0:47 ──────────────── ] [ Expand ] [ Skip ] +``` + +Props: `remaining`, `total`, `onExpand`, `onSkip`. + +Design: fixed at the bottom of the content area (not the screen -- inside the scrollable page container), `bg-surface-iron`, ember-colored countdown text, thin ember progress bar below the text row. Zero border-radius (Iron & Ember). Min-height 48px (gym-floor touch target). + +### 5. Update `log.$workoutId.tsx` render logic + +```tsx +{restTimer && !restMinimized && ( + setRestMinimized(true)} + /> +)} + +{restTimer && restMinimized && ( + setRestMinimized(false)} + onSkip={skipRest} + /> +)} + +{(!restTimer || restMinimized) && ( + /* existing exercise view */ +)} +``` + +Reset `restMinimized` when rest ends: +```tsx +useEffect(() => { + if (!restTimer) setRestMinimized(false) +}, [restTimer]) +``` + +--- + +## Verification + +- Resting -> tap minimize -> exercise list is visible, compact banner shows at bottom with live countdown. +- Tapping "Expand" on the banner returns to full-screen `RestView`. +- Tapping "Skip" on the banner ends the rest and clears the banner. +- Rest naturally expiring (countdown hits 0) clears the banner and returns to exercise view. +- `restMinimized` state does not persist across rest periods. +- Touch targets on banner >= 48px. + +--- + +## Risks + +- The banner countdown must stay live. Since `restTimer.remaining` comes from the store, it will tick naturally -- no extra wiring needed. +- `RestPanel` already has `onAdjust` as optional; `onMinimize` follows the same pattern -- safe additive change. +- Circuit `RestPanel` usage in `circuit-panel.tsx` omits `onMinimize` (no prop passed) -- no change required there. diff --git a/Context/QuickPlans/Done/2026-04-12-remove-set-note-affordance.md b/Context/QuickPlans/Done/2026-04-12-remove-set-note-affordance.md new file mode 100644 index 00000000..22da828c --- /dev/null +++ b/Context/QuickPlans/Done/2026-04-12-remove-set-note-affordance.md @@ -0,0 +1,21 @@ +# Quick Plan: Remove Note Affordance from Set Rows + +**Task:** Remove the ADD NOTE button from individual set rows in the workout log. + +**Goal:** Set rows should show only: set number, weight, reps, and confirm/status. No note affordance. + +**Approach:** +1. In `src/components/workout/set-row.tsx`: + - Remove the `{loggedSetId && }` block and its surrounding div (lines ~291-303) + - Remove the `setSetNote` store selector (no longer needed) + - Remove the `storedSet` store selector (no longer needed) + - Remove the `noteValue` useMemo (no longer needed) + - Remove the `hasNote` computed value (no longer needed) + - Remove the `loggedSetId` prop and its JSDoc from `SetRowProps` + - Remove the `NoteAffordance` import +2. In `src/components/workout/exercise-block.tsx`: + - Remove `loggedSetId={set.id}` from the `` usage + +**Verification:** `bun run build` passes with no TS errors. + +**Risks:** Low. The set-level note store action (`setSetNote`) and data model remain intact -- only the UI entry point is removed. If set notes need to return, the prop and affordance can be re-added. diff --git a/Context/QuickPlans/Done/2026-04-12-set-add-swipe-delete.md b/Context/QuickPlans/Done/2026-04-12-set-add-swipe-delete.md new file mode 100644 index 00000000..c531d91a --- /dev/null +++ b/Context/QuickPlans/Done/2026-04-12-set-add-swipe-delete.md @@ -0,0 +1,249 @@ +# Quick Plan: Explicit ADD SET + Swipe-to-Delete + +**Branch:** `feat/exercise-completion-signal` (extend current branch) +**Date:** 2026-04-12 +**Source:** UX feedback on exercise completion signal + +--- + +## Task + +Two UX improvements to the strength exercise block: + +1. Replace the ambiguous auto-trailing row with an explicit **ADD SET** button. Users tap it to reveal an input row; intent is unambiguous. +2. Add **swipe-to-delete** on set rows so athletes can remove a set without using Undo. + +Keep the existing "Done with this exercise" skip functionality. + +--- + +## Goal + +- No set row appears automatically. User explicitly taps ADD SET to get an input row. +- Swipe left on any set row to reveal a DELETE button. Works for both confirmed and pending rows. +- Footer: ADD SET on the left, DONE on the right (only when `confirmedSets.length > 0`). + +--- + +## Approach + +### 1. Store: `deleteSet` action + +**File:** `src/stores/active-workout-store.ts` + +Add to `ActiveWorkoutActions`: +```ts +deleteSet(loggedActivityId: string, setId: string): void +``` + +Implementation -- remove the set from the activity, clear undoAction if it references the deleted set: +```ts +deleteSet(loggedActivityId: string, setId: string) { + if (!loggedActivityId || !setId) { + console.warn('[active-workout] deleteSet called with missing ids') + return + } + set((state) => ({ + loggedGroups: state.loggedGroups.map((group) => ({ + ...group, + activities: group.activities.map((activity) => { + if (activity.id !== loggedActivityId) return activity + return { ...activity, sets: activity.sets.filter((s) => s.id !== setId) } + }), + })), + undoAction: state.undoAction?.setId === setId ? null : state.undoAction, + })) +}, +``` + +### 2. Route: remove auto-trailing row, track pending input rows + +**File:** `src/routes/_authenticated/log.$workoutId.tsx` + +Add local state: +```ts +const [pendingInputs, setPendingInputs] = useState>({}) +``` + +`pendingInputs[activityId] = true` means "show one input row for this activity". + +Remove the entire `setRows.push({ id: 'pending-...', ... })` block. + +Instead, when rendering setRows, append a pending row only if `pendingInputs[activity.id]`: +```ts +if (pendingInputs[activity.id]) { + setRows.push({ + id: `pending-${activity.id}-${nextSetNumber}`, + setNumber: nextSetNumber, + weight: lastConfirmedSet?.actualWeight?.value?.toString() + ?? nextPrescribedWeight?.value?.toString(), + reps: lastConfirmedSet?.actualReps?.toString() + ?? (nextPrescribedReps != null ? String(nextPrescribedReps) : undefined), + confirmed: false, + prescribedWeight: nextPrescribedWeight, + prescribedReps: nextPrescribedReps, + isPending: true, + }) +} +``` + +When a set is confirmed (`handleConfirmSet`), clear `pendingInputs[activityId]`: +```ts +setPendingInputs((prev) => ({ ...prev, [loggedActivityId]: false })) +``` + +Wire `onAddSet`: +```tsx +onAddSet={() => setPendingInputs((prev) => ({ ...prev, [activity.id]: true }))} +``` + +Wire `onDeleteSet`: +```tsx +onDeleteSet={(setId) => deleteSet(activity.id, setId)} +``` + +Pull `deleteSet` from the store alongside `skipActivity`. + +### 3. `ExerciseBlock`: ADD SET + DONE footer + +**File:** `src/components/workout/exercise-block.tsx` + +Add props: +```ts +onAddSet?: () => void +onDeleteSet?: (setId: string) => void +``` + +Pass `onDeleteSet` down to each `SetRow`: +```tsx +onDelete={onDeleteSet ? () => onDeleteSet(set.id) : undefined} +``` + +Replace the current single-button footer with a two-button row: +```tsx +{isActive && (onAddSet || onSkipExercise) && ( +
+ {onAddSet && ( + + )} + {onSkipExercise && ( + + )} +
+)} +``` + +Iron & Ember: no border between them, no rounding -- tonal separation via spacing only. + +### 4. `SetRow`: swipe-to-delete + +**File:** `src/components/workout/set-row.tsx` + +Add prop: `onDelete?: () => void` + +Track swipe state: +```ts +const [swipeX, setSwipeX] = useState(0) +const [isSwiping, setIsSwiping] = useState(false) +const touchStartX = useRef(0) +const SWIPE_THRESHOLD = 64 // px +``` + +Touch handlers on the outermost div: +```ts +onTouchStart={(e) => { + touchStartX.current = e.touches[0].clientX + setIsSwiping(false) +}} +onTouchMove={(e) => { + const delta = touchStartX.current - e.touches[0].clientX + if (delta > 0) { + setSwipeX(Math.min(delta, SWIPE_THRESHOLD)) + setIsSwiping(true) + } +}} +onTouchEnd={() => { + if (swipeX >= SWIPE_THRESHOLD) { + // hold open -- don't snap back, show DELETE + } else { + setSwipeX(0) + setIsSwiping(false) + } +}} +``` + +Layout: wrap the existing row content in a relative container with a DELETE button revealed by translateX: +```tsx +
+ {/* Delete button behind the row */} + {onDelete && ( + + )} + {/* Row slides left to reveal delete */} +
+ {/* existing row content */} +
+
+``` + +Tapping anywhere outside the DELETE button (or swiping back right) snaps the row closed. + +--- + +## Files Touched + +| File | Change | +|------|--------| +| `src/stores/active-workout-store.ts` | Add `deleteSet` action | +| `src/components/workout/exercise-block.tsx` | Add `onAddSet`, `onDeleteSet` props; two-button footer | +| `src/components/workout/set-row.tsx` | Add `onDelete` prop + swipe-to-reveal gesture | +| `src/routes/_authenticated/log.$workoutId.tsx` | Track `pendingInputs`, wire `onAddSet`/`onDeleteSet`, clear pending on confirm | + +--- + +## Verification + +- [ ] No input row shown on exercise load -- requires explicit ADD SET tap. +- [ ] Tapping ADD SET reveals one input row with carry-forward values. +- [ ] Confirming a set clears the input row (user must tap ADD SET again for next set). +- [ ] Footer shows ADD SET + DONE side by side when `confirmedSets.length > 0`. +- [ ] Footer shows ADD SET only (no DONE) before any sets are confirmed. +- [ ] Swipe left on any set row reveals DEL button. +- [ ] Tapping DEL removes the set; confirmed sets removed from store. +- [ ] Swipe right (or release before threshold) snaps row back closed. +- [ ] `bun run build` passes with no TypeScript errors. + +--- + +## Risks + +- **Programmed workouts with initial prescription**: The first time the exercise loads there is no confirmed set, so ADD SET carries no pre-filled weight/reps. User sees empty inputs. This is acceptable -- they see the prescribed values in the PRESCRIBED column. +- **Delete + Undo interaction**: Deleting a confirmed set clears the undoAction if it matches. The Undo banner disappears. This is correct -- undo was for that set. +- **Multiple pending rows**: Design limits to one pending row per activity (`pendingInputs` is boolean). A second ADD SET tap while a pending row is already visible is a no-op (already true). This prevents stacking empty rows. diff --git a/Context/QuickPlans/Done/2026-04-12-undo-set-done-status.md b/Context/QuickPlans/Done/2026-04-12-undo-set-done-status.md new file mode 100644 index 00000000..f7f87ac7 --- /dev/null +++ b/Context/QuickPlans/Done/2026-04-12-undo-set-done-status.md @@ -0,0 +1,91 @@ +# Quick Plan: Undo "Done" Status on a Set + +**Date:** 2026-04-12 +**Branch:** feat/workout-set-ux + +--- + +## Task + +Allow users to un-confirm (undo "done") a set at any time during an active workout -- not just within the 10-second undo window. + +## Goal + +Tapping the done indicator on a confirmed `SetRow` removes the set from the logged activity, restores the row to its editable state with the previously entered values, and deletes the persisted record from the database. + +## Context + +- `SetRow` (`src/components/workout/set-row.tsx:29`): `confirmed` prop drives read-only state; done indicator is currently a static badge/icon -- not interactive. +- `ExerciseBlock` (`src/components/workout/exercise-block.tsx:36`): renders `SetRow` per set, owns the `onConfirmSet` wiring. +- Store (`src/stores/active-workout-store.ts`): `confirmSet` appends to `activity.sets`; `undoLastSet` removes the last set but only within a 10s window via `UndoAction`. No persistent un-confirm action exists. +- Route (`src/routes/_authenticated/log.$workoutId.tsx`): handles `onConfirmSet` and likely calls the DB adapter. Un-confirm will need a parallel path to delete the persisted set. + +## Approach + +### 1. Store -- add `unconfirmSet` + +Add `unconfirmSet(loggedActivityId: string, setId: string): void` to `ActiveWorkoutActions`: + +```ts +unconfirmSet(loggedActivityId: string, setId: string) { + set((prev) => ({ + loggedGroups: prev.loggedGroups.map((group) => ({ + ...group, + activities: group.activities.map((activity) => { + if (activity.id !== loggedActivityId) return activity + return { ...activity, sets: activity.sets.filter((s) => s.id !== setId) } + }), + })), + })) + _publishCurrentState() +} +``` + +### 2. SetRow -- make the done indicator tappable + +Add `onUnconfirm?: () => void` to `SetRowProps`. Replace the static done indicator with a tappable button: + +```tsx + +``` + +Keep the visual appearance identical -- only the wrapping element changes. + +### 3. ExerciseBlock -- wire `onUnconfirm` + +Add `onUnconfirmSet?: (loggedActivityId: string, setId: string) => void` to `ExerciseBlockProps`. Pass it through to each `SetRow` as `onUnconfirm`. + +### 4. Route -- handle `onUnconfirmSet` + +In `log.$workoutId.tsx`, add a handler alongside `handleConfirmSet`: + +```ts +const handleUnconfirmSet = async (loggedActivityId: string, setId: string) => { + useActiveWorkoutStore.getState().unconfirmSet(loggedActivityId, setId) + await db.deleteLoggedSet(setId) // adapter call -- mirrors existing delete path +} +``` + +Pass `handleUnconfirmSet` down to `ExerciseBlock`. + +## Verification + +- [ ] Tapping a done indicator on any set (not just the last one) restores it to editable state +- [ ] Previously entered weight/reps values are pre-filled after un-confirm (from `initialWeight`/`initialReps` props -- these come from the set's stored data and remain populated since we filter the set out rather than modify it; the parent will re-derive a fresh empty row or carry-forward) +- [ ] The database record is deleted (adapter call succeeds; no orphaned row) +- [ ] The existing 10-second undo toast / `undoLastSet` flow is unaffected +- [ ] Touch target is >= 48px (gym-floor usability) +- [ ] Works correctly for bodyweight sets (no weight field) + +## Risks + +- **Pre-fill state after un-confirm**: SetRow initializes weight/reps from `initialWeight`/`initialReps`. Once the set is removed from the store, the parent renders a fresh row with carry-forward values from the *previous* set (or empty). Verify this matches expected behavior -- users may want to see their prior values back. If not, carry-forward from the deleted set's data before filtering it out. +- **DB adapter `deleteLoggedSet`**: Confirm this exists in both `supabase-adapter.ts` and `tauri-adapter.ts`. If it doesn't, it needs to be added to `DatabaseAdapter` interface and both implementations. +- **Exercise completion signal**: The branch already has exercise completion signal work (`feat/workout-set-ux`). Un-confirming the last set of an exercise should re-open the exercise as incomplete -- confirm the completion signal reactivity handles this automatically. diff --git a/Context/QuickPlans/Done/2026-04-13-b008-gym-picker-every-start.md b/Context/QuickPlans/Done/2026-04-13-b008-gym-picker-every-start.md new file mode 100644 index 00000000..3cd4c1b7 --- /dev/null +++ b/Context/QuickPlans/Done/2026-04-13-b008-gym-picker-every-start.md @@ -0,0 +1,83 @@ +# Quick Plan: B008 -- Gym Picker Shown on Every Workout Start + +**Date:** 2026-04-13 +**Bug:** B008 + +--- + +## Task + +The gym selector sheet appears on every workout start, even when the user previously selected a gym. + +--- + +## Root Cause + +All three workout-start handlers gate on `firstWorkoutCompleted` (an onboarding flag) to decide whether to use the saved gym choice: + +```ts +if (firstWorkoutCompleted) { + choice = readLastGymChoice() + if (choice === null) { + choice = await openGymPicker({ userId }) + } +} else { + // First workout: always prompt + choice = await openGymPicker({ userId }) +} +``` + +This is the wrong proxy. `firstWorkoutCompleted` reflects whether onboarding is done -- not whether a gym choice was ever saved. If the flag is `false` for any reason (store not yet initialized, state mismatch, user cleared storage), the picker shows unconditionally, regardless of what `readLastGymChoice()` would return. + +The correct gate is `readLastGymChoice()` itself: if a valid choice is stored, use it; otherwise show the picker. No `firstWorkoutCompleted` dependency needed. + +--- + +## Goal + +Gym picker only shows when there is no saved gym choice. On all subsequent starts (after the user has picked once), the saved choice is used silently. + +--- + +## Approach + +Simplify the gym-choice block in all three handlers to a single expression: + +```ts +choice = readLastGymChoice() ?? await openGymPicker({ userId }) +``` + +**Files and locations:** + +1. `src/routes/_authenticated/index.tsx` + - `handleStartWorkout` (~line 162-172) + - `handleStartProgrammedSession` (~line 215-225) + +2. `src/routes/_authenticated/library.tsx` + - `handleStartFromTemplate` (~line 165-175) + +After editing, remove `firstWorkoutCompleted` from the dependency arrays of the two `useCallback` hooks in `index.tsx` (it will no longer be referenced inside them). The `useOnboardingStore((s) => s.firstWorkoutCompleted)` subscription on line 97 of `index.tsx` must remain -- it is used in JSX on line 295. + +**No change needed in:** +- `gym-picker-storage.ts` -- `readLastGymChoice()` already handles first-time (returns null) and validates stored values +- `onboarding-store.ts` / `markFirstWorkoutCompleted` -- still called on workout completion for other onboarding purposes + +--- + +## Verification + +1. `bun run build` passes (TypeScript clean) +2. Manual flow -- new user: + - Start first workout → picker shows → select a gym → workout starts + - Finish workout, return to today page + - Start second workout → picker does NOT show → workout starts with saved gym choice +3. Manual flow -- picker shows when no choice stored: + - Clear `ardent_forge_last_gym_choice` from localStorage + - Start workout → picker shows as expected + +--- + +## Risks + +- Low. The change removes a conditional branch; all existing behavior for "no saved choice" is preserved (picker shows). The only behavioral change is: when `firstWorkoutCompleted` was `false` but a saved choice exists in localStorage, the picker no longer shows. This is the correct behavior. +- `firstWorkoutCompleted` is still read in `index.tsx` for the JSX at line 295; the subscription line must not be removed. diff --git a/Context/Reviews/0018-pr109-workout-set-ux-2026-04-13.md b/Context/Reviews/0018-pr109-workout-set-ux-2026-04-13.md new file mode 100644 index 00000000..8eb2d25f --- /dev/null +++ b/Context/Reviews/0018-pr109-workout-set-ux-2026-04-13.md @@ -0,0 +1,143 @@ +# PR Review: feat/workout-set-ux → main + +**Date:** 2026-04-13 +**Feature:** Context/Features/018-Workout-Session-UX/ +**Branch:** feat/workout-set-ux +**PR:** #109 -- feat(workout): set UX overhaul -- checkbox confirm, swipe delete, gym picker fix, rest minimize +**Reviewers:** code-reviewer, silent-failure-hunter, pr-test-analyzer +**Status:** 🟡 Partially resolved + +## Summary + +15 findings total: 4 critical fixes, 5 high/medium fixes, 3 missing task items elevated from important findings, 3 additional task/fix items from suggestions. The two most severe issues are that swipe-to-delete and remove-exercise both write to the Zustand store only, with no database call -- deletions appear to succeed but reappear on reload. One test in the suite is actively failing. All other findings are convention violations, missing tests, or minor edge cases. + +## Findings + +### Fix-Now + +#### [FIX] P18-001: Swipe-to-delete has no DB persistence +- **File:** `src/routes/_authenticated/log.$workoutId.tsx:775`, `src/stores/active-workout-store.ts:436` +- **Severity:** Critical +- **Detail:** `onDeleteSet` calls the Zustand `deleteSet` store action directly. The store removes the set from in-memory state only; the adapter is never called. The user sees the set disappear, but on page reload or crash recovery it reappears from the database. `useDeleteLoggedSet` exists in `use-workout-logs.ts` but is not called from this path. Fix: add a coordinated `deleteSet` to `use-active-workout.ts` that calls the mutation before updating the store, and wire `onDeleteSet` through it. +- **Status:** ✅ Fixed +- **Resolution:** Added coordinated `deleteSet` to `use-active-workout.ts` that awaits `useDeleteLoggedSet` mutation before calling the store action. `log.$workoutId.tsx` `onDeleteSet` now calls the bridge function and surfaces failures via `setPageError`. + +#### [FIX] P18-002: `removeActivity` has no DB persistence +- **File:** `src/routes/_authenticated/log.$workoutId.tsx:782`, `src/stores/active-workout-store.ts:346` +- **Severity:** Critical +- **Detail:** Same class of bug as P18-001. `onRemoveExercise` calls `removeActivity(activity.id)` which only updates local Zustand state. Confirmed sets in the removed exercise remain in the DB. Impact elevated by this PR because the hard-focus filter was removed, making exercise deletion accessible for all exercises (not just the active one). Fix: create a coordinated hook function that deletes all persisted sets via the adapter before removing from the store. +- **Status:** ✅ Fixed +- **Resolution:** Added coordinated `removeActivity` to `use-active-workout.ts` that awaits `useDeleteLoggedActivity` mutation (relies on the DB cascade to clean up sets) before calling the store action. `log.$workoutId.tsx` `onRemoveExercise` now calls the bridge and surfaces failures via `setPageError`. + +#### [FIX] P18-003: `useDeleteLoggedSet` has no `onError` handler +- **File:** `src/hooks/use-workout-logs.ts:138` +- **Severity:** Critical +- **Detail:** Even once P18-001 is fixed and the mutation is called, a failed DB delete would be completely silent -- no log, no cache rollback. `useDeleteLoggedActivity` (line 167) and `useDeleteLoggedActivityGroup` (line 186) have the same problem. Per the project's `error-handling.md` convention, every mutation that can fail needs an `onError` with a `[workout]` prefix log and cache rollback. +- **Status:** ✅ Fixed +- **Resolution:** Added `onError` handlers with `[workout-logs]` prefix logging (operation name + entity id + workoutLogId) to `useDeleteLoggedSet`, `useDeleteLoggedActivity`, and `useDeleteLoggedActivityGroup`. These mutations had no `onMutate` optimistic updates, so no cache rollback is required; the existing `onSettled` still invalidates the affected `workout-full` query. + +#### [FIX] P18-004: Failing test -- `getByText('DONE')` references removed UI +- **File:** `src/components/workout/__tests__/set-row.test.tsx:84` +- **Severity:** Critical +- **Detail:** The test `does not call onConfirm when already confirmed` asserts `expect(screen.getByText('DONE')).toBeInTheDocument()`. The old confirmed state rendered a `DONE`; this PR replaced it with a `check_box` icon button. The text no longer exists in the DOM. This test was not updated and will fail on `bun run test`. Fix: update the assertion to check for `screen.getByLabelText('Undo set 1')` (or equivalent aria-label) instead. +- **Status:** ✅ Fixed +- **Resolution:** Replaced `getByText('DONE')` assertion with `getByLabelText('Undo set 1')` to match the current confirmed-state DOM. All 11 `set-row.test.tsx` cases pass. + +#### [FIX] P18-005: Triple error logging on `unconfirmSet` violates Hook Log Ownership rule +- **Files:** `src/hooks/use-workout-logs.ts:230`, `src/hooks/use-active-workout.ts:479`, `src/routes/_authenticated/log.$workoutId.tsx:426` +- **Severity:** High +- **Detail:** A single `unconfirmSet` failure produces three log entries from three different `[module-name]` prefixes. Violates the project's "Mutation Hook Log Ownership" rule in `error-handling.md`. Fix: the mutation hook's `onError` is the single log owner. The `catch` in `use-active-workout.ts` should re-throw without logging. The page handler `catch` should set `pageError` without logging. +- **Status:** ✅ Fixed +- **Resolution:** `useUpdateLoggedSet`'s `onError` (already in place) remains the sole log owner. Removed the duplicate `console.error` from the `unconfirmSet` catch in `use-active-workout.ts` (re-throws with an explanatory comment). Changed `handleUnconfirmSet` in the page to `catch {}` with only `setPageError` (no re-log). + +#### [FIX] P18-006: Silent guard clauses in user-action handlers +- **Files:** `src/routes/_authenticated/log.$workoutId.tsx:293` (handleFinish), `src/routes/_authenticated/log.$workoutId.tsx:378` (handleConfirmSet) +- **Severity:** High +- **Detail:** Both handlers have `if (!workoutLog) return` with no log and no user feedback. Per `error-handling.md`, guard clauses in user-action handlers must never silently return -- they must log with a `[workout-page]` prefix and set `pageError`. If the guard fires during a crash recovery race condition, the user gets no indication that their action was dropped. +- **Status:** ✅ Fixed +- **Resolution:** Both guards now log `[workout-page]` errors describing which guard fired and call `setPageError` with a user-facing message pointing the user to reload. + +#### [FIX] P18-007: `isSwiping` not reset when DEL button tapped +- **File:** `src/components/workout/set-row.tsx:105` +- **Severity:** Medium +- **Detail:** The DEL button's `onClick` calls `setSwipeX(0)` but not `setIsSwiping(false)`. Since `isSwiping = true` sets `transition: 'none'`, if the deleted set causes a re-render without full unmount (e.g., list reordering), the stale `isSwiping = true` persists. Fix: add `setIsSwiping(false)` alongside `setSwipeX(0)` in the DEL button's onClick. +- **Status:** ✅ Fixed +- **Resolution:** Added `setIsSwiping(false)` alongside `setSwipeX(0)` in the DEL button's `onClick` handler. + +#### [FIX] P18-008: `startingTemplateId` spinner not cleared if `openGymPicker` throws +- **File:** `src/routes/_authenticated/library.tsx:162` +- **Severity:** Medium +- **Detail:** `setStartingTemplateId(templateId)` is called before the `try` block that contains `openGymPicker`. If `openGymPicker` rejects (currently it only resolves, but the structure is a trap), `startingTemplateId` is set and never cleared -- the button stays in its loading state permanently. Fix: move `setStartingTemplateId(templateId)` inside the `try` block, or wrap the outer scope in its own `try/finally` that calls `setStartingTemplateId(null)`. +- **Status:** ✅ Fixed +- **Resolution:** Moved the `openGymPicker` call inside the existing `try/finally`. The `finally` clears `startingTemplateId` even if `openGymPicker` rejects. Removed the now-redundant inline `setStartingTemplateId(null)` before the early return. + +#### [FIX] P18-009: Undo timer expiry after failed `undoSet` diverges store and DB +- **File:** `src/hooks/use-active-workout.ts:89` +- **Severity:** Medium +- **Detail:** If the user taps UNDO and `undoSet()` throws (network failure), `handleUndoSet` correctly sets `pageError`. However, `undoAction` remains set in the store and the undo expiry timer will still fire after 10 seconds, calling `storeClearUndo()` silently. There is no user message at expiry time indicating the undo failed and the set was retained. Fix: in the timer effect, detect whether the undo already failed (via a ref or additional store flag) before silently clearing, or clear `undoAction` immediately on error in `handleUndoSet`. +- **Status:** ✅ Fixed +- **Resolution:** `undoSet` in `use-active-workout.ts` now calls `storeClearUndo()` in its catch block before re-throwing. The expiry timer effect has nothing to clear at timeout, eliminating the silent divergence. + +#### [FIX] P18-013: `_publishCurrentState` no-op in private mode is undocumented +- **File:** `src/stores/active-workout-store.ts:108` +- **Severity:** Low +- **Detail:** The early return `if (!_snapshotContext) return` silently no-ops for private-mode workouts with no comment explaining the intent. A developer adding a new action that calls `_publishCurrentState()` has no indication it can silently do nothing. Add an inline comment: `// Private-mode workouts have no snapshot context -- publishing is intentionally a no-op.` +- **Status:** ✅ Fixed +- **Resolution:** Added the inline explanatory comment above the `if (!_snapshotContext) return` early-return in `_publishCurrentState`. + +#### [FIX] P18-014: `skippedActivityIds` reset inconsistency across lifecycle actions +- **File:** `src/stores/active-workout-store.ts` +- **Severity:** Low +- **Detail:** `finishWorkout` and `discardWorkout` reset `skippedActivityIds` via `set({ ...initialState })`. `resumeWorkout` does not include `skippedActivityIds` in its explicit `set()` call. In practice, since `skippedActivityIds` is not persisted, a resume after crash starts with an empty set anyway -- but the inconsistency is confusing. Add a comment to `resumeWorkout` explaining that ephemeral-only fields like `skippedActivityIds` are implicitly cleared by Zustand's non-persistent initialization. +- **Status:** ✅ Fixed +- **Resolution:** Added a clarifying comment in `resumeWorkout` explaining that ephemeral-only fields (`skippedActivityIds`, `pauseTimingError`) are implicitly cleared by Zustand's non-persistent initialization and are intentionally omitted from the explicit `set()` call. + +### Missing Tasks + +#### [TASK] P18-010: No tests for new store actions +- **File:** `src/stores/__tests__/active-workout-store.test.ts` +- **Severity:** High +- **Detail:** Four new store actions added in this PR have no tests: `deleteSet`, `unconfirmSet`, `removeActivity` (undo-clearing side effect), `skipActivity` (lifecycle reset). Most critical gap: `deleteSet` and `removeActivity` both null out `undoAction` when the targeted set matches -- this non-obvious side effect has no coverage and is easy to break in a refactor. Also: `skipActivity` uses a `Set` from `initialState`; spreading `initialState` shares the reference -- a test that calls `skipActivity` then `finishWorkout` and checks `skippedActivityIds.size === 0` would catch a latent reference bug. +- **Relates to:** Context/Features/018-Workout-Session-UX/Steps.md +- **Status:** ✅ Task created +- **Resolution:** Added as S051 in Steps.md (Wave 7). + +#### [TASK] P18-011: No component test for swipe-to-delete DEL button +- **File:** `src/components/workout/__tests__/set-row.test.tsx` +- **Severity:** High +- **Detail:** The DEL button render and `onDelete` callback are completely untested. Touch/swipe simulation is impractical in happy-dom, but the tap-to-delete path (directly clicking the revealed DEL button) is fully testable. Tests needed: DEL button renders when `onDelete` prop provided; DEL button absent when `onDelete` is absent; clicking DEL calls `onDelete` and resets `swipeX` to 0. +- **Relates to:** Context/Features/018-Workout-Session-UX/Steps.md +- **Status:** ✅ Task created +- **Resolution:** Added as S052 in Steps.md (Wave 7). + +#### [TASK] P18-012: No regression test for B008 gym picker fix +- **File:** `src/routes/_authenticated/__tests__/` (if exists) or new test file +- **Severity:** High +- **Detail:** The core behavioral contract of the B008 fix -- "gym picker only shows when no saved choice exists in localStorage" -- has no test coverage. The existing `start-workout-flow.test.tsx` predates the fix and does not cover the corrected path. A regression test would: (1) mock `readLastGymChoice` to return a saved gym, start the workout, assert `openGymPicker` is not called; (2) mock `readLastGymChoice` to return null, start the workout, assert `openGymPicker` is called. +- **Relates to:** Context/Features/018-Workout-Session-UX/Spec.md +- **Status:** ✅ Task created +- **Resolution:** Added as S053 in Steps.md (Wave 7). + +#### [TASK] P18-015: No tests for `RestTimerBanner` component +- **File:** `src/components/workout/rest-timer-banner.tsx` (new file, no test file) +- **Severity:** Medium +- **Detail:** New component added with no test. The progress bar clamping logic (`Math.max(0, Math.min(100, ...))`) and the `total > 0` guard are worth unit tests. A `remaining > total` edge case (possible if Rust timer and JS state briefly diverge) would produce `progressPct > 100` without the clamp. Tests also needed for: Expand button calls `onExpand`, Skip button calls `onSkip`, countdown text renders from `formatCountdown(remaining)`. +- **Status:** ✅ Task created +- **Resolution:** Added as S054 in Steps.md (Wave 7). + +## Resolution Checklist +- [x] All [FIX] findings resolved +- [x] All [TASK] findings added to Steps.md +- [x] All [ADR] findings have ADRs created or dismissed (N/A -- none) +- [x] All [RULE] findings applied or dismissed (N/A -- none) +- [x] Review verified by review-verify agent + +## Resolution Summary +**Resolved at:** 2026-04-13 +**Session:** Resolve PR #109 review findings + +| Category | Total | Resolved | +|---|---|---| +| [FIX] | 11 | 11 | +| [TASK] | 4 | 4 | +| **Total** | **15** | **15** | diff --git a/src-tauri/migrations/015_drop_set_notes.sql b/src-tauri/migrations/015_drop_set_notes.sql new file mode 100644 index 00000000..f259cef7 --- /dev/null +++ b/src-tauri/migrations/015_drop_set_notes.sql @@ -0,0 +1,6 @@ +-- Remove notes and note_tags from logged_sets. +-- Set-level notes are no longer exposed in the UI. +-- Activity-level and session-level notes are unchanged. + +ALTER TABLE logged_sets DROP COLUMN note_tags; +ALTER TABLE logged_sets DROP COLUMN notes; diff --git a/src/components/history/workout-detail-exercises.tsx b/src/components/history/workout-detail-exercises.tsx index e5870ff3..965acb89 100644 --- a/src/components/history/workout-detail-exercises.tsx +++ b/src/components/history/workout-detail-exercises.tsx @@ -146,14 +146,6 @@ export function WorkoutDetailExercises({ )} - - {/* Set-level note (Spec assertion 9: null when empty) */} - ))} diff --git a/src/components/workout/__tests__/exercise-block.test.tsx b/src/components/workout/__tests__/exercise-block.test.tsx index bcb42613..384f7463 100644 --- a/src/components/workout/__tests__/exercise-block.test.tsx +++ b/src/components/workout/__tests__/exercise-block.test.tsx @@ -1,5 +1,6 @@ // @vitest-environment happy-dom import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' import { ExerciseBlock, type SetRowData } from '@/components/workout/exercise-block' // Mock onboarding store to avoid AuthProvider dependency @@ -17,15 +18,22 @@ vi.mock('@/components/workout/set-row', () => ({ setNumber, prescribedWeight, prescribedReps, + onUnconfirm, }: { setNumber: number prescribedWeight?: { value: number; unit: string } prescribedReps?: number + onUnconfirm?: () => void }) => (
Set {setNumber} {prescribedWeight && Rx: {prescribedWeight.value}} {prescribedReps != null && Rx reps: {prescribedReps}} + {onUnconfirm && ( + + )}
), })) @@ -89,4 +97,28 @@ describe('ExerciseBlock', () => { expect(screen.getByText('SET')).toBeInTheDocument() expect(screen.getByText('STATUS')).toBeInTheDocument() }) + + it('passes onUnconfirm to confirmed set rows when onUnconfirmSet is provided', async () => { + const user = userEvent.setup() + const onUnconfirmSet = vi.fn() + const confirmedSets: SetRowData[] = [ + { id: 's1', setNumber: 1, confirmed: true }, + { id: 's2', setNumber: 2, confirmed: false }, + ] + render( + , + ) + + // Only the confirmed set (s1) should have an undo button + const undoBtn = screen.getByLabelText('Undo set 1') + await user.click(undoBtn) + + expect(onUnconfirmSet).toHaveBeenCalledWith('la-1', 's1') + // Unconfirmed set should not have an undo button + expect(screen.queryByLabelText('Undo set 2')).not.toBeInTheDocument() + }) }) diff --git a/src/components/workout/__tests__/rest-timer-banner.test.tsx b/src/components/workout/__tests__/rest-timer-banner.test.tsx new file mode 100644 index 00000000..e7045d75 --- /dev/null +++ b/src/components/workout/__tests__/rest-timer-banner.test.tsx @@ -0,0 +1,105 @@ +// @vitest-environment happy-dom +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { describe, it, expect, vi } from 'vitest' +import { RestTimerBanner } from '@/components/workout/rest-timer-banner' +import { formatCountdown } from '@/lib/format-duration' + +function makeProps(overrides: Partial> = {}) { + return { + remaining: 60, + total: 120, + onExpand: vi.fn(), + onSkip: vi.fn(), + ...overrides, + } +} + +/** + * The progress bar is the inner
with an inline `width` style. It is the + * first
with style.width inside the banner. We locate it by walking the + * DOM from the container. + */ +function getProgressBarWidth(container: HTMLElement): string { + const bars = container.querySelectorAll('div[style*="width"]') + expect(bars.length).toBeGreaterThan(0) + return bars[0].style.width +} + +describe('RestTimerBanner', () => { + describe('progress bar clamping', () => { + it('caps progress at 100% when remaining > total', () => { + const { container } = render() + expect(getProgressBarWidth(container)).toBe('100%') + }) + + it('clamps progress to 0% when remaining is negative', () => { + const { container } = render() + expect(getProgressBarWidth(container)).toBe('0%') + }) + + it('clamps progress to 0% when remaining is zero', () => { + const { container } = render() + expect(getProgressBarWidth(container)).toBe('0%') + }) + + it('computes ~50% progress when remaining is half of total', () => { + const { container } = render() + expect(getProgressBarWidth(container)).toBe('50%') + }) + }) + + describe('total = 0 guard', () => { + it('renders safely with 0% progress when total is 0 (no NaN, no divide-by-zero)', () => { + const { container } = render() + const width = getProgressBarWidth(container) + expect(width).toBe('0%') + expect(width).not.toMatch(/NaN/i) + expect(container.innerHTML).not.toMatch(/NaN/i) + }) + }) + + describe('Expand button', () => { + it('renders an Expand button', () => { + render() + expect(screen.getByRole('button', { name: /expand/i })).toBeInTheDocument() + }) + + it('fires onExpand when clicked', async () => { + const user = userEvent.setup() + const onExpand = vi.fn() + render() + await user.click(screen.getByRole('button', { name: /expand/i })) + expect(onExpand).toHaveBeenCalledTimes(1) + }) + }) + + describe('Skip button', () => { + it('renders a Skip button', () => { + render() + expect(screen.getByRole('button', { name: /skip/i })).toBeInTheDocument() + }) + + it('fires onSkip when clicked', async () => { + const user = userEvent.setup() + const onSkip = vi.fn() + render() + await user.click(screen.getByRole('button', { name: /skip/i })) + expect(onSkip).toHaveBeenCalledTimes(1) + }) + }) + + describe('countdown text', () => { + it('renders the formatted countdown from formatCountdown(remaining)', () => { + render() + // formatCountdown(90) -> "1:30" + expect(screen.getByText(formatCountdown(90))).toBeInTheDocument() + expect(screen.getByText('1:30')).toBeInTheDocument() + }) + + it('renders "0:00" when remaining is 0', () => { + render() + expect(screen.getByText('0:00')).toBeInTheDocument() + }) + }) +}) diff --git a/src/components/workout/__tests__/set-row.test.tsx b/src/components/workout/__tests__/set-row.test.tsx index b7a5b22a..070df21a 100644 --- a/src/components/workout/__tests__/set-row.test.tsx +++ b/src/components/workout/__tests__/set-row.test.tsx @@ -80,13 +80,29 @@ describe('SetRow', () => { const onConfirm = vi.fn() render() - // DONE badge should be visible (since variance is null) - expect(screen.getByText('DONE')).toBeInTheDocument() + // Confirmed state renders an Undo button (checkbox icon) instead of a DONE badge. + expect(screen.getByLabelText('Undo set 1')).toBeInTheDocument() // No confirm button when confirmed expect(screen.queryByLabelText('Confirm set 1')).not.toBeInTheDocument() expect(onConfirm).not.toHaveBeenCalled() }) + it('tapping the done indicator calls onUnconfirm when provided', async () => { + const user = userEvent.setup() + const onUnconfirm = vi.fn() + render() + + const undoBtn = screen.getByLabelText('Undo set 1') + await user.click(undoBtn) + + expect(onUnconfirm).toHaveBeenCalledOnce() + }) + + it('renders undo button for confirmed set with correct aria-label', () => { + render() + expect(screen.getByLabelText('Undo set 1')).toBeInTheDocument() + }) + it('pre-fills initialWeight and initialReps', () => { render() const weightInput = screen.getByLabelText('Weight for set 1') as HTMLInputElement @@ -100,4 +116,27 @@ describe('SetRow', () => { // In confirmed state the confirm button is removed and replaced with a status badge expect(screen.queryByLabelText('Confirm set 1')).not.toBeInTheDocument() }) + + describe('DEL button (swipe-to-delete)', () => { + it('renders the DEL button when onDelete prop is provided', () => { + render() + expect(screen.getByRole('button', { name: 'Delete set' })).toBeInTheDocument() + }) + + it('does not render the DEL button when onDelete prop is not provided', () => { + render() + expect(screen.queryByRole('button', { name: 'Delete set' })).not.toBeInTheDocument() + }) + + it('calls onDelete when the DEL button is clicked', async () => { + const user = userEvent.setup() + const onDelete = vi.fn() + render() + + const delBtn = screen.getByRole('button', { name: 'Delete set' }) + await user.click(delBtn) + + expect(onDelete).toHaveBeenCalledOnce() + }) + }) }) diff --git a/src/components/workout/exercise-block.tsx b/src/components/workout/exercise-block.tsx index 9c547e99..fba9e81c 100644 --- a/src/components/workout/exercise-block.tsx +++ b/src/components/workout/exercise-block.tsx @@ -4,6 +4,7 @@ import { OnboardingHint } from '@/components/onboarding/onboarding-hint' import { useOnboardingStore } from '@/stores/onboarding-store' import { useActiveWorkoutStore } from '@/stores/active-workout-store' import { NoteAffordance } from '@/components/workout/notes/note-affordance' +import { Icon } from '@/components/icon' import { cn } from '@/lib/utils' import type { SetType, NoteContent } from '@/domain/types' @@ -15,6 +16,7 @@ interface SetRowData { confirmed: boolean prescribedWeight?: { value: number; unit: string } prescribedReps?: number + isPending?: boolean } interface ExerciseBlockProps { @@ -31,6 +33,11 @@ interface ExerciseBlockProps { isConfirming?: boolean isBodyweight?: boolean isActive?: boolean + onSkipExercise?: () => void + onAddSet?: () => void + onDeleteSet?: (setId: string) => void + onUnconfirmSet?: (loggedActivityId: string, setId: string) => void + onRemoveExercise?: () => void } export function ExerciseBlock({ @@ -41,6 +48,11 @@ export function ExerciseBlock({ isConfirming = false, isBodyweight = false, isActive = true, + onSkipExercise, + onAddSet, + onDeleteSet, + onUnconfirmSet, + onRemoveExercise, }: ExerciseBlockProps) { const firstWorkoutCompleted = useOnboardingStore((s) => s.firstWorkoutCompleted) const hasPrescribed = sets.some((s) => s.prescribedWeight != null || s.prescribedReps != null) @@ -77,12 +89,24 @@ export function ExerciseBlock({ > {exerciseName} - setActivityNote(loggedActivityId, next)} - level="exercise" - variant="inline" - /> +
+ setActivityNote(loggedActivityId, next)} + level="exercise" + variant="inline" + /> + {onRemoveExercise && ( + + )} +
{/* Column headers */} @@ -137,13 +161,41 @@ export function ExerciseBlock({ prescribedWeight={set.prescribedWeight} prescribedReps={set.prescribedReps} isBodyweight={isBodyweight} - loggedSetId={set.id} + isPending={!set.confirmed && set.id.startsWith('pending-')} onConfirm={(weight, reps, setType) => onConfirmSet(loggedActivityId, set.setNumber, weight, reps, setType) } + onDelete={onDeleteSet ? () => onDeleteSet(set.id) : undefined} + onUnconfirm={ + set.confirmed && onUnconfirmSet + ? () => onUnconfirmSet(loggedActivityId, set.id) + : undefined + } /> ))}
+ {(onAddSet || (isActive && onSkipExercise)) && ( +
+ {onAddSet && ( + + )} + {isActive && onSkipExercise && ( + + )} +
+ )} ) } diff --git a/src/components/workout/manual-workout-form.tsx b/src/components/workout/manual-workout-form.tsx index e3267e1d..57e3159e 100644 --- a/src/components/workout/manual-workout-form.tsx +++ b/src/components/workout/manual-workout-form.tsx @@ -244,7 +244,6 @@ function buildDefaults(initial: WorkoutLogFull | undefined): FormValues { actualHeartRate: s.actualHeartRate, rpe: s.rpe, completed: s.completed, - notes: s.notes ?? '', ruckLoad: fromWeight(s.ruckLoad), elevationGain: fromDistance(s.elevationGain), })) @@ -654,7 +653,6 @@ export function ManualWorkoutForm({ mode, initialValue, userId, onSaved }: Manua actualHeartRate: s.actualHeartRate, rpe: s.rpe, completed: s.completed, - notes: s.notes || undefined, ruckLoad: toWeight(s.ruckLoad, weightUnit), elevationGain: toDistance(s.elevationGain, distanceUnit), }) @@ -794,7 +792,6 @@ export function ManualWorkoutForm({ mode, initialValue, userId, onSaved }: Manua actualHeartRate: s.actualHeartRate, rpe: s.rpe, completed: s.completed, - notes: s.notes || undefined, ruckLoad: toWeight(s.ruckLoad, weightUnit), elevationGain: toDistance(s.elevationGain, distanceUnit), workoutLogId, @@ -814,7 +811,6 @@ export function ManualWorkoutForm({ mode, initialValue, userId, onSaved }: Manua actualHeartRate: s.actualHeartRate, rpe: s.rpe, completed: s.completed, - notes: s.notes || undefined, ruckLoad: toWeight(s.ruckLoad, weightUnit), elevationGain: toDistance(s.elevationGain, distanceUnit), }) diff --git a/src/components/workout/notes/__tests__/note-integration.test.tsx b/src/components/workout/notes/__tests__/note-integration.test.tsx deleted file mode 100644 index 425e59b0..00000000 --- a/src/components/workout/notes/__tests__/note-integration.test.tsx +++ /dev/null @@ -1,120 +0,0 @@ -// @vitest-environment happy-dom -import { render, screen } from '@testing-library/react' -import userEvent from '@testing-library/user-event' -import { SetRow } from '@/components/workout/set-row' -import { useActiveWorkoutStore } from '@/stores/active-workout-store' -import { useRecentTagsStore } from '@/stores/recent-tags-store' -import type { WorkoutLog, LoggedSet } from '@/domain/types' - -vi.mock('@/components/icon', () => ({ - Icon: ({ name }: { name: string }) => {name}, -})) -vi.mock('@/lib/set-variance', () => ({ - computeVariance: vi.fn().mockReturnValue(null), -})) - -const NOW = '2026-03-27T10:00:00Z' - -function primeStore(overrides?: { restTimer?: { remaining: number; total: number } | null }) { - useActiveWorkoutStore.setState({ - workoutLog: { - id: 'wl-1', - createdAt: NOW, - updatedAt: NOW, - userId: 'u-1', - startedAt: NOW, - totalPausedMs: 0, - } as WorkoutLog, - loggedGroups: [ - { - id: 'g-1', - workoutLogId: 'wl-1', - groupType: 'STRAIGHT_SETS', - ordinal: 1, - activities: [ - { - id: 'a-1', - loggedGroupId: 'g-1', - exerciseId: 'e-1', - ordinal: 1, - sets: [ - { - id: 's-1', - loggedActivityId: 'a-1', - setNumber: 1, - setType: 'WORKING', - completed: false, - } as LoggedSet, - ], - }, - ], - }, - ], - elapsedSeconds: 0, - restTimer: overrides?.restTimer ?? null, - undoAction: null, - }) -} - -beforeEach(() => { - useRecentTagsStore.setState({ recent: [] }) -}) - -afterEach(() => { - useActiveWorkoutStore.getState().cleanup() - useActiveWorkoutStore.setState({ - workoutLog: null, - loggedGroups: [], - elapsedSeconds: 0, - restTimer: null, - undoAction: null, - }) -}) - -describe('set-row note affordance', () => { - it('opens the note sheet in a single tap (<=2 taps from set row default state)', async () => { - const user = userEvent.setup() - primeStore() - - render() - - const trigger = screen.getByRole('button', { name: /add set note/i }) - await user.click(trigger) - - // Sheet opens -- its title is rendered - expect(screen.getByText(/set note/i)).toBeInTheDocument() - }) - - it('keeps the rest timer running (unchanged) while the note sheet is open', async () => { - const user = userEvent.setup() - primeStore({ restTimer: { remaining: 42, total: 60 } }) - - render() - - // Sanity: rest timer is armed before interaction - expect(useActiveWorkoutStore.getState().restTimer).toEqual({ remaining: 42, total: 60 }) - - await user.click(screen.getByRole('button', { name: /add set note/i })) - - // Sheet is open - expect(screen.getByText(/set note/i)).toBeInTheDocument() - - // Rest timer state in the store must be untouched (Spec assertion 7) - expect(useActiveWorkoutStore.getState().restTimer).toEqual({ remaining: 42, total: 60 }) - }) - - it('writes set note text through to the active-workout store on textarea blur', async () => { - const user = userEvent.setup() - primeStore() - - render() - - await user.click(screen.getByRole('button', { name: /add set note/i })) - const textarea = screen.getByLabelText(/set note text/i) - await user.type(textarea, 'grindy') - await user.tab() // blur triggers commit - - const set = useActiveWorkoutStore.getState().loggedGroups[0].activities[0].sets[0] - expect(set.notes).toBe('grindy') - }) -}) diff --git a/src/components/workout/notes/note-affordance.tsx b/src/components/workout/notes/note-affordance.tsx index 89568238..70108d1f 100644 --- a/src/components/workout/notes/note-affordance.tsx +++ b/src/components/workout/notes/note-affordance.tsx @@ -51,7 +51,7 @@ export function NoteAffordance({ - {hasNote ? 'Edit Note' : 'Add Note'} + {hasNote ? 'Edit Note' : 'Add Note'} {hasNote && } void } /** @@ -40,6 +43,7 @@ export function RestPanel({ nextLabel, nextPrimary, nextSecondary, + onMinimize, }: RestPanelProps) { const progress = total > 0 ? Math.max(0, Math.min(1, remaining / total)) : 0 @@ -61,6 +65,18 @@ export function RestPanel({ }} /> + {/* Minimize button -- top-right, only when caller provides onMinimize */} + {onMinimize && ( + + )} +
{topBadge} Rest diff --git a/src/components/workout/rest-timer-banner.tsx b/src/components/workout/rest-timer-banner.tsx new file mode 100644 index 00000000..c4acb99c --- /dev/null +++ b/src/components/workout/rest-timer-banner.tsx @@ -0,0 +1,60 @@ +import { formatCountdown } from '@/lib/format-duration' + +interface RestTimerBannerProps { + /** Seconds remaining on the rest countdown. */ + remaining: number + /** Total rest duration in seconds (used to compute progress bar width). */ + total: number + /** Expand back to the full-page rest view. */ + onExpand: () => void + /** Skip rest immediately. */ + onSkip: () => void +} + +/** + * Compact sticky strip shown when the rest timer is minimized. + * Renders below the exercise view so the user can keep logging notes + * or reviewing sets while the countdown runs in the background. + */ +export function RestTimerBanner({ remaining, total, onExpand, onSkip }: RestTimerBannerProps) { + const progressPct = total > 0 ? Math.max(0, Math.min(100, (remaining / total) * 100)) : 0 + + return ( +
+ {/* Ember progress bar -- full width, thin, shows time remaining */} +
+
+
+ +
+ {/* REST label + countdown */} + Rest + {formatCountdown(remaining)} + + {/* Spacer */} +
+ + {/* Expand button */} + + + {/* Skip button */} + +
+
+ ) +} diff --git a/src/components/workout/rest-view.tsx b/src/components/workout/rest-view.tsx index a9ae3604..bf794921 100644 --- a/src/components/workout/rest-view.tsx +++ b/src/components/workout/rest-view.tsx @@ -8,6 +8,7 @@ interface RestViewProps { exerciseNames: Record onSkip: () => void onAdjust: (delta: number) => void + onMinimize?: () => void } interface NextUpInfo { @@ -37,6 +38,7 @@ export function RestView({ exerciseNames, onSkip, onAdjust, + onMinimize, }: RestViewProps) { // Compute next-up (first activity with any incomplete set) and last-set // (most recently confirmed set, by iteration order). @@ -84,6 +86,7 @@ export function RestView({ total={restTimer.total} onSkip={onSkip} onAdjust={onAdjust} + onMinimize={onMinimize} nextLabel={nextUp ? 'Next up' : undefined} nextPrimary={ nextUp ? ( diff --git a/src/components/workout/set-row.tsx b/src/components/workout/set-row.tsx index ae9acbc5..e3b7a3ba 100644 --- a/src/components/workout/set-row.tsx +++ b/src/components/workout/set-row.tsx @@ -1,10 +1,8 @@ -import { useState, useCallback, useEffect, useMemo } from 'react' -import { Badge } from '@/components/ui/badge' +import { useState, useCallback, useEffect, useRef } from 'react' import { Icon } from '@/components/icon' import { computeVariance } from '@/lib/set-variance' -import type { SetType, NoteContent } from '@/domain/types' -import { NoteAffordance } from '@/components/workout/notes/note-affordance' -import { useActiveWorkoutStore } from '@/stores/active-workout-store' +import type { SetType } from '@/domain/types' +import { cn } from '@/lib/utils' const SET_TYPES: SetType[] = ['WORKING', 'WARMUP', 'DROP', 'AMRAP', 'PEAK', 'BACKOFF'] @@ -19,13 +17,24 @@ interface SetRowProps { prescribedReps?: number isBodyweight?: boolean /** - * Logged-set id. When provided, a note affordance is rendered and - * tap-to-edit wires directly to the active workout store's setSetNote - * action. Omitted for unsaved scaffolds. + * When true, the row represents an unconfirmed placeholder (the auto-populated + * next-set row). Renders dimmed to distinguish it from confirmed sets. */ - loggedSetId?: string + isPending?: boolean + /** + * When provided, the row supports swipe-to-delete. Tapping the revealed DEL + * button calls this handler. + */ + onDelete?: () => void + /** + * When provided, tapping the done indicator on a confirmed set calls this + * handler, allowing the user to un-confirm (remove) the set. + */ + onUnconfirm?: () => void } +const SWIPE_THRESHOLD = 64 // px -- full reveal width + export function SetRow({ setNumber, initialWeight = '', @@ -36,24 +45,10 @@ export function SetRow({ prescribedWeight, prescribedReps, isBodyweight = false, - loggedSetId, + isPending = false, + onDelete, + onUnconfirm, }: SetRowProps) { - const setSetNote = useActiveWorkoutStore((s) => s.setSetNote) - const storedSet = useActiveWorkoutStore((s) => { - if (!loggedSetId) return undefined - for (const g of s.loggedGroups) { - for (const a of g.activities) { - const match = a.sets.find((x) => x.id === loggedSetId) - if (match) return match - } - } - return undefined - }) - const noteValue = useMemo( - () => ({ text: storedSet?.notes ?? '', tags: storedSet?.noteTags ?? [] }), - [storedSet?.notes, storedSet?.noteTags], - ) - const hasNote = noteValue.text.trim().length > 0 || noteValue.tags.length > 0 const hasPrescription = prescribedWeight != null || prescribedReps != null const [weight, setWeight] = useState(initialWeight) @@ -61,6 +56,11 @@ export function SetRow({ const [setType, setSetType] = useState('WORKING') const [showTypeSelector, setShowTypeSelector] = useState(false) + // Swipe-to-delete state + const [swipeX, setSwipeX] = useState(0) + const [isSwiping, setIsSwiping] = useState(false) + const touchStartX = useRef(0) + // Sync local state when parent pre-fills new values (e.g. carry-forward from last set) useEffect(() => { setWeight(initialWeight) @@ -98,177 +98,214 @@ export function SetRow({ : null return ( -
- {/* Set number + type */} -
+
+ {onDelete && ( - {!confirmed && ( - { + touchStartX.current = e.touches[0].clientX + setIsSwiping(false) + }} + onTouchMove={(e) => { + const delta = touchStartX.current - e.touches[0].clientX + if (delta > 0) { + setSwipeX(Math.min(delta, SWIPE_THRESHOLD)) + setIsSwiping(true) + } else if (swipeX > 0) { + setSwipeX(Math.max(0, swipeX + delta)) + setIsSwiping(true) + } + }} + onTouchEnd={() => { + if (swipeX >= SWIPE_THRESHOLD) { + // latched open -- DEL button is visible + } else { + setSwipeX(0) + setIsSwiping(false) + } + }} + > + {/* Set number + type */} +
+ - ))} -
- )} -
- - {hasPrescription ? ( - <> - {/* Prescribed column */} -
- - {prescribedLabel} + {setNumber} + + {!confirmed && ( + + {setType === 'WORKING' ? 'W' : setType} -
+ )} + + {/* Set type selector dropdown */} + {showTypeSelector && !confirmed && ( +
+ {SET_TYPES.map((type) => ( + + ))} +
+ )} +
- {/* Actual column -- weight x reps inline */} -
- {isBodyweight ? ( - - BW + {hasPrescription ? ( + <> + {/* Prescribed column */} +
+ + {prescribedLabel} - ) : ( +
+ + {/* Actual column -- weight x reps inline */} +
+ {isBodyweight ? ( + + BW + + ) : ( + setWeight(e.target.value)} + disabled={confirmed} + placeholder="--" + className="w-1/2 border-b border-warm-ash/30 bg-transparent py-2 text-center font-display text-sm tabular-nums text-bone-white placeholder:text-warm-ash/40 focus:border-ember focus:outline-none disabled:opacity-60" + aria-label={`Actual weight for set ${setNumber}`} + /> + )} + x setWeight(e.target.value)} + inputMode="numeric" + value={reps} + onChange={(e) => setReps(e.target.value)} disabled={confirmed} placeholder="--" className="w-1/2 border-b border-warm-ash/30 bg-transparent py-2 text-center font-display text-sm tabular-nums text-bone-white placeholder:text-warm-ash/40 focus:border-ember focus:outline-none disabled:opacity-60" - aria-label={`Actual weight for set ${setNumber}`} + aria-label={`Actual reps for set ${setNumber}`} /> - )} - x - setReps(e.target.value)} - disabled={confirmed} - placeholder="--" - className="w-1/2 border-b border-warm-ash/30 bg-transparent py-2 text-center font-display text-sm tabular-nums text-bone-white placeholder:text-warm-ash/40 focus:border-ember focus:outline-none disabled:opacity-60" - aria-label={`Actual reps for set ${setNumber}`} - /> -
- - ) : ( - <> - {/* Weight input -- ad-hoc path (hidden for bodyweight) */} -
- {isBodyweight ? ( - BW - ) : ( +
+ + ) : ( + <> + {/* Weight input -- ad-hoc path (hidden for bodyweight) */} +
+ {isBodyweight ? ( + BW + ) : ( + setWeight(e.target.value)} + disabled={confirmed} + placeholder="--" + className="w-full border-b border-warm-ash/30 bg-transparent py-2 text-center font-display text-sm tabular-nums text-bone-white placeholder:text-warm-ash/40 focus:border-ember focus:outline-none disabled:opacity-60" + aria-label={`Weight for set ${setNumber}`} + /> + )} +
+ + {/* Reps input -- ad-hoc path (unchanged) */} +
setWeight(e.target.value)} + inputMode="numeric" + value={reps} + onChange={(e) => setReps(e.target.value)} disabled={confirmed} placeholder="--" className="w-full border-b border-warm-ash/30 bg-transparent py-2 text-center font-display text-sm tabular-nums text-bone-white placeholder:text-warm-ash/40 focus:border-ember focus:outline-none disabled:opacity-60" - aria-label={`Weight for set ${setNumber}`} + aria-label={`Reps for set ${setNumber}`} /> - )} -
- - {/* Reps input -- ad-hoc path (unchanged) */} -
- setReps(e.target.value)} - disabled={confirmed} - placeholder="--" - className="w-full border-b border-warm-ash/30 bg-transparent py-2 text-center font-display text-sm tabular-nums text-bone-white placeholder:text-warm-ash/40 focus:border-ember focus:outline-none disabled:opacity-60" - aria-label={`Reps for set ${setNumber}`} - /> -
- - )} - - {/* Note affordance -- one tap opens the editor (Spec assertion 1) */} - {loggedSetId && ( -
- setSetNote(loggedSetId, next)} - level="set" - variant="inline" - className="px-2" - /> - {hasNote ? 'Note attached' : 'No note'} -
- )} +
+ + )} - {/* Confirm / Status */} -
- {confirmed ? ( - variance === 'met' ? ( - - ) : variance === 'under' ? ( - + {/* Confirm / Status */} +
+ {confirmed ? ( + ) : ( - DONE - ) - ) : ( - - )} + + )} +
) diff --git a/src/domain/types/workout-log.ts b/src/domain/types/workout-log.ts index ac4d95f0..32773cfb 100644 --- a/src/domain/types/workout-log.ts +++ b/src/domain/types/workout-log.ts @@ -131,8 +131,6 @@ export const loggedSetSchema = z // L-7: RPE 1-10, half-values allowed (e.g. 7.5) per standard RPE scale rpe: z.number().min(1).max(10).multipleOf(0.5).optional(), completed: z.boolean(), - notes: z.string().optional(), - noteTags: z.array(noteTagSchema).max(16).optional(), ruckLoad: weightSchema.optional(), elevationGain: distanceSchema.optional(), }) diff --git a/src/hooks/use-active-workout.ts b/src/hooks/use-active-workout.ts index 491d2a01..d9ca9596 100644 --- a/src/hooks/use-active-workout.ts +++ b/src/hooks/use-active-workout.ts @@ -14,6 +14,8 @@ import { useUpdateLoggedSet, useUpdateWorkoutLog, useDeleteWorkoutLog, + useDeleteLoggedSet, + useDeleteLoggedActivity, } from '@/hooks/use-workout-logs' import { getAdapter } from '@/lib/adapter' import { DEFAULT_REST_SECONDS } from '@/lib/workout-utils' @@ -56,6 +58,7 @@ export function useActiveWorkout() { const storeAddExercise = useActiveWorkoutStore((s) => s.addExerciseToWorkout) const storeConfirmSet = useActiveWorkoutStore((s) => s.confirmSet) const storeUpdateSetInPlace = useActiveWorkoutStore((s) => s.updateSetInPlace) + const storeUnconfirmSet = useActiveWorkoutStore((s) => s.unconfirmSet) const storeUndoLastSet = useActiveWorkoutStore((s) => s.undoLastSet) const storeClearUndo = useActiveWorkoutStore((s) => s.clearUndo) const storeFinishWorkout = useActiveWorkoutStore((s) => s.finishWorkout) @@ -65,6 +68,8 @@ export function useActiveWorkout() { const storeAdjustRest = useActiveWorkoutStore((s) => s.adjustRest) const storePauseWorkout = useActiveWorkoutStore((s) => s.pauseWorkout) const storeUnpauseWorkout = useActiveWorkoutStore((s) => s.unpauseWorkout) + const storeDeleteSet = useActiveWorkoutStore((s) => s.deleteSet) + const storeRemoveActivity = useActiveWorkoutStore((s) => s.removeActivity) // --------------------------------------------------------------------------- // TanStack Query client (for manual invalidation after program advancement) @@ -81,6 +86,8 @@ export function useActiveWorkout() { const updateLoggedSetMutation = useUpdateLoggedSet() const updateWorkoutLogMutation = useUpdateWorkoutLog() const deleteWorkoutLogMutation = useDeleteWorkoutLog() + const deleteLoggedSetMutation = useDeleteLoggedSet() + const deleteLoggedActivityMutation = useDeleteLoggedActivity() // --------------------------------------------------------------------------- // Undo expiry timer @@ -441,6 +448,9 @@ export function useActiveWorkout() { storeUndoLastSet() } catch (err) { + // Clear the pending undo immediately so the expiry timer does not fire + // a silent clearUndo() later and diverge the store from DB state. + storeClearUndo() console.error('[workout] Failed to undo set:', { workoutId: workoutLog?.id, setId: undoAction?.setId, @@ -448,7 +458,37 @@ export function useActiveWorkout() { }) throw err } - }, [workoutLog, undoAction, updateLoggedSetMutation, storeUndoLastSet]) + }, [workoutLog, undoAction, updateLoggedSetMutation, storeUndoLastSet, storeClearUndo]) + + /** + * Unconfirm a previously confirmed set. Marks completed=false in the DB so + * actual weight/reps are preserved for re-editing, then reflects that in the + * store without removing the set. + */ + const unconfirmSet = useCallback( + async (loggedActivityId: string, setId: string) => { + if (!workoutLog) { + throw new Error('No active workout to unconfirm set in') + } + const currentGroups = useActiveWorkoutStore.getState().loggedGroups + const targetSet = findSetById(currentGroups, setId) + if (!targetSet) { + console.error('[workout] unconfirmSet: set not found in store', { setId }) + return + } + // The mutation hook's onError is the single log owner for this + // rejection (see error-handling.md "Mutation Hook Log Ownership"). + // Errors propagate naturally to the caller. + await updateLoggedSetMutation.mutateAsync({ + ...targetSet, + completed: false, + workoutLogId: workoutLog.id, + userId: workoutLog.userId, + }) + storeUnconfirmSet(loggedActivityId, setId) + }, + [workoutLog, updateLoggedSetMutation, storeUnconfirmSet], + ) /** * Finish the active workout. Updates WorkoutLog.completedAt in DB, @@ -617,6 +657,53 @@ export function useActiveWorkout() { } }, [storeUnpauseWorkout, updateWorkoutLogMutation]) + /** + * Delete a logged set. Deletes the set row from the DB first, then removes + * it from the in-memory store. If the DB delete fails the store is left + * untouched so the UI accurately reflects persisted state. + * + * The mutation hook's onError is the single log owner for the rejection + * (see error-handling.md "Mutation Hook Log Ownership"). We intentionally + * re-throw without additional logging. + */ + const deleteSet = useCallback( + async (loggedActivityId: string, setId: string) => { + if (!workoutLog) { + throw new Error('No active workout to delete set in') + } + // Hook owns the log; errors propagate naturally to the caller. + await deleteLoggedSetMutation.mutateAsync({ + id: setId, + workoutLogId: workoutLog.id, + }) + storeDeleteSet(loggedActivityId, setId) + }, + [workoutLog, deleteLoggedSetMutation, storeDeleteSet], + ) + + /** + * Remove an activity (exercise) from the active workout. Deletes the logged + * activity row from the DB (which cascades to its sets via FK on delete) and + * only then updates the in-memory store. If the DB delete fails the store + * is left untouched so the UI accurately reflects persisted state. + * + * The mutation hook's onError is the single log owner for the rejection. + */ + const removeActivity = useCallback( + async (activityId: string) => { + if (!workoutLog) { + throw new Error('No active workout to remove activity from') + } + // Hook owns the log; errors propagate naturally to the caller. + await deleteLoggedActivityMutation.mutateAsync({ + id: activityId, + workoutLogId: workoutLog.id, + }) + storeRemoveActivity(activityId) + }, + [workoutLog, deleteLoggedActivityMutation, storeRemoveActivity], + ) + /** * Discard the active workout. Deletes the WorkoutLog from DB, clears store. */ @@ -659,7 +746,10 @@ export function useActiveWorkout() { startProgrammedWorkout, addExercise, confirmSet, + unconfirmSet, undoSet, + deleteSet, + removeActivity, finishWorkout, resumeWorkout, discardWorkout, diff --git a/src/hooks/use-workout-logs.ts b/src/hooks/use-workout-logs.ts index c68e1e69..de126aac 100644 --- a/src/hooks/use-workout-logs.ts +++ b/src/hooks/use-workout-logs.ts @@ -140,6 +140,13 @@ export function useDeleteLoggedSet() { return useMutation({ mutationFn: ({ id }: { id: string; workoutLogId: string }) => getAdapter().deleteLoggedSet(id), + onError: (err, variables) => { + console.error('[workout-logs] deleteLoggedSet failed:', { + id: variables.id, + workoutLogId: variables.workoutLogId, + err, + }) + }, onSettled: (_data, _err, variables) => { queryClient.invalidateQueries({ queryKey: ['workout-full', variables.workoutLogId] }) }, @@ -170,6 +177,13 @@ export function useDeleteLoggedActivity() { return useMutation({ mutationFn: ({ id }: { id: string; workoutLogId: string }) => getAdapter().deleteLoggedActivity(id), + onError: (err, variables) => { + console.error('[workout-logs] deleteLoggedActivity failed:', { + id: variables.id, + workoutLogId: variables.workoutLogId, + err, + }) + }, onSettled: (_data, _err, variables) => { queryClient.invalidateQueries({ queryKey: ['workout-full', variables.workoutLogId] }) }, @@ -200,6 +214,13 @@ export function useDeleteLoggedActivityGroup() { return useMutation({ mutationFn: ({ id }: { id: string; workoutLogId: string }) => getAdapter().deleteLoggedActivityGroup(id), + onError: (err, variables) => { + console.error('[workout-logs] deleteLoggedActivityGroup failed:', { + id: variables.id, + workoutLogId: variables.workoutLogId, + err, + }) + }, onSettled: (_data, _err, variables) => { queryClient.invalidateQueries({ queryKey: ['workout-full', variables.workoutLogId] }) }, diff --git a/src/lib/__tests__/supabase-adapter.test.ts b/src/lib/__tests__/supabase-adapter.test.ts index dcc90a51..73067623 100644 --- a/src/lib/__tests__/supabase-adapter.test.ts +++ b/src/lib/__tests__/supabase-adapter.test.ts @@ -123,8 +123,6 @@ const setRow: LoggedSetRow = { elevation_gain: null, rpe: 8, completed: true, - notes: 'Solid set', - note_tags: [], created_at: now, updated_at: now, } diff --git a/src/lib/__tests__/tauri-adapter.test.ts b/src/lib/__tests__/tauri-adapter.test.ts index 34cd508b..cfabb42a 100644 --- a/src/lib/__tests__/tauri-adapter.test.ts +++ b/src/lib/__tests__/tauri-adapter.test.ts @@ -116,7 +116,6 @@ const tauriLoggedSetResponse = { elevation_gain: null, rpe: 8, completed: 1, - notes: 'Solid set', created_at: now, updated_at: now, } diff --git a/src/lib/database.types.ts b/src/lib/database.types.ts index b94cfaf7..6eb3c4c4 100644 --- a/src/lib/database.types.ts +++ b/src/lib/database.types.ts @@ -135,8 +135,6 @@ export interface LoggedSetRow { elevation_gain: unknown rpe: number | null completed: boolean - notes: string | null - note_tags: string[] created_at: string updated_at: string } diff --git a/src/lib/supabase-adapter.ts b/src/lib/supabase-adapter.ts index 69ecacf2..fc067b0e 100644 --- a/src/lib/supabase-adapter.ts +++ b/src/lib/supabase-adapter.ts @@ -238,11 +238,6 @@ export class SupabaseAdapter implements DataAdapter { actualHeartRate: (r.actualHeartRate ?? undefined) as number | undefined, rpe: (r.rpe ?? undefined) as number | undefined, completed: r.completed as boolean, - notes: (r.notes ?? undefined) as string | undefined, - noteTags: - Array.isArray(r.noteTags) && (r.noteTags as unknown[]).length > 0 - ? (r.noteTags as string[]) - : undefined, ruckLoad: r.ruckLoad != null ? weightSchema.parse(r.ruckLoad) : undefined, elevationGain: r.elevationGain != null ? distanceSchema.parse(r.elevationGain) : undefined, } @@ -1075,8 +1070,6 @@ export class SupabaseAdapter implements DataAdapter { elevation_gain: set.elevationGain ?? null, rpe: set.rpe ?? null, completed: set.completed, - notes: set.notes ?? null, - note_tags: set.noteTags ?? [], } const { data, error } = await this.client.from('logged_sets').insert(row).select().single() @@ -1101,8 +1094,6 @@ export class SupabaseAdapter implements DataAdapter { elevation_gain: set.elevationGain ?? null, rpe: set.rpe ?? null, completed: set.completed, - notes: set.notes ?? null, - note_tags: set.noteTags ?? [], } const { data, error } = await this.client diff --git a/src/lib/tauri-adapter.ts b/src/lib/tauri-adapter.ts index 4a25bec9..afc1853a 100644 --- a/src/lib/tauri-adapter.ts +++ b/src/lib/tauri-adapter.ts @@ -169,8 +169,6 @@ interface TauriLoggedSetResponse { elevation_gain: string | null rpe: number | null completed: number | null - notes: string | null - note_tags: string created_at: string | null updated_at: string | null } @@ -782,7 +780,6 @@ function toLoggedSet(r: TauriLoggedSetResponse): LoggedSet { const actualPace = parseJson(r.actual_pace, 'actual_pace') const ruckLoad = parseJson(r.ruck_load, 'ruck_load') const elevationGain = parseJson(r.elevation_gain, 'elevation_gain') - const noteTags = parseNoteTags(r.note_tags, 'logged_sets.note_tags') return { id: r.id, loggedActivityId: r.logged_activity_id, @@ -797,8 +794,6 @@ function toLoggedSet(r: TauriLoggedSetResponse): LoggedSet { actualHeartRate: r.actual_heart_rate ?? undefined, rpe: r.rpe ?? undefined, completed: intToBool(r.completed, 'completed'), - notes: r.notes ?? undefined, - noteTags: noteTags.length > 0 ? noteTags : undefined, ruckLoad: ruckLoad != null ? weightSchema.parse(ruckLoad) : undefined, elevationGain: elevationGain != null ? distanceSchema.parse(elevationGain) : undefined, } @@ -821,8 +816,6 @@ function fromLoggedSet(set: Omit, userId: string): Record('create_logged_set', { @@ -1562,8 +1553,6 @@ export class TauriAdapter implements DataAdapter { partial.elevation_gain != null ? JSON.stringify(partial.elevation_gain) : null, rpe: (partial.rpe as number | undefined) ?? null, completed: (partial.completed as boolean | undefined) ?? null, - notes: (partial.notes as string | undefined) ?? null, - note_tags: JSON.stringify(partial.note_tags ?? []), } const row = await invokeCommand('update_logged_set', { @@ -1726,8 +1715,6 @@ export class TauriAdapter implements DataAdapter { elevation_gain: s.elevationGain ? JSON.stringify(s.elevationGain) : null, rpe: s.rpe ?? null, completed: s.completed ?? null, - notes: s.notes ?? null, - note_tags: JSON.stringify(s.noteTags ?? []), })), })), })), diff --git a/src/routes/_authenticated/__tests__/-start-workout-flow.test.tsx b/src/routes/_authenticated/__tests__/-start-workout-flow.test.tsx index 16d455a6..18aad612 100644 --- a/src/routes/_authenticated/__tests__/-start-workout-flow.test.tsx +++ b/src/routes/_authenticated/__tests__/-start-workout-flow.test.tsx @@ -35,6 +35,7 @@ const { mockStartProgrammedWorkout, mockConfigureDisplayPublisher, mockWriteLastGymChoice, + mockReadLastGymChoice, mockOpenGymPicker, capturedComponent, } = vi.hoisted(() => { @@ -43,6 +44,7 @@ const { const mockStartProgrammedWorkout = vi.fn() const mockConfigureDisplayPublisher = vi.fn() const mockWriteLastGymChoice = vi.fn() + const mockReadLastGymChoice = vi.fn<() => GymPickerChoice | null>() const mockOpenGymPicker = vi.fn<(args: { userId: string }) => Promise>() const capturedComponent: { current: React.ComponentType | undefined } = { current: undefined } return { @@ -51,6 +53,7 @@ const { mockStartProgrammedWorkout, mockConfigureDisplayPublisher, mockWriteLastGymChoice, + mockReadLastGymChoice, mockOpenGymPicker, capturedComponent, } @@ -133,6 +136,7 @@ vi.mock('@/lib/display-realtime', () => ({ vi.mock('@/lib/gym-picker-storage', () => ({ writeLastGymChoice: mockWriteLastGymChoice, + readLastGymChoice: mockReadLastGymChoice, })) // Stubs for all the presentational components in the route -- they don't @@ -202,6 +206,12 @@ describe('TodayPage start-workout flow (F018)', () => { vi.clearAllMocks() mockStartWorkout.mockResolvedValue({ id: 'workout-abc' }) mockStartProgrammedWorkout.mockResolvedValue({ id: 'workout-abc' }) + // Default: no saved gym choice -- exercises the picker path. The B008 + // regression tests below override this to return a saved gym id. + mockReadLastGymChoice.mockReturnValue(null) + // writeLastGymChoice returns true on success in production; matching that + // here prevents the toast-warning branch in the handlers from firing. + mockWriteLastGymChoice.mockReturnValue(true) }) // ------------------------------------------------------------------------- @@ -342,4 +352,63 @@ describe('TodayPage start-workout flow (F018)', () => { errorSpy.mockRestore() }) + + // ------------------------------------------------------------------------- + // B008 regression: the start-workout handlers gate the picker on + // readLastGymChoice(), NOT on the firstWorkoutCompleted onboarding flag. + // + // Before the fix, the handlers opened the picker on every start because + // the onboarding flag was used as the gate. These two cases pin the + // corrected behavior: + // (1) readLastGymChoice returns a saved gym -> picker is NOT opened + // (2) readLastGymChoice returns null -> picker IS opened + // ------------------------------------------------------------------------- + + it('B008 regression: skips the gym picker when a saved gym choice exists', async () => { + mockReadLastGymChoice.mockReturnValue('gym-123') + const user = userEvent.setup() + + renderTodayPage() + + await user.click(screen.getByRole('button', { name: /execute workout/i })) + + // Workout still starts and navigates, but the picker must not open. + await waitFor(() => { + expect(mockStartWorkout).toHaveBeenCalledWith('user-1') + }) + + expect(mockOpenGymPicker).not.toHaveBeenCalled() + + // The saved choice is used to configure the publisher. + expect(mockConfigureDisplayPublisher).toHaveBeenCalledWith({ + gymId: 'gym-123', + intent: 'broadcasting', + }) + expect(mockNavigate).toHaveBeenCalledWith({ + to: '/log/$workoutId', + params: { workoutId: 'workout-abc' }, + }) + }) + + it('B008 regression: opens the gym picker when no saved gym choice exists', async () => { + mockReadLastGymChoice.mockReturnValue(null) + mockOpenGymPicker.mockResolvedValue('gym-456') + const user = userEvent.setup() + + renderTodayPage() + + await user.click(screen.getByRole('button', { name: /execute workout/i })) + + await waitFor(() => { + expect(mockOpenGymPicker).toHaveBeenCalledWith({ userId: 'user-1' }) + }) + + // Sanity: picker result flows through to publisher configuration. + await waitFor(() => { + expect(mockConfigureDisplayPublisher).toHaveBeenCalledWith({ + gymId: 'gym-456', + intent: 'broadcasting', + }) + }) + }) }) diff --git a/src/routes/_authenticated/index.tsx b/src/routes/_authenticated/index.tsx index 06f68130..b392bbd6 100644 --- a/src/routes/_authenticated/index.tsx +++ b/src/routes/_authenticated/index.tsx @@ -159,18 +159,7 @@ function TodayPage() { } setStartError(null) - // After first workout is completed, use saved gym choice without prompting - let choice: string | 'private' | null - if (firstWorkoutCompleted) { - choice = readLastGymChoice() - if (choice === null) { - // Fallback to picker if no saved choice exists - choice = await openGymPicker({ userId }) - } - } else { - // First workout: prompt for gym selection - choice = await openGymPicker({ userId }) - } + const choice = readLastGymChoice() ?? (await openGymPicker({ userId })) if (choice === null) return try { @@ -212,18 +201,7 @@ function TodayPage() { } setStartError(null) - // After first workout is completed, use saved gym choice without prompting - let choice: string | 'private' | null - if (firstWorkoutCompleted) { - choice = readLastGymChoice() - if (choice === null) { - // Fallback to picker if no saved choice exists - choice = await openGymPicker({ userId }) - } - } else { - // First workout: prompt for gym selection - choice = await openGymPicker({ userId }) - } + const choice = readLastGymChoice() ?? (await openGymPicker({ userId })) if (choice === null) return try { diff --git a/src/routes/_authenticated/library.tsx b/src/routes/_authenticated/library.tsx index 1a515864..1ddf0fe8 100644 --- a/src/routes/_authenticated/library.tsx +++ b/src/routes/_authenticated/library.tsx @@ -54,7 +54,6 @@ import { writeLastGymChoice, readLastGymChoice } from '@/lib/gym-picker-storage' import { toast } from 'sonner' import { OnboardingHint } from '@/components/onboarding/onboarding-hint' import { useOnboarding } from '@/hooks/use-onboarding' -import { useOnboardingStore } from '@/stores/onboarding-store' import type { Program, ExerciseCategory, @@ -89,7 +88,6 @@ function LibraryPage() { } const { markRouteVisited } = useOnboarding() - const firstWorkoutCompleted = useOnboardingStore((s) => s.firstWorkoutCompleted) useEffect(() => { markRouteVisited('/library') @@ -163,24 +161,12 @@ function LibraryPage() { setStartingTemplateId(templateId) - // After first workout is completed, use saved gym choice without prompting - let choice: string | 'private' | null - if (firstWorkoutCompleted) { - choice = readLastGymChoice() + try { + const choice = readLastGymChoice() ?? (await openGymPicker({ userId })) if (choice === null) { - // Fallback to picker if no saved choice exists - choice = await openGymPicker({ userId }) + return } - } else { - // First workout: prompt for gym selection - choice = await openGymPicker({ userId }) - } - if (choice === null) { - setStartingTemplateId(null) - return - } - try { const workoutLog = await startProgrammedWorkout(userId, templateId) if (choice === 'private') { configureDisplayPublisher({ gymId: null, intent: 'private' }) diff --git a/src/routes/_authenticated/log.$workoutId.tsx b/src/routes/_authenticated/log.$workoutId.tsx index 7b6061d8..99bb0cef 100644 --- a/src/routes/_authenticated/log.$workoutId.tsx +++ b/src/routes/_authenticated/log.$workoutId.tsx @@ -20,6 +20,7 @@ import { WorkoutHeaderMenu } from '@/components/workout/workout-header-menu' import { ExerciseBlock, type SetRowData } from '@/components/workout/exercise-block' import { ProgramContextBanner } from '@/components/workout/program-context-banner' import { RestView } from '@/components/workout/rest-view' +import { RestTimerBanner } from '@/components/workout/rest-timer-banner' import { UndoBanner } from '@/components/workout/undo-banner' import { AddExerciseSheet } from '@/components/workout/add-exercise-sheet' import { CardioPanel } from '@/components/workout/cardio-panel' @@ -60,7 +61,10 @@ function ActiveWorkoutPage() { restTimer, undoAction, confirmSet, + unconfirmSet, undoSet, + deleteSet, + removeActivity, finishWorkout, discardWorkout, pauseWorkout, @@ -73,6 +77,9 @@ function ActiveWorkoutPage() { isDiscarding, } = useActiveWorkout() + const skipActivity = useActiveWorkoutStore((s) => s.skipActivity) + const skippedActivityIds = useActiveWorkoutStore((s) => s.skippedActivityIds) + const { markFirstWorkoutCompleted } = useOnboarding() const firstWorkoutCompleted = useOnboardingStore((s) => s.firstWorkoutCompleted) @@ -130,6 +137,10 @@ function ActiveWorkoutPage() { const [showDiscardDialog, setShowDiscardDialog] = useState(false) const [showSummary, setShowSummary] = useState(false) const [pageError, setPageError] = useState(null) + // pendingInputs[activityId] = true means "show one input row for this activity" + const [pendingInputs, setPendingInputs] = useState>({}) + // restMinimized collapses the full-page rest view to the RestTimerBanner strip + const [restMinimized, setRestMinimized] = useState(false) // Pause/resume derived state and handlers. // Pause UI is hidden on the Tauri (mobile) adapter because pause-state @@ -170,6 +181,10 @@ function ActiveWorkoutPage() { } }, [isActive, showSummary, navigate]) + // restMinimized is reset to false in handleConfirmSet (below) so each new + // rest timer starts expanded rather than relying on a useEffect to react to + // restTimer becoming null. + // --------------------------------------------------------------------------- // Elapsed timer ownership (Tech.md D-1) // @@ -254,12 +269,11 @@ function ActiveWorkoutPage() { lastId = group.id const incomplete = group.activities.length === 0 || - group.activities.some( - (a) => a.sets.length === 0 || a.sets.some((s) => !s.completed), - ) + group.activities.some((a) => a.sets.length === 0 || a.sets.some((s) => !s.completed)) if (incomplete) return group.id } else { for (const activity of group.activities) { + if (skippedActivityIds.has(activity.id)) continue // treat as done lastId = activity.id if (activity.sets.length === 0 || activity.sets.some((s) => !s.completed)) { return activity.id @@ -268,14 +282,18 @@ function ActiveWorkoutPage() { } } return lastId - }, [loggedGroups]) + }, [loggedGroups, skippedActivityIds]) // ----------------------------------------------------------------------- // Handlers // ----------------------------------------------------------------------- const handleFinish = useCallback(async () => { - if (!workoutLog) return + if (!workoutLog) { + console.error('[workout-page] handleFinish blocked: no active workoutLog in store') + setPageError('Cannot finish workout: no active session. Please reload.') + return + } // Capture snapshot before store clears it (needed for summary even on success) const snapshot = { workoutLog: { ...workoutLog }, @@ -312,15 +330,22 @@ function ActiveWorkoutPage() { } if (result?.advancementFailed) { - setPageError( - 'Workout saved, but program position could not update. Check your connection.', - ) + setPageError('Workout saved, but program position could not update. Check your connection.') } } catch (err) { console.error('[workout-page] handleFinish:', err) setPageError('Failed to save workout. Please try again.') } - }, [workoutLog, loggedGroups, finishWorkout, programBannerProps, userProfile, exerciseNames, firstWorkoutCompleted, markFirstWorkoutCompleted]) + }, [ + workoutLog, + loggedGroups, + finishWorkout, + programBannerProps, + userProfile, + exerciseNames, + firstWorkoutCompleted, + markFirstWorkoutCompleted, + ]) const handleDiscard = useCallback(async () => { try { @@ -353,10 +378,18 @@ function ActiveWorkoutPage() { reps: string, setType: SetType, ) => { - if (!workoutLog) return + if (!workoutLog) { + console.error('[workout-page] handleConfirmSet blocked: no active workoutLog in store') + setPageError('Cannot save set: no active session. Please reload.') + return + } const weightValue = parseNumericInput(weight) - const repsValue = parseNumericInput(reps) + // Reps support 0 (failed sets), so use >= 0 check rather than the > 0 + // guard inside parseNumericInput. A falsy check on repsValue would coerce + // reps=0 to null, violating the DB completed_check constraint. + const repsRaw = parseFloat(reps) + const repsValue = !isNaN(repsRaw) && repsRaw >= 0 ? Math.round(repsRaw) : undefined // Determine unit from prescription or default const existingSet = loggedGroups @@ -366,14 +399,18 @@ function ActiveWorkoutPage() { const unit = existingSet?.prescribed?.weight?.unit ?? 'lb' try { + // Reset minimize state before confirming so the incoming rest timer + // always starts in the expanded view (avoids a useEffect on restTimer). + setRestMinimized(false) await confirmSet(loggedActivityId, { loggedActivityId, setNumber, setType, completed: true, actualWeight: weightValue ? { value: weightValue, unit } : undefined, - actualReps: repsValue ? Math.round(repsValue) : undefined, + actualReps: repsValue != null ? repsValue : undefined, }) + setPendingInputs((prev) => ({ ...prev, [loggedActivityId]: false })) } catch (err) { console.error('[workout-page] handleConfirmSet:', err) setPageError('Failed to save set.') @@ -391,6 +428,19 @@ function ActiveWorkoutPage() { } }, [undoSet]) + const handleUnconfirmSet = useCallback( + async (loggedActivityId: string, setId: string) => { + try { + await unconfirmSet(loggedActivityId, setId) + } catch { + // The underlying mutation hook (useUpdateLoggedSet) is the single log + // owner for this rejection. Set a user-facing error without logging. + setPageError('Failed to undo set.') + } + }, + [unconfirmSet], + ) + const handleSummaryDone = useCallback(() => { setShowSummary(false) setSummaryData(null) @@ -527,215 +577,259 @@ function ActiveWorkoutPage() { /> )} - {/* REST mode: full-page rest view owns the screen between sets. */} - {restTimer && ( + {/* REST mode (full-page): shown when rest is active and not minimized. */} + {restTimer && !restMinimized && ( setRestMinimized(true)} /> )} - {/* SET mode: only the active exercise block renders (hard focus). */} - {!restTimer && ( - <> -
- {loggedGroups.map((group) => { - // Group-level rendering: circuits render once per group - if (group.groupType === 'CIRCUIT') { - const circuitExercises = group.activities.map((a) => ({ - name: exerciseNames[a.exerciseId] ?? 'Unknown', - targetReps: DEFAULT_CIRCUIT_REPS, - })) - // Hard focus: only render the active block in SET mode. - if (group.id !== activeFocusId) return null - return ( -
- { - const activity = group.activities[exerciseIndex] - if (!activity) return - try { - // Pass restSeconds: 0 -- CircuitPanel runs its own inter-exercise - // and inter-round rest, so the global rest timer must not fire. - await confirmSet( - activity.id, - { - loggedActivityId: activity.id, - setNumber: round, - setType: 'WORKING', - completed: true, - actualReps, - }, - 0, - ) - } catch (err) { - console.error('[workout-log] Failed to log circuit set:', err) - setPageError('Failed to save circuit set.') - } - }} - onComplete={() => { - // Per-set logging happens in onExerciseDone; nothing to do here. - }} - /> -
- ) - } - - return group.activities.map((activity) => { - // Hard focus: only render the active activity in SET mode. - if (activity.id !== activeFocusId) return null - const exercise = exerciseMap[activity.exerciseId] - const modality = getExerciseModality(exercise, group.groupType) - - // Cardio panel - if (modality === 'cardio' && exercise) { - return ( -
- { - try { - await confirmSet(activity.id, { - loggedActivityId: activity.id, - setNumber: 1, - setType: 'WORKING', - completed: true, - actualDuration: { seconds: data.durationSeconds }, - actualDistance: data.distance - ? { value: parseFloat(data.distance), unit: 'mi' } - : undefined, - actualHeartRate: data.heartRate ? parseInt(data.heartRate, 10) : undefined, - }) - } catch (err) { - console.error('[workout-page] cardio confirmSet:', err) - setPageError('Failed to save cardio session.') - } - }} - /> -
- ) - } - - // Ruck panel - if (modality === 'ruck') { - return ( -
- { - try { - await confirmSet(activity.id, { - loggedActivityId: activity.id, - setNumber: 1, - setType: 'WORKING', - completed: true, - actualDuration: { seconds: data.durationSeconds }, - actualDistance: data.distance - ? { value: parseFloat(data.distance), unit: 'mi' } - : undefined, - ruckLoad: data.loadWeight - ? { value: parseFloat(data.loadWeight), unit: 'lb' } - : undefined, - elevationGain: data.elevation - ? { value: parseFloat(data.elevation), unit: 'm' } - : undefined, - }) - } catch (err) { - console.error('[workout-page] ruck confirmSet:', err) - setPageError('Failed to save ruck session.') - } - }} - /> -
- ) - } - - // Standard strength exercise block - const confirmedSets = activity.sets.filter((s) => s.completed) - const lastConfirmedSet = - confirmedSets.length > 0 ? confirmedSets[confirmedSets.length - 1] : undefined - - // Build set row data: confirmed sets + one empty row for the next set - const setRows: SetRowData[] = [ - ...activity.sets.map((set, idx) => ({ - id: set.id, - setNumber: idx + 1, - weight: set.actualWeight?.value?.toString(), - reps: set.actualReps?.toString(), - confirmed: set.completed, - prescribedWeight: set.prescribed?.weight ?? undefined, - prescribedReps: set.prescribed?.reps ?? undefined, - })), - ] - - // Add an empty row for the next set to log. - // If this is a programmed workout, inherit prescribed values from the - // last set so the user sees the same prescription on the input row. - const lastSetWithPrescription = [...activity.sets] - .reverse() - .find((s) => s.prescribed != null) - const nextPrescribedWeight = - lastSetWithPrescription?.prescribed?.weight ?? undefined - const nextPrescribedReps = - lastSetWithPrescription?.prescribed?.reps ?? undefined - - const nextSetNumber = setRows.length + 1 - setRows.push({ - id: `pending-${activity.id}-${nextSetNumber}`, - setNumber: nextSetNumber, - weight: - lastConfirmedSet?.actualWeight?.value?.toString() - ?? nextPrescribedWeight?.value?.toString(), - reps: - lastConfirmedSet?.actualReps?.toString() - ?? (nextPrescribedReps != null ? String(nextPrescribedReps) : undefined), - confirmed: false, - prescribedWeight: nextPrescribedWeight, - prescribedReps: nextPrescribedReps, - }) - - return ( -
- -
- ) - }) - })} -
+ {/* REST mode (minimized): compact sticky banner with expand + skip. */} + {restTimer && restMinimized && ( + setRestMinimized(false)} + onSkip={skipRest} + /> + )} - {/* Sticky footer -- single forward action for free-form workouts. + {/* SET mode: only the active exercise block renders (hard focus). */} + {(!restTimer || restMinimized) && ( + <> +
+ {loggedGroups.map((group) => { + // Group-level rendering: circuits render once per group + if (group.groupType === 'CIRCUIT') { + const circuitExercises = group.activities.map((a) => ({ + name: exerciseNames[a.exerciseId] ?? 'Unknown', + targetReps: DEFAULT_CIRCUIT_REPS, + })) + // Hard focus: only render the active block in SET mode. + if (group.id !== activeFocusId) return null + return ( +
+ { + const activity = group.activities[exerciseIndex] + if (!activity) return + try { + // Pass restSeconds: 0 -- CircuitPanel runs its own inter-exercise + // and inter-round rest, so the global rest timer must not fire. + await confirmSet( + activity.id, + { + loggedActivityId: activity.id, + setNumber: round, + setType: 'WORKING', + completed: true, + actualReps, + }, + 0, + ) + } catch (err) { + console.error('[workout-log] Failed to log circuit set:', err) + setPageError('Failed to save circuit set.') + } + }} + onComplete={() => { + // Per-set logging happens in onExerciseDone; nothing to do here. + }} + /> +
+ ) + } + + return group.activities.map((activity) => { + // Guard: skipped activities never render + if (skippedActivityIds.has(activity.id)) return null + const exercise = exerciseMap[activity.exerciseId] + const modality = getExerciseModality(exercise, group.groupType) + + // Cardio panel + if (modality === 'cardio' && exercise) { + return ( +
+ { + try { + await confirmSet(activity.id, { + loggedActivityId: activity.id, + setNumber: 1, + setType: 'WORKING', + completed: true, + actualDuration: { seconds: data.durationSeconds }, + actualDistance: data.distance + ? { value: parseFloat(data.distance), unit: 'mi' } + : undefined, + actualHeartRate: data.heartRate + ? parseInt(data.heartRate, 10) + : undefined, + }) + } catch (err) { + console.error('[workout-page] cardio confirmSet:', err) + setPageError('Failed to save cardio session.') + } + }} + /> +
+ ) + } + + // Ruck panel + if (modality === 'ruck') { + return ( +
+ { + try { + await confirmSet(activity.id, { + loggedActivityId: activity.id, + setNumber: 1, + setType: 'WORKING', + completed: true, + actualDuration: { seconds: data.durationSeconds }, + actualDistance: data.distance + ? { value: parseFloat(data.distance), unit: 'mi' } + : undefined, + ruckLoad: data.loadWeight + ? { value: parseFloat(data.loadWeight), unit: 'lb' } + : undefined, + elevationGain: data.elevation + ? { value: parseFloat(data.elevation), unit: 'm' } + : undefined, + }) + } catch (err) { + console.error('[workout-page] ruck confirmSet:', err) + setPageError('Failed to save ruck session.') + } + }} + /> +
+ ) + } + + // Standard strength exercise block + const confirmedSets = activity.sets.filter((s) => s.completed) + const lastConfirmedSet = + confirmedSets.length > 0 ? confirmedSets[confirmedSets.length - 1] : undefined + + // Build set row data: confirmed sets + one empty row for the next set + const setRows: SetRowData[] = [ + ...activity.sets.map((set, idx) => ({ + id: set.id, + setNumber: idx + 1, + weight: set.actualWeight?.value?.toString(), + reps: set.actualReps?.toString(), + confirmed: set.completed, + prescribedWeight: set.prescribed?.weight ?? undefined, + prescribedReps: set.prescribed?.reps ?? undefined, + })), + ] + + // Carry-forward prescribed values for the pending input row. + const lastSetWithPrescription = [...activity.sets] + .reverse() + .find((s) => s.prescribed != null) + const nextPrescribedWeight = + lastSetWithPrescription?.prescribed?.weight ?? undefined + const nextPrescribedReps = lastSetWithPrescription?.prescribed?.reps ?? undefined + + // Show a pending input row by default for the first set; subsequent + // rows require an explicit ADD SET tap. Don't add a pending row if + // there's already an unconfirmed (completed: false) set in the list -- + // that happens when the user un-confirms a set. + const hasUncompletedSets = activity.sets.some((s) => !s.completed) + if ( + (confirmedSets.length === 0 && !hasUncompletedSets) || + pendingInputs[activity.id] + ) { + const nextSetNumber = setRows.length + 1 + setRows.push({ + id: `pending-${activity.id}-${nextSetNumber}`, + setNumber: nextSetNumber, + weight: + lastConfirmedSet?.actualWeight?.value?.toString() ?? + nextPrescribedWeight?.value?.toString(), + reps: + lastConfirmedSet?.actualReps?.toString() ?? + (nextPrescribedReps != null ? String(nextPrescribedReps) : undefined), + confirmed: false, + prescribedWeight: nextPrescribedWeight, + prescribedReps: nextPrescribedReps, + isPending: true, + }) + } + + return ( +
+ + setPendingInputs((prev) => ({ ...prev, [activity.id]: true })) + } + onDeleteSet={(setId) => { + if (setId.startsWith('pending-')) { + setPendingInputs((prev) => ({ ...prev, [activity.id]: false })) + } else { + deleteSet(activity.id, setId).catch(() => { + // Hook already logged; surface a user-facing error. + setPageError('Failed to delete set. Please try again.') + }) + } + }} + onUnconfirmSet={handleUnconfirmSet} + onSkipExercise={ + confirmedSets.length > 0 ? () => skipActivity(activity.id) : undefined + } + onRemoveExercise={() => { + removeActivity(activity.id).catch(() => { + // Hook already logged; surface a user-facing error. + setPageError('Failed to remove exercise. Please try again.') + }) + }} + /> +
+ ) + }) + })} +
+ + {/* Sticky footer -- single forward action for free-form workouts. Programmed workouts render no footer (the flow is predetermined). */} - {!isProgrammedWorkout && ( -
- {loggedGroups.length === 0 && !firstWorkoutCompleted && ( - - Tap below to add your first exercise. - + {!isProgrammedWorkout && ( +
+ {loggedGroups.length === 0 && !firstWorkoutCompleted && ( + + Tap below to add your first exercise. + + )} + +
)} - -
- )} - + )} {/* Undo banner (shown when rest timer is NOT active) */} diff --git a/src/stores/__tests__/active-workout-store.test.ts b/src/stores/__tests__/active-workout-store.test.ts index 9b97ea04..f1818591 100644 --- a/src/stores/__tests__/active-workout-store.test.ts +++ b/src/stores/__tests__/active-workout-store.test.ts @@ -552,7 +552,7 @@ describe('notes (F020)', () => { expect(activity.noteTags).toBeUndefined() }) - it('setSetNote updates notes and noteTags on the matching set deep in the tree', () => { + it('note state survives a crash-recovery snapshot round-trip (JSON serialize/deserialize)', () => { const wl = makeWorkoutLog() getState().startWorkout('user-1', wl) useActiveWorkoutStore.setState({ @@ -568,97 +568,249 @@ describe('notes (F020)', () => { }, ], }) + getState().setSessionNote({ text: 'session level', tags: ['FAST'] }) + getState().setActivityNote('la-1', { text: 'activity level', tags: ['SCALED'] }) - getState().setSetNote('ls-1', { text: 'grindy', tags: ['GRINDY'] }) + // Capture the snapshot shape crash-recovery would serialize + const snapshot = { + workoutLog: getState().workoutLog, + loggedGroups: getState().loggedGroups, + elapsedSeconds: getState().elapsedSeconds, + } + const roundTripped = JSON.parse(JSON.stringify(snapshot)) as typeof snapshot - const updatedSet = getState().loggedGroups[0].activities[0].sets[0] - expect(updatedSet.notes).toBe('grindy') - expect(updatedSet.noteTags).toEqual(['GRINDY']) + expect(roundTripped.workoutLog?.overallNotes).toBe('session level') + expect(roundTripped.workoutLog?.noteTags).toEqual(['FAST']) + expect(roundTripped.loggedGroups[0].activities[0].notes).toBe('activity level') + expect(roundTripped.loggedGroups[0].activities[0].noteTags).toEqual(['SCALED']) }) +}) + +// =========================================================================== +// deleteSet (F018 -- PR #109) +// =========================================================================== - it('setSetNote is a no-op for an unknown setId', () => { +describe('deleteSet', () => { + beforeEach(() => { const wl = makeWorkoutLog() - getState().startWorkout('user-1', wl) - useActiveWorkoutStore.setState({ - loggedGroups: [ + const group: LoggedActivityGroupWithActivities = { + ...makeLoggedActivityGroup(), + activities: [ { - ...makeLoggedActivityGroup(), - activities: [ - { - ...makeLoggedActivity(), - sets: [makeLoggedSet()], - }, + ...makeLoggedActivity(), + sets: [ + makeLoggedSet({ id: 'ls-a', setNumber: 1 }), + makeLoggedSet({ id: 'ls-b', setNumber: 2 }), ], }, ], + } + useActiveWorkoutStore.setState({ + workoutLog: wl, + loggedGroups: [group], + elapsedSeconds: 0, + restTimer: null, + undoAction: null, + }) + }) + + it('removes the target set from its activity', () => { + getState().deleteSet('la-1', 'ls-a') + + const sets = getState().loggedGroups[0].activities[0].sets + expect(sets).toHaveLength(1) + expect(sets[0].id).toBe('ls-b') + }) + + it('clears undoAction when undoAction.setId matches the deleted set', () => { + useActiveWorkoutStore.setState({ + undoAction: { + setId: 'ls-a', + loggedActivityId: 'la-1', + expiresAt: Date.now() + 10_000, + }, }) - getState().setSetNote('missing', { text: 'x', tags: [] }) + getState().deleteSet('la-1', 'ls-a') + + expect(getState().undoAction).toBeNull() + }) + + it('does not clear undoAction when setId does not match', () => { + const undoAction = { + setId: 'ls-other', + loggedActivityId: 'la-1', + expiresAt: Date.now() + 10_000, + } + useActiveWorkoutStore.setState({ undoAction }) - const s = getState().loggedGroups[0].activities[0].sets[0] - expect(s.notes).toBeUndefined() + getState().deleteSet('la-1', 'ls-a') + + expect(getState().undoAction).toEqual(undoAction) }) +}) - it('setSetNote rejects over-limit tag array at the boundary', () => { +// =========================================================================== +// unconfirmSet (F018 -- PR #109) +// =========================================================================== + +describe('unconfirmSet', () => { + beforeEach(() => { const wl = makeWorkoutLog() - getState().startWorkout('user-1', wl) - useActiveWorkoutStore.setState({ - loggedGroups: [ + const group: LoggedActivityGroupWithActivities = { + ...makeLoggedActivityGroup(), + activities: [ { - ...makeLoggedActivityGroup(), - activities: [ - { - ...makeLoggedActivity(), - sets: [{ ...makeLoggedSet(), notes: 'untouched' } as LoggedSet], - }, + ...makeLoggedActivity(), + sets: [ + makeLoggedSet({ id: 'ls-a', setNumber: 1, completed: true }), + makeLoggedSet({ id: 'ls-b', setNumber: 2, completed: true }), ], }, ], + } + useActiveWorkoutStore.setState({ + workoutLog: wl, + loggedGroups: [group], + elapsedSeconds: 0, + restTimer: null, + undoAction: null, }) + }) - const tooMany = Array.from({ length: 17 }, (_, i) => `TAG${i}`) - getState().setSetNote('ls-1', { text: 'x', tags: tooMany }) + it('sets completed=false on the target set', () => { + getState().unconfirmSet('la-1', 'ls-a') - // Unchanged - expect(getState().loggedGroups[0].activities[0].sets[0].notes).toBe('untouched') + const sets = getState().loggedGroups[0].activities[0].sets + const target = sets.find((s) => s.id === 'ls-a') + expect(target?.completed).toBe(false) }) - it('note state survives a crash-recovery snapshot round-trip (JSON serialize/deserialize)', () => { + it('does not affect other sets', () => { + getState().unconfirmSet('la-1', 'ls-a') + + const sets = getState().loggedGroups[0].activities[0].sets + const other = sets.find((s) => s.id === 'ls-b') + expect(other?.completed).toBe(true) + }) +}) + +// =========================================================================== +// removeActivity (F018 -- PR #109) +// =========================================================================== + +describe('removeActivity', () => { + beforeEach(() => { const wl = makeWorkoutLog() - getState().startWorkout('user-1', wl) - useActiveWorkoutStore.setState({ - loggedGroups: [ + const group1: LoggedActivityGroupWithActivities = { + ...makeLoggedActivityGroup({ id: 'lag-1' }), + activities: [ { - ...makeLoggedActivityGroup(), - activities: [ - { - ...makeLoggedActivity(), - sets: [makeLoggedSet()], - }, - ], + ...makeLoggedActivity({ id: 'la-1', loggedGroupId: 'lag-1' }), + sets: [makeLoggedSet({ id: 'ls-a', loggedActivityId: 'la-1' })], + }, + ], + } + const group2: LoggedActivityGroupWithActivities = { + ...makeLoggedActivityGroup({ id: 'lag-2', ordinal: 2 }), + activities: [ + { + ...makeLoggedActivity({ id: 'la-2', loggedGroupId: 'lag-2' }), + sets: [makeLoggedSet({ id: 'ls-b', loggedActivityId: 'la-2' })], }, ], + } + useActiveWorkoutStore.setState({ + workoutLog: wl, + loggedGroups: [group1, group2], + elapsedSeconds: 0, + restTimer: null, + undoAction: null, }) - getState().setSessionNote({ text: 'session level', tags: ['FAST'] }) - getState().setActivityNote('la-1', { text: 'activity level', tags: ['SCALED'] }) - getState().setSetNote('ls-1', { text: 'set level', tags: ['GRINDY', 'PAUSED'] }) + }) - // Capture the snapshot shape crash-recovery would serialize - const snapshot = { - workoutLog: getState().workoutLog, - loggedGroups: getState().loggedGroups, - elapsedSeconds: getState().elapsedSeconds, + it('removes the target activity (and empties the group)', () => { + getState().removeActivity('la-1') + + const groups = getState().loggedGroups + // Group with only the removed activity is pruned + expect(groups).toHaveLength(1) + expect(groups[0].id).toBe('lag-2') + expect(groups[0].activities[0].id).toBe('la-2') + }) + + it('clears undoAction when the undo belongs to the removed activity', () => { + useActiveWorkoutStore.setState({ + undoAction: { + setId: 'ls-a', + loggedActivityId: 'la-1', + expiresAt: Date.now() + 10_000, + }, + }) + + getState().removeActivity('la-1') + + expect(getState().undoAction).toBeNull() + }) + + it('does not clear undoAction when the undo belongs to a different activity', () => { + const undoAction = { + setId: 'ls-b', + loggedActivityId: 'la-2', + expiresAt: Date.now() + 10_000, } - const roundTripped = JSON.parse(JSON.stringify(snapshot)) as typeof snapshot + useActiveWorkoutStore.setState({ undoAction }) - expect(roundTripped.workoutLog?.overallNotes).toBe('session level') - expect(roundTripped.workoutLog?.noteTags).toEqual(['FAST']) - expect(roundTripped.loggedGroups[0].activities[0].notes).toBe('activity level') - expect(roundTripped.loggedGroups[0].activities[0].noteTags).toEqual(['SCALED']) - expect(roundTripped.loggedGroups[0].activities[0].sets[0].notes).toBe('set level') - expect(roundTripped.loggedGroups[0].activities[0].sets[0].noteTags).toEqual([ - 'GRINDY', - 'PAUSED', - ]) + getState().removeActivity('la-1') + + expect(getState().undoAction).toEqual(undoAction) + }) +}) + +// =========================================================================== +// skipActivity (F018 -- PR #109) +// =========================================================================== + +describe('skipActivity', () => { + it('adds the activity id to skippedActivityIds', () => { + getState().skipActivity('la-1') + + const { skippedActivityIds } = getState() + expect(skippedActivityIds.has('la-1')).toBe(true) + expect(skippedActivityIds.size).toBe(1) + }) + + it('accumulates multiple skipped activity ids', () => { + getState().skipActivity('la-1') + getState().skipActivity('la-2') + + const { skippedActivityIds } = getState() + expect(skippedActivityIds.size).toBe(2) + expect(skippedActivityIds.has('la-1')).toBe(true) + expect(skippedActivityIds.has('la-2')).toBe(true) + }) + + it('finishWorkout resets skippedActivityIds to a fresh empty Set (no shared reference)', () => { + // Start a workout so finishWorkout has a realistic lifecycle context. + const wl = makeWorkoutLog() + getState().startWorkout('user-1', wl) + + getState().skipActivity('la-1') + getState().skipActivity('la-2') + expect(getState().skippedActivityIds.size).toBe(2) + + // finishWorkout spreads initialState. If initialState.skippedActivityIds + // were a shared reference, it would still hold the mutated entries here. + getState().finishWorkout() + + const { skippedActivityIds } = getState() + expect(skippedActivityIds.size).toBe(0) + expect(skippedActivityIds.has('la-1')).toBe(false) + expect(skippedActivityIds.has('la-2')).toBe(false) + + // Re-skipping after reset should not resurrect the prior entries. + getState().skipActivity('la-3') + expect(getState().skippedActivityIds.size).toBe(1) + expect(getState().skippedActivityIds.has('la-3')).toBe(true) }) }) diff --git a/src/stores/active-workout-store.ts b/src/stores/active-workout-store.ts index 0211e5e2..7d101877 100644 --- a/src/stores/active-workout-store.ts +++ b/src/stores/active-workout-store.ts @@ -68,6 +68,8 @@ interface ActiveWorkoutState { // store's unpauseWorkout action hits the invalid-pausedAt branch (the // store cannot render UI itself). Cleared on successful pause/unpause. pauseTimingError: string | null + + skippedActivityIds: Set } // --------------------------------------------------------------------------- @@ -104,6 +106,7 @@ export function setSnapshotContext(ctx: SnapshotContext | null): void { } function _publishCurrentState(): void { + // Private-mode workouts have no snapshot context -- publishing is intentionally a no-op. if (!_snapshotContext) return const state = useActiveWorkoutStore.getState() if (!state.workoutLog) return @@ -143,10 +146,14 @@ interface ActiveWorkoutActions { newGroup: LoggedActivityGroup, newActivity: LoggedActivity, ): void + skipActivity(activityId: string): void + removeActivity(activityId: string): void // Set management confirmSet(loggedActivityId: string, newSet: LoggedSet): void updateSetInPlace(loggedActivityId: string, updatedSet: LoggedSet): void + deleteSet(loggedActivityId: string, setId: string): void + unconfirmSet(loggedActivityId: string, setId: string): void undoLastSet(): void clearUndo(): void @@ -158,10 +165,9 @@ interface ActiveWorkoutActions { // Elapsed timer setter (owned by the workout log page, see Tech.md D-1) setElapsedSeconds(seconds: number): void - // Notes (F020) -- validate at boundary, store on workoutLog / activity / set + // Notes (F020) -- validate at boundary, store on workoutLog / activity setSessionNote(content: NoteContent): void setActivityNote(activityId: string, content: NoteContent): void - setSetNote(setId: string, content: NoteContent): void // Timer ticks (called by intervals internally) tickRest(): void @@ -181,6 +187,7 @@ const initialState: ActiveWorkoutState = { restTimer: null, undoAction: null, pauseTimingError: null, + skippedActivityIds: new Set(), } export const useActiveWorkoutStore = create()( @@ -234,6 +241,12 @@ export const useActiveWorkoutStore = create ({ + skippedActivityIds: new Set([...state.skippedActivityIds, activityId]), + })) + }, + + removeActivity(activityId: string) { + if (!activityId) { + console.warn('[active-workout] removeActivity called with empty activityId') + return + } + set((state) => ({ + loggedGroups: state.loggedGroups + .map((group) => ({ + ...group, + activities: group.activities.filter((a) => a.id !== activityId), + })) + .filter((group) => group.activities.length > 0), + undoAction: state.undoAction?.loggedActivityId === activityId ? null : state.undoAction, + })) + }, + // ------------------------------------------------------------------ // Set management // ------------------------------------------------------------------ @@ -401,6 +440,44 @@ export const useActiveWorkoutStore = create ({ + loggedGroups: state.loggedGroups.map((group) => ({ + ...group, + activities: group.activities.map((activity) => { + if (activity.id !== loggedActivityId) return activity + return { ...activity, sets: activity.sets.filter((s) => s.id !== setId) } + }), + })), + undoAction: state.undoAction?.setId === setId ? null : state.undoAction, + })) + }, + + unconfirmSet(loggedActivityId: string, setId: string) { + if (!loggedActivityId || !setId) { + console.warn('[active-workout] unconfirmSet called with missing ids') + return + } + set((prev) => ({ + loggedGroups: prev.loggedGroups.map((group) => ({ + ...group, + activities: group.activities.map((activity) => { + if (activity.id !== loggedActivityId) return activity + return { + ...activity, + sets: activity.sets.map((s) => (s.id === setId ? { ...s, completed: false } : s)), + } + }), + })), + undoAction: prev.undoAction?.setId === setId ? null : prev.undoAction, + })) + _publishCurrentState() + }, + clearUndo() { set({ undoAction: null }) }, @@ -572,37 +649,6 @@ export const useActiveWorkoutStore = create ({ - loggedGroups: state.loggedGroups.map((group) => ({ - ...group, - activities: group.activities.map((activity) => ({ - ...activity, - sets: activity.sets.map((s) => { - if (s.id !== setId) return s - found = true - return { - ...s, - notes: parsed.data.text, - noteTags: parsed.data.tags, - } - }), - })), - })), - })) - if (!found) { - console.warn('[active-workout] setSetNote: set not found', setId) - return - } - _publishCurrentState() - }, - tickRest() { const state = get() if (!state.restTimer) return diff --git a/supabase/migrations/20260412000001_drop_set_notes.sql b/supabase/migrations/20260412000001_drop_set_notes.sql new file mode 100644 index 00000000..371741b2 --- /dev/null +++ b/supabase/migrations/20260412000001_drop_set_notes.sql @@ -0,0 +1,11 @@ +-- Remove notes and note_tags from logged_sets. +-- Set-level notes are no longer exposed in the UI. +-- Activity-level and session-level notes are unchanged. + +drop index if exists idx_logged_sets_note_tags; + +alter table logged_sets + drop column if exists note_tags; + +alter table logged_sets + drop column if exists notes;