Edit modals for Sets are complete
This commit is contained in:
@@ -55,6 +55,9 @@ export class SessionService {
|
||||
reps: s.reps,
|
||||
distanceMeters: s.distanceMeters,
|
||||
durationSeconds: s.durationSeconds,
|
||||
height: s.height,
|
||||
bodyWeightPercentage: s.bodyWeightPercentage,
|
||||
side: s.side,
|
||||
completed: s.completed !== undefined ? s.completed : true
|
||||
}))
|
||||
}
|
||||
@@ -95,6 +98,9 @@ export class SessionService {
|
||||
reps: s.reps,
|
||||
distanceMeters: s.distanceMeters,
|
||||
durationSeconds: s.durationSeconds,
|
||||
height: s.height,
|
||||
bodyWeightPercentage: s.bodyWeightPercentage,
|
||||
side: s.side,
|
||||
completed: s.completed !== undefined ? s.completed : true
|
||||
}))
|
||||
}
|
||||
@@ -160,6 +166,9 @@ export class SessionService {
|
||||
reps: s.reps,
|
||||
distanceMeters: s.distanceMeters,
|
||||
durationSeconds: s.durationSeconds,
|
||||
height: s.height,
|
||||
bodyWeightPercentage: s.bodyWeightPercentage,
|
||||
side: s.side,
|
||||
completed: s.completed !== undefined ? s.completed : true
|
||||
}))
|
||||
}
|
||||
@@ -248,6 +257,8 @@ export class SessionService {
|
||||
reps: reps ? parseInt(reps) : null,
|
||||
distanceMeters: distanceMeters ? parseFloat(distanceMeters) : null,
|
||||
durationSeconds: durationSeconds ? parseInt(durationSeconds) : null,
|
||||
height: height ? parseFloat(height) : null,
|
||||
bodyWeightPercentage: bodyWeightPercentage ? parseFloat(bodyWeightPercentage) : null,
|
||||
side: side || null
|
||||
},
|
||||
include: { exercise: true }
|
||||
@@ -261,7 +272,7 @@ export class SessionService {
|
||||
}
|
||||
|
||||
static async logSetToActiveSession(userId: string, data: any) {
|
||||
const { exerciseId, reps, weight, distanceMeters, durationSeconds, side } = data;
|
||||
const { exerciseId, reps, weight, distanceMeters, durationSeconds, side, height, bodyWeightPercentage } = data;
|
||||
|
||||
const activeSession = await prisma.workoutSession.findFirst({
|
||||
where: { userId, endTime: null, type: 'STANDARD' },
|
||||
@@ -283,6 +294,8 @@ export class SessionService {
|
||||
weight: weight ? parseFloat(weight) : null,
|
||||
distanceMeters: distanceMeters ? parseFloat(distanceMeters) : null,
|
||||
durationSeconds: durationSeconds ? parseInt(durationSeconds) : null,
|
||||
height: height ? parseFloat(height) : null,
|
||||
bodyWeightPercentage: bodyWeightPercentage ? parseFloat(bodyWeightPercentage) : null,
|
||||
side: side || null,
|
||||
completed: true
|
||||
},
|
||||
|
||||
BIN
server/test.db
BIN
server/test.db
Binary file not shown.
217
src/components/EditSetModal.tsx
Normal file
217
src/components/EditSetModal.tsx
Normal file
@@ -0,0 +1,217 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Dumbbell, Activity, Percent, Timer, ArrowRight, ArrowUp, Save, Calendar, Clock } from 'lucide-react';
|
||||
import { WorkoutSet, ExerciseType, ExerciseDef, Language } from '../types';
|
||||
import { t } from '../services/i18n';
|
||||
import { formatSetMetrics } from '../utils/setFormatting';
|
||||
import { Modal } from './ui/Modal';
|
||||
import { Button } from './ui/Button';
|
||||
|
||||
interface EditSetModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
set: WorkoutSet;
|
||||
exerciseDef?: ExerciseDef;
|
||||
onSave: (updatedSet: WorkoutSet) => Promise<void> | void;
|
||||
lang: Language;
|
||||
}
|
||||
|
||||
const EditSetModal: React.FC<EditSetModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
set: initialSet,
|
||||
exerciseDef,
|
||||
onSave,
|
||||
lang
|
||||
}) => {
|
||||
const [set, setSet] = useState<WorkoutSet>(initialSet);
|
||||
|
||||
// Reset state when modal opens with a new set
|
||||
useEffect(() => {
|
||||
setSet(initialSet);
|
||||
}, [initialSet, isOpen]);
|
||||
|
||||
const handleUpdate = (field: keyof WorkoutSet, value: any) => {
|
||||
setSet(prev => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
await onSave(set);
|
||||
onClose();
|
||||
};
|
||||
|
||||
// Date/Time handling
|
||||
const setDate = new Date(set.timestamp);
|
||||
const dateStr = dateStrFromDate(setDate);
|
||||
const timeStr = timeStrFromDate(setDate);
|
||||
|
||||
function dateStrFromDate(d: Date) {
|
||||
// YYYY-MM-DD
|
||||
const pad = (n: number) => n < 10 ? '0' + n : n;
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
||||
}
|
||||
|
||||
function timeStrFromDate(d: Date) {
|
||||
// HH:mm
|
||||
const pad = (n: number) => n < 10 ? '0' + n : n;
|
||||
return `${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
const handleDateChange = (newDateStr: string) => {
|
||||
// Keep time, change date
|
||||
const current = new Date(set.timestamp);
|
||||
const [y, m, d] = newDateStr.split('-').map(Number);
|
||||
const newDate = new Date(y, m - 1, d, current.getHours(), current.getMinutes(), current.getSeconds());
|
||||
handleUpdate('timestamp', newDate.getTime());
|
||||
};
|
||||
|
||||
const handleTimeChange = (newTimeStr: string) => {
|
||||
// Keep date, change time
|
||||
const current = new Date(set.timestamp);
|
||||
const [h, min] = newTimeStr.split(':').map(Number);
|
||||
const newDate = new Date(current.getFullYear(), current.getMonth(), current.getDate(), h, min, current.getSeconds());
|
||||
handleUpdate('timestamp', newDate.getTime());
|
||||
};
|
||||
|
||||
// Determine functionality based on resolved type
|
||||
// Fallback: Check feature availability based on Type String or Prop existence
|
||||
const type = (exerciseDef?.type || set.type) as string;
|
||||
|
||||
const hasWeight = ['STRENGTH', 'BODYWEIGHT', 'STATIC', 'Strength', 'Bodyweight', 'Static'].includes(type) || set.weight !== null;
|
||||
const hasReps = ['STRENGTH', 'BODYWEIGHT', 'PLYOMETRIC', 'Strength', 'Bodyweight', 'Plyometric'].includes(type) || set.reps !== null;
|
||||
const hasBwPercent = ['BODYWEIGHT', 'STATIC', 'Bodyweight', 'Static'].includes(type) || set.bodyWeightPercentage !== null;
|
||||
const hasTime = ['CARDIO', 'STATIC', 'Cardio', 'Static'].includes(type) || set.durationSeconds !== null;
|
||||
const hasDist = ['CARDIO', 'LONG_JUMP', 'Cardio', 'Long Jump'].includes(type) || set.distanceMeters !== null;
|
||||
const hasHeight = ['HIGH_JUMP', 'High Jump'].includes(type) || set.height !== null;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title={t('edit_set', lang) || 'Edit Set'}
|
||||
maxWidth="sm"
|
||||
>
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-medium text-on-surface mb-1">
|
||||
{set.exerciseName}
|
||||
</h3>
|
||||
<p className="text-sm text-on-surface-variant">
|
||||
{exerciseDef?.type || set.type}
|
||||
{set.side && ` • ${t(set.side.toLowerCase() as any, lang)}`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Date & Time */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="bg-surface-container-high rounded px-3 py-2">
|
||||
<label className="text-[10px] text-on-surface-variant font-bold flex gap-1 items-center mb-1">
|
||||
<Calendar size={10} /> {t('date', lang) || 'Date'}
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={dateStr}
|
||||
onChange={(e) => handleDateChange(e.target.value)}
|
||||
className="w-full bg-transparent text-sm text-on-surface focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div className="bg-surface-container-high rounded px-3 py-2">
|
||||
<label className="text-[10px] text-on-surface-variant font-bold flex gap-1 items-center mb-1">
|
||||
<Clock size={10} /> {t('time', lang) || 'Time'}
|
||||
</label>
|
||||
<input
|
||||
type="time"
|
||||
value={timeStr}
|
||||
onChange={(e) => handleTimeChange(e.target.value)}
|
||||
className="w-full bg-transparent text-sm text-on-surface focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Metrics */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{hasWeight && (
|
||||
<div className="bg-surface-container-high rounded px-3 py-2">
|
||||
<label className="text-[10px] text-on-surface-variant flex gap-1 items-center mb-1"><Dumbbell size={10} /> {t('weight_kg', lang)}</label>
|
||||
<input
|
||||
type="number"
|
||||
className="w-full bg-transparent text-lg text-on-surface focus:outline-none"
|
||||
value={set.weight === 0 ? '' : (set.weight ?? '')}
|
||||
onChange={(e) => handleUpdate('weight', parseFloat(e.target.value))}
|
||||
placeholder="0"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{hasReps && (
|
||||
<div className="bg-surface-container-high rounded px-3 py-2">
|
||||
<label className="text-[10px] text-on-surface-variant flex gap-1 items-center mb-1"><Activity size={10} /> {t('reps', lang)}</label>
|
||||
<input
|
||||
type="number"
|
||||
className="w-full bg-transparent text-lg text-on-surface focus:outline-none"
|
||||
value={set.reps === 0 ? '' : (set.reps ?? '')}
|
||||
onChange={(e) => handleUpdate('reps', parseInt(e.target.value))}
|
||||
placeholder="0"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{hasBwPercent && (
|
||||
<div className="bg-surface-container-high rounded px-3 py-2">
|
||||
<label className="text-[10px] text-on-surface-variant flex gap-1 items-center mb-1"><Percent size={10} /> {t('body_weight_percent', lang)}</label>
|
||||
<input
|
||||
type="number"
|
||||
className="w-full bg-transparent text-lg text-on-surface focus:outline-none"
|
||||
value={set.bodyWeightPercentage === 0 ? '' : (set.bodyWeightPercentage ?? 100)}
|
||||
onChange={(e) => handleUpdate('bodyWeightPercentage', parseFloat(e.target.value))}
|
||||
placeholder="100"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{hasTime && (
|
||||
<div className="bg-surface-container-high rounded px-3 py-2">
|
||||
<label className="text-[10px] text-on-surface-variant flex gap-1 items-center mb-1"><Timer size={10} /> {t('time_sec', lang)}</label>
|
||||
<input
|
||||
type="number"
|
||||
className="w-full bg-transparent text-lg text-on-surface focus:outline-none"
|
||||
value={set.durationSeconds === 0 ? '' : (set.durationSeconds ?? '')}
|
||||
onChange={(e) => handleUpdate('durationSeconds', parseFloat(e.target.value))}
|
||||
placeholder="0"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{hasDist && (
|
||||
<div className="bg-surface-container-high rounded px-3 py-2">
|
||||
<label className="text-[10px] text-on-surface-variant flex gap-1 items-center mb-1"><ArrowRight size={10} /> {t('dist_m', lang)}</label>
|
||||
<input
|
||||
type="number"
|
||||
className="w-full bg-transparent text-lg text-on-surface focus:outline-none"
|
||||
value={set.distanceMeters === 0 ? '' : (set.distanceMeters ?? '')}
|
||||
onChange={(e) => handleUpdate('distanceMeters', parseFloat(e.target.value))}
|
||||
placeholder="0"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{hasHeight && (
|
||||
<div className="bg-surface-container-high rounded px-3 py-2">
|
||||
<label className="text-[10px] text-on-surface-variant flex gap-1 items-center mb-1"><ArrowUp size={10} /> {t('height_cm', lang)}</label>
|
||||
<input
|
||||
type="number"
|
||||
className="w-full bg-transparent text-lg text-on-surface focus:outline-none"
|
||||
value={set.height === 0 ? '' : (set.height ?? '')}
|
||||
onChange={(e) => handleUpdate('height', parseFloat(e.target.value))}
|
||||
placeholder="0"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end pt-4 border-t border-outline-variant">
|
||||
<Button onClick={handleSave}>
|
||||
<Save size={16} className="mr-2" />
|
||||
{t('save', lang)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditSetModal;
|
||||
@@ -9,6 +9,7 @@ import { getExercises } from '../services/storage';
|
||||
import { Button } from './ui/Button';
|
||||
import { Card } from './ui/Card';
|
||||
import { Modal } from './ui/Modal';
|
||||
import EditSetModal from './EditSetModal';
|
||||
import FilledInput from './FilledInput';
|
||||
|
||||
interface HistoryProps {
|
||||
@@ -25,6 +26,38 @@ const History: React.FC<HistoryProps> = ({ lang }) => {
|
||||
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
const [deletingSetInfo, setDeletingSetInfo] = useState<{ sessionId: string, setId: string } | null>(null);
|
||||
const [editingSetInfo, setEditingSetInfo] = useState<{ sessionId: string, set: WorkoutSet } | null>(null);
|
||||
|
||||
const handleSaveSingleSet = async (updatedSet: WorkoutSet) => {
|
||||
if (!editingSetInfo) return;
|
||||
const session = sessions.find(s => s.id === editingSetInfo.sessionId);
|
||||
if (session) {
|
||||
const updatedSets = session.sets.map(s => s.id === updatedSet.id ? updatedSet : s);
|
||||
const updatedSession = { ...session, sets: updatedSets };
|
||||
try {
|
||||
await updateSession(updatedSession);
|
||||
setEditingSetInfo(null);
|
||||
} catch (e) {
|
||||
console.error("Failed to update set", e);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveSetFromModal = async (updatedSet: WorkoutSet) => {
|
||||
if (!editingSetInfo) return;
|
||||
|
||||
// If editing within the session edit modal, update local state only
|
||||
if (editingSession && editingSession.id === editingSetInfo.sessionId) {
|
||||
const updatedSets = editingSession.sets.map(s =>
|
||||
s.id === updatedSet.id ? updatedSet : s
|
||||
);
|
||||
setEditingSession({ ...editingSession, sets: updatedSets });
|
||||
setEditingSetInfo(null);
|
||||
} else {
|
||||
// Otherwise save to backend (Quick Log flow)
|
||||
await handleSaveSingleSet(updatedSet);
|
||||
}
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!userId) return;
|
||||
@@ -87,13 +120,7 @@ const History: React.FC<HistoryProps> = ({ lang }) => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateSet = (setId: string, field: keyof WorkoutSet, value: number | string) => {
|
||||
if (!editingSession) return;
|
||||
const updatedSets = editingSession.sets.map(s =>
|
||||
s.id === setId ? { ...s, [field]: value } : s
|
||||
);
|
||||
setEditingSession({ ...editingSession, sets: updatedSets });
|
||||
};
|
||||
|
||||
|
||||
const handleDeleteSet = (setId: string) => {
|
||||
if (!editingSession) return;
|
||||
@@ -267,7 +294,7 @@ const History: React.FC<HistoryProps> = ({ lang }) => {
|
||||
// Find the session this set belongs to and open edit mode
|
||||
const parentSession = daySessions.find(s => s.sets.some(st => st.id === set.id));
|
||||
if (parentSession) {
|
||||
setEditingSession(JSON.parse(JSON.stringify(parentSession)));
|
||||
setEditingSetInfo({ sessionId: parentSession.id, set: set });
|
||||
}
|
||||
}}
|
||||
variant="ghost"
|
||||
@@ -380,122 +407,37 @@ const History: React.FC<HistoryProps> = ({ lang }) => {
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-medium text-primary ml-1">{t('sets_count', lang)} ({editingSession.sets.length})</h3>
|
||||
{editingSession.sets.map((set, idx) => (
|
||||
<div key={set.id} className="bg-surface-container-low p-3 rounded-xl border border-outline-variant/20 flex flex-col gap-3">
|
||||
<div className="flex justify-between items-center border-b border-outline-variant/50 pb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-6 h-6 rounded-full bg-secondary-container text-on-secondary-container text-xs font-bold flex items-center justify-center">{idx + 1}</span>
|
||||
<span className="font-medium text-on-surface text-sm">{set.exerciseName}{set.side && <span className="ml-2 text-xs font-medium text-on-surface-variant">{t(set.side.toLowerCase() as any, lang)}</span>}</span>
|
||||
<div key={set.id} className="bg-surface-container-low p-3 rounded-xl border border-outline-variant/20 flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="w-5 h-5 rounded-full bg-secondary-container text-on-secondary-container text-[10px] font-bold flex items-center justify-center">{idx + 1}</span>
|
||||
<span className="font-medium text-on-surface text-sm">{set.exerciseName}</span>
|
||||
{set.side && <span className="text-xs text-on-surface-variant font-medium bg-surface-container px-1.5 py-0.5 rounded">{t(set.side.toLowerCase() as any, lang)}</span>}
|
||||
</div>
|
||||
<div className="text-sm text-on-surface-variant pl-7">
|
||||
{formatSetMetrics(set, lang)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
onClick={() => setEditingSetInfo({ sessionId: editingSession.id, set: set })}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-on-surface-variant hover:text-primary"
|
||||
title={t('edit', lang)}
|
||||
>
|
||||
<Pencil size={18} />
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => handleDeleteSet(set.id)}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-on-surface-variant hover:text-error hover:bg-error-container/10"
|
||||
className="text-on-surface-variant hover:text-error hover:bg-error-container/10"
|
||||
title={t('delete', lang)}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
<Trash2 size={18} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
|
||||
{(set.type === ExerciseType.STRENGTH || set.type === ExerciseType.BODYWEIGHT || set.type === ExerciseType.STATIC) && (
|
||||
<div className="bg-surface-container-high rounded px-2 py-1">
|
||||
<label className="text-[10px] text-on-surface-variant flex gap-1 items-center"><Dumbbell size={10} /> {t('weight_kg', lang)}</label>
|
||||
<input
|
||||
type="number"
|
||||
className="w-full bg-transparent text-sm text-on-surface focus:outline-none"
|
||||
value={set.weight === 0 ? '' : (set.weight ?? '')}
|
||||
onChange={(e) => handleUpdateSet(set.id, 'weight', parseFloat(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{(set.type === ExerciseType.STRENGTH || set.type === ExerciseType.BODYWEIGHT || set.type === ExerciseType.PLYOMETRIC) && (
|
||||
<div className="bg-surface-container-high rounded px-2 py-1">
|
||||
<label className="text-[10px] text-on-surface-variant flex gap-1 items-center"><Activity size={10} /> {t('reps', lang)}</label>
|
||||
<input
|
||||
type="number"
|
||||
className="w-full bg-transparent text-sm text-on-surface focus:outline-none"
|
||||
value={set.reps === 0 ? '' : (set.reps ?? '')}
|
||||
onChange={(e) => handleUpdateSet(set.id, 'reps', parseInt(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{(set.type === ExerciseType.BODYWEIGHT || set.type === ExerciseType.STATIC) && (
|
||||
<div className="bg-surface-container-high rounded px-2 py-1">
|
||||
<label className="text-[10px] text-on-surface-variant flex gap-1 items-center"><Percent size={10} /> {t('body_weight_percent', lang)}</label>
|
||||
<input
|
||||
type="number"
|
||||
className="w-full bg-transparent text-sm text-on-surface focus:outline-none"
|
||||
value={set.bodyWeightPercentage === 0 ? '' : (set.bodyWeightPercentage ?? 100)}
|
||||
onChange={(e) => handleUpdateSet(set.id, 'bodyWeightPercentage', parseFloat(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{(set.type === ExerciseType.CARDIO || set.type === ExerciseType.STATIC) && (
|
||||
<div className="bg-surface-container-high rounded px-2 py-1">
|
||||
<label className="text-[10px] text-on-surface-variant flex gap-1 items-center"><Timer size={10} /> {t('time_sec', lang)}</label>
|
||||
<input
|
||||
type="number"
|
||||
className="w-full bg-transparent text-sm text-on-surface focus:outline-none"
|
||||
value={set.durationSeconds === 0 ? '' : (set.durationSeconds ?? '')}
|
||||
onChange={(e) => handleUpdateSet(set.id, 'durationSeconds', parseFloat(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{(set.type === ExerciseType.CARDIO || set.type === ExerciseType.LONG_JUMP) && (
|
||||
<div className="bg-surface-container-high rounded px-2 py-1">
|
||||
<label className="text-[10px] text-on-surface-variant flex gap-1 items-center"><ArrowRight size={10} /> {t('dist_m', lang)}</label>
|
||||
<input
|
||||
type="number"
|
||||
className="w-full bg-transparent text-sm text-on-surface focus:outline-none"
|
||||
value={set.distanceMeters === 0 ? '' : (set.distanceMeters ?? '')}
|
||||
onChange={(e) => handleUpdateSet(set.id, 'distanceMeters', parseFloat(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{(set.type === ExerciseType.HIGH_JUMP) && (
|
||||
<div className="bg-surface-container-high rounded px-2 py-1">
|
||||
<label className="text-[10px] text-on-surface-variant flex gap-1 items-center"><ArrowUp size={10} /> {t('height_cm', lang)}</label>
|
||||
<input
|
||||
type="number"
|
||||
className="w-full bg-transparent text-sm text-on-surface focus:outline-none"
|
||||
value={set.height === 0 ? '' : (set.height ?? '')}
|
||||
onChange={(e) => handleUpdateSet(set.id, 'height', parseFloat(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Side Selector - Full width on mobile, 1 col on desktop if space */}
|
||||
{(() => {
|
||||
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', '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'
|
||||
}`}
|
||||
>
|
||||
{labelMap[sideOption]}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -510,6 +452,16 @@ const History: React.FC<HistoryProps> = ({ lang }) => {
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
{editingSetInfo && (
|
||||
<EditSetModal
|
||||
isOpen={!!editingSetInfo}
|
||||
onClose={() => setEditingSetInfo(null)}
|
||||
set={editingSetInfo.set}
|
||||
exerciseDef={exercises.find(e => e.id === editingSetInfo.set.exerciseId)}
|
||||
onSave={handleSaveSetFromModal}
|
||||
lang={lang}
|
||||
/>
|
||||
)}
|
||||
</div >
|
||||
);
|
||||
};
|
||||
|
||||
@@ -10,6 +10,8 @@ import { formatSetMetrics } from '../../utils/setFormatting';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
import { api } from '../../services/api';
|
||||
import RestTimerFAB from '../ui/RestTimerFAB';
|
||||
import EditSetModal from '../EditSetModal';
|
||||
|
||||
|
||||
interface ActiveSessionViewProps {
|
||||
tracker: ReturnType<typeof useTracker>;
|
||||
@@ -79,6 +81,15 @@ const ActiveSessionView: React.FC<ActiveSessionViewProps> = ({ tracker, activeSe
|
||||
// Timer Logic is now managed in useTracker to persist across re-renders/step changes
|
||||
const { timer } = tracker;
|
||||
|
||||
const [editingSet, setEditingSet] = React.useState<WorkoutSet | null>(null);
|
||||
|
||||
const handleSaveSetFromModal = async (updatedSet: WorkoutSet) => {
|
||||
if (tracker.updateSet) {
|
||||
tracker.updateSet(updatedSet);
|
||||
}
|
||||
setEditingSet(null);
|
||||
};
|
||||
|
||||
const handleLogSet = async () => {
|
||||
await handleAddSet();
|
||||
|
||||
@@ -244,131 +255,22 @@ const ActiveSessionView: React.FC<ActiveSessionViewProps> = ({ tracker, activeSe
|
||||
<div className="flex flex-col gap-2">
|
||||
{[...activeSession.sets].reverse().map((set: WorkoutSet, idx: number) => {
|
||||
const setNumber = activeSession.sets.length - idx;
|
||||
const isEditing = editingSetId === set.id;
|
||||
return (
|
||||
<div key={set.id} className="flex justify-between items-center p-4 bg-surface-container rounded-xl shadow-elevation-1 animate-in fade-in slide-in-from-bottom-2">
|
||||
<div className="flex items-center gap-4 flex-1">
|
||||
<div className="w-8 h-8 rounded-full bg-secondary-container text-on-secondary-container flex items-center justify-center text-xs font-bold">
|
||||
{setNumber}
|
||||
</div>
|
||||
{isEditing ? (
|
||||
<div className="flex-1">
|
||||
<div className="text-base font-medium text-on-surface mb-2">{set.exerciseName}{set.side && <span className="ml-2 text-xs font-medium text-on-surface-variant">{t(set.side.toLowerCase() as any, lang)}</span>}</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{set.weight !== undefined && (
|
||||
<input
|
||||
type="number"
|
||||
step="0.1"
|
||||
value={editWeight}
|
||||
onChange={(e) => setEditWeight(e.target.value)}
|
||||
className="px-2 py-1 bg-surface-container-high rounded text-sm text-on-surface border border-outline-variant focus:border-primary focus:outline-none"
|
||||
placeholder="Weight (kg)"
|
||||
/>
|
||||
)}
|
||||
{set.reps !== undefined && (
|
||||
<input
|
||||
type="number"
|
||||
value={editReps}
|
||||
onChange={(e) => setEditReps(e.target.value)}
|
||||
className="px-2 py-1 bg-surface-container-high rounded text-sm text-on-surface border border-outline-variant focus:border-primary focus:outline-none"
|
||||
placeholder="Reps"
|
||||
/>
|
||||
)}
|
||||
{set.durationSeconds !== undefined && (
|
||||
<input
|
||||
type="number"
|
||||
value={editDuration}
|
||||
onChange={(e) => setEditDuration(e.target.value)}
|
||||
className="px-2 py-1 bg-surface-container-high rounded text-sm text-on-surface border border-outline-variant focus:border-primary focus:outline-none"
|
||||
placeholder="Duration (s)"
|
||||
/>
|
||||
)}
|
||||
{set.distanceMeters !== undefined && (
|
||||
<input
|
||||
type="number"
|
||||
step="0.1"
|
||||
value={editDistance}
|
||||
onChange={(e) => setEditDistance(e.target.value)}
|
||||
className="px-2 py-1 bg-surface-container-high rounded text-sm text-on-surface border border-outline-variant focus:border-primary focus:outline-none"
|
||||
placeholder="Distance (m)"
|
||||
/>
|
||||
)}
|
||||
{set.height !== undefined && (
|
||||
<input
|
||||
type="number"
|
||||
step="0.1"
|
||||
value={editHeight}
|
||||
onChange={(e) => setEditHeight(e.target.value)}
|
||||
className="px-2 py-1 bg-surface-container-high rounded text-sm text-on-surface border border-outline-variant focus:border-primary focus:outline-none"
|
||||
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 className="text-base font-medium text-on-surface">{set.exerciseName}{set.side && <span className="ml-2 text-xs font-medium text-on-surface-variant">{t(set.side.toLowerCase() as any, lang)}</span>}</div>
|
||||
<div className="text-sm text-on-surface-variant">
|
||||
{formatSetMetrics(set, lang)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{isEditing ? (
|
||||
<>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={handleCancelEdit}
|
||||
className="p-2 text-on-surface-variant hover:text-on-surface hover:bg-surface-container-high rounded-full transition-colors"
|
||||
aria-label={t('cancel', lang)}
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleSaveEdit(set)}
|
||||
className="p-2 text-primary hover:bg-primary-container/20 rounded-full transition-colors"
|
||||
aria-label={t('save', lang)}
|
||||
>
|
||||
<CheckCircle size={20} />
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
onClick={() => handleEditSet(set)}
|
||||
onClick={() => setEditingSet(set)}
|
||||
className="p-2 text-on-surface-variant hover:text-primary hover:bg-primary-container/20 rounded-full transition-colors"
|
||||
aria-label={t('edit', lang)}
|
||||
>
|
||||
@@ -381,8 +283,6 @@ const ActiveSessionView: React.FC<ActiveSessionViewProps> = ({ tracker, activeSe
|
||||
>
|
||||
<Trash2 size={20} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -456,6 +356,18 @@ const ActiveSessionView: React.FC<ActiveSessionViewProps> = ({ tracker, activeSe
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Edit Set Modal */}
|
||||
{editingSet && (
|
||||
<EditSetModal
|
||||
isOpen={!!editingSet}
|
||||
onClose={() => setEditingSet(null)}
|
||||
set={editingSet}
|
||||
exerciseDef={tracker.exercises.find(e => e.id === editingSet.exerciseId) || tracker.exercises.find(e => e.name === editingSet.exerciseName)}
|
||||
onSave={handleSaveSetFromModal}
|
||||
lang={lang}
|
||||
/>
|
||||
)}
|
||||
|
||||
<RestTimerFAB timer={timer} onDurationChange={handleDurationChange} />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { CheckCircle, Plus, Pencil, Trash2, X, Save } from 'lucide-react';
|
||||
import { Language, WorkoutSet } from '../../types';
|
||||
import { t } from '../../services/i18n';
|
||||
import ExerciseModal from '../ExerciseModal';
|
||||
import EditSetModal from '../EditSetModal';
|
||||
import { useTracker } from './useTracker';
|
||||
import SetLogger from './SetLogger';
|
||||
import { formatSetMetrics } from '../../utils/setFormatting';
|
||||
@@ -157,113 +158,15 @@ const SporadicView: React.FC<SporadicViewProps> = ({ tracker, lang }) => {
|
||||
|
||||
{/* Edit Set Modal */}
|
||||
{editingSetId && editingSet && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
|
||||
<div className="bg-surface-container w-full max-w-md rounded-[28px] p-6 shadow-elevation-3 max-h-[80vh] overflow-y-auto">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h3 className="text-xl font-normal text-on-surface">{t('edit', lang)}</h3>
|
||||
<button
|
||||
onClick={() => {
|
||||
<EditSetModal
|
||||
isOpen={!!editingSetId}
|
||||
onClose={() => {
|
||||
setEditingSetId(null);
|
||||
setEditingSet(null);
|
||||
}}
|
||||
className="p-2 hover:bg-surface-container-high rounded-full transition-colors"
|
||||
>
|
||||
<X size={20} />
|
||||
</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>
|
||||
<label className="text-sm text-on-surface-variant">{t('weight_kg', lang)}</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.1"
|
||||
value={editingSet.weight || ''}
|
||||
onChange={(e) => setEditingSet({ ...editingSet, weight: parseFloat(e.target.value) || 0 })}
|
||||
className="w-full mt-1 px-4 py-2 bg-surface-container-high text-on-surface rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm text-on-surface-variant">{t('reps', lang)}</label>
|
||||
<input
|
||||
type="number"
|
||||
value={editingSet.reps || ''}
|
||||
onChange={(e) => setEditingSet({ ...editingSet, reps: parseInt(e.target.value) || 0 })}
|
||||
className="w-full mt-1 px-4 py-2 bg-surface-container-high text-on-surface rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{(editingSet.type === 'CARDIO' || editingSet.type === 'STATIC') && (
|
||||
<div>
|
||||
<label className="text-sm text-on-surface-variant">{t('time_sec', lang)}</label>
|
||||
<input
|
||||
type="number"
|
||||
value={editingSet.durationSeconds || ''}
|
||||
onChange={(e) => setEditingSet({ ...editingSet, durationSeconds: parseInt(e.target.value) || 0 })}
|
||||
className="w-full mt-1 px-4 py-2 bg-surface-container-high text-on-surface rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{editingSet.type === 'CARDIO' && (
|
||||
<div>
|
||||
<label className="text-sm text-on-surface-variant">{t('dist_m', lang)}</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.1"
|
||||
value={editingSet.distanceMeters || ''}
|
||||
onChange={(e) => setEditingSet({ ...editingSet, distanceMeters: parseFloat(e.target.value) || 0 })}
|
||||
className="w-full mt-1 px-4 py-2 bg-surface-container-high text-on-surface rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 mt-6">
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditingSetId(null);
|
||||
setEditingSet(null);
|
||||
}}
|
||||
className="px-4 py-2 rounded-full text-primary font-medium hover:bg-white/5"
|
||||
>
|
||||
{t('cancel', lang)}
|
||||
</button>
|
||||
<button
|
||||
onClick={async () => {
|
||||
set={editingSet}
|
||||
exerciseDef={exercises.find(e => e.id === editingSet.exerciseId || e.name === editingSet.exerciseName)}
|
||||
onSave={async (updatedSet) => {
|
||||
try {
|
||||
const response = await fetch(`/api/sessions/active/set/${editingSetId}`, {
|
||||
method: 'PATCH',
|
||||
@@ -271,7 +174,7 @@ const SporadicView: React.FC<SporadicViewProps> = ({ tracker, lang }) => {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
},
|
||||
body: JSON.stringify(editingSet)
|
||||
body: JSON.stringify(updatedSet)
|
||||
});
|
||||
if (response.ok) {
|
||||
await loadQuickLogSession();
|
||||
@@ -282,14 +185,8 @@ const SporadicView: React.FC<SporadicViewProps> = ({ tracker, lang }) => {
|
||||
console.error('Failed to update set:', error);
|
||||
}
|
||||
}}
|
||||
className="px-4 py-2 rounded-full bg-primary text-on-primary font-medium flex items-center gap-2"
|
||||
>
|
||||
<Save size={18} />
|
||||
{t('save', lang)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
lang={lang}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Delete Confirmation Modal */}
|
||||
|
||||
@@ -263,6 +263,7 @@ export const useTracker = (props: any) => { // Props ignored/removed
|
||||
onSessionEnd: endSession,
|
||||
onSessionQuit: quitSession,
|
||||
onRemoveSet: removeSet,
|
||||
updateSet: handleUpdateSetWrapper,
|
||||
activeSession, // Need this in view
|
||||
timer // Expose timer to views
|
||||
};
|
||||
|
||||
@@ -9,7 +9,9 @@ interface ApiSession extends Omit<WorkoutSession, 'startTime' | 'endTime' | 'set
|
||||
exercise?: {
|
||||
name: string;
|
||||
type: ExerciseType;
|
||||
}
|
||||
};
|
||||
exerciseName?: string;
|
||||
type?: ExerciseType;
|
||||
})[];
|
||||
}
|
||||
|
||||
@@ -30,8 +32,8 @@ export const getSessions = async (userId: string): Promise<WorkoutSession[]> =>
|
||||
endTime: session.endTime ? new Date(session.endTime).getTime() : undefined,
|
||||
sets: session.sets.map((set) => ({
|
||||
...set,
|
||||
exerciseName: set.exercise?.name || 'Unknown',
|
||||
type: set.exercise?.type || ExerciseType.STRENGTH
|
||||
exerciseName: set.exerciseName || set.exercise?.name || 'Unknown',
|
||||
type: set.type || set.exercise?.type || ExerciseType.STRENGTH
|
||||
})) as WorkoutSet[]
|
||||
}));
|
||||
} catch {
|
||||
@@ -58,8 +60,8 @@ export const getActiveSession = async (userId: string): Promise<WorkoutSession |
|
||||
endTime: session.endTime ? new Date(session.endTime).getTime() : undefined,
|
||||
sets: session.sets.map((set) => ({
|
||||
...set,
|
||||
exerciseName: set.exercise?.name || 'Unknown',
|
||||
type: set.exercise?.type || ExerciseType.STRENGTH
|
||||
exerciseName: set.exerciseName || set.exercise?.name || 'Unknown',
|
||||
type: set.type || set.exercise?.type || ExerciseType.STRENGTH
|
||||
})) as WorkoutSet[]
|
||||
};
|
||||
} catch {
|
||||
|
||||
Reference in New Issue
Block a user