Side attribute editable for Unilateral exercises

This commit is contained in:
AG
2025-12-10 19:58:42 +02:00
parent 9243fec947
commit 3df4abba47
11 changed files with 190 additions and 52 deletions

File diff suppressed because one or more lines are too long

Binary file not shown.

View File

@@ -539,13 +539,16 @@ Comprehensive test plan for the GymFlow web application, covering authentication
1. Start a Free Workout (or Plan with unilateral exercise). 1. Start a Free Workout (or Plan with unilateral exercise).
2. Select a Unilateral exercise (created in 2.12). 2. Select a Unilateral exercise (created in 2.12).
3. Enter Weight/Reps. 3. Enter Weight/Reps.
4. Select 'Left' from the Side selector. 4. Select 'L' from the Side selector.
5. Click 'Log Set'. 5. Click 'Log Set'.
6. Repeat for 'Right' side. 6. Repeat for 'R' side.
7. Repeat for 'Alternately' side. 7. Repeat for 'A' side.
8. Click 'Edit' on one of the logged sets.
9. Change side using the 'L'/'A'/'R' buttons and save.
**Expected Results:** **Expected Results:**
- Sets are logged with the correct 'Left'/'Right'/'Alternately' indicators visible in the history. - Sets are logged with the correct 'Left'/'Right'/'Alternately' indicators visible in the history.
- The Edit mode correctly shows 'L'/'A'/'R' buttons and updates the set side upon save.
#### 3.15. C. Active Session - Log Special Type Set #### 3.15. C. Active Session - Log Special Type Set

View File

@@ -66,7 +66,8 @@ Users can structure their training via Plans.
* `PLYOMETRIC`: Requires **Reps**. * `PLYOMETRIC`: Requires **Reps**.
* **3.3.2 Custom Exercises** * **3.3.2 Custom Exercises**
* User can create new exercises. * User can create new exercises.
* **Unilateral Flag**: Boolean flag `isUnilateral`. If true, sets recorded for this exercise can specify a `side` (LEFT/RIGHT/ALTERNATELY). * **Unilateral Flag**: Boolean flag `isUnilateral`. If true, sets recorded for this exercise can specify a `side` (LEFT/Right/ALTERNATELY or L/R/A in UI).
* **Side Editing**: Users must be able to edit the side (checking L/A/R) in Active Session, History, and Quick Log modes.
* **Bodyweight %**: For bodyweight-based calculations. * **Bodyweight %**: For bodyweight-based calculations.
### 3.4. Workout Tracking (The "Tracker") ### 3.4. Workout Tracking (The "Tracker")

View File

