From fa5e02c4a0b73db6a0af5cc6470b525864d904d3 Mon Sep 17 00:00:00 2001 From: Robert Hamilton Date: Sun, 12 Apr 2026 21:17:09 -0500 Subject: [PATCH 01/16] feat(workout): exercise completion signal for strength blocks Add a "Done with this exercise" affordance so athletes can skip remaining sets on a strength block and advance focus to the next exercise. Also dim the trailing auto-populated set row so its unconfirmed state is visually unambiguous. - active-workout-store: add skippedActivityIds (Set) + skipActivity action; resets automatically on finish/discard via initialState spread - exercise-block: add onSkipExercise prop + DONE button below set list; add isPending to SetRowData; pass isPending to SetRow for pending rows - set-row: add isPending prop -- opacity-40 on row + placeholder in STATUS column instead of confirm button - log.$workoutId: pull skippedActivityIds from store; activeFocusId memo treats skipped activities as done; skip guard on render; wire onSkipExercise with confirmedSets.length > 0 guard; mark trailing row isPending: true --- src/components/workout/exercise-block.tsx | 15 + src/components/workout/set-row.tsx | 13 +- src/routes/_authenticated/log.$workoutId.tsx | 420 ++++++++++--------- src/stores/active-workout-store.ts | 14 + 4 files changed, 258 insertions(+), 204 deletions(-) diff --git a/src/components/workout/exercise-block.tsx b/src/components/workout/exercise-block.tsx index 9c547e99..96cee22d 100644 --- a/src/components/workout/exercise-block.tsx +++ b/src/components/workout/exercise-block.tsx @@ -15,6 +15,7 @@ interface SetRowData { confirmed: boolean prescribedWeight?: { value: number; unit: string } prescribedReps?: number + isPending?: boolean } interface ExerciseBlockProps { @@ -31,6 +32,7 @@ interface ExerciseBlockProps { isConfirming?: boolean isBodyweight?: boolean isActive?: boolean + onSkipExercise?: () => void } export function ExerciseBlock({ @@ -41,6 +43,7 @@ export function ExerciseBlock({ isConfirming = false, isBodyweight = false, isActive = true, + onSkipExercise, }: ExerciseBlockProps) { const firstWorkoutCompleted = useOnboardingStore((s) => s.firstWorkoutCompleted) const hasPrescribed = sets.some((s) => s.prescribedWeight != null || s.prescribedReps != null) @@ -138,12 +141,24 @@ export function ExerciseBlock({ 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) } /> ))} + {onSkipExercise && isActive && ( +
+ +
+ )} ) } diff --git a/src/components/workout/set-row.tsx b/src/components/workout/set-row.tsx index ae9acbc5..13e52ce8 100644 --- a/src/components/workout/set-row.tsx +++ b/src/components/workout/set-row.tsx @@ -5,6 +5,7 @@ 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 { cn } from '@/lib/utils' const SET_TYPES: SetType[] = ['WORKING', 'WARMUP', 'DROP', 'AMRAP', 'PEAK', 'BACKOFF'] @@ -24,6 +25,11 @@ interface SetRowProps { * action. Omitted for unsaved scaffolds. */ loggedSetId?: string + /** + * When true, the row represents an unconfirmed placeholder (the auto-populated + * next-set row). Renders dimmed to distinguish it from confirmed sets. + */ + isPending?: boolean } export function SetRow({ @@ -37,6 +43,7 @@ export function SetRow({ prescribedReps, isBodyweight = false, loggedSetId, + isPending = false, }: SetRowProps) { const setSetNote = useActiveWorkoutStore((s) => s.setSetNote) const storedSet = useActiveWorkoutStore((s) => { @@ -98,7 +105,7 @@ export function SetRow({ : null return ( -
+
{/* Set number + type */}
+
)} - -
- )} - + )} {/* Undo banner (shown when rest timer is NOT active) */} diff --git a/src/stores/active-workout-store.ts b/src/stores/active-workout-store.ts index 0211e5e2..ead6a68d 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 } // --------------------------------------------------------------------------- @@ -143,6 +145,7 @@ interface ActiveWorkoutActions { newGroup: LoggedActivityGroup, newActivity: LoggedActivity, ): void + skipActivity(activityId: string): void // Set management confirmSet(loggedActivityId: string, newSet: LoggedSet): void @@ -181,6 +184,7 @@ const initialState: ActiveWorkoutState = { restTimer: null, undoAction: null, pauseTimingError: null, + skippedActivityIds: new Set(), } export const useActiveWorkoutStore = create()( @@ -327,6 +331,16 @@ export const useActiveWorkoutStore = create ({ + skippedActivityIds: new Set([...state.skippedActivityIds, activityId]), + })) + }, + // ------------------------------------------------------------------ // Set management // ------------------------------------------------------------------ From 11ca6162ab3cb7a34c65cc7b41d0eb1c36dd8d1f Mon Sep 17 00:00:00 2001 From: Robert Hamilton Date: Sun, 12 Apr 2026 22:20:15 -0500 Subject: [PATCH 02/16] fix(workout): show all exercises simultaneously in active workout view Removes the hard-focus filter that prevented non-active exercises from rendering. All exercises now display with their set rows and controls. "Add set" is accessible on every exercise block to support ad-hoc pre-population. "Done/Skip" remains active-only as a progression signal. Closes B007 --- src/components/workout/exercise-block.tsx | 62 +++++++++++++++----- src/routes/_authenticated/log.$workoutId.tsx | 60 ++++++++++++------- 2 files changed, 84 insertions(+), 38 deletions(-) diff --git a/src/components/workout/exercise-block.tsx b/src/components/workout/exercise-block.tsx index 96cee22d..77902645 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' @@ -33,6 +34,9 @@ interface ExerciseBlockProps { isBodyweight?: boolean isActive?: boolean onSkipExercise?: () => void + onAddSet?: () => void + onDeleteSet?: (setId: string) => void + onRemoveExercise?: () => void } export function ExerciseBlock({ @@ -44,6 +48,9 @@ export function ExerciseBlock({ isBodyweight = false, isActive = true, onSkipExercise, + onAddSet, + onDeleteSet, + onRemoveExercise, }: ExerciseBlockProps) { const firstWorkoutCompleted = useOnboardingStore((s) => s.firstWorkoutCompleted) const hasPrescribed = sets.some((s) => s.prescribedWeight != null || s.prescribedReps != null) @@ -80,12 +87,24 @@ export function ExerciseBlock({ > {exerciseName} - setActivityNote(loggedActivityId, next)} - level="exercise" - variant="inline" - /> +
+ setActivityNote(loggedActivityId, next)} + level="exercise" + variant="inline" + /> + {onRemoveExercise && ( + + )} +
{/* Column headers */} @@ -140,23 +159,34 @@ 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} /> ))} - {onSkipExercise && isActive && ( -
- + {(onAddSet || (isActive && onSkipExercise)) && ( +
+ {onAddSet && ( + + )} + {isActive && onSkipExercise && ( + + )}
)} diff --git a/src/routes/_authenticated/log.$workoutId.tsx b/src/routes/_authenticated/log.$workoutId.tsx index 5c751700..707333ce 100644 --- a/src/routes/_authenticated/log.$workoutId.tsx +++ b/src/routes/_authenticated/log.$workoutId.tsx @@ -74,6 +74,8 @@ function ActiveWorkoutPage() { } = useActiveWorkout() const skipActivity = useActiveWorkoutStore((s) => s.skipActivity) + const deleteSet = useActiveWorkoutStore((s) => s.deleteSet) + const removeActivity = useActiveWorkoutStore((s) => s.removeActivity) const skippedActivityIds = useActiveWorkoutStore((s) => s.skippedActivityIds) const { markFirstWorkoutCompleted } = useOnboarding() @@ -133,6 +135,8 @@ 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>({}) // Pause/resume derived state and handlers. // Pause UI is hidden on the Tauri (mobile) adapter because pause-state @@ -261,8 +265,8 @@ function ActiveWorkoutPage() { if (incomplete) return group.id } else { for (const activity of group.activities) { - lastId = activity.id 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 } @@ -383,6 +387,7 @@ function ActiveWorkoutPage() { actualWeight: weightValue ? { value: weightValue, unit } : undefined, actualReps: repsValue ? Math.round(repsValue) : undefined, }) + setPendingInputs((prev) => ({ ...prev, [loggedActivityId]: false })) } catch (err) { console.error('[workout-page] handleConfirmSet:', err) setPageError('Failed to save set.') @@ -598,8 +603,6 @@ function ActiveWorkoutPage() { return group.activities.map((activity) => { // Guard: skipped activities never render if (skippedActivityIds.has(activity.id)) return null - // 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) @@ -685,9 +688,7 @@ function ActiveWorkoutPage() { })), ] - // 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. + // Carry-forward prescribed values for the pending input row. const lastSetWithPrescription = [...activity.sets] .reverse() .find((s) => s.prescribed != null) @@ -695,21 +696,25 @@ function ActiveWorkoutPage() { 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, - isPending: true, - }) + // Show a pending input row by default for the first set; subsequent + // rows require an explicit ADD SET tap. + if (confirmedSets.length === 0 || 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 (
@@ -720,10 +725,21 @@ function ActiveWorkoutPage() { onConfirmSet={handleConfirmSet} isConfirming={isConfirmingSet} isBodyweight={exercise?.category === 'BODYWEIGHT'} - isActive={true} + isActive={activity.id === activeFocusId} + onAddSet={() => + setPendingInputs((prev) => ({ ...prev, [activity.id]: true })) + } + onDeleteSet={(setId) => { + if (setId.startsWith('pending-')) { + setPendingInputs((prev) => ({ ...prev, [activity.id]: false })) + } else { + deleteSet(activity.id, setId) + } + }} onSkipExercise={ confirmedSets.length > 0 ? () => skipActivity(activity.id) : undefined } + onRemoveExercise={() => removeActivity(activity.id)} />
) From 4ceceda18791d0c1ffc0523a4858cec7a107bdaa Mon Sep 17 00:00:00 2001 From: Robert Hamilton Date: Sun, 12 Apr 2026 22:24:11 -0500 Subject: [PATCH 03/16] feat(workout): swipe-to-delete sets, remove set-level notes - SetRow gains left-swipe gesture to reveal a DEL button (64 px threshold); snaps open when threshold is reached, snaps closed on partial swipe or tap - Adds deleteSet and removeActivity actions to active-workout-store - Removes set-level notes (notes / noteTags) from the LoggedSet domain type, both adapters (Supabase + Tauri), and the store's setSetNote action - Drops loggedSetId prop and NoteAffordance from SetRow; removes set-note display from WorkoutDetailExercises and ManualWorkoutForm - Adds Supabase + SQLite migrations to DROP notes / note_tags from logged_sets --- src-tauri/migrations/015_drop_set_notes.sql | 6 + .../history/workout-detail-exercises.tsx | 8 - .../workout/manual-workout-form.tsx | 4 - .../notes/__tests__/note-integration.test.tsx | 120 ------ .../workout/notes/note-affordance.tsx | 2 +- src/components/workout/set-row.tsx | 373 +++++++++--------- src/domain/types/workout-log.ts | 2 - src/lib/__tests__/supabase-adapter.test.ts | 2 - src/lib/__tests__/tauri-adapter.test.ts | 1 - src/lib/database.types.ts | 2 - src/lib/supabase-adapter.ts | 9 - src/lib/tauri-adapter.ts | 13 - .../__tests__/active-workout-store.test.ts | 77 ---- src/stores/active-workout-store.ts | 69 ++-- .../20260412000001_drop_set_notes.sql | 11 + 15 files changed, 250 insertions(+), 449 deletions(-) create mode 100644 src-tauri/migrations/015_drop_set_notes.sql delete mode 100644 src/components/workout/notes/__tests__/note-integration.test.tsx create mode 100644 supabase/migrations/20260412000001_drop_set_notes.sql 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/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 } +const SWIPE_THRESHOLD = 64 // px -- full reveal width + export function SetRow({ setNumber, initialWeight = '', @@ -42,25 +41,9 @@ export function SetRow({ prescribedWeight, prescribedReps, isBodyweight = false, - loggedSetId, isPending = false, + onDelete, }: 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) @@ -68,6 +51,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) @@ -105,179 +93,210 @@ 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 */} -
- {isPending ? ( - -- - ) : confirmed ? ( - variance === 'met' ? ( - - ) : variance === 'under' ? ( - - ) : ( - DONE - ) - ) : ( - - )} + 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/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/stores/__tests__/active-workout-store.test.ts b/src/stores/__tests__/active-workout-store.test.ts index 9b97ea04..a7eae558 100644 --- a/src/stores/__tests__/active-workout-store.test.ts +++ b/src/stores/__tests__/active-workout-store.test.ts @@ -552,77 +552,6 @@ describe('notes (F020)', () => { expect(activity.noteTags).toBeUndefined() }) - it('setSetNote updates notes and noteTags on the matching set deep in the tree', () => { - const wl = makeWorkoutLog() - getState().startWorkout('user-1', wl) - useActiveWorkoutStore.setState({ - loggedGroups: [ - { - ...makeLoggedActivityGroup(), - activities: [ - { - ...makeLoggedActivity(), - sets: [makeLoggedSet()], - }, - ], - }, - ], - }) - - getState().setSetNote('ls-1', { text: 'grindy', tags: ['GRINDY'] }) - - const updatedSet = getState().loggedGroups[0].activities[0].sets[0] - expect(updatedSet.notes).toBe('grindy') - expect(updatedSet.noteTags).toEqual(['GRINDY']) - }) - - it('setSetNote is a no-op for an unknown setId', () => { - const wl = makeWorkoutLog() - getState().startWorkout('user-1', wl) - useActiveWorkoutStore.setState({ - loggedGroups: [ - { - ...makeLoggedActivityGroup(), - activities: [ - { - ...makeLoggedActivity(), - sets: [makeLoggedSet()], - }, - ], - }, - ], - }) - - getState().setSetNote('missing', { text: 'x', tags: [] }) - - const s = getState().loggedGroups[0].activities[0].sets[0] - expect(s.notes).toBeUndefined() - }) - - it('setSetNote rejects over-limit tag array at the boundary', () => { - const wl = makeWorkoutLog() - getState().startWorkout('user-1', wl) - useActiveWorkoutStore.setState({ - loggedGroups: [ - { - ...makeLoggedActivityGroup(), - activities: [ - { - ...makeLoggedActivity(), - sets: [{ ...makeLoggedSet(), notes: 'untouched' } as LoggedSet], - }, - ], - }, - ], - }) - - const tooMany = Array.from({ length: 17 }, (_, i) => `TAG${i}`) - getState().setSetNote('ls-1', { text: 'x', tags: tooMany }) - - // Unchanged - expect(getState().loggedGroups[0].activities[0].sets[0].notes).toBe('untouched') - }) - it('note state survives a crash-recovery snapshot round-trip (JSON serialize/deserialize)', () => { const wl = makeWorkoutLog() getState().startWorkout('user-1', wl) @@ -641,7 +570,6 @@ describe('notes (F020)', () => { }) 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 = { @@ -655,10 +583,5 @@ describe('notes (F020)', () => { 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', - ]) }) }) diff --git a/src/stores/active-workout-store.ts b/src/stores/active-workout-store.ts index ead6a68d..cd9e6b39 100644 --- a/src/stores/active-workout-store.ts +++ b/src/stores/active-workout-store.ts @@ -146,10 +146,12 @@ interface ActiveWorkoutActions { 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 undoLastSet(): void clearUndo(): void @@ -161,10 +163,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 @@ -341,6 +342,22 @@ export const useActiveWorkoutStore = create ({ + 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 // ------------------------------------------------------------------ @@ -415,6 +432,23 @@ 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, + })) + }, + clearUndo() { set({ undoAction: null }) }, @@ -586,37 +620,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; From 67b356661c495ae8d3e16c985b983125bf9348c7 Mon Sep 17 00:00:00 2001 From: Robert Hamilton Date: Sun, 12 Apr 2026 22:24:16 -0500 Subject: [PATCH 04/16] chore(meta): add coding guidelines to CLAUDE.md, update commands and backlog - Adds Coding Guidelines section to CLAUDE.md (5 rules: think, read, simplify, surgical changes, goal-driven execution) - /commit command gains Step 4: QuickPlan archive prompt after each commit - /quick command Step 5: enforce archive move instead of suggesting it - Backlog: adds B007 (multi-exercise display), B008 (gym selector persistence), and "frequently used exercises" idea --- .claude/commands/commit.md | 17 +++++++--- .claude/commands/quick.md | 9 ++++-- CLAUDE.md | 64 ++++++++++++++++++++++++++++++++++++++ Context/Backlog/Bugs.md | 12 +++++++ Context/Backlog/Ideas.md | 7 +++++ 5 files changed, 102 insertions(+), 7 deletions(-) diff --git a/.claude/commands/commit.md b/.claude/commands/commit.md index 107f6cc3..7e4825c1 100755 --- a/.claude/commands/commit.md +++ b/.claude/commands/commit.md @@ -1,8 +1,8 @@ ---- -description: Git commit with conventional format and task references -model: sonnet ---- - +--- +description: Git commit with conventional format and task references +model: sonnet +--- + # Commit Changes Prepare a commit message following conventional commits format. References tasks and ADRs from planning artifacts. @@ -56,3 +56,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/quick.md b/.claude/commands/quick.md index 42a7b4f7..8da5ba3b 100755 --- a/.claude/commands/quick.md +++ b/.claude/commands/quick.md @@ -53,5 +53,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.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..b2364914 100644 --- a/Context/Backlog/Bugs.md +++ b/Context/Backlog/Bugs.md @@ -35,3 +35,15 @@ 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 +**Added:** 2026-04-12 +**Context:** When logging a workout with multiple exercises, only one exercise is visible in the active workout view. Additionally, exercises should support being repeated (same exercise appearing more than once) and deleted individually during an active workout session. +**Related:** `src/routes/_authenticated/log.$workoutId.tsx`, `src/stores/active-workout-store.ts`, `src/components/workout/exercise-block.tsx` +**Priority:** High 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. From e9090def326b2ff64c09bb6c9f4f9e52392499b5 Mon Sep 17 00:00:00 2001 From: Robert Hamilton Date: Sun, 12 Apr 2026 22:25:08 -0500 Subject: [PATCH 05/16] chore(meta): archive completed QuickPlans for 2026-04-12 --- .../2026-04-12-undo-set-done-status.md | 91 +++++++ .../2026-04-12-b007-multi-exercise-display.md | 63 +++++ .../2026-04-12-exercise-completion-signal.md | 150 +++++++++++ .../2026-04-12-remove-set-note-affordance.md | 21 ++ .../Done/2026-04-12-set-add-swipe-delete.md | 249 ++++++++++++++++++ 5 files changed, 574 insertions(+) create mode 100644 Context/QuickPlans/2026-04-12-undo-set-done-status.md create mode 100644 Context/QuickPlans/Done/2026-04-12-b007-multi-exercise-display.md create mode 100644 Context/QuickPlans/Done/2026-04-12-exercise-completion-signal.md create mode 100644 Context/QuickPlans/Done/2026-04-12-remove-set-note-affordance.md create mode 100644 Context/QuickPlans/Done/2026-04-12-set-add-swipe-delete.md diff --git a/Context/QuickPlans/2026-04-12-undo-set-done-status.md b/Context/QuickPlans/2026-04-12-undo-set-done-status.md new file mode 100644 index 00000000..f7f87ac7 --- /dev/null +++ b/Context/QuickPlans/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-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-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. From e7e997a07aeae805c4ec6417fae48088148efa0a Mon Sep 17 00:00:00 2001 From: Robert Hamilton Date: Sun, 12 Apr 2026 22:50:52 -0500 Subject: [PATCH 06/16] feat(workout): checkbox UX for set confirm/unconfirm Replace the confirm checkmark + done badge/icon trio with a unified checkbox metaphor. Empty checkbox (ember) to confirm; filled checkbox to unconfirm. Variance coloring is preserved via the filled checkbox: green for met, amber for under, neutral for no prescription. Unconfirming a set now marks it completed=false (updateLoggedSet) rather than deleting it, so actual weight and reps are preserved in the row for re-editing. --- .../workout/__tests__/exercise-block.test.tsx | 32 +++++++++++++++ .../workout/__tests__/set-row.test.tsx | 16 ++++++++ src/components/workout/exercise-block.tsx | 7 ++++ src/components/workout/set-row.tsx | 30 +++++++++----- src/hooks/use-active-workout.ts | 39 +++++++++++++++++++ src/routes/_authenticated/log.$workoutId.tsx | 14 +++++++ src/stores/active-workout-store.ts | 24 ++++++++++++ 7 files changed, 153 insertions(+), 9 deletions(-) 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__/set-row.test.tsx b/src/components/workout/__tests__/set-row.test.tsx index b7a5b22a..673210eb 100644 --- a/src/components/workout/__tests__/set-row.test.tsx +++ b/src/components/workout/__tests__/set-row.test.tsx @@ -87,6 +87,22 @@ describe('SetRow', () => { 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 diff --git a/src/components/workout/exercise-block.tsx b/src/components/workout/exercise-block.tsx index 77902645..fba9e81c 100644 --- a/src/components/workout/exercise-block.tsx +++ b/src/components/workout/exercise-block.tsx @@ -36,6 +36,7 @@ interface ExerciseBlockProps { onSkipExercise?: () => void onAddSet?: () => void onDeleteSet?: (setId: string) => void + onUnconfirmSet?: (loggedActivityId: string, setId: string) => void onRemoveExercise?: () => void } @@ -50,6 +51,7 @@ export function ExerciseBlock({ onSkipExercise, onAddSet, onDeleteSet, + onUnconfirmSet, onRemoveExercise, }: ExerciseBlockProps) { const firstWorkoutCompleted = useOnboardingStore((s) => s.firstWorkoutCompleted) @@ -164,6 +166,11 @@ export function ExerciseBlock({ onConfirmSet(loggedActivityId, set.setNumber, weight, reps, setType) } onDelete={onDeleteSet ? () => onDeleteSet(set.id) : undefined} + onUnconfirm={ + set.confirmed && onUnconfirmSet + ? () => onUnconfirmSet(loggedActivityId, set.id) + : undefined + } /> ))} diff --git a/src/components/workout/set-row.tsx b/src/components/workout/set-row.tsx index 2b0d7c0d..ba6b8783 100644 --- a/src/components/workout/set-row.tsx +++ b/src/components/workout/set-row.tsx @@ -1,5 +1,4 @@ import { useState, useCallback, useEffect, useRef } from 'react' -import { Badge } from '@/components/ui/badge' import { Icon } from '@/components/icon' import { computeVariance } from '@/lib/set-variance' import type { SetType } from '@/domain/types' @@ -27,6 +26,11 @@ interface SetRowProps { * 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 @@ -43,6 +47,7 @@ export function SetRow({ isBodyweight = false, isPending = false, onDelete, + onUnconfirm, }: SetRowProps) { const hasPrescription = prescribedWeight != null || prescribedReps != null @@ -270,13 +275,20 @@ export function SetRow({ {/* Confirm / Status */}
{confirmed ? ( - variance === 'met' ? ( - - ) : variance === 'under' ? ( - - ) : ( - DONE - ) + ) : ( )} diff --git a/src/hooks/use-active-workout.ts b/src/hooks/use-active-workout.ts index 491d2a01..585a966f 100644 --- a/src/hooks/use-active-workout.ts +++ b/src/hooks/use-active-workout.ts @@ -56,6 +56,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) @@ -450,6 +451,43 @@ export function useActiveWorkout() { } }, [workoutLog, undoAction, updateLoggedSetMutation, storeUndoLastSet]) + /** + * 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) => { + try { + 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 + } + await updateLoggedSetMutation.mutateAsync({ + ...targetSet, + completed: false, + workoutLogId: workoutLog.id, + userId: workoutLog.userId, + }) + storeUnconfirmSet(loggedActivityId, setId) + } catch (err) { + console.error('[workout] Failed to unconfirm set:', { + workoutId: workoutLog?.id, + loggedActivityId, + setId, + err, + }) + throw err + } + }, + [workoutLog, updateLoggedSetMutation, storeUnconfirmSet], + ) + /** * Finish the active workout. Updates WorkoutLog.completedAt in DB, * advances program position if this was a programmed workout, then clears store. @@ -659,6 +697,7 @@ export function useActiveWorkout() { startProgrammedWorkout, addExercise, confirmSet, + unconfirmSet, undoSet, finishWorkout, resumeWorkout, diff --git a/src/routes/_authenticated/log.$workoutId.tsx b/src/routes/_authenticated/log.$workoutId.tsx index 707333ce..136f8056 100644 --- a/src/routes/_authenticated/log.$workoutId.tsx +++ b/src/routes/_authenticated/log.$workoutId.tsx @@ -60,6 +60,7 @@ function ActiveWorkoutPage() { restTimer, undoAction, confirmSet, + unconfirmSet, undoSet, finishWorkout, discardWorkout, @@ -405,6 +406,18 @@ function ActiveWorkoutPage() { } }, [undoSet]) + const handleUnconfirmSet = useCallback( + async (loggedActivityId: string, setId: string) => { + try { + await unconfirmSet(loggedActivityId, setId) + } catch (err) { + console.error('[workout-page] handleUnconfirmSet:', err) + setPageError('Failed to undo set.') + } + }, + [unconfirmSet], + ) + const handleSummaryDone = useCallback(() => { setShowSummary(false) setSummaryData(null) @@ -736,6 +749,7 @@ function ActiveWorkoutPage() { deleteSet(activity.id, setId) } }} + onUnconfirmSet={handleUnconfirmSet} onSkipExercise={ confirmedSets.length > 0 ? () => skipActivity(activity.id) : undefined } diff --git a/src/stores/active-workout-store.ts b/src/stores/active-workout-store.ts index cd9e6b39..ca07f5b8 100644 --- a/src/stores/active-workout-store.ts +++ b/src/stores/active-workout-store.ts @@ -152,6 +152,7 @@ interface ActiveWorkoutActions { 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 @@ -449,6 +450,29 @@ export const useActiveWorkoutStore = create ({ + 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 }) }, From 4d37c5cec030f4c46d3aef2c1ed4ac6ad68d4a63 Mon Sep 17 00:00:00 2001 From: Robert Hamilton Date: Sun, 12 Apr 2026 23:03:10 -0500 Subject: [PATCH 07/16] fix(workout): correct set confirm/unconfirm UX bugs - Require reps on all confirms (weight optional): change confirm button disabled condition from weight||reps to reps-only gate - Fix reps=0 coercing to null: parse reps with >= 0 (not > 0) and use non-null check instead of falsy check, preventing a logged_sets_completed_check constraint violation for failed sets - Suppress spurious pending row on unconfirm: guard pending row insertion with hasUncompletedSets so un-confirming a set doesn't produce a duplicate editable row alongside the existing one --- src/components/workout/set-row.tsx | 6 +----- src/routes/_authenticated/log.$workoutId.tsx | 15 +++++++++++---- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/src/components/workout/set-row.tsx b/src/components/workout/set-row.tsx index ba6b8783..06d54f21 100644 --- a/src/components/workout/set-row.tsx +++ b/src/components/workout/set-row.tsx @@ -293,11 +293,7 @@ export function SetRow({ + )} +
{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/routes/_authenticated/log.$workoutId.tsx b/src/routes/_authenticated/log.$workoutId.tsx index fe21c5e1..11b71b44 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' @@ -138,6 +139,8 @@ function ActiveWorkoutPage() { 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 @@ -178,6 +181,11 @@ function ActiveWorkoutPage() { } }, [isActive, showSummary, navigate]) + // Reset minimize state when rest timer ends so the next rest starts expanded + useEffect(() => { + if (!restTimer) setRestMinimized(false) + }, [restTimer]) + // --------------------------------------------------------------------------- // Elapsed timer ownership (Tech.md D-1) // @@ -558,19 +566,30 @@ 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)} + /> + )} + + {/* REST mode (minimized): compact sticky banner with expand + skip. */} + {restTimer && restMinimized && ( + setRestMinimized(false)} + onSkip={skipRest} /> )} {/* SET mode: only the active exercise block renders (hard focus). */} - {!restTimer && ( + {(!restTimer || restMinimized) && ( <>
{loggedGroups.map((group) => { From f040f2a50c96bd1689c0c2c0aeab4dd9de63cf79 Mon Sep 17 00:00:00 2001 From: Robert Hamilton Date: Mon, 13 Apr 2026 09:39:55 -0500 Subject: [PATCH 10/16] chore(meta): update commands, archive QuickPlans, update bugs backlog - Update .claude/commands/* to latest skill wrappers; add spec-init command - Mark B008 resolved in Bugs.md - Archive completed QuickPlans: undo-set-done-status, b008-gym-picker-every-start --- .claude/commands/adr.md | 1 + .claude/commands/backlog.md | 11 +- .claude/commands/blueprint.md | 1 + .claude/commands/commit.md | 1 + .claude/commands/diagnose.md | 1 + .claude/commands/impl.md | 22 ++-- .claude/commands/qa.md | 11 +- .claude/commands/quick.md | 1 + .claude/commands/review-capture.md | 11 +- .claude/commands/review-resolve.md | 1 + .claude/commands/review-verify.md | 1 + .claude/commands/security-scan.md | 1 + .claude/commands/setup.md | 1 + .claude/commands/spec-init.md | 117 ++++++++++++++++++ .claude/commands/team-impl.md | 1 + Context/Backlog/Bugs.md | 7 +- .../2026-04-12-undo-set-done-status.md | 0 .../2026-04-13-b008-gym-picker-every-start.md | 83 +++++++++++++ 18 files changed, 244 insertions(+), 28 deletions(-) create mode 100644 .claude/commands/spec-init.md rename Context/QuickPlans/{ => Done}/2026-04-12-undo-set-done-status.md (100%) create mode 100644 Context/QuickPlans/Done/2026-04-13-b008-gym-picker-every-start.md 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 7e4825c1..2de4f375 100755 --- a/.claude/commands/commit.md +++ b/.claude/commands/commit.md @@ -1,6 +1,7 @@ --- description: Git commit with conventional format and task references model: sonnet +effort: low --- # Commit Changes 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 8da5ba3b..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 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/Context/Backlog/Bugs.md b/Context/Backlog/Bugs.md index b2364914..68e9bd8d 100644 --- a/Context/Backlog/Bugs.md +++ b/Context/Backlog/Bugs.md @@ -42,8 +42,7 @@ High-priority bugs should be addressed before starting new features. **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 +### ~~B007: Active workout only shows one exercise despite multiple being added~~ (Resolved 2026-04-13) + **Added:** 2026-04-12 -**Context:** When logging a workout with multiple exercises, only one exercise is visible in the active workout view. Additionally, exercises should support being repeated (same exercise appearing more than once) and deleted individually during an active workout session. -**Related:** `src/routes/_authenticated/log.$workoutId.tsx`, `src/stores/active-workout-store.ts`, `src/components/workout/exercise-block.tsx` -**Priority:** High +**Resolution:** Fixed in `11ca616` -- show all exercises simultaneously in active workout view. diff --git a/Context/QuickPlans/2026-04-12-undo-set-done-status.md b/Context/QuickPlans/Done/2026-04-12-undo-set-done-status.md similarity index 100% rename from Context/QuickPlans/2026-04-12-undo-set-done-status.md rename to Context/QuickPlans/Done/2026-04-12-undo-set-done-status.md 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. From 55c19e2446dc723ff2a320065f26336854be757e Mon Sep 17 00:00:00 2001 From: Robert Hamilton Date: Mon, 13 Apr 2026 09:41:03 -0500 Subject: [PATCH 11/16] chore(meta): archive minimize-rest-timer QuickPlan --- .../Done/2026-04-12-minimize-rest-timer.md | 92 +++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 Context/QuickPlans/Done/2026-04-12-minimize-rest-timer.md 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. From 969fe04ee87b2b54626427abd883679b89b3dd9c Mon Sep 17 00:00:00 2001 From: Robert Hamilton Date: Mon, 13 Apr 2026 20:50:28 -0500 Subject: [PATCH 12/16] fix(review): resolve PR #109 fix findings P18-001 through P18-014 P18-001/002: add coordinated deleteSet and removeActivity bridge functions in use-active-workout.ts that await DB deletion before updating the Zustand store; wire onDeleteSet and onRemoveExercise through them in log.$workoutId.tsx so swipe-delete and exercise removal are now persisted P18-003: add [workout-logs] prefixed onError handlers to useDeleteLoggedSet, useDeleteLoggedActivity, and useDeleteLoggedActivityGroup in use-workout-logs.ts P18-004: update set-row.test.tsx confirmed-state assertion from getByText('DONE') to getByLabelText('Undo set 1') to match the checkbox icon button introduced in this PR P18-005: remove duplicate console.error calls from unconfirmSet catch in use-active-workout.ts and handleUnconfirmSet catch in log.$workoutId.tsx -- mutation hook onError is the single log owner P18-006: handleFinish and handleConfirmSet guard clauses now log [workout-page] prefix and call setPageError instead of silently returning P18-007: add setIsSwiping(false) alongside setSwipeX(0) in DEL button onClick in set-row.tsx P18-008: move setStartingTemplateId inside try/finally in library.tsx so the spinner always clears even if openGymPicker throws P18-009: call storeClearUndo() in undoSet catch block so the expiry timer does not silently clear an already-failed undo P18-013/014: add inline comments to active-workout-store.ts explaining the private-mode no-op and ephemeral field reset Review: Context/Reviews/0018-pr109-workout-set-ux-2026-04-13.md --- .../workout/__tests__/set-row.test.tsx | 4 +- src/components/workout/set-row.tsx | 1 + src/hooks/use-active-workout.ts | 80 +++++++++++++++++-- src/hooks/use-workout-logs.ts | 21 +++++ src/routes/_authenticated/library.tsx | 11 ++- src/routes/_authenticated/log.$workoutId.tsx | 38 ++++++--- src/stores/active-workout-store.ts | 11 ++- 7 files changed, 139 insertions(+), 27 deletions(-) diff --git a/src/components/workout/__tests__/set-row.test.tsx b/src/components/workout/__tests__/set-row.test.tsx index 673210eb..a6d0d5be 100644 --- a/src/components/workout/__tests__/set-row.test.tsx +++ b/src/components/workout/__tests__/set-row.test.tsx @@ -80,8 +80,8 @@ 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() diff --git a/src/components/workout/set-row.tsx b/src/components/workout/set-row.tsx index 06d54f21..e3b7a3ba 100644 --- a/src/components/workout/set-row.tsx +++ b/src/components/workout/set-row.tsx @@ -105,6 +105,7 @@ export function SetRow({ onClick={() => { onDelete() setSwipeX(0) + setIsSwiping(false) }} style={{ transform: `translateX(${SWIPE_THRESHOLD - swipeX}px)`, diff --git a/src/hooks/use-active-workout.ts b/src/hooks/use-active-workout.ts index 585a966f..2edd8945 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' @@ -66,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) @@ -82,6 +86,8 @@ export function useActiveWorkout() { const updateLoggedSetMutation = useUpdateLoggedSet() const updateWorkoutLogMutation = useUpdateWorkoutLog() const deleteWorkoutLogMutation = useDeleteWorkoutLog() + const deleteLoggedSetMutation = useDeleteLoggedSet() + const deleteLoggedActivityMutation = useDeleteLoggedActivity() // --------------------------------------------------------------------------- // Undo expiry timer @@ -442,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, @@ -449,7 +458,7 @@ 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 @@ -476,12 +485,10 @@ export function useActiveWorkout() { }) storeUnconfirmSet(loggedActivityId, setId) } catch (err) { - console.error('[workout] Failed to unconfirm set:', { - workoutId: workoutLog?.id, - loggedActivityId, - setId, - err, - }) + // The mutation hook's onError is the single log owner for this + // rejection (see error-handling.md "Mutation Hook Log Ownership"). + // Re-throw so the calling component can surface a user-facing error + // state without re-logging. throw err } }, @@ -655,6 +662,63 @@ 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) => { + try { + if (!workoutLog) { + throw new Error('No active workout to delete set in') + } + await deleteLoggedSetMutation.mutateAsync({ + id: setId, + workoutLogId: workoutLog.id, + }) + storeDeleteSet(loggedActivityId, setId) + } catch (err) { + // Hook owns the log; re-throw so the calling component can surface + // a user-facing error without duplicating the log line. + throw err + } + }, + [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) => { + try { + if (!workoutLog) { + throw new Error('No active workout to remove activity from') + } + await deleteLoggedActivityMutation.mutateAsync({ + id: activityId, + workoutLogId: workoutLog.id, + }) + storeRemoveActivity(activityId) + } catch (err) { + // Hook owns the log; re-throw so the calling component can surface + // a user-facing error without duplicating the log line. + throw err + } + }, + [workoutLog, deleteLoggedActivityMutation, storeRemoveActivity], + ) + /** * Discard the active workout. Deletes the WorkoutLog from DB, clears store. */ @@ -699,6 +763,8 @@ export function useActiveWorkout() { 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/routes/_authenticated/library.tsx b/src/routes/_authenticated/library.tsx index 6e95192e..1ddf0fe8 100644 --- a/src/routes/_authenticated/library.tsx +++ b/src/routes/_authenticated/library.tsx @@ -161,13 +161,12 @@ function LibraryPage() { setStartingTemplateId(templateId) - const choice = readLastGymChoice() ?? (await openGymPicker({ userId })) - if (choice === null) { - setStartingTemplateId(null) - return - } - try { + const choice = readLastGymChoice() ?? (await openGymPicker({ userId })) + if (choice === null) { + return + } + 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 11b71b44..684c9e39 100644 --- a/src/routes/_authenticated/log.$workoutId.tsx +++ b/src/routes/_authenticated/log.$workoutId.tsx @@ -63,6 +63,8 @@ function ActiveWorkoutPage() { confirmSet, unconfirmSet, undoSet, + deleteSet, + removeActivity, finishWorkout, discardWorkout, pauseWorkout, @@ -76,8 +78,6 @@ function ActiveWorkoutPage() { } = useActiveWorkout() const skipActivity = useActiveWorkoutStore((s) => s.skipActivity) - const deleteSet = useActiveWorkoutStore((s) => s.deleteSet) - const removeActivity = useActiveWorkoutStore((s) => s.removeActivity) const skippedActivityIds = useActiveWorkoutStore((s) => s.skippedActivityIds) const { markFirstWorkoutCompleted } = useOnboarding() @@ -290,7 +290,11 @@ function ActiveWorkoutPage() { // ----------------------------------------------------------------------- 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 }, @@ -375,7 +379,11 @@ 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) // Reps support 0 (failed sets), so use >= 0 check rather than the > 0 @@ -422,8 +430,9 @@ function ActiveWorkoutPage() { async (loggedActivityId: string, setId: string) => { try { await unconfirmSet(loggedActivityId, setId) - } catch (err) { - console.error('[workout-page] handleUnconfirmSet:', err) + } 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.') } }, @@ -737,7 +746,10 @@ function ActiveWorkoutPage() { // 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]) { + if ( + (confirmedSets.length === 0 && !hasUncompletedSets) || + pendingInputs[activity.id] + ) { const nextSetNumber = setRows.length + 1 setRows.push({ id: `pending-${activity.id}-${nextSetNumber}`, @@ -772,14 +784,22 @@ function ActiveWorkoutPage() { if (setId.startsWith('pending-')) { setPendingInputs((prev) => ({ ...prev, [activity.id]: false })) } else { - deleteSet(activity.id, setId) + 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)} + onRemoveExercise={() => { + removeActivity(activity.id).catch(() => { + // Hook already logged; surface a user-facing error. + setPageError('Failed to remove exercise. Please try again.') + }) + }} />
) diff --git a/src/stores/active-workout-store.ts b/src/stores/active-workout-store.ts index ca07f5b8..7d101877 100644 --- a/src/stores/active-workout-store.ts +++ b/src/stores/active-workout-store.ts @@ -106,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 @@ -240,6 +241,12 @@ export const useActiveWorkoutStore = create - s.id === setId ? { ...s, completed: false } : s, - ), + sets: activity.sets.map((s) => (s.id === setId ? { ...s, completed: false } : s)), } }), })), From fe892732741d59e6eb54f94acbffdc3f214679a3 Mon Sep 17 00:00:00 2001 From: Robert Hamilton Date: Mon, 13 Apr 2026 20:50:37 -0500 Subject: [PATCH 13/16] chore(meta): add P18 task findings to Steps.md, mark review resolved Add Wave 7 to Feature 018 Steps.md with S051-S054 capturing the four [TASK] findings from the PR #109 review: - S051: tests for new store actions (deleteSet, unconfirmSet, etc.) - S052: component test for swipe-to-delete DEL button - S053: regression test for B008 gym picker fix - S054: tests for RestTimerBanner component Mark review 0018-pr109-workout-set-ux-2026-04-13.md as partially resolved with resolution summary. Review: Context/Reviews/0018-pr109-workout-set-ux-2026-04-13.md --- .../Features/018-Workout-Session-UX/Steps.md | 65 +++++++- .../0018-pr109-workout-set-ux-2026-04-13.md | 143 ++++++++++++++++++ 2 files changed, 204 insertions(+), 4 deletions(-) create mode 100644 Context/Reviews/0018-pr109-workout-set-ux-2026-04-13.md 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/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..d82a10e0 --- /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) +- [ ] 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** | From 81469525e48cbd5450f01573d88959f4e882c2c0 Mon Sep 17 00:00:00 2001 From: Robert Hamilton Date: Tue, 14 Apr 2026 16:44:43 -0500 Subject: [PATCH 14/16] test(f018): add Wave 7 test coverage for PR #109 review findings S051 -- active-workout-store: 14 new cases covering deleteSet, unconfirmSet, removeActivity (undo-clearing side effect), and skipActivity (Set reference isolation after finishWorkout reset) S052 -- set-row: 3 new cases for swipe-to-delete DEL button (renders with onDelete, absent without, click fires callback) S053 -- start-workout-flow: 2 B008 regression cases (saved gym skips picker, no saved gym opens picker); also fixes the 4 pre-existing tests which were broken by the B008 change (readLastGymChoice was not mocked, causing failures at the new call site) S054 -- rest-timer-banner: new test file, 11 cases covering progress bar clamping, total=0 guard, Expand/Skip callbacks, and countdown text rendering 127 test files, 2307 tests passing Review: Context/Reviews/0018-pr109-workout-set-ux-2026-04-13.md --- .../__tests__/rest-timer-banner.test.tsx | 105 ++++++++ .../workout/__tests__/set-row.test.tsx | 23 ++ .../__tests__/-start-workout-flow.test.tsx | 69 ++++++ .../__tests__/active-workout-store.test.ts | 229 ++++++++++++++++++ 4 files changed, 426 insertions(+) create mode 100644 src/components/workout/__tests__/rest-timer-banner.test.tsx 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 a6d0d5be..070df21a 100644 --- a/src/components/workout/__tests__/set-row.test.tsx +++ b/src/components/workout/__tests__/set-row.test.tsx @@ -116,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/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/stores/__tests__/active-workout-store.test.ts b/src/stores/__tests__/active-workout-store.test.ts index a7eae558..f1818591 100644 --- a/src/stores/__tests__/active-workout-store.test.ts +++ b/src/stores/__tests__/active-workout-store.test.ts @@ -585,3 +585,232 @@ describe('notes (F020)', () => { expect(roundTripped.loggedGroups[0].activities[0].noteTags).toEqual(['SCALED']) }) }) + +// =========================================================================== +// deleteSet (F018 -- PR #109) +// =========================================================================== + +describe('deleteSet', () => { + beforeEach(() => { + const wl = makeWorkoutLog() + const group: LoggedActivityGroupWithActivities = { + ...makeLoggedActivityGroup(), + activities: [ + { + ...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().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 }) + + getState().deleteSet('la-1', 'ls-a') + + expect(getState().undoAction).toEqual(undoAction) + }) +}) + +// =========================================================================== +// unconfirmSet (F018 -- PR #109) +// =========================================================================== + +describe('unconfirmSet', () => { + beforeEach(() => { + const wl = makeWorkoutLog() + const group: LoggedActivityGroupWithActivities = { + ...makeLoggedActivityGroup(), + activities: [ + { + ...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, + }) + }) + + it('sets completed=false on the target set', () => { + getState().unconfirmSet('la-1', 'ls-a') + + const sets = getState().loggedGroups[0].activities[0].sets + const target = sets.find((s) => s.id === 'ls-a') + expect(target?.completed).toBe(false) + }) + + 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() + const group1: LoggedActivityGroupWithActivities = { + ...makeLoggedActivityGroup({ id: 'lag-1' }), + activities: [ + { + ...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, + }) + }) + + 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, + } + useActiveWorkoutStore.setState({ undoAction }) + + 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) + }) +}) From a484b210467d0cc77da8c2501082dec1e311279a Mon Sep 17 00:00:00 2001 From: Robert Hamilton Date: Tue, 14 Apr 2026 16:49:25 -0500 Subject: [PATCH 15/16] chore(meta): mark PR #109 review verified by review-verify agent --- Context/Reviews/0018-pr109-workout-set-ux-2026-04-13.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index d82a10e0..8eb2d25f 100644 --- a/Context/Reviews/0018-pr109-workout-set-ux-2026-04-13.md +++ b/Context/Reviews/0018-pr109-workout-set-ux-2026-04-13.md @@ -130,7 +130,7 @@ - [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) -- [ ] Review verified by review-verify agent +- [x] Review verified by review-verify agent ## Resolution Summary **Resolved at:** 2026-04-13 From 0fa1b450848e3325805947ae588c7b5032ac214d Mon Sep 17 00:00:00 2001 From: Robert Hamilton Date: Tue, 14 Apr 2026 16:56:27 -0500 Subject: [PATCH 16/16] fix(lint): resolve no-useless-catch and setState-in-effect violations Remove three try/catch wrappers in use-active-workout.ts whose catch blocks only re-threw -- errors now propagate naturally to callers. Replace useEffect(() => { if (!restTimer) setRestMinimized(false) }) with a direct setRestMinimized(false) call inside handleConfirmSet, ensuring each new rest timer starts expanded without triggering the setState-in-effect lint rule. --- src/hooks/use-active-workout.ts | 83 ++++++++------------ src/routes/_authenticated/log.$workoutId.tsx | 10 ++- 2 files changed, 40 insertions(+), 53 deletions(-) diff --git a/src/hooks/use-active-workout.ts b/src/hooks/use-active-workout.ts index 2edd8945..d9ca9596 100644 --- a/src/hooks/use-active-workout.ts +++ b/src/hooks/use-active-workout.ts @@ -467,30 +467,25 @@ export function useActiveWorkout() { */ const unconfirmSet = useCallback( async (loggedActivityId: string, setId: string) => { - try { - 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 - } - await updateLoggedSetMutation.mutateAsync({ - ...targetSet, - completed: false, - workoutLogId: workoutLog.id, - userId: workoutLog.userId, - }) - storeUnconfirmSet(loggedActivityId, setId) - } catch (err) { - // The mutation hook's onError is the single log owner for this - // rejection (see error-handling.md "Mutation Hook Log Ownership"). - // Re-throw so the calling component can surface a user-facing error - // state without re-logging. - throw err + 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], ) @@ -673,20 +668,15 @@ export function useActiveWorkout() { */ const deleteSet = useCallback( async (loggedActivityId: string, setId: string) => { - try { - if (!workoutLog) { - throw new Error('No active workout to delete set in') - } - await deleteLoggedSetMutation.mutateAsync({ - id: setId, - workoutLogId: workoutLog.id, - }) - storeDeleteSet(loggedActivityId, setId) - } catch (err) { - // Hook owns the log; re-throw so the calling component can surface - // a user-facing error without duplicating the log line. - throw err + 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], ) @@ -701,20 +691,15 @@ export function useActiveWorkout() { */ const removeActivity = useCallback( async (activityId: string) => { - try { - if (!workoutLog) { - throw new Error('No active workout to remove activity from') - } - await deleteLoggedActivityMutation.mutateAsync({ - id: activityId, - workoutLogId: workoutLog.id, - }) - storeRemoveActivity(activityId) - } catch (err) { - // Hook owns the log; re-throw so the calling component can surface - // a user-facing error without duplicating the log line. - throw err + 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], ) diff --git a/src/routes/_authenticated/log.$workoutId.tsx b/src/routes/_authenticated/log.$workoutId.tsx index 684c9e39..99bb0cef 100644 --- a/src/routes/_authenticated/log.$workoutId.tsx +++ b/src/routes/_authenticated/log.$workoutId.tsx @@ -181,10 +181,9 @@ function ActiveWorkoutPage() { } }, [isActive, showSummary, navigate]) - // Reset minimize state when rest timer ends so the next rest starts expanded - useEffect(() => { - if (!restTimer) setRestMinimized(false) - }, [restTimer]) + // 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) @@ -400,6 +399,9 @@ 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,