docs: add phase 3 design polish planning, update progress

This commit is contained in:
2026-02-26 23:53:22 +01:00
parent 30d79927ff
commit d98db37fff
12 changed files with 2695 additions and 22 deletions
@@ -0,0 +1,440 @@
---
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>
@@ -0,0 +1,220 @@
---
phase: 02-flexible-sets
plan: "02"
type: execute
wave: 2
depends_on: ["02-01"]
files_modified:
- backend/src/index.js
- frontend/src/App.jsx
autonomous: true
must_haves:
truths:
- "Deleting a set row that was previously logged removes it from the database"
- "Adding and logging sets beyond the original program count persists to the database"
- "After saving/refreshing, the dynamic set count reflects what was logged (no phantom sets, no missing sets)"
- "The backend DELETE endpoint returns 200 on success and 404 if the log row did not exist"
artifacts:
- path: "backend/src/index.js"
provides: "DELETE /api/logs endpoint accepting user_id, program_exercise_id, date, set_number"
contains: "DELETE.*workout_logs"
- path: "frontend/src/App.jsx"
provides: "deleteLog function that calls DELETE /api/logs; passed to WorkoutPage as onDeleteSet"
contains: "deleteLog"
key_links:
- from: "ExerciseCard handleDeleteSet"
to: "App.jsx deleteLog"
via: "onDeleteSet prop through WorkoutPage"
pattern: "onDeleteSet"
- from: "App.jsx deleteLog"
to: "DELETE /api/logs"
via: "fetch with method DELETE"
pattern: "method.*DELETE"
- from: "DELETE /api/logs"
to: "workout_logs table"
via: "DELETE FROM workout_logs WHERE user_id=$1 AND program_exercise_id=$2 AND date=$3 AND set_number=$4"
pattern: "DELETE FROM workout_logs"
---
<objective>
Add a DELETE /api/logs backend endpoint and wire it through App.jsx so that when a user removes a set row that was already logged, the database record is deleted.
Purpose: Without this, deleted set rows leave orphan rows in workout_logs, causing ghost sets to reappear on next load. The frontend dynamic state from plan 01 is correct per-session, but only persists with proper backend deletion.
Output: Backend DELETE endpoint + App.jsx deleteLog function + WorkoutPage onDeleteSet prop wired to ExerciseCard.
</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/phases/02-flexible-sets/02-CONTEXT.md
@.planning/phases/02-flexible-sets/02-01-SUMMARY.md
@backend/src/index.js
@frontend/src/App.jsx
@frontend/src/pages/WorkoutPage.jsx
</context>
<tasks>
<task type="auto">
<name>Task 1: Add DELETE /api/logs endpoint to backend</name>
<files>backend/src/index.js</files>
<action>
Add a DELETE endpoint that removes a specific set log row. Place it directly after the existing POST /api/logs route (around line 329).
```js
// Delete a specific set log
app.delete('/api/logs', async (req, res) => {
try {
const { user_id, program_exercise_id, date, set_number } = req.body;
const result = await pool.query(
'DELETE FROM workout_logs WHERE user_id = $1 AND program_exercise_id = $2 AND date = $3 AND set_number = $4 RETURNING id',
[user_id, program_exercise_id, date, set_number]
);
if (result.rows.length === 0) {
return res.status(404).json({ error: 'Log not found' });
}
res.json({ deleted: result.rows[0].id });
} catch (err) {
console.error('Error deleting log:', err);
res.status(500).json({ error: 'Database error' });
}
});
```
No auth middleware on this endpoint — consistent with the existing POST /api/logs which also has no auth middleware (user_id comes from the request body). Do not add authMiddleware unless the existing POST /api/logs has it (it does not).
</action>
<verify>
Start backend (`npm start` in backend/) and run:
```
curl -X DELETE http://localhost:3001/api/logs \
-H "Content-Type: application/json" \
-d '{"user_id":1,"program_exercise_id":1,"date":"2026-02-21","set_number":1}'
```
Returns `{"deleted": N}` if row existed, or `{"error":"Log not found"}` with 404 if not. Server does not crash on missing body fields (returns 404 or 500 gracefully).
</verify>
<done>
DELETE /api/logs endpoint exists, deletes the matching workout_logs row by composite key (user_id, program_exercise_id, date, set_number), returns 200+id on success, 404 if not found.
</done>
</task>
<task type="auto">
<name>Task 2: Wire deleteLog through App.jsx and WorkoutPage to ExerciseCard</name>
<files>frontend/src/App.jsx, frontend/src/pages/WorkoutPage.jsx</files>
<action>
**In App.jsx:**
Add a `deleteLog` function alongside the existing `logSet` function:
```js
const deleteLog = async (programExerciseId, setNumber) => {
try {
await fetch(`${API_URL}/logs`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
user_id: userId,
program_exercise_id: programExerciseId,
date: today,
set_number: setNumber
})
})
// Remove from local logs state
setLogs(prev => ({
...prev,
[programExerciseId]: (prev[programExerciseId] || []).filter(l => l.set_number !== setNumber)
}))
} catch (err) {
console.error('Failed to delete log:', err)
}
}
```
Pass `deleteLog` to `WorkoutPage` as `onDeleteSet`:
```jsx
<WorkoutPage
day={selectedDay}
week={currentWeek}
logs={logs}
onLogSet={logSet}
onDeleteSet={deleteLog}
onBack={() => setView('dashboard')}
fetchProgression={fetchProgression}
/>
```
**In WorkoutPage.jsx:**
Update the `WorkoutPage` function signature to accept `onDeleteSet`:
```js
function WorkoutPage({ day, week, logs, onLogSet, onDeleteSet, onBack, fetchProgression }) {
```
Pass `onDeleteSet` through to each `ExerciseCard`:
```jsx
<ExerciseCard
key={exercise.id || idx}
exercise={exercise}
logs={logs[exercise.id] || []}
progression={progressions[exercise.id]}
expanded={expandedExercise === exercise.id}
onToggle={() => setExpandedExercise(
expandedExercise === exercise.id ? null : exercise.id
)}
onLogSet={onLogSet}
onDeleteSet={onDeleteSet}
/>
```
The ExerciseCard already has `onDeleteSet` in its prop signature and calls it in `handleDeleteSet` from plan 01. No changes needed inside ExerciseCard itself — just confirm `onDeleteSet` is being passed through correctly.
**Behavior when delete is called:**
- If the set being deleted has `completed: true` in setList (was logged to DB), `deleteLog` fires and removes the DB row
- If the set is new and not yet logged (e.g. user added a set but didn't complete it), `onDeleteSet` is still called but the DELETE request will return 404 — the catch block silently ignores this (the row didn't exist; no harm done)
This means the `handleDeleteSet` in ExerciseCard should always call `onDeleteSet` regardless of whether the set was completed — the backend handles the "not found" case gracefully.
</action>
<verify>
In the dev server:
1. Start a workout, complete set 1 of an exercise (logs it to DB)
2. Verify it's logged: `curl "http://localhost:3001/api/logs?user_id=1&program_exercise_id=1&date=TODAY"`
3. Delete set 1 row using the trash icon
4. Verify it's gone from DB: repeat the curl — set_number=1 should no longer appear
5. Add a new set (Vanligt set), complete it — verify it appears in DB as the next set_number
6. Reload the workout — no ghost sets, count matches what was logged
</verify>
<done>
deleteLog in App.jsx calls DELETE /api/logs. WorkoutPage passes onDeleteSet to ExerciseCard. Deleting a completed set removes it from the database and from local logs state. Non-logged sets delete silently without error.
</done>
</task>
</tasks>
<verification>
Run `npm run build` in frontend/ — must pass with no errors.
Full flow test:
1. Open a workout
2. Add 2 extra sets to the first exercise (Vanligt set)
3. Complete all sets — verify they all persist in DB
4. Delete the middle set — verify DB row removed, UI renumbers
5. Save workout (navigate back to dashboard)
6. Re-open same workout — set count matches what was logged, no ghost rows
</verification>
<success_criteria>
DELETE /api/logs endpoint exists in backend. App.jsx has deleteLog function. WorkoutPage passes onDeleteSet to ExerciseCard. Deleting a logged set removes it from DB. Adding and completing new sets saves beyond original program set count. Frontend build passes.
</success_criteria>
<output>
After completion, create `.planning/phases/02-flexible-sets/02-02-SUMMARY.md`
</output>