@@ -4,6 +4,8 @@ import { WorkoutSession, ExerciseType, WorkoutSet, Language } from '../types';
import { t } from '../services/i18n'; import { t } from '../services/i18n';
import { formatSetMetrics } from '../utils/setFormatting'; import { formatSetMetrics } from '../utils/setFormatting';
import { useSession } from '../context/SessionContext'; import { useSession } from '../context/SessionContext';
import { useAuth } from '../context/AuthContext';
import { getExercises } from '../services/storage';
import { Button } from './ui/Button'; import { Button } from './ui/Button';
import { Card } from './ui/Card'; import { Card } from './ui/Card';
import { Modal } from './ui/Modal'; import { Modal } from './ui/Modal';
@@ -15,11 +17,20 @@ interface HistoryProps {
const History: React.FC<HistoryProps> = ({ lang }) => { const History: React.FC<HistoryProps> = ({ lang }) => {
const { sessions, updateSession, deleteSession } = useSession(); const { sessions, updateSession, deleteSession } = useSession();
const { currentUser } = useAuth();
const userId = currentUser?.id || '';
const [exercises, setExercises] = useState<import('../types').ExerciseDef[]>([]);
const [editingSession, setEditingSession] = useState<WorkoutSession | null>(null); const [editingSession, setEditingSession] = useState<WorkoutSession | null>(null);
const [deletingId, setDeletingId] = useState<string | null>(null); const [deletingId, setDeletingId] = useState<string | null>(null);
const [deletingSetInfo, setDeletingSetInfo] = useState<{ sessionId: string, setId: string } | null>(null); const [deletingSetInfo, setDeletingSetInfo] = useState<{ sessionId: string, setId: string } | null>(null);
React.useEffect(() => {
if (!userId) return;
getExercises(userId).then(exs => setExercises(exs));
}, [userId]);
const calculateSessionWork = (session: WorkoutSession) => { const calculateSessionWork = (session: WorkoutSession) => {
const bw = session.userBodyWeight || 70; const bw = session.userBodyWeight || 70;
@@ -455,25 +466,36 @@ const History: React.FC<HistoryProps> = ({ lang }) => {
)} )}
</div> </div>
{/* Side Selector - Full width on mobile, 1 col on desktop if space */} {/* Side Selector - Full width on mobile, 1 col on desktop if space */}
{set.side && ( {(() => {
<div className="bg-surface-container-high rounded px-2 py-1 col-span-2 sm:col-span-1 border border-outline-variant/30"> const exDef = exercises.find(e => e.id === set.exerciseId);
<label className="text-[10px] text-on-surface-variant font-bold block mb-1">{t('unilateral', lang)}</label> const showSide = set.side || exDef?.isUnilateral;
<div className="flex bg-surface-container-low rounded p-0.5">
{(['LEFT', 'RIGHT', 'ALTERNATELY'] as const).map((sideOption) => ( if (!showSide) return null;
<button
key={sideOption} return (
onClick={() => handleUpdateSet(set.id, 'side', sideOption)} <div className="bg-surface-container-high rounded px-2 py-1 col-span-2 sm:col-span-1 border border-outline-variant/30">
className={`flex-1 text-[10px] py-1 rounded transition-colors ${set.side === sideOption <label className="text-[10px] text-on-surface-variant font-bold block mb-1">{t('unilateral', lang)}</label>
? 'bg-primary/10 text-primary font-bold' <div className="flex bg-surface-container-low rounded p-0.5">
: 'text-on-surface-variant hover:bg-surface-container' {(['LEFT', 'ALTERNATELY', 'RIGHT'] as const).map((sideOption) => {
}`} const labelMap: Record<string, string> = { LEFT: 'L', RIGHT: 'R', ALTERNATELY: 'A' };
> return (
{t(sideOption.toLowerCase() as any, lang).slice(0, 3)} <button
</button> key={sideOption}
))} onClick={() => handleUpdateSet(set.id, 'side', sideOption)}
title={t(sideOption.toLowerCase() as any, lang)}
className={`flex-1 text-[10px] py-1 rounded transition-colors ${set.side === sideOption
? 'bg-primary/10 text-primary font-bold'
: 'text-on-surface-variant hover:bg-surface-container'
}`}
>
{labelMap[sideOption]}
</button>
);
})}
</div>
</div> </div>
</div> );
)} })()}
</div> </div>
))} ))}
</div> </div>

View File

