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 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}
-
-
-
-
- ))}
+ {steps.map((step, idx) => (
+
+ ))}
+
+
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) => {