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

21 KiB
Raw Blame History

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

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:

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

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:

// 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>
    </>
  );
}
/* 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

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:

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

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)

// 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)

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

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

// 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)

Secondary (MEDIUM confidence)

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)