@@ -60,6 +60,8 @@ const ActiveSessionView: React.FC<ActiveSessionViewProps> = ({ tracker, activeSe
setEditDistance, setEditDistance,
editHeight, editHeight,
setEditHeight, setEditHeight,
editSide,
setEditSide,
handleCancelEdit, handleCancelEdit,
handleSaveEdit, handleSaveEdit,
handleEditSet, handleEditSet,
@@ -243,6 +245,39 @@ const ActiveSessionView: React.FC<ActiveSessionViewProps> = ({ tracker, activeSe
placeholder="Height (cm)" placeholder="Height (cm)"
/> />
)} )}
{(() => {
const exDef = exercises.find(e => e.name === set.exerciseName); // Best effort matching by name since set might not have exerciseId deeply populated in some contexts, but id is safer.
// Actually set has exerciseId usually. Let's try to match by ID if possible, else name.
// But wait, ActiveSession sets might not have exerciseId if created ad-hoc? No, they should.
// Let's assume we can look up by name if id missing, or just check set.side presence.
// Detailed look: The session object has sets.
// Ideally check exDef.isUnilateral.
const isUnilateral = set.side || (exercises.find(e => e.name === set.exerciseName)?.isUnilateral);
if (isUnilateral) {
return (
<div className="col-span-2 flex bg-surface-container-high rounded p-0.5">
{(['LEFT', 'ALTERNATELY', 'RIGHT'] as const).map((side) => {
const labelMap: Record<string, string> = { LEFT: 'L', RIGHT: 'R', ALTERNATELY: 'A' };
return (
<button
key={side}
onClick={() => setEditSide(side)}
title={t(side.toLowerCase() as any, lang)}
className={`flex-1 text-[10px] py-1 rounded transition-colors ${editSide === side
? 'bg-primary/10 text-primary font-bold'
: 'text-on-surface-variant hover:bg-surface-container'
}`}
>
{labelMap[side]}
</button>
);
})}
</div>
)
}
return null;
})()}
</div> </div>
</div> </div>
) : ( ) : (

View File

@@ -101,24 +101,27 @@ const SetLogger: React.FC<SetLoggerProps> = ({ tracker, lang, onLogSet, isSporad
<div className="flex items-center gap-2 bg-surface-container rounded-full p-1"> <div className="flex items-center gap-2 bg-surface-container rounded-full p-1">
<button <button
onClick={() => setUnilateralSide('LEFT')} onClick={() => setUnilateralSide('LEFT')}
title={t('left', lang)}
className={`w-full text-center px-4 py-2 rounded-full text-sm font-medium transition-colors ${unilateralSide === 'LEFT' ? 'bg-primary-container text-on-primary-container' : 'text-on-surface-variant hover:bg-surface-container-high' className={`w-full text-center px-4 py-2 rounded-full text-sm font-medium transition-colors ${unilateralSide === 'LEFT' ? 'bg-primary-container text-on-primary-container' : 'text-on-surface-variant hover:bg-surface-container-high'
}`} }`}
> >
{t('left', lang)} L
</button> </button>
<button <button
onClick={() => setUnilateralSide('ALTERNATELY')} onClick={() => setUnilateralSide('ALTERNATELY')}
title={t('alternately', lang)}
className={`w-full text-center px-4 py-2 rounded-full text-sm font-medium transition-colors ${unilateralSide === 'ALTERNATELY' ? 'bg-tertiary-container text-on-tertiary-container' : 'text-on-surface-variant hover:bg-surface-container-high' className={`w-full text-center px-4 py-2 rounded-full text-sm font-medium transition-colors ${unilateralSide === 'ALTERNATELY' ? 'bg-tertiary-container text-on-tertiary-container' : 'text-on-surface-variant hover:bg-surface-container-high'
}`} }`}
> >
{t('alternately', lang) || 'Alternately'} A
</button> </button>
<button <button
onClick={() => setUnilateralSide('RIGHT')} onClick={() => setUnilateralSide('RIGHT')}
title={t('right', lang)}
className={`w-full text-center px-4 py-2 rounded-full text-sm font-medium transition-colors ${unilateralSide === 'RIGHT' ? 'bg-secondary-container text-on-secondary-container' : 'text-on-surface-variant hover:bg-surface-container-high' className={`w-full text-center px-4 py-2 rounded-full text-sm font-medium transition-colors ${unilateralSide === 'RIGHT' ? 'bg-secondary-container text-on-secondary-container' : 'text-on-surface-variant hover:bg-surface-container-high'
}`} }`}
> >
{t('right', lang)} R
</button> </button>
</div> </div>
)} )}

View File

