diff --git a/package-lock.json b/package-lock.json index 1c13cda..3cedb88 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,9 @@ "name": "gymflow-ai", "version": "0.0.0", "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@google/genai": "^1.30.0", "lucide-react": "^0.554.0", "npm-run-all": "^4.1.5", @@ -353,6 +356,59 @@ "node": ">=18" } }, + "node_modules/@dnd-kit/accessibility": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", + "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/core": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", + "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", + "license": "MIT", + "dependencies": { + "@dnd-kit/accessibility": "^3.1.1", + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/sortable": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz", + "integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==", + "license": "MIT", + "dependencies": { + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.3.0", + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/utilities": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz", + "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", @@ -7260,7 +7316,6 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, "license": "0BSD" }, "node_modules/type-fest": { diff --git a/package.json b/package.json index 97aacc6..b40c362 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,9 @@ "test:full": "npm-run-all --parallel dev server:test" }, "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@google/genai": "^1.30.0", "lucide-react": "^0.554.0", "npm-run-all": "^4.1.5", @@ -36,4 +39,4 @@ "typescript": "~5.8.2", "vite": "^6.2.0" } -} \ No newline at end of file +} diff --git a/src/components/Plans.tsx b/src/components/Plans.tsx index 4e4dd6c..1206ad3 100644 --- a/src/components/Plans.tsx +++ b/src/components/Plans.tsx @@ -1,5 +1,24 @@ import React, { useState, useEffect } from 'react'; -import { Plus, Trash2, PlayCircle, Dumbbell, Save, X, ChevronRight, List, ArrowUp, ArrowDown, Pencil, User, Flame, Timer as TimerIcon, Ruler, Footprints, Activity, Percent, CheckCircle, GripVertical, Scale, Search } from 'lucide-react'; +import { Plus, Trash2, PlayCircle, Dumbbell, Save, X, List, ArrowUp, Pencil, User, Flame, Timer as TimerIcon, Ruler, Footprints, Percent, CheckCircle, GripVertical } from 'lucide-react'; +import { + DndContext, + closestCenter, + KeyboardSensor, + PointerSensor, + useSensor, + useSensors, + DragEndEvent, + TouchSensor, + MouseSensor +} from '@dnd-kit/core'; +import { + arrayMove, + SortableContext, + sortableKeyboardCoordinates, + verticalListSortingStrategy, + useSortable, +} from '@dnd-kit/sortable'; +import { CSS } from '@dnd-kit/utilities'; import { WorkoutPlan, ExerciseDef, PlannedSet, Language, ExerciseType } from '../types'; import { getExercises, saveExercise } from '../services/storage'; import { t } from '../services/i18n'; @@ -21,6 +40,87 @@ interface PlansProps { lang: Language; } +// Sortable Item Component +interface SortablePlanStepProps { + step: PlannedSet; + index: number; + toggleWeighted: (id: string) => void; + updateRestTime: (id: string, val: number | undefined) => void; + removeStep: (id: string) => void; + lang: Language; +} + +const SortablePlanStep = ({ step, index, toggleWeighted, updateRestTime, removeStep, lang }: SortablePlanStepProps) => { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging + } = useSortable({ id: step.id }); + + const style = { + transform: CSS.Transform.toString(transform), + transition, + opacity: isDragging ? 0.5 : 1, + zIndex: isDragging ? 1000 : 1, + position: 'relative' as 'relative', + }; + + return ( +
+ +
+ +
+ +
+ {index + 1} +
+ +
+
{step.exerciseName}
+
+ + +
+ + { + const val = parseInt(e.target.value); + updateRestTime(step.id, isNaN(val) ? undefined : val); + }} + /> + s +
+
+
+ +
+
+ ); +}; + const Plans: React.FC = ({ lang }) => { const { currentUser } = useAuth(); const userId = currentUser?.id || ''; @@ -34,14 +134,25 @@ const Plans: React.FC = ({ lang }) => { const [description, setDescription] = useState(''); const [steps, setSteps] = useState([]); - const [availableExercises, setAvailableExercises] = useState([]); - const [showExerciseSelector, setShowExerciseSelector] = useState(false); - - // Drag and Drop Refs - const dragItem = React.useRef(null); - const [draggingIndex, setDraggingIndex] = useState(null); + // Dnd Sensors + const sensors = useSensors( + useSensor(PointerSensor), // Handle mouse and basic pointer events + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }), + useSensor(TouchSensor, { + // Small delay or tolerance can help distinguish scrolling from dragging, + // but usually for a handle drag, instant is fine or defaults work. + // Let's add a small activation constraint to prevent accidental drags while scrolling if picking by handle + activationConstraint: { + distance: 5, + }, + }) + ); // Create Exercise State + const [availableExercises, setAvailableExercises] = useState([]); + const [showExerciseSelector, setShowExerciseSelector] = useState(false); const [isCreatingExercise, setIsCreatingExercise] = useState(false); // Preparation Modal State @@ -143,29 +254,16 @@ const Plans: React.FC = ({ lang }) => { setSteps(steps.filter(s => s.id !== stepId)); }; - const onDragStart = (index: number) => { - console.log('Drag Start:', index); - dragItem.current = index; - setDraggingIndex(index); - }; - const onDragEnter = (index: number) => { - console.log('Drag Enter:', index); - if (dragItem.current === null) return; - if (dragItem.current === index) return; + const handleDragEnd = (event: DragEndEvent) => { + const { active, over } = event; - const newSteps = [...steps]; - const draggedItemContent = newSteps.splice(dragItem.current, 1)[0]; - newSteps.splice(index, 0, draggedItemContent); - - setSteps(newSteps); - dragItem.current = index; - setDraggingIndex(index); - console.log(`Swapped ${draggedItemContent.id} to ${index}`); - }; - - const onDragEnd = () => { - dragItem.current = null; - setDraggingIndex(null); + if (active.id !== over?.id) { + setSteps((items) => { + const oldIndex = items.findIndex((item) => item.id === active.id); + const newIndex = items.findIndex((item) => item.id === over?.id); + return arrayMove(items, oldIndex, newIndex); + }); + } }; if (isEditing) { @@ -206,61 +304,28 @@ const Plans: React.FC = ({ lang }) => {
- {steps.map((step, idx) => ( - onDragStart(idx)} - onDragEnter={() => onDragEnter(idx)} - onDragOver={(e) => e.preventDefault()} - onDragEnd={onDragEnd} + + s.id)} + strategy={verticalListSortingStrategy} > -
- -
- -
- {idx + 1} -
- -
-
{step.exerciseName}
-
- - -
- - { - const val = parseInt(e.target.value); - updateRestTime(step.id, isNaN(val) ? undefined : val); - }} - /> - s -
-
-
- -
- ))} + {steps.map((step, idx) => ( + + ))} + +
+ +
+ {availableExercises + .slice() + .sort((a, b) => a.name.localeCompare(b.name)) + .map(ex => ( + + ))} +
+ + + + setShowExerciseSelector(false)} diff --git a/src/components/Tracker/SetLogger.tsx b/src/components/Tracker/SetLogger.tsx index f70acb6..e814ed6 100644 --- a/src/components/Tracker/SetLogger.tsx +++ b/src/components/Tracker/SetLogger.tsx @@ -180,7 +180,7 @@ const SetLogger: React.FC = ({ tracker, lang, onLogSet, isSporad : 'bg-primary-container text-on-primary-container' }`} > - {isSporadic && sporadicSuccess ? : (isSporadic ? : )} + {isSporadic && sporadicSuccess ? : } {isSporadic && sporadicSuccess ? t('saved', lang) : t('log_set', lang)} diff --git a/src/hooks/useRestTimer.ts b/src/hooks/useRestTimer.ts index 6c2e09a..0b9a7b8 100644 --- a/src/hooks/useRestTimer.ts +++ b/src/hooks/useRestTimer.ts @@ -10,14 +10,91 @@ interface UseRestTimerProps { } export const useRestTimer = ({ defaultTime, onFinish }: UseRestTimerProps) => { - const [timeLeft, setTimeLeft] = useState(defaultTime); - const [status, setStatus] = useState('IDLE'); - const [duration, setDuration] = useState(defaultTime); // The set duration to reset to + // Initial state function to restore from localStorage if available + const getInitialState = () => { + try { + const saved = localStorage.getItem('gymflow_rest_timer'); + if (saved) { + const parsed = JSON.parse(saved); + // Validate parsed data structure lightly + if (parsed && typeof parsed.timeLeft === 'number') { + return parsed; + } + } + } catch (e) { + console.error("Failed to parse saved timer", e); + } + return null; + }; - const endTimeRef = useRef(null); + const savedState = getInitialState(); + + // If we have a saved running timer, we need to recalculate time left + let initialTimeLeft = defaultTime; + let initialStatus: TimerStatus = 'IDLE'; + let initialDuration = defaultTime; + + if (savedState) { + initialDuration = savedState.duration || defaultTime; + initialStatus = savedState.status; + initialTimeLeft = savedState.timeLeft; + + if (initialStatus === 'RUNNING' && savedState.endTime) { + const now = Date.now(); + const remaining = Math.max(0, Math.ceil((savedState.endTime - now) / 1000)); + if (remaining > 0) { + initialTimeLeft = remaining; + } else { + initialStatus = 'FINISHED'; // It finished while we were away + initialTimeLeft = 0; + } + } + } + + const [timeLeft, setTimeLeft] = useState(initialTimeLeft); + const [status, setStatus] = useState(initialStatus); + const [duration, setDuration] = useState(initialDuration); + + const endTimeRef = useRef(savedState?.endTime || null); const rafRef = useRef(null); const prevDefaultTimeRef = useRef(defaultTime); + // Tick function - defined before effects + const tick = useCallback(() => { + if (!endTimeRef.current) return; + const now = Date.now(); + const remaining = Math.max(0, Math.ceil((endTimeRef.current - now) / 1000)); + + setTimeLeft(remaining); + + if (remaining <= 0) { + setStatus('FINISHED'); + playTimeUpSignal(); + if (onFinish) onFinish(); + + endTimeRef.current = null; // Clear end time + + // Auto-reset visuals after 3 seconds of "FINISHED" state? + setTimeout(() => { + setStatus(prev => prev === 'FINISHED' ? 'IDLE' : prev); + setTimeLeft(prev => prev === 0 ? duration : prev); + }, 3000); + } else { + rafRef.current = requestAnimationFrame(tick); + } + }, [duration, onFinish]); + + // Save to localStorage whenever relevant state changes + useEffect(() => { + const stateToSave = { + status, + timeLeft, + duration, + endTime: endTimeRef.current + }; + localStorage.setItem('gymflow_rest_timer', JSON.stringify(stateToSave)); + }, [status, timeLeft, duration]); + // Update internal duration when defaultTime changes useEffect(() => { if (prevDefaultTimeRef.current !== defaultTime) { @@ -30,34 +107,27 @@ export const useRestTimer = ({ defaultTime, onFinish }: UseRestTimerProps) => { } }, [defaultTime, status]); + // Manage RAF based on status useEffect(() => { - return () => { - if (rafRef.current) cancelAnimationFrame(rafRef.current); - }; - }, []); - - const tick = useCallback(() => { - if (!endTimeRef.current) return; - const now = Date.now(); - const remaining = Math.max(0, Math.ceil((endTimeRef.current - now) / 1000)); - - setTimeLeft(remaining); - - if (remaining <= 0) { - setStatus('FINISHED'); - playTimeUpSignal(); - if (onFinish) onFinish(); - - // Auto-reset visuals after 3 seconds of "FINISHED" state? - // Requirement says: "The FAB must first change color to red for 3 seconds, and then return to the idle state" - // So the hook stays in FINISHED. - setTimeout(() => { - reset(); - }, 3000); + if (status === 'RUNNING') { + if (!rafRef.current) { + rafRef.current = requestAnimationFrame(tick); + } } else { - rafRef.current = requestAnimationFrame(tick); + if (rafRef.current) { + cancelAnimationFrame(rafRef.current); + rafRef.current = null; + } } - }, [onFinish]); + + return () => { + if (rafRef.current) { + cancelAnimationFrame(rafRef.current); + rafRef.current = null; + } + }; + }, [status, tick]); + const start = useCallback(() => { if (status === 'RUNNING') return; @@ -67,24 +137,23 @@ export const useRestTimer = ({ defaultTime, onFinish }: UseRestTimerProps) => { endTimeRef.current = Date.now() + targetSeconds * 1000; setStatus('RUNNING'); - rafRef.current = requestAnimationFrame(tick); - }, [status, timeLeft, duration, tick]); + // Effect will trigger tick + }, [status, timeLeft, duration]); const pause = useCallback(() => { if (status !== 'RUNNING') return; setStatus('PAUSED'); - if (rafRef.current) cancelAnimationFrame(rafRef.current); + // Effect calls cancelAnimationFrame endTimeRef.current = null; }, [status]); const reset = useCallback((newDuration?: number) => { - if (rafRef.current) cancelAnimationFrame(rafRef.current); - const nextDuration = newDuration !== undefined ? newDuration : duration; setDuration(nextDuration); setTimeLeft(nextDuration); setStatus('IDLE'); endTimeRef.current = null; + // Effect calls cancelAnimationFrame (since status becomes IDLE) }, [duration]); const addTime = useCallback((seconds: number) => {