14 KiB
phase, plan, type, wave, depends_on, files_modified, autonomous, must_haves
| phase | plan | type | wave | depends_on | files_modified | autonomous | must_haves | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 02-flexible-sets | 01 | execute | 1 |
|
true |
|
Purpose: Core Phase 2 feature — flexible set count entirely in the frontend. The backend already persists individual sets via upsert; adding/deleting on the frontend just means the next save will include the correct set_number sequence.
Output: ExerciseCard with dynamic set array state, set-type chooser modal, delete-set button on each row.
<execution_context> @/home/intense/.claude/get-shit-done/workflows/execute-plan.md @/home/intense/.claude/get-shit-done/templates/summary.md </execution_context>
@.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/phases/02-flexible-sets/02-CONTEXT.md @.planning/phases/02-flexible-sets/02-RESEARCH.md @frontend/src/pages/WorkoutPage.jsx @frontend/src/App.css @frontend/src/components/Icons.jsx Task 1: Refactor ExerciseCard to dynamic set array + add-set modal + delete-set button frontend/src/pages/WorkoutPage.jsx Replace the fixed-length set rendering in ExerciseCard with a dynamic `setList` array in state. Each element in the array is an object `{ weight, reps, completed }` (indexed by position, not by set_number — set_number is derived as index+1 when logging).State refactor (ExerciseCard):
Replace:
const [setInputs, setSetInputs] = useState({})
With:
const [setList, setSetList] = useState([])
const [showAddModal, setShowAddModal] = useState(false)
Update the useEffect that initializes state. Build setList as an ordered array instead of a keyed object:
useEffect(() => {
const initial = []
for (let i = 1; i <= exercise.sets; i++) {
const existingLog = logs.find(l => l.set_number === i)
initial.push({
weight: existingLog?.weight?.toString() || progression?.suggestedWeight?.toString() || '',
reps: existingLog?.reps?.toString() || '',
completed: existingLog?.completed || false
})
}
setSetList(initial)
}, [exercise, logs, progression])
handleInputChange — update to use array index:
const handleInputChange = (idx, field, value) => {
setSetList(prev => prev.map((s, i) => i === idx ? { ...s, [field]: value } : s))
}
handleComplete — update to use array index, pass set_number as idx+1 to onLogSet:
const handleComplete = (idx) => {
const input = setList[idx]
const newCompleted = !input.completed
setSetList(prev => prev.map((s, i) => i === idx ? { ...s, completed: newCompleted } : s))
onLogSet(exercise.id, idx + 1, input.weight, input.reps, newCompleted)
}
handleAddNormal — append one set pre-filled from the last row:
const handleAddNormal = () => {
const last = setList[setList.length - 1] || { weight: '', reps: '' }
setSetList(prev => [...prev, { weight: last.weight, reps: last.reps, completed: false }])
setShowAddModal(false)
}
handleAddDropset — append 3 sets with 20% weight reduction per step, 10 reps each:
const handleAddDropset = () => {
const last = setList[setList.length - 1] || { weight: '0', reps: '10' }
const baseWeight = parseFloat(last.weight) || 0
const drop1 = Math.round((baseWeight * 0.80) / 2.5) * 2.5
const drop2 = Math.round((baseWeight * 0.60) / 2.5) * 2.5
const newSets = [
{ weight: last.weight, reps: '10', completed: false },
{ weight: drop1.toString(), reps: '10', completed: false },
{ weight: drop2.toString(), reps: '10', completed: false },
]
setSetList(prev => [...prev, ...newSets])
setShowAddModal(false)
}
Note: Dropset weight steps use research-confirmed 20% drops per step (80% then 60% of base), rounded to nearest 2.5kg per the app's progression convention.
handleDeleteSet — remove by index, guard against last set:
const handleDeleteSet = (idx) => {
if (setList.length <= 1) return // last-set guard: block deletion
setSetList(prev => prev.filter((_, i) => i !== idx))
if (onDeleteSet) onDeleteSet(exercise.id, idx + 1)
}
completedSets count — update to use setList:
const completedSets = setList.filter(s => s.completed).length
ExerciseCard props — add onDeleteSet prop (optional, will be wired in plan 02):
function ExerciseCard({ exercise, logs, progression, expanded, onToggle, onLogSet, onDeleteSet }) {
Render update — set rows:
Replace the Array.from({ length: exercise.sets }, ...) map with setList.map((input, idx) => ...):
{setList.map((input, idx) => (
<div key={idx} className={`set-row ${input.completed ? 'completed' : ''}`}>
<span className="set-number">Set {idx + 1}</span>
<div className="set-inputs">
<WeightInput
value={input.weight}
onChange={(val) => handleInputChange(idx, 'weight', val)}
/>
<span className="input-separator">×</span>
<RepsInput
value={input.reps}
onChange={(val) => handleInputChange(idx, 'reps', val)}
/>
</div>
<button
className={`delete-set-btn ${setList.length <= 1 ? 'disabled' : ''}`}
onClick={() => handleDeleteSet(idx)}
disabled={setList.length <= 1}
aria-label={`Ta bort set ${idx + 1}`}
>
<Icon name="trash" size={16} />
</button>
<button
className={`complete-btn ${input.completed ? 'done' : ''}`}
onClick={() => handleComplete(idx)}
>
{input.completed ? <Icon name="check" size={18} /> : ''}
</button>
</div>
))}
Render update — below sets list, add "Lägg till set" button and modal:
<button
className="add-set-btn"
onClick={() => setShowAddModal(true)}
>
+ Lägg till set
</button>
{showAddModal && (
<div className="set-type-modal-overlay" onClick={() => setShowAddModal(false)}>
<div className="set-type-modal" onClick={e => e.stopPropagation()}>
<h3>Välj settyp</h3>
<button className="set-type-option" onClick={handleAddNormal}>
<strong>Vanligt set</strong>
<span>Lägg till ett set</span>
</button>
<button className="set-type-option dropset" onClick={handleAddDropset}>
<strong>Dropset</strong>
<span>3 set med viktnedtrappning (20% per steg)</span>
</button>
<button className="set-type-cancel" onClick={() => setShowAddModal(false)}>
Avbryt
</button>
</div>
</div>
)}
Place the modal JSX inside {expanded && (...)} after the .sets-list div but before closing .exercise-body.
Progress badge in exercise header — update to use setList.length instead of exercise.sets:
<span className={`progress-badge ${completedSets === setList.length ? 'complete' : ''}`}>
{completedSets}/{setList.length}
</span>
Also update the exercise-card class condition:
className={`exercise-card ${expanded ? 'expanded' : ''} ${completedSets === setList.length && setList.length > 0 ? 'all-done' : ''}`}
Check Icons.jsx for a "trash" icon — if none exists, add it (an SVG trash can outline, consistent with the existing Icon component pattern). Check the file before assuming it exists.
Open frontend dev server (npm run dev in frontend/) and load a workout. Verify:
1. Set rows render correctly with existing set count
2. "Lägg till set" button is visible below set list
3. Tapping it opens modal with two choices
4. "Vanligt set" adds one row, weight pre-filled from row above
5. "Dropset" adds 3 rows with progressively lower weights
6. Trash icon appears on each row; clicking removes the row
7. Trash icon on the only remaining set is disabled (cannot delete)
8. Set numbers re-number correctly after deletion (Set 1, Set 2, ...)
ExerciseCard renders from a dynamic setList array. Add-set modal works for both Vanligt set and Dropset. Delete button removes rows with last-set guard enforced. Set numbering is always sequential from 1.
Add-set button — sits below the sets-list, full width, secondary style:
/* Add set button */
.add-set-btn {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
min-height: 44px;
margin-top: 0.5rem;
padding: 0.5rem 1rem;
background: transparent;
border: 1px dashed var(--border);
border-radius: 8px;
color: var(--text-secondary);
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: border-color 0.15s, color 0.15s;
}
.add-set-btn:hover {
border-color: var(--accent);
color: var(--accent);
}
Delete set button — inline on the set row, between inputs and complete-btn:
/* Delete set button */
.delete-set-btn {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
min-height: 44px;
background: transparent;
border: none;
color: var(--text-secondary);
cursor: pointer;
opacity: 0.6;
transition: opacity 0.15s, color 0.15s;
flex-shrink: 0;
}
.delete-set-btn:hover:not(:disabled) {
color: #e53e3e;
opacity: 1;
}
.delete-set-btn:disabled,
.delete-set-btn.disabled {
opacity: 0.2;
cursor: not-allowed;
}
Set type modal — CSS overlay + card, dark theme consistent:
/* Set type modal */
.set-type-modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: flex-end;
justify-content: center;
z-index: 200;
padding-bottom: env(safe-area-inset-bottom, 0);
}
.set-type-modal {
background: var(--surface);
border-radius: 16px 16px 0 0;
padding: 1.5rem 1rem 2rem;
width: 100%;
max-width: 600px;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.set-type-modal h3 {
font-size: 1rem;
font-weight: 600;
color: var(--text-primary);
margin: 0 0 0.25rem;
text-align: center;
}
.set-type-option {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.2rem;
width: 100%;
min-height: 56px;
padding: 0.75rem 1rem;
background: var(--surface-2, rgba(255,255,255,0.05));
border: 1px solid var(--border);
border-radius: 10px;
cursor: pointer;
text-align: left;
transition: border-color 0.15s;
}
.set-type-option strong {
font-size: 1rem;
color: var(--text-primary);
}
.set-type-option span {
font-size: 0.8rem;
color: var(--text-secondary);
}
.set-type-option:hover {
border-color: var(--accent);
}
.set-type-option.dropset strong {
color: var(--accent);
}
.set-type-cancel {
width: 100%;
min-height: 44px;
padding: 0.75rem;
background: transparent;
border: none;
color: var(--text-secondary);
font-size: 0.9rem;
cursor: pointer;
margin-top: 0.25rem;
}
Note: Use var(--surface), var(--border), var(--text-primary), var(--text-secondary), var(--accent) — all existing CSS custom properties from the dark theme. Verify property names against existing App.css before writing.
Check in browser that:
1. "Lägg till set" button renders with dashed border, no background
2. Trash icon on set rows is subtle (low opacity), turns red on hover
3. Modal slides up from bottom as a sheet (bottom-anchored overlay)
4. Modal has the two option cards and a cancel button
5. All touch targets are at least 44px tall
All new interactive elements styled with dark theme variables. Modal is a bottom sheet at max-width 600px. Delete button is subtle but discoverable. All touch targets ≥ 44px.
In the dev server, open a workout and test:
- Add normal set: weight copies from row above, reps copy from row above, set number increments
- Add dropset: 3 rows appear, weights drop at 20% intervals (rounded to 2.5kg), reps default to 10
- Delete middle set: remaining rows renumber correctly
- Delete when only 1 set remains: button disabled, no row removed
- Modal dismisses on overlay click and on "Avbryt"
<success_criteria> ExerciseCard renders from dynamic setList. Both add-set paths work (Vanligt set, Dropset). Delete works with last-set guard. Build passes. All new buttons meet 44px touch target. CSS uses only existing theme variables. </success_criteria>
After completion, create `.planning/phases/02-flexible-sets/02-01-SUMMARY.md`