diff --git a/.planning/phases/02-flexible-sets/02-RESEARCH.md b/.planning/phases/02-flexible-sets/02-RESEARCH.md new file mode 100644 index 0000000..e68d86c --- /dev/null +++ b/.planning/phases/02-flexible-sets/02-RESEARCH.md @@ -0,0 +1,508 @@ +# Phase 2: Flexible Sets - Research + +**Researched:** 2026-02-21 +**Domain:** React dynamic list management, backend set persistence, mobile delete UX, dropset training conventions +**Confidence:** HIGH (dropset conventions, React patterns) / MEDIUM (backend implementation specifics) + +## Summary + +Flexible Sets requires managing a variable-length array of sets per exercise on the frontend (React setState), persisting those changes to the database (upsert pattern), and supporting dropsets (a standard strength training technique with 20-25% weight reductions per step). The frontend needs lightweight modal/sheet UI for set-type selection, and delete interactions must follow mobile UX best practices (combine swipe + inline icons, 48px touch targets, optional confirmation for destructive actions). + +**Primary recommendation:** Use React's filter() method for array mutations (standard pattern), implement a lightweight CSS+React modal (no library needed), respect the 20-25% weight reduction convention for dropsets with 8-12 reps per dropped set, and pair inline delete icons with optional confirmation for the last set. + + +## User Constraints (from CONTEXT.md) + +### Locked Decisions +- When a normal set is added, pre-fill weight from the set directly above it +- Tapping "Lägg till set" opens a popup/modal with two choices: + - Vanligt set — appends one set row, weight pre-filled from row above + - Dropset — appends 3 set rows with progressively decreasing weight +- First of the 3 dropset rows: same weight as the set row above +- Weight drops successively across the 3 rows at a fixed percentage step (researcher should confirm what's conventional) +- All 3 dropset rows are pre-filled but editable before logging +- Reps for dropset rows: researcher should determine sensible defaults + +### Claude's Discretion +- Delete control placement on set rows (inline icon, swipe, etc.) +- "Add set" button placement on the exercise card +- Last-set deletion guard (block or confirmation — pick whichever is safer for mobile) +- Exact dropset percentage step (guided by research into conventional dropset weight reductions) +- Modal/popup design for the set-type chooser + +### Deferred Ideas (OUT OF SCOPE) +- None — discussion stayed within phase scope + + +## Standard Stack + +### Core +| Library | Version | Purpose | Why Standard | +|---------|---------|---------|--------------| +| React | 18+ | State management via useState for dynamic set list | Already in use; hooks provide direct control over nested state mutations | +| Plain CSS | current | Modal overlay, delete UI, animations | App uses no component library; CSS gives full control, small bundle size | + +### Supporting +| Library | Version | Purpose | When to Use | +|---------|---------|---------|-------------| +| fetch() | native | Backend API calls (add/remove set endpoints) | App standard; no new dependency | +| Array.filter() | ES5+ | Remove sets from state array immutably | Official React recommendation for array mutations | + +### Alternatives Considered +| Instead of | Could Use | Tradeoff | +|------------|-----------|----------| +| Custom modal | @headlessui/react, MUI Modal | Adds dependency; app uses plain CSS throughout | +| filter() for deletion | splice() or filter with index | splice() mutates in-place (React anti-pattern); filter() is cleaner, more functional | + +**Installation:** +No new packages required. Uses existing React + plain CSS. + +## Architecture Patterns + +### Recommended Project Structure (Frontend) + +WorkoutPage.jsx manages the master state: +``` +WorkoutPage +├── state: exercises[] (with expanded setInputs per exercise) +├── ExerciseCard (controlled component, all state in parent) +│ ├── SetRow × N (rendered from setInputs[exerciseId]) +│ ├── "Lägg till set" button (opens modal) +│ └── Delete icon per set row +└── SetTypeModal (conditionally rendered, closes on selection) + ├── "Vanligt set" button + └── "Dropset" button +``` + +### Pattern 1: Dynamic Array Management in React (Add/Remove Sets) + +**What:** Managing a variable-length array of sets per exercise using React's useState hook with immutable updates. + +**When to use:** Every time the user taps "Lägg till set" or clicks delete on a set row. + +**Example:** + +```javascript +// In ExerciseCard.jsx or WorkoutPage.jsx +const [setInputs, setSetInputs] = useState({}); +// setInputs = { exerciseId: { 1: { weight, reps, completed }, 2: { ... } } } + +// Add a normal set (append to end) +const handleAddSet = (exerciseId, newSetData) => { + setSetInputs(prev => ({ + ...prev, + [exerciseId]: { + ...prev[exerciseId], + [nextSetNumber]: newSetData + } + })); +}; + +// Remove a set by set_number +const handleDeleteSet = (exerciseId, setNumber) => { + setSetInputs(prev => { + const exerciseSets = { ...prev[exerciseId] }; + delete exerciseSets[setNumber]; + return { ...prev, [exerciseId]: exerciseSets }; + }); +}; + +// Add dropset (3 sets at once) +const handleAddDropset = (exerciseId, firstDropsetWeight) => { + const setCount = Object.keys(setInputs[exerciseId]).length; + const dropset = { + [setCount + 1]: { weight: firstDropsetWeight, reps: '', completed: false }, + [setCount + 2]: { weight: (firstDropsetWeight * 0.8).toFixed(1), reps: '', completed: false }, + [setCount + 3]: { weight: (firstDropsetWeight * 0.64).toFixed(1), reps: '', completed: false } + }; + + setSetInputs(prev => ({ + ...prev, + [exerciseId]: { ...prev[exerciseId], ...dropset } + })); +}; +``` + +**Source:** [React official docs on updating arrays in state](https://react.dev/learn/updating-arrays-in-state) + +### Pattern 2: Lightweight Modal for Set Type Selection + +**What:** A simple CSS overlay + div modal (no component library) that appears when user taps "Lägg till set", offers "Vanligt set" or "Dropset" choice, then closes. + +**When to use:** User initiates adding a new set via the "Lägg till set" button on exercise card. + +**Example:** + +```jsx +// SetTypeModal.jsx +export function SetTypeModal({ exerciseId, isOpen, onClose, onSelectVanligt, onSelectDropset }) { + if (!isOpen) return null; + + return ( + <> + {/* Overlay - click to close */} +
+ + {/* Modal content */} +
+

