Timer is persistent with Local Storage. Drag&Drop of planned sets fixed on mobile.

This commit is contained in:
AG
2025-12-12 20:59:51 +02:00
parent e1253f4100
commit 7d82444e94
5 changed files with 343 additions and 120 deletions

57
package-lock.json generated
View File

@@ -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": {

View File

@@ -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",

View File

@@ -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 (
<div ref={setNodeRef} style={style} {...attributes}>
<Card
className={`flex items-center gap-3 transition-all hover:bg-surface-container-high ${isDragging ? 'bg-surface-container-high shadow-elevation-3' : ''}`}
>
<div className="text-on-surface-variant p-1 cursor-grab touch-none" {...listeners}>
<GripVertical size={20} />
</div>
<div className="w-8 h-8 rounded-full bg-primary-container text-on-primary-container flex items-center justify-center text-xs font-bold shrink-0">
{index + 1}
</div>
<div className="flex-1">
<div className="text-base font-medium text-on-surface">{step.exerciseName}</div>
<div className="flex items-center gap-4 mt-1">
<label className="flex items-center gap-2 cursor-pointer w-fit">
<div className={`w-4 h-4 border rounded flex items-center justify-center ${step.isWeighted ? 'bg-primary border-primary' : 'border-outline'}`}>
{step.isWeighted && <Dumbbell size={10} className="text-on-primary" />}
</div>
<input
type="checkbox"
checked={step.isWeighted}
onChange={() => toggleWeighted(step.id)}
className="hidden"
/>
<span className="text-xs text-on-surface-variant">{t('weighted', lang)}</span>
</label>
<div className="flex items-center gap-2">
<TimerIcon size={14} className="text-on-surface-variant" />
<input
type="number"
placeholder="Rest (s)"
className="w-16 bg-transparent border-b border-outline-variant text-xs text-on-surface focus:border-primary focus:outline-none text-center"
value={step.restTimeSeconds || ''}
onChange={(e) => {
const val = parseInt(e.target.value);
updateRestTime(step.id, isNaN(val) ? undefined : val);
}}
/>
<span className="text-xs text-on-surface-variant">s</span>
</div>
</div>
</div>
<Button onClick={() => removeStep(step.id)} variant="ghost" size="icon" className="text-on-surface-variant hover:text-error hover:bg-error/10">
<Trash2 size={20} />
</Button>
</Card>
</div>
);
};
const Plans: React.FC<PlansProps> = ({ lang }) => {
const { currentUser } = useAuth();
const userId = currentUser?.id || '';
@@ -34,14 +134,25 @@ const Plans: React.FC<PlansProps> = ({ lang }) => {
const [description, setDescription] = useState('');
const [steps, setSteps] = useState<PlannedSet[]>([]);
const [availableExercises, setAvailableExercises] = useState<ExerciseDef[]>([]);
const [showExerciseSelector, setShowExerciseSelector] = useState(false);
// Drag and Drop Refs
const dragItem = React.useRef<number | null>(null);
const [draggingIndex, setDraggingIndex] = useState<number | null>(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<ExerciseDef[]>([]);
const [showExerciseSelector, setShowExerciseSelector] = useState(false);
const [isCreatingExercise, setIsCreatingExercise] = useState(false);
// Preparation Modal State
@@ -143,29 +254,16 @@ const Plans: React.FC<PlansProps> = ({ 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<PlansProps> = ({ lang }) => {
</div>
<div className="space-y-2">
{steps.map((step, idx) => (
<Card
key={step.id}
className={`flex items-center gap-3 transition-all hover:bg-surface-container-high ${draggingIndex === idx ? 'opacity-50 ring-2 ring-primary bg-surface-container-high' : ''}`}
draggable
onDragStart={() => onDragStart(idx)}
onDragEnter={() => onDragEnter(idx)}
onDragOver={(e) => e.preventDefault()}
onDragEnd={onDragEnd}
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={steps.map(s => s.id)}
strategy={verticalListSortingStrategy}
>
<div className={`text-on-surface-variant p-1 ${draggingIndex === idx ? 'cursor-grabbing' : 'cursor-grab'}`}>
<GripVertical size={20} />
</div>
<div className="w-8 h-8 rounded-full bg-primary-container text-on-primary-container flex items-center justify-center text-xs font-bold shrink-0">
{idx + 1}
</div>
<div className="flex-1">
<div className="text-base font-medium text-on-surface">{step.exerciseName}</div>
<div className="flex items-center gap-4 mt-1">
<label className="flex items-center gap-2 cursor-pointer w-fit">
<div className={`w-4 h-4 border rounded flex items-center justify-center ${step.isWeighted ? 'bg-primary border-primary' : 'border-outline'}`}>
{step.isWeighted && <Dumbbell size={10} className="text-on-primary" />}
</div>
<input
type="checkbox"
checked={step.isWeighted}
onChange={() => toggleWeighted(step.id)}
className="hidden"
/>
<span className="text-xs text-on-surface-variant">{t('weighted', lang)}</span>
</label>
<div className="flex items-center gap-2">
<TimerIcon size={14} className="text-on-surface-variant" />
<input
type="number"
placeholder="Rest (s)"
className="w-16 bg-transparent border-b border-outline-variant text-xs text-on-surface focus:border-primary focus:outline-none text-center"
value={step.restTimeSeconds || ''}
onChange={(e) => {
const val = parseInt(e.target.value);
updateRestTime(step.id, isNaN(val) ? undefined : val);
}}
/>
<span className="text-xs text-on-surface-variant">s</span>
</div>
</div>
</div>
<Button onClick={() => removeStep(step.id)} variant="ghost" size="icon" className="text-on-surface-variant hover:text-error hover:bg-error/10">
<X size={20} />
</Button>
</Card>
))}
{steps.map((step, idx) => (
<SortablePlanStep
key={step.id}
step={step}
index={idx}
toggleWeighted={toggleWeighted}
updateRestTime={updateRestTime}
removeStep={removeStep}
lang={lang}
/>
))}
</SortableContext>
</DndContext>
</div>
<Button
@@ -275,6 +340,37 @@ const Plans: React.FC<PlansProps> = ({ lang }) => {
</div>
</div>
<SideSheet
isOpen={showExerciseSelector}
onClose={() => setShowExerciseSelector(false)}
title={t('select_exercise', lang)}
width="lg"
>
<div className="flex flex-col h-[60vh]">
<div className="flex justify-end mb-2">
<Button onClick={() => setIsCreatingExercise(true)} variant="ghost" className="text-primary hover:bg-primary-container/20 flex gap-2">
<Plus size={18} /> {t('create_exercise', lang)}
</Button>
</div>
<div className="flex-1 overflow-y-auto -mx-6 px-6">
{availableExercises
.slice()
.sort((a, b) => a.name.localeCompare(b.name))
.map(ex => (
<button
key={ex.id}
onClick={() => addStep(ex)}
className="w-full text-left p-4 border-b border-outline-variant hover:bg-surface-container-high text-on-surface flex justify-between group"
>
<span className="group-hover:text-primary transition-colors">{ex.name}</span>
<span className="text-xs bg-secondary-container text-on-secondary-container px-2 py-1 rounded-full">{ex.type}</span>
</button>
))}
</div>
</div>
</SideSheet>
<SideSheet
isOpen={showExerciseSelector}
onClose={() => setShowExerciseSelector(false)}

View File

@@ -180,7 +180,7 @@ const SetLogger: React.FC<SetLoggerProps> = ({ tracker, lang, onLogSet, isSporad
: 'bg-primary-container text-on-primary-container'
}`}
>
{isSporadic && sporadicSuccess ? <CheckCircle size={24} /> : (isSporadic ? <Plus size={24} /> : <CheckCircle size={24} />)}
{isSporadic && sporadicSuccess ? <CheckCircle size={24} /> : <Plus size={24} />}
<span>{isSporadic && sporadicSuccess ? t('saved', lang) : t('log_set', lang)}</span>
</button>
</div>

View File

@@ -10,14 +10,91 @@ interface UseRestTimerProps {
}
export const useRestTimer = ({ defaultTime, onFinish }: UseRestTimerProps) => {
const [timeLeft, setTimeLeft] = useState(defaultTime);
const [status, setStatus] = useState<TimerStatus>('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<number | null>(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<TimerStatus>(initialStatus);
const [duration, setDuration] = useState(initialDuration);
const endTimeRef = useRef<number | null>(savedState?.endTime || null);
const rafRef = useRef<number | null>(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) => {