441 lines
14 KiB
Markdown
441 lines
14 KiB
Markdown
---
|
||
phase: 02-flexible-sets
|
||
plan: "01"
|
||
type: execute
|
||
wave: 1
|
||
depends_on: []
|
||
files_modified:
|
||
- frontend/src/pages/WorkoutPage.jsx
|
||
- frontend/src/App.css
|
||
autonomous: true
|
||
|
||
must_haves:
|
||
truths:
|
||
- "Every exercise card shows a 'Lägg till set' button"
|
||
- "Tapping 'Lägg till set' opens a modal with two choices: Vanligt set and Dropset"
|
||
- "Choosing Vanligt set appends one set row pre-filled with weight from the row above (same reps as row above)"
|
||
- "Choosing Dropset appends 3 set rows: first at same weight as row above, second at ~75-80% of that, third at ~55-60% of that, each with 10 reps pre-filled"
|
||
- "Every set row has an inline trash icon button that removes that row"
|
||
- "Tapping delete on the last remaining set is blocked (button disabled or no-op)"
|
||
- "Set numbers display correctly after adds and deletions (Set 1, Set 2, Set 3...)"
|
||
artifacts:
|
||
- path: "frontend/src/pages/WorkoutPage.jsx"
|
||
provides: "ExerciseCard with dynamic set array, add-set modal, delete-set button, onDeleteSet prop (stub)"
|
||
contains: "setList"
|
||
- path: "frontend/src/App.css"
|
||
provides: "Modal overlay CSS, add-set button CSS, delete-set button CSS"
|
||
contains: ".set-type-modal"
|
||
key_links:
|
||
- from: "ExerciseCard setList state"
|
||
to: "set rows rendered"
|
||
via: "setList.map() instead of Array.from({ length: exercise.sets })"
|
||
pattern: "setList\\.map"
|
||
- from: "Trash icon button"
|
||
to: "setList filter"
|
||
via: "handleDeleteSet removes index from setList array"
|
||
pattern: "handleDeleteSet"
|
||
- from: "'Lägg till set' button"
|
||
to: "modal open state"
|
||
via: "setShowAddModal(true)"
|
||
pattern: "showAddModal"
|
||
---
|
||
|
||
<objective>
|
||
Refactor ExerciseCard to support a dynamic, variable-length set list. Add UI for adding sets (via modal with Vanligt set / Dropset choice) and deleting sets (inline trash icon, last-set guard).
|
||
|
||
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.
|
||
</objective>
|
||
|
||
<execution_context>
|
||
@/home/intense/.claude/get-shit-done/workflows/execute-plan.md
|
||
@/home/intense/.claude/get-shit-done/templates/summary.md
|
||
</execution_context>
|
||
|
||
<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
|
||
</context>
|
||
|
||
<tasks>
|
||
|
||
<task type="auto">
|
||
<name>Task 1: Refactor ExerciseCard to dynamic set array + add-set modal + delete-set button</name>
|
||
<files>frontend/src/pages/WorkoutPage.jsx</files>
|
||
<action>
|
||
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:
|
||
```js
|
||
const [setInputs, setSetInputs] = useState({})
|
||
```
|
||
With:
|
||
```js
|
||
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:
|
||
```js
|
||
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:
|
||
```js
|
||
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:
|
||
```js
|
||
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:
|
||
```js
|
||
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:
|
||
```js
|
||
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:
|
||
```js
|
||
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:
|
||
```js
|
||
const completedSets = setList.filter(s => s.completed).length
|
||
```
|
||
|
||
**ExerciseCard props** — add `onDeleteSet` prop (optional, will be wired in plan 02):
|
||
```js
|
||
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) => ...)`:
|
||
```jsx
|
||
{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:**
|
||
```jsx
|
||
<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`:
|
||
```jsx
|
||
<span className={`progress-badge ${completedSets === setList.length ? 'complete' : ''}`}>
|
||
{completedSets}/{setList.length}
|
||
</span>
|
||
```
|
||
|
||
Also update the `exercise-card` class condition:
|
||
```jsx
|
||
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.
|
||
</action>
|
||
<verify>
|
||
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, ...)
|
||
</verify>
|
||
<done>
|
||
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.
|
||
</done>
|
||
</task>
|
||
|
||
<task type="auto">
|
||
<name>Task 2: Add CSS for modal overlay, add-set button, and delete-set button</name>
|
||
<files>frontend/src/App.css</files>
|
||
<action>
|
||
Add the following CSS blocks to App.css. Append after the existing stepper CSS section.
|
||
|
||
**Add-set button** — sits below the sets-list, full width, secondary style:
|
||
```css
|
||
/* 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:
|
||
```css
|
||
/* 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:
|
||
```css
|
||
/* 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.
|
||
</action>
|
||
<verify>
|
||
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
|
||
</verify>
|
||
<done>
|
||
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.
|
||
</done>
|
||
</task>
|
||
|
||
</tasks>
|
||
|
||
<verification>
|
||
Run `npm run build` in frontend/ — build must pass with no errors.
|
||
|
||
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"
|
||
</verification>
|
||
|
||
<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>
|
||
|
||
<output>
|
||
After completion, create `.planning/phases/02-flexible-sets/02-01-SUMMARY.md`
|
||
</output>
|