Files
gravl/.planning/phases/02-flexible-sets/02-RESEARCH.md
T
clawd ce31ef8dae docs(phase-02): research flexible sets phase
Researched:
- Dropset conventions: 20-25% weight reduction per step (HIGH confidence, strength training literature)
- React array management: Use filter() for immutable removals (HIGH confidence, official React docs)
- Mobile delete UX: Combine inline icons + optional swipe, 48px touch targets (HIGH confidence, WCAG + NN/G)
- Lightweight modal: Plain CSS overlay pattern without component library (MEDIUM confidence, verified with community)
- Backend set numbering: Recommend frontend renumbering before save to handle gaps (MEDIUM confidence, needs verification)

Key deliverables:
- Standard Stack: React 18 + plain CSS (no new dependencies)
- Architecture Patterns: Dynamic array management, lightweight modal, inline delete with optional confirmation
- Don't Hand-Roll: Array mutations (use filter), modal dialog (CSS is simpler than library), set calculations
- Common Pitfalls: Set numbering gaps, missing reps defaults, arbitrary weight reductions, last-set deletion
- Code Examples: Add/remove sets, dropset calculations, delete patterns with renumbering

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-21 18:01:42 +01:00

509 lines
21 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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>
## 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
</user_constraints>
## 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 */}
<div className="modal-overlay" onClick={onClose} />
{/* Modal content */}
<div className="modal-content">
<h3>Lägg till set</h3>
<div className="modal-buttons">
<button
className="modal-btn modal-btn-primary"
onClick={() => {
onSelectVanligt();
onClose();
}}
>
Vanligt set
</button>
<button
className="modal-btn modal-btn-secondary"
onClick={() => {
onSelectDropset();
onClose();
}}
>
Dropset (3 set)
</button>
</div>
</div>
</>
);
}
```
```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
<div className="set-row">
<span className="set-number">Set {setNum}</span>
<div className="set-inputs">
{/* Weight and reps inputs */}
</div>
<button
className="set-delete-btn"
onClick={handleDeleteSet}
title="Radera set"
aria-label="Radera set"
>
×
</button>
</div>
```
```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)