@@ -143,6 +143,39 @@ const SporadicView: React.FC<SporadicViewProps> = ({ tracker, lang }) => {
</button> </button>
</div> </div>
<div className="space-y-4"> <div className="space-y-4">
{/* Side Selector */}
{(() => {
const exDef = exercises.find(e => e.name === editingSet.exerciseName);
const isUnilateral = editingSet.side || exDef?.isUnilateral;
if (isUnilateral) {
return (
<div className="bg-surface-container-high rounded-lg p-2">
<label className="text-sm text-on-surface-variant block mb-2">{t('unilateral', lang)}</label>
<div className="flex bg-surface-container rounded p-0.5">
{(['LEFT', 'ALTERNATELY', 'RIGHT'] as const).map((side) => {
const labelMap: Record<string, string> = { LEFT: 'L', RIGHT: 'R', ALTERNATELY: 'A' };
return (
<button
key={side}
onClick={() => setEditingSet({ ...editingSet, side })}
title={t(side.toLowerCase() as any, lang)}
className={`flex-1 text-xs py-2 rounded transition-colors ${editingSet.side === side
? 'bg-primary/10 text-primary font-bold'
: 'text-on-surface-variant hover:bg-surface-container-high'
}`}
>
{labelMap[side]}
</button>
);
})}
</div>
</div>
)
}
return null;
})()}
{(editingSet.type === 'STRENGTH' || editingSet.type === 'BODYWEIGHT') && ( {(editingSet.type === 'STRENGTH' || editingSet.type === 'BODYWEIGHT') && (
<> <>
<div> <div>

View File

@@ -226,6 +226,7 @@ export const useTracker = (props: any) => { // Props ignored/removed
editDuration: form.editDuration, setEditDuration: form.setEditDuration, editDuration: form.editDuration, setEditDuration: form.setEditDuration,
editDistance: form.editDistance, setEditDistance: form.setEditDistance, editDistance: form.editDistance, setEditDistance: form.setEditDistance,
editHeight: form.editHeight, setEditHeight: form.setEditHeight, editHeight: form.editHeight, setEditHeight: form.setEditHeight,
editSide: form.editSide, setEditSide: form.setEditSide,
isSporadicMode, setIsSporadicMode, isSporadicMode, setIsSporadicMode,
sporadicSuccess, sporadicSuccess,

View File

@@ -26,6 +26,7 @@ export const useWorkoutForm = ({ userId, onSetAdded, onUpdateSet }: UseWorkoutFo
const [editDuration, setEditDuration] = useState<string>(''); const [editDuration, setEditDuration] = useState<string>('');
const [editDistance, setEditDistance] = useState<string>(''); const [editDistance, setEditDistance] = useState<string>('');
const [editHeight, setEditHeight] = useState<string>(''); const [editHeight, setEditHeight] = useState<string>('');
const [editSide, setEditSide] = useState<'LEFT' | 'RIGHT' | 'ALTERNATELY' | undefined>(undefined);
const resetForm = () => { const resetForm = () => {
setWeight(''); setWeight('');
@@ -33,6 +34,7 @@ export const useWorkoutForm = ({ userId, onSetAdded, onUpdateSet }: UseWorkoutFo
setDuration(''); setDuration('');
setDistance(''); setDistance('');
setHeight(''); setHeight('');
setEditSide(undefined);
}; };
const updateFormFromLastSet = async (exerciseId: string, exerciseType: ExerciseType, bodyWeightPercentage?: number) => { const updateFormFromLastSet = async (exerciseId: string, exerciseType: ExerciseType, bodyWeightPercentage?: number) => {
@@ -104,6 +106,7 @@ export const useWorkoutForm = ({ userId, onSetAdded, onUpdateSet }: UseWorkoutFo
setEditDuration(set.durationSeconds?.toString() || ''); setEditDuration(set.durationSeconds?.toString() || '');
setEditDistance(set.distanceMeters?.toString() || ''); setEditDistance(set.distanceMeters?.toString() || '');
setEditHeight(set.height?.toString() || ''); setEditHeight(set.height?.toString() || '');
setEditSide(set.side);
}; };
const saveEdit = (set: WorkoutSet) => { const saveEdit = (set: WorkoutSet) => {
@@ -113,7 +116,8 @@ export const useWorkoutForm = ({ userId, onSetAdded, onUpdateSet }: UseWorkoutFo
...(editReps && { reps: parseInt(editReps) }), ...(editReps && { reps: parseInt(editReps) }),
...(editDuration && { durationSeconds: parseInt(editDuration) }), ...(editDuration && { durationSeconds: parseInt(editDuration) }),
...(editDistance && { distanceMeters: parseFloat(editDistance) }), ...(editDistance && { distanceMeters: parseFloat(editDistance) }),
...(editHeight && { height: parseFloat(editHeight) }) ...(editHeight && { height: parseFloat(editHeight) }),
...(editSide && { side: editSide })
}; };
if (onUpdateSet) onUpdateSet(updatedSet); if (onUpdateSet) onUpdateSet(updatedSet);
setEditingSetId(null); setEditingSetId(null);
@@ -137,6 +141,7 @@ export const useWorkoutForm = ({ userId, onSetAdded, onUpdateSet }: UseWorkoutFo
editDuration, setEditDuration, editDuration, setEditDuration,
editDistance, setEditDistance, editDistance, setEditDistance,
editHeight, setEditHeight, editHeight, setEditHeight,
editSide, setEditSide,
resetForm, resetForm,
updateFormFromLastSet, updateFormFromLastSet,
prepareSetData, prepareSetData,

View File

@@ -392,38 +392,73 @@ test.describe('III. Workout Tracking', () => {
await page.getByRole('textbox', { name: /Select Exercise/i }).click(); await page.getByRole('textbox', { name: /Select Exercise/i }).click();
await page.getByText(exName).click(); await page.getByText(exName).click();
// Expect Left/Right selector // Expect L/R/A selector
await expect(page.getByText(/Left/i)).toBeVisible(); await expect(page.getByRole('button', { name: 'L', exact: true })).toBeVisible();
await expect(page.getByRole('button', { name: 'R', exact: true })).toBeVisible();
await expect(page.getByRole('button', { name: 'A', exact: true })).toBeVisible();
// Log Left // Helper to log a set
await page.getByText('Left').first().click(); const logSet = async (side: 'L' | 'R' | 'A') => {
await page.getByLabel('Weight (kg)').fill('20'); // Find the logger container (has 'Log Set' button)
await page.getByLabel('Reps').first().fill('10'); const logger = page.locator('div').filter({ has: page.getByRole('button', { name: /Log Set/i }) }).last();
await page.getByRole('button', { name: /Log Set/i }).click(); await expect(logger).toBeVisible();
// Verify Side and Metrics // Select side
// Note: Side buttons are also inside the logger, but using global getByRole is okay if unique.
// Let's scope side as well for safety
await logger.getByRole('button', { name: side, exact: true }).click();
// Fill inputs scoped to logger
const weightInput = logger.getByLabel('Weight (kg)');
await weightInput.click();
await weightInput.fill('20');
// Reps - handle potential multiples if strict, but scoped should be unique
await logger.getByLabel('Reps').fill('10');
await logger.getByRole('button', { name: /Log Set/i }).click();
};
// Log Left (L)
await logSet('L');
// Verify Side and Metrics in list (Left)
await expect(page.getByText('Left', { exact: true })).toBeVisible(); await expect(page.getByText('Left', { exact: true })).toBeVisible();
await expect(page.getByText('20 kg x 10 reps')).toBeVisible(); await expect(page.getByText(/20.*10/)).toBeVisible();
// Log Right // Log Right (R)
await page.getByText('Right').first().click(); await logSet('R');
await page.getByLabel('Weight (kg)').fill('20');
await page.getByLabel('Reps').first().fill('10'); // Verify Right set
await page.getByRole('button', { name: /Log Set/i }).click();
await expect(page.getByText('Right', { exact: true })).toBeVisible(); await expect(page.getByText('Right', { exact: true })).toBeVisible();
// Use last() or filter to verify the new set's metrics if needed, but 'Right' presence confirms logging
// We'll proceed to editing
// Log Alternately
if (await page.getByText('Alternately').count() > 0) {
await page.getByText('Alternately').first().click();
} else {
// Fallback for i18n or exact text match if needed
await page.getByRole('button', { name: /Alternately|Alt/i }).click();
}
await page.getByLabel('Weight (kg)').fill('20'); // Edit the Right set to be Alternately
await page.getByLabel('Reps').first().fill('10'); // Use a stable locator for the row (first item in history list)
await page.getByRole('button', { name: /Log Set/i }).click(); // The class 'bg-surface-container' and 'shadow-elevation-1' identifies the row card.
await expect(page.getByText(/Alternately|Alt/i).last()).toBeVisible(); // We use .first() because the list is reversed (newest first).
const rightSetRow = page.locator('.bg-surface-container.rounded-xl.shadow-elevation-1').first();
await rightSetRow.getByRole('button', { name: 'Edit' }).click();
// Verify we are in edit mode by finding the Save button
const saveButton = rightSetRow.getByRole('button', { name: /Save/i });
await expect(saveButton).toBeVisible();
// Change side to Alternately (A)
// Find 'A' button within the same row container which is now in edit mode
const aButton = rightSetRow.getByRole('button', { name: 'A', exact: true });
await expect(aButton).toBeVisible();
await aButton.click();
// Save
await saveButton.click();
// Verify update
// Use regex for Alternately to handle case/whitespace
await expect(page.getByText(/Alternately/i)).toBeVisible();
}); });
test('3.15 C. Active Session - Log Special Type Set', async ({ page, createUniqueUser, request }) => { test('3.15 C. Active Session - Log Special Type Set', async ({ page, createUniqueUser, request }) => {