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).
2. Select a Unilateral exercise (created in 2.12).
3. Enter Weight/Reps.
4. Select 'Left' from the Side selector.
4. Select 'L' from the Side selector.
5. Click 'Log Set'.
6. Repeat for 'Right' side.
7. Repeat for 'Alternately' side.
6. Repeat for 'R' 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:**
- 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

View File

@@ -66,7 +66,8 @@ Users can structure their training via Plans.
* `PLYOMETRIC`: Requires **Reps**.
* **3.3.2 Custom 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.
### 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 { formatSetMetrics } from '../utils/setFormatting';
import { useSession } from '../context/SessionContext';
import { useAuth } from '../context/AuthContext';
import { getExercises } from '../services/storage';
import { Button } from './ui/Button';
import { Card } from './ui/Card';
import { Modal } from './ui/Modal';
@@ -15,11 +17,20 @@ interface HistoryProps {
const History: React.FC<HistoryProps> = ({ lang }) => {
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 [deletingId, setDeletingId] = useState<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 bw = session.userBodyWeight || 70;
@@ -455,25 +466,36 @@ const History: React.FC<HistoryProps> = ({ lang }) => {
)}
</div>
{/* Side Selector - Full width on mobile, 1 col on desktop if space */}
{set.side && (
{(() => {
const exDef = exercises.find(e => e.id === set.exerciseId);
const showSide = set.side || exDef?.isUnilateral;
if (!showSide) return null;
return (
<div className="bg-surface-container-high rounded px-2 py-1 col-span-2 sm:col-span-1 border border-outline-variant/30">
<label className="text-[10px] text-on-surface-variant font-bold block mb-1">{t('unilateral', lang)}</label>
<div className="flex bg-surface-container-low rounded p-0.5">
{(['LEFT', 'RIGHT', 'ALTERNATELY'] as const).map((sideOption) => (
{(['LEFT', 'ALTERNATELY', 'RIGHT'] as const).map((sideOption) => {
const labelMap: Record<string, string> = { LEFT: 'L', RIGHT: 'R', ALTERNATELY: 'A' };
return (
<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'
}`}
>
{t(sideOption.toLowerCase() as any, lang).slice(0, 3)}
{labelMap[sideOption]}
</button>
))}
);
})}
</div>
</div>
)}
);
})()}
</div>
))}
</div>

View File

@@ -60,6 +60,8 @@ const ActiveSessionView: React.FC<ActiveSessionViewProps> = ({ tracker, activeSe
setEditDistance,
editHeight,
setEditHeight,
editSide,
setEditSide,
handleCancelEdit,
handleSaveEdit,
handleEditSet,
@@ -243,6 +245,39 @@ const ActiveSessionView: React.FC<ActiveSessionViewProps> = ({ tracker, activeSe
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>
) : (

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">
<button
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'
}`}
>
{t('left', lang)}
L
</button>
<button
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'
}`}
>
{t('alternately', lang) || 'Alternately'}
A
</button>
<button
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'
}`}
>
{t('right', lang)}
R
</button>
</div>
)}

View File

@@ -143,6 +143,39 @@ const SporadicView: React.FC<SporadicViewProps> = ({ tracker, lang }) => {
</button>
</div>
<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') && (
<>
<div>

View File

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

View File

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

View File

@@ -392,38 +392,73 @@ test.describe('III. Workout Tracking', () => {
await page.getByRole('textbox', { name: /Select Exercise/i }).click();
await page.getByText(exName).click();
// Expect Left/Right selector
await expect(page.getByText(/Left/i)).toBeVisible();
// Expect L/R/A selector
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
await page.getByText('Left').first().click();
await page.getByLabel('Weight (kg)').fill('20');
await page.getByLabel('Reps').first().fill('10');
await page.getByRole('button', { name: /Log Set/i }).click();
// Helper to log a set
const logSet = async (side: 'L' | 'R' | 'A') => {
// Find the logger container (has 'Log Set' button)
const logger = page.locator('div').filter({ has: page.getByRole('button', { name: /Log Set/i }) }).last();
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('20 kg x 10 reps')).toBeVisible();
await expect(page.getByText(/20.*10/)).toBeVisible();
// Log Right
await page.getByText('Right').first().click();
await page.getByLabel('Weight (kg)').fill('20');
await page.getByLabel('Reps').first().fill('10');
await page.getByRole('button', { name: /Log Set/i }).click();
// Log Right (R)
await logSet('R');
// Verify Right set
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');
await page.getByLabel('Reps').first().fill('10');
await page.getByRole('button', { name: /Log Set/i }).click();
await expect(page.getByText(/Alternately|Alt/i).last()).toBeVisible();
// Edit the Right set to be Alternately
// Use a stable locator for the row (first item in history list)
// The class 'bg-surface-container' and 'shadow-elevation-1' identifies the row card.
// 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 }) => {