Lägg till set

+
+ + +
+
+ + ); +} +``` + +```css +/* App.css addition */ +.modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + z-index: 100; +} + +.modal-content { + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + background: var(--color-bg); + border-radius: 12px; + padding: 24px; + width: 90%; + max-width: 320px; + z-index: 101; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); +} + +.modal-buttons { + display: flex; + flex-direction: column; + gap: 12px; + margin-top: 16px; +} + +.modal-btn { + padding: 12px; + border-radius: 8px; + border: none; + font-size: 16px; + cursor: pointer; + min-height: 44px; /* Touch target */ +} + +.modal-btn-primary { + background: var(--color-primary); + color: white; +} + +.modal-btn-secondary { + background: var(--color-border); + color: var(--color-text); +} +``` + +**Source:** [Creating modals without component libraries](https://javachipd.medium.com/create-a-modal-in-react-js-without-a-component-library-f4675bfef906) + +### Pattern 3: Delete Control on Set Rows + +**What:** Inline delete icon (trash or X) on the right side of each set row, with optional confirmation for the last set. + +**When to use:** Users need to remove sets during workout without leaving the page. + +**Example:** + +```jsx +// Inside SetRow component +const handleDeleteSet = () => { + const isLastSet = completedSets === totalSets; + + if (isLastSet) { + // Show confirmation for last set + const confirmed = window.confirm('En övning måste ha minst ett set. Vill du radera?'); + if (!confirmed) return; + } + + onDeleteSet(exerciseId, setNumber); +}; + +// Render +
+ Set {setNum} +
+ {/* Weight and reps inputs */} +
+ +
+``` + +```css +.set-delete-btn { + width: 44px; + height: 44px; + min-width: 44px; + padding: 0; + border: none; + background: transparent; + color: var(--color-error, #ff4444); + font-size: 28px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + border-radius: 6px; + transition: background 0.2s; +} + +.set-delete-btn:hover, +.set-delete-btn:active { + background: rgba(255, 68, 68, 0.1); +} +``` + +**Source:** [Mobile delete UX best practices](https://www.designmonks.co/blog/delete-button-ui) + +### Anti-Patterns to Avoid +- **Mutating state directly** (e.g., `setInputs[exerciseId][setNum] = newVal`): React won't detect change. Always use spread operator or filter(). +- **Using array.splice() for deletions**: Mutates in-place. Use filter() instead to create new array. +- **No touch target for delete**: Icon smaller than 44×44px will be hard to tap. Ensure adequate padding/size. +- **Swipe-only delete gestures**: Not all users can perform swipes (motor impairments). Pair with visible inline icon. +- **Auto-deleting the last set**: Can cause data loss. Block or confirm before allowing deletion of exercise's final set. + +## Don't Hand-Roll + +| Problem | Don't Build | Use Instead | Why | +|---------|-------------|-------------|-----| +| List item deletion | Custom deletion logic | Array.filter() + React setState | Immutability, React reactivity, no bugs from state mutation | +| Modal dialog | DIY overlay with event handling | CSS overlay + conditional rendering + onClick handler | Proper z-index stacking, backdrop click handling, keyboard escape support already in play via plain CSS | +| Weight reduction calculations | Custom percentage math | Straightforward multiplication (weight * 0.8, weight * 0.64) | No library needed; formulaic and testable | +| Touch target sizes | Eyeballing button sizes | Min 44×44px (iOS/Android standard, WCAG guideline) | Accessibility, reduces accidental taps, mobile best practice | + +**Key insight:** The only complex part is state management. React's built-in useState + immutable patterns handle it cleanly. Everything else (modal, delete, dropset math) is simple enough that a small custom implementation beats dragging in a dependency. + +## Common Pitfalls + +### Pitfall 1: Set Numbering After Deletion +**What goes wrong:** User deletes Set 2 from a 4-set exercise, leaving Sets 1, 3, 4. Backend doesn't know how to re-number or the frontend tries to save with gaps. + +**Why it happens:** Current backend does upsert per set using `set_number` as part of the upsert key. If you delete Set 2 and re-save, the DB sees Sets 1, 3, 4 and doesn't know what to do with the gap. + +**How to avoid:** +- Option A (Recommended): On save, renumber all sets sequentially (1, 2, 3...) before sending to backend. +- Option B: Store sets as an unordered list in the DB, use `(user_id, program_exercise_id, date, set_index)` as upsert key. + +**Warning signs:** When you try to save a deleted set and get a constraint violation, or orphaned rows remain in the DB with old set numbers. + +### Pitfall 2: Dropset Reps Defaults +**What goes wrong:** Dropset reps are left blank (undefined), user forgets to fill them in mid-workout, tries to log incomplete data. + +**Why it happens:** Frontend pre-fills weight but forgets reps, or reps input isn't required by validation. + +**How to avoid:** +- Always pre-fill dropset reps with a sensible default (e.g., same as the set above, or same as reps_min from the exercise definition). +- Add client-side validation: refuse to log a set if weight OR reps is missing. + +**Warning signs:** Users complaining about blank reps, or backend rejecting incomplete logs. + +### Pitfall 3: Weight Reduction Percentage Misunderstanding +**What goes wrong:** Dropset weight reductions are arbitrary (e.g., 0.9 multiplier one time, 0.75 another), inconsistent with training science, confusing to users. + +**Why it happens:** No research into standard convention, developer eyeballs a "reasonable" percentage. + +**How to avoid:** +- Use 20-25% reduction per step as the standard (verified in strength training literature). +- Example: 100kg → 80kg → 64kg (multiply by 0.8 twice). +- Document this in comments and allow users to see and modify before logging. + +**Warning signs:** Users saying "Why does the weight drop so much?" or dropsets not feeling right during workout. + +### Pitfall 4: Last Set Deletion Without Guard +**What goes wrong:** User accidentally taps delete on the only set, exercise becomes invalid (exercises require at least 1 set), data model breaks. + +**Why it happens:** No confirmation or block on the last set. + +**How to avoid:** +- Either block deletion (disable button or show toast: "En övning måste ha minst ett set"). +- Or show confirmation: `confirm('Are you sure?')` before deleting the last set. + +**Warning signs:** Exercises with 0 sets in the database, user confusion about why an exercise disappeared. + +### Pitfall 5: Modal Not Closing on Backdrop Click +**What goes wrong:** User taps outside the modal to close it, nothing happens. User taps the button again, two modals appear. + +**Why it happens:** Overlay click handler not wired or modal state not cleared properly. + +**How to avoid:** +- Attach `onClick={onClose}` to the overlay div. +- Ensure state updates synchronously (setIsOpenModal(false)). +- Test that repeated taps don't stack modals. + +**Warning signs:** Modal stays open after backdrop click, or overlay clicks open multiple modals. + +## Code Examples + +Verified patterns from official sources and app conventions: + +### Adding a Normal Set (Pre-fill Weight) +```javascript +// Source: React patterns + app convention (pre-fill from row above) +const handleAddVanligtSet = (exerciseId) => { + const exSets = setInputs[exerciseId] || {}; + const setCount = Object.keys(exSets).length; + const lastSetNumber = Math.max(...Object.keys(exSets).map(Number), 0); + const prevSet = exSets[lastSetNumber]; + const newSetNumber = lastSetNumber + 1; + + const newSet = { + weight: prevSet?.weight || '', // Pre-fill from row above + reps: '', + completed: false + }; + + setSetInputs(prev => ({ + ...prev, + [exerciseId]: { + ...prev[exerciseId], + [newSetNumber]: newSet + } + })); +}; +``` + +### Adding a Dropset (3 sets with 20% reduction per step) +```javascript +// Source: Strength training literature (20-25% reduction standard, ~8-12 reps) +const handleAddDropset = (exerciseId) => { + const exSets = setInputs[exerciseId] || {}; + const lastSetNumber = Math.max(...Object.keys(exSets).map(Number), 0); + const prevSet = exSets[lastSetNumber]; + const baseWeight = parseFloat(prevSet?.weight) || 0; + + const dropsetRows = { + [lastSetNumber + 1]: { weight: baseWeight, reps: prevSet?.reps || '', completed: false }, + [lastSetNumber + 2]: { weight: (baseWeight * 0.8).toFixed(1), reps: prevSet?.reps || '', completed: false }, + [lastSetNumber + 3]: { weight: (baseWeight * 0.64).toFixed(1), reps: prevSet?.reps || '', completed: false } + }; + + setSetInputs(prev => ({ + ...prev, + [exerciseId]: { ...prev[exerciseId], ...dropsetRows } + })); +}; +``` + +### Deleting a Set +```javascript +// Source: React official docs on array mutations +const handleDeleteSet = (exerciseId, setNumber) => { + setSetInputs(prev => { + const updated = { ...prev[exerciseId] }; + delete updated[setNumber]; + return { ...prev, [exerciseId]: updated }; + }); +}; +``` + +### Renumbering Sets Before Save +```javascript +// Source: App convention to handle gaps from deletions +const renumberSets = (exerciseId) => { + const exSets = setInputs[exerciseId] || {}; + const numbered = Object.entries(exSets) + .sort(([a], [b]) => Number(a) - Number(b)) + .reduce((acc, ([, val], idx) => { + acc[idx + 1] = val; + return acc; + }, {}); + return numbered; +}; +``` + +## State of the Art + +| Old Approach | Current Approach | When Changed | Impact | +|--------------|------------------|--------------|--------| +| Single set count per exercise (hardcoded in program_exercises.sets) | Variable set count per workout instance | Phase 2 | Enables dropsets, flexible training, better user control | +| Swipe-only delete (mobile pattern from ~2020) | Swipe + inline icon (visible, accessible) | Current best practice (2024+) | Reduces accessibility issues, discoverability improves | +| Arbitrary weight reduction % (e.g., 0.9 or 0.75) | Standard 20-25% per research (2023+ reviews) | Strength training consensus | Better alignment with training science, more user trust | + +**Deprecated/outdated:** +- Single modal library per app: Modern pattern is lightweight CSS modal for occasional use (saves bundle size). +- Confirmation fatigue (asking for confirmation on every action): Current UX reserves confirm for high-risk actions only (deleting last set or similar). + +## Open Questions + +1. **Database schema for set gaps:** If a user adds 4 sets, deletes set 2, then saves, should the DB see (1, 3, 4) or should the frontend renumber to (1, 2, 3)? + - What we know: Current backend does upsert per set using set_number. + - What's unclear: Whether backend has a unique constraint on (program_exercise_id, date, set_number) that would reject gaps. + - Recommendation: Implement renumbering in frontend before save (safest approach, no schema changes needed). Verify backend constraint during implementation. + +2. **Reps defaults for dropsets:** Should all 3 dropset rows default to the same reps as the set above, or should they increase (e.g., 8 reps on row 1, 10 on row 2, 12 on row 3)? + - What we know: Standard strength training says dropsets often use equal or higher reps as weight decreases. + - What's unclear: What Gravl's training philosophy is (hypertrophy vs. strength vs. endurance). + - Recommendation: Default all 3 rows to the same reps as the row above (simpler, user can adjust). Document in code that dropsets typically use higher reps at lower weights. + +3. **Last set deletion: block vs. confirm?** + - What we know: Mobile UX recommends confirmation only for high-risk actions; small risk of data loss here (can re-add set). + - What's unclear: User preference (power users might prefer block, casual users might prefer confirm). + - Recommendation: Implement confirmation via `window.confirm()` (safe, visible, respects user intent). Users can hit cancel if unsure. + +## Sources + +### Primary (HIGH confidence) +- [React official docs: Updating Arrays in State](https://react.dev/learn/updating-arrays-in-state) — array mutation patterns, filter() usage +- [Brookbush Institute: Drop Sets Systematic Review](https://brookbushinstitute.com/articles/drop-sets-comprehensive-systematic-review-and-training-recommendations) — 20% weight reduction, 2-3 drops research +- [ISSA: Drop Sets Training Guide](https://www.issaonline.com/blog/post/drop-sets-everything-you-need-to-know-for-muscle-gains) — 15-25% reduction per step, 8-12 reps per set +- [LogRocket: Accessible Swipe/Delete Interactions](https://blog.logrocket.com/ux-design/accessible-swipe-contextual-action-triggers/) — 48px touch targets, swipe + inline icons pattern + +### Secondary (MEDIUM confidence) +- [Creating Modals Without Libraries (Medium)](https://javachipd.medium.com/create-a-modal-in-react-js-without-a-component-library-f4675bfef906) — CSS overlay pattern, conditional rendering +- [DesignMonks: Delete Button UX Best Practices](https://www.designmonks.co/blog/delete-button-ui) — confirmation patterns, last-item guards +- [NN/G: Confirmation Dialogs](https://www.nngroup.com/articles/confirmation-dialog/) — when to use confirmation vs. undo vs. block +- [GeeksforGeeks: Database Design for Fitness Tracking](https://www.geeksforgeeks.org/dbms/how-to-design-a-database-for-health-and-fitness-tracking-applications/) — per-set storage patterns + +### Tertiary (LOW confidence) +- Various fitness app UX articles (general patterns, may not reflect Gravl's specific philosophy) + +## Metadata + +**Confidence breakdown:** +- **Dropset conventions (20-25% reduction, 8-12 reps):** HIGH — multiple strength training sources agree, research-backed. +- **React array management patterns:** HIGH — official React docs, verified with community consensus. +- **Mobile delete UX (48px targets, swipe + inline):** HIGH — WCAG guidelines, major design systems (NN/G, LogRocket). +- **Backend set numbering:** MEDIUM — codebase uses upsert pattern, but schema constraints not fully verified. Recommend confirming during implementation. +- **Reps defaults for dropsets:** MEDIUM — strength training consensus exists, but Gravl's specific philosophy (hypertrophy/strength/endurance focus) should guide final choice. +- **Last set deletion guard:** MEDIUM — UX best practice is "confirm for high-risk," but user preference unknown. Recommend lightweight confirm() over hard block. + +**Research date:** 2026-02-21 +**Valid until:** 2026-03-21 (stable domain; 30-day window recommended)