Files
gravl/.planning/phases/02-flexible-sets/02-01-PLAN.md
T

14 KiB
Raw Blame History

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
frontend/src/pages/WorkoutPage.jsx
frontend/src/App.css
true
truths artifacts key_links
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...)
path provides contains
frontend/src/pages/WorkoutPage.jsx ExerciseCard with dynamic set array, add-set modal, delete-set button, onDeleteSet prop (stub) setList
path provides contains
frontend/src/App.css Modal overlay CSS, add-set button CSS, delete-set button CSS .set-type-modal
from to via pattern
ExerciseCard setList state set rows rendered setList.map() instead of Array.from({ length: exercise.sets }) setList.map
from to via pattern
Trash icon button setList filter handleDeleteSet removes index from setList array handleDeleteSet
from to via pattern
'Lägg till set' button modal open state setShowAddModal(true) showAddModal
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.

<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.

Task 2: Add CSS for modal overlay, add-set button, and delete-set button frontend/src/App.css 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:

/* 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.

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"

<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`