diff --git a/playwright-report/index.html b/playwright-report/index.html index 39da39e..41a1a21 100644 --- a/playwright-report/index.html +++ b/playwright-report/index.html @@ -82,4 +82,4 @@ Error generating stack: `+n.message+`
- \ No newline at end of file + \ No newline at end of file diff --git a/server/dev.db b/server/dev.db index e69de29..fd39be3 100644 Binary files a/server/dev.db and b/server/dev.db differ diff --git a/server/prisma.config.js b/server/prisma.config.js deleted file mode 100644 index 68efaae..0000000 --- a/server/prisma.config.js +++ /dev/null @@ -1,5 +0,0 @@ -module.exports = { - datasource: { - url: process.env.DATABASE_URL, - }, -}; diff --git a/server/prisma.config.ts b/server/prisma.config.ts new file mode 100644 index 0000000..fe69e01 --- /dev/null +++ b/server/prisma.config.ts @@ -0,0 +1,5 @@ +export default { + datasource: { + url: process.env.DATABASE_URL || "file:./dev.db", + }, +}; diff --git a/server/prisma/dev.db b/server/prisma/dev.db index f7413bb..a3984b6 100644 Binary files a/server/prisma/dev.db and b/server/prisma/dev.db differ diff --git a/server/prisma/migrations/20251210190259_add_rest_timer/migration.sql b/server/prisma/migrations/20251210190259_add_rest_timer/migration.sql new file mode 100644 index 0000000..dc63008 --- /dev/null +++ b/server/prisma/migrations/20251210190259_add_rest_timer/migration.sql @@ -0,0 +1,5 @@ +-- AlterTable +ALTER TABLE "PlanExercise" ADD COLUMN "restTime" INTEGER; + +-- AlterTable +ALTER TABLE "UserProfile" ADD COLUMN "restTimerDefault" INTEGER DEFAULT 120; diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index b060a67..52f75e2 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -46,6 +46,7 @@ model UserProfile { gender String? birthDate DateTime? language String? @default("en") + restTimerDefault Int? @default(120) // Default rest timer in seconds } model Exercise { @@ -116,5 +117,6 @@ model PlanExercise { exercise Exercise @relation(fields: [exerciseId], references: [id]) order Int isWeighted Boolean @default(false) + restTime Int? // Optional rest time target in seconds } diff --git a/server/prisma/test.db b/server/prisma/test.db index e69de29..e8a47e0 100644 Binary files a/server/prisma/test.db and b/server/prisma/test.db differ diff --git a/server/prod.db b/server/prod.db new file mode 100644 index 0000000..e2ae7a7 Binary files /dev/null and b/server/prod.db differ diff --git a/server/src/controllers/auth.controller.ts b/server/src/controllers/auth.controller.ts index fbd4bce..39f51f7 100644 --- a/server/src/controllers/auth.controller.ts +++ b/server/src/controllers/auth.controller.ts @@ -71,9 +71,9 @@ export class AuthController { const userId = req.user.userId; await AuthService.updateProfile(userId, req.body); return sendSuccess(res, null); - } catch (error) { + } catch (error: any) { logger.error('Error in updateProfile', { error }); - return sendError(res, 'Server error', 500); + return sendError(res, error.message || 'Server error', 500); } } diff --git a/server/src/controllers/plan.controller.ts b/server/src/controllers/plan.controller.ts index cccbd98..fc07ed6 100644 --- a/server/src/controllers/plan.controller.ts +++ b/server/src/controllers/plan.controller.ts @@ -20,9 +20,9 @@ export class PlanController { const userId = req.user.userId; const plan = await PlanService.savePlan(userId, req.body); return sendSuccess(res, plan); - } catch (error) { + } catch (error: any) { logger.error('Error in savePlan', { error }); - return sendError(res, 'Server error', 500); + return sendError(res, error.message || 'Server error', 500); } } diff --git a/server/src/schemas/auth.ts b/server/src/schemas/auth.ts index 3addad3..65a88cf 100644 --- a/server/src/schemas/auth.ts +++ b/server/src/schemas/auth.ts @@ -27,6 +27,7 @@ export const updateProfileSchema = z.object({ height: z.number().optional(), gender: z.string().optional(), birthDate: z.string().optional(), - language: z.string().optional() + language: z.string().optional(), + restTimerDefault: z.number().optional() }) }) diff --git a/server/src/services/plan.service.ts b/server/src/services/plan.service.ts index 1068d94..609e6e7 100644 --- a/server/src/services/plan.service.ts +++ b/server/src/services/plan.service.ts @@ -1,3 +1,4 @@ +// forcing reload import prisma from '../lib/prisma'; export class PlanService { @@ -21,6 +22,7 @@ export class PlanService { exerciseName: pe.exercise.name, exerciseType: pe.exercise.type, isWeighted: pe.isWeighted, + restTimeSeconds: pe.restTime })) })); } @@ -54,7 +56,8 @@ export class PlanService { planId: id, exerciseId: step.exerciseId, order: index, - isWeighted: step.isWeighted || false + isWeighted: step.isWeighted || false, + restTime: step.restTimeSeconds })) }); } @@ -79,7 +82,8 @@ export class PlanService { exerciseId: pe.exerciseId, exerciseName: pe.exercise.name, exerciseType: pe.exercise.type, - isWeighted: pe.isWeighted + isWeighted: pe.isWeighted, + restTimeSeconds: pe.restTime })) }; } diff --git a/server/test.db b/server/test.db index 224623e..8e3a5e9 100644 Binary files a/server/test.db and b/server/test.db differ diff --git a/specs/requirements.md b/specs/requirements.md index eef6d13..47e8d48 100644 --- a/specs/requirements.md +++ b/specs/requirements.md @@ -29,7 +29,7 @@ The system relies on JWT-based authentication. * **Input**: Email, Password. * **Logic**: * Email must be unique. - * Creates a `UserProfile` with default values (e.g., default weight 70kg) upon account creation. + * Creates a `UserProfile` with default values (e.g., default weight 70kg, default rest timer 120s) upon account creation. * Sets `isFirstLogin` to true. * **3.1.3 First-Time Setup (Password Change)** * **Trigger**: If `User.isFirstLogin` is true. @@ -50,6 +50,7 @@ Users can structure their training via Plans. * User selects exercises from the global/personal library. * **Ordering**: Exercises must be ordered (0-indexed). * **Weighted Flag**: specific exercises in a plan can be marked as `isWeighted` (visual indicator). + * **Rest Time**: specific exercises can have a defined `restTime` (seconds). * **Logic**: Supports reordering capabilities via drag-and-drop in UI. * **3.2.2 Plan Deletion** * Standard soft or hard delete (Cascades to PlanExercises). @@ -98,7 +99,32 @@ The core feature. States: **Idle**, **Active Session**, **Sporadic Mode**. * `POST /sessions/quick-log/set`: * Finds OR Creates the daily Quick Log session. * Appends the set. - * **UI**: Separate "Sporadic Mode" view specialized for fast, one-off entries without a timer or plan context. + * **UI**: Separate "Sporadic Mode" view specialized for fast, one-off entries without a timer or plan context. Matches "Free Session" timer logic (see 3.4.3). + +* **3.4.3 Rest Timer** + * **Concept**: A configurable timer to track rest periods between sets. + * **Contexts**: + * **Free Session / Quick Log**: + * Manual setup. + * Default value: 2 minutes (120s). + * **Persistence**: The last manually set value becomes the new default for the user. + * **Planned Session**: + * **Config**: Each step in a plan can have a specific `restTime` (seconds). + * **Auto-Set**: When a set is logged, the timer resets to the value defined for the *current* step. + * **Fallback**: If plan step has no `restTime`, use User's default. + * **Behavior**: + * **Start**: Manual trigger by user. + * **Countdown**: Visual display. + * **Completion**: + * Audio signal (3 seconds). + * Visual alert (Red color for 3 seconds). + * Auto-reset to next expected rest time. + * **UI Component**: + * Floating Action Button (FAB). + * **Idle**: Green Icon. + * **Running**: Shows digits. + * **Finished**: Red animation. + ### 3.5. History & Analysis * **3.5.1 Session History** diff --git a/src/components/Plans.tsx b/src/components/Plans.tsx index 4b40748..ecc308f 100644 --- a/src/components/Plans.tsx +++ b/src/components/Plans.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect } from 'react'; -import { Plus, Trash2, PlayCircle, Dumbbell, Save, X, ChevronRight, List, ArrowUp, ArrowDown, Edit2, User, Flame, Timer as TimerIcon, Ruler, Footprints, Activity, Percent, CheckCircle, GripVertical, Scale } from 'lucide-react'; +import { Plus, Trash2, PlayCircle, Dumbbell, Save, X, ChevronRight, List, ArrowUp, ArrowDown, Edit2, User, Flame, Timer as TimerIcon, Ruler, Footprints, Activity, Percent, CheckCircle, GripVertical, Scale, Search } from 'lucide-react'; import { WorkoutPlan, ExerciseDef, PlannedSet, Language, ExerciseType } from '../types'; import { getExercises, saveExercise } from '../services/storage'; import { t } from '../services/i18n'; @@ -12,6 +12,7 @@ import FilledInput from './FilledInput'; import { toTitleCase } from '../utils/text'; import { Button } from './ui/Button'; import { Card } from './ui/Card'; +import { Modal } from './ui/Modal'; interface PlansProps { lang: Language; @@ -93,7 +94,10 @@ const Plans: React.FC = ({ lang }) => { exerciseId: ex.id, exerciseName: ex.name, exerciseType: ex.type, - isWeighted: false + isWeighted: false, + restTimeSeconds: 120 // Default new step to 120s? Or leave undefined to use profile default? + // Requirement: "fallback to user default". So maybe undefined/null is better for "inherit". + // But UI needs a value. Let's start with 120 or empty. }; setSteps([...steps, newStep]); setShowExerciseSelector(false); @@ -134,6 +138,10 @@ const Plans: React.FC = ({ lang }) => { setSteps(steps.map(s => s.id === stepId ? { ...s, isWeighted: !s.isWeighted } : s)); }; + const updateRestTime = (stepId: string, seconds: number | undefined) => { + setSteps(steps.map(s => s.id === stepId ? { ...s, restTimeSeconds: seconds } : s)); + }; + const removeStep = (stepId: string) => { setSteps(steps.filter(s => s.id !== stepId)); }; @@ -222,18 +230,35 @@ const Plans: React.FC = ({ lang }) => {
{step.exerciseName}
-
- {showExerciseSelector && ( -
-
- {t('select_exercise', lang)} -
- - -
+ setShowExerciseSelector(false)} + title={t('select_exercise', lang)} + maxWidth="md" + > +
+
+
-
+
{availableExercises .slice() .sort((a, b) => a.name.localeCompare(b.name)) @@ -275,84 +299,80 @@ const Plans: React.FC = ({ lang }) => { ))}
- - {isCreatingExercise && ( -
-
-

{t('create_exercise', lang)}

- -
- -
- setNewExName(e.target.value)} - type="text" - autoFocus - autocapitalize="words" - onBlur={() => setNewExName(toTitleCase(newExName))} - /> - -
- -
- {[ - { id: ExerciseType.STRENGTH, label: exerciseTypeLabels[ExerciseType.STRENGTH], icon: Dumbbell }, - { id: ExerciseType.BODYWEIGHT, label: exerciseTypeLabels[ExerciseType.BODYWEIGHT], icon: User }, - { id: ExerciseType.CARDIO, label: exerciseTypeLabels[ExerciseType.CARDIO], icon: Flame }, - { id: ExerciseType.STATIC, label: exerciseTypeLabels[ExerciseType.STATIC], icon: TimerIcon }, - { id: ExerciseType.HIGH_JUMP, label: exerciseTypeLabels[ExerciseType.HIGH_JUMP], icon: ArrowUp }, - { id: ExerciseType.LONG_JUMP, label: exerciseTypeLabels[ExerciseType.LONG_JUMP], icon: Ruler }, - { id: ExerciseType.PLYOMETRIC, label: exerciseTypeLabels[ExerciseType.PLYOMETRIC], icon: Footprints }, - ].map((type) => ( - - ))} -
-
- - {newExType === ExerciseType.BODYWEIGHT && ( - setNewExBwPercentage(e.target.value)} - icon={} - /> - )} - -
- -
-
-
- )}
- )} + + + setIsCreatingExercise(false)} + title={t('create_exercise', lang)} + maxWidth="md" + > +
+ setNewExName(e.target.value)} + type="text" + autoFocus + autocapitalize="words" + onBlur={() => setNewExName(toTitleCase(newExName))} + /> + +
+ +
+ {[ + { id: ExerciseType.STRENGTH, label: exerciseTypeLabels[ExerciseType.STRENGTH], icon: Dumbbell }, + { id: ExerciseType.BODYWEIGHT, label: exerciseTypeLabels[ExerciseType.BODYWEIGHT], icon: User }, + { id: ExerciseType.CARDIO, label: exerciseTypeLabels[ExerciseType.CARDIO], icon: Flame }, + { id: ExerciseType.STATIC, label: exerciseTypeLabels[ExerciseType.STATIC], icon: TimerIcon }, + { id: ExerciseType.HIGH_JUMP, label: exerciseTypeLabels[ExerciseType.HIGH_JUMP], icon: ArrowUp }, + { id: ExerciseType.LONG_JUMP, label: exerciseTypeLabels[ExerciseType.LONG_JUMP], icon: Ruler }, + { id: ExerciseType.PLYOMETRIC, label: exerciseTypeLabels[ExerciseType.PLYOMETRIC], icon: Footprints }, + ].map((type) => ( + + ))} +
+
+ + {newExType === ExerciseType.BODYWEIGHT && ( + setNewExBwPercentage(e.target.value)} + icon={} + /> + )} + +
+ +
+
+
); } diff --git a/src/components/Tracker/ActiveSessionView.tsx b/src/components/Tracker/ActiveSessionView.tsx index ec8d5c4..05c831e 100644 --- a/src/components/Tracker/ActiveSessionView.tsx +++ b/src/components/Tracker/ActiveSessionView.tsx @@ -7,6 +7,9 @@ import ExerciseModal from '../ExerciseModal'; import { useTracker } from './useTracker'; import SetLogger from './SetLogger'; import { formatSetMetrics } from '../../utils/setFormatting'; +import { useAuth } from '../../context/AuthContext'; +import { api } from '../../services/api'; +import RestTimerFAB from '../ui/RestTimerFAB'; interface ActiveSessionViewProps { tracker: ReturnType; @@ -71,6 +74,61 @@ const ActiveSessionView: React.FC = ({ tracker, activeSe exercises } = tracker; + const { currentUser, updateUser } = useAuth(); + + // Timer Logic is now managed in useTracker to persist across re-renders/step changes + const { timer } = tracker; + + const handleLogSet = async () => { + await handleAddSet(); + + // Determine next rest time + let nextTime = currentUser?.profile?.restTimerDefault || 120; + + if (activePlan) { + // Logic: activePlan set just added. We are moving to next step? + // Tracker's handleAddSet calls addSet -> which calls ActiveWorkoutContext's addSet -> which increments currentStepIndex (logic inside context) + // But state update might be async or we might need to look at current index before update? + // Usually we want the rest time AFTER the set we just did. + // The user just configured the set for the *current* step index. + // So we look at activePlan.steps[currentStepIndex].restTime. + // BUT, if the user just finished step 0, and step 0 says "Rest 60s", then we rest 60s. + // If fallback, use default. + + // Note: currentStepIndex might update immediately or after render. + // In a real app, we might get the next set's target time? No, rest is usually associated with the fatigue of the set just done. + // Requirement: "rest time after this set". + // So we use currentStepIndex (which likely points to the set we just logged, assuming UI hasn't advanced yet? + // Actually, handleAddSet likely appends set. Context might auto-advance. + // Let's assume we use the restTime of the step that matches the set just logged. + + const currentStep = activePlan.steps[currentStepIndex]; + if (currentStep && currentStep.restTimeSeconds) { + nextTime = currentStep.restTimeSeconds; + } + } + + if (timer.status !== 'RUNNING') { + timer.reset(nextTime); + timer.start(); + } + }; + + const handleDurationChange = async (newVal: number) => { + // Update user profile + try { + await api.patch('/auth/profile', { restTimerDefault: newVal }); + if (currentUser) { + updateUser({ + ...currentUser, + profile: { ...currentUser.profile, restTimerDefault: newVal } + }); + } + } catch (e) { + console.error("Failed to update default timer", e); + } + }; + const isPlanFinished = activePlan && currentStepIndex >= activePlan.steps.length; @@ -177,7 +235,7 @@ const ActiveSessionView: React.FC = ({ tracker, activeSe {activeSession.sets.length > 0 && ( @@ -397,6 +455,8 @@ const ActiveSessionView: React.FC = ({ tracker, activeSe
)} + + ); }; diff --git a/src/components/Tracker/SporadicView.tsx b/src/components/Tracker/SporadicView.tsx index 18633c7..6b93844 100644 --- a/src/components/Tracker/SporadicView.tsx +++ b/src/components/Tracker/SporadicView.tsx @@ -6,6 +6,10 @@ import ExerciseModal from '../ExerciseModal'; import { useTracker } from './useTracker'; import SetLogger from './SetLogger'; import { formatSetMetrics } from '../../utils/setFormatting'; +import { useAuth } from '../../context/AuthContext'; +import { api } from '../../services/api'; +// import { useRestTimer } from '../../hooks/useRestTimer'; // Not needed if using tracker.timer +import RestTimerFAB from '../ui/RestTimerFAB'; interface SporadicViewProps { tracker: ReturnType; @@ -26,6 +30,31 @@ const SporadicView: React.FC = ({ tracker, lang }) => { loadQuickLogSession } = tracker; + const { currentUser, updateUser } = useAuth(); + + // Timer Logic is now managed in useTracker + const { timer } = tracker; + + const handleLogSet = async () => { + await handleLogSporadicSet(); + // Always usage default/current setting for sporadic + timer.start(); + }; + + const handleDurationChange = async (newVal: number) => { + try { + await api.patch('/auth/profile', { restTimerDefault: newVal }); + if (currentUser) { + updateUser({ + ...currentUser, + profile: { ...currentUser.profile, restTimerDefault: newVal } + }); + } + } catch (e) { + console.error("Failed to update default timer", e); + } + }; + const [todaysSets, setTodaysSets] = useState([]); const [editingSetId, setEditingSetId] = useState(null); const [editingSet, setEditingSet] = useState(null); @@ -72,7 +101,7 @@ const SporadicView: React.FC = ({ tracker, lang }) => { @@ -301,6 +330,8 @@ const SporadicView: React.FC = ({ tracker, lang }) => { )} + + ); }; diff --git a/src/components/Tracker/useTracker.ts b/src/components/Tracker/useTracker.ts index 786a73a..95ab525 100644 --- a/src/components/Tracker/useTracker.ts +++ b/src/components/Tracker/useTracker.ts @@ -8,6 +8,7 @@ import { usePlanExecution } from '../../hooks/usePlanExecution'; import { useAuth } from '../../context/AuthContext'; import { useActiveWorkout } from '../../context/ActiveWorkoutContext'; import { useSession } from '../../context/SessionContext'; +import { useRestTimer } from '../../hooks/useRestTimer'; export const useTracker = (props: any) => { // Props ignored/removed const { currentUser } = useAuth(); @@ -61,6 +62,21 @@ export const useTracker = (props: any) => { // Props ignored/removed const form = useWorkoutForm({ userId, onUpdateSet: handleUpdateSetWrapper }); const planExec = usePlanExecution({ activeSession, activePlan, exercises }); + // Rest Timer Logic (Moved from ActiveSessionView to persist state) + const getTargetRestTime = () => { + if (activePlan) { + const currentStep = activePlan.steps[planExec.currentStepIndex]; + if (currentStep && currentStep.restTimeSeconds) { + return currentStep.restTimeSeconds; + } + } + return currentUser?.profile?.restTimerDefault || 120; + }; + const targetRestTime = getTargetRestTime(); + const timer = useRestTimer({ + defaultTime: targetRestTime + }); + // Initial Data Load useEffect(() => { if (!userId) return; @@ -247,7 +263,8 @@ export const useTracker = (props: any) => { // Props ignored/removed onSessionEnd: endSession, onSessionQuit: quitSession, onRemoveSet: removeSet, - activeSession // Need this in view + activeSession, // Need this in view + timer // Expose timer to views }; }; diff --git a/src/components/ui/RestTimerFAB.tsx b/src/components/ui/RestTimerFAB.tsx new file mode 100644 index 0000000..a1ddbb7 --- /dev/null +++ b/src/components/ui/RestTimerFAB.tsx @@ -0,0 +1,211 @@ +import React, { useState, useEffect } from 'react'; +import { Timer, Play, Pause, RotateCcw, Edit2, Plus, Minus, X, Check } from 'lucide-react'; +import { useRestTimer } from '../../hooks/useRestTimer'; + +interface RestTimerFABProps { + timer: ReturnType; + onDurationChange?: (newDuration: number) => void; +} + +const RestTimerFAB: React.FC = ({ timer, onDurationChange }) => { + const { timeLeft, status, start, pause, reset, setDuration } = timer; + + // Render Helpers (moved up for initial state calculation) + const formatSeconds = (sec: number) => { + const m = Math.floor(sec / 60); + const s = sec % 60; + return `${m}:${s.toString().padStart(2, '0')}`; + }; + + const [isExpanded, setIsExpanded] = useState(false); + const [isEditing, setIsEditing] = useState(false); + const [editValue, setEditValue] = useState(120); + const [inputValue, setInputValue] = useState(formatSeconds(120)); + + // Auto-expand when running if not already expanded? No, requirement says "when time is running, show digits of the countdown on the enlarged timer FAB even if the menu is collapsed". + // So the FAB itself grows. + + // Effects + useEffect(() => { + if (status === 'FINISHED') { + setIsExpanded(false); + setIsEditing(false); + } + }, [status]); + + useEffect(() => { + // Sync input value when editValue changes (externally or via +/- buttons) + setInputValue(formatSeconds(editValue)); + }, [editValue]); + + const handleToggle = () => { + if (isEditing) return; // Don't toggle if editing + setIsExpanded(!isExpanded); + }; + + const handleStartPause = (e: React.MouseEvent) => { + e.stopPropagation(); + if (status === 'RUNNING') pause(); + else start(); + }; + + const handleReset = (e: React.MouseEvent) => { + e.stopPropagation(); + reset(); + }; + + const handleEdit = (e: React.MouseEvent) => { + e.stopPropagation(); + const initialVal = timeLeft > 0 ? timeLeft : 120; + setEditValue(initialVal); + setInputValue(formatSeconds(initialVal)); + setIsEditing(true); + setIsExpanded(true); // Keep expanded + }; + + const saveEdit = (e: React.MouseEvent) => { + e.stopPropagation(); + // Ensure we capture any pending input value on save + // (Though blur usually handles it, explicit save should too) + let finalVal = editValue; + + // Try parsing current input just in case + if (inputValue.includes(':')) { + const [m, s] = inputValue.split(':').map(Number); + if (!isNaN(m) && !isNaN(s)) finalVal = m * 60 + s; + } else if (/^\d+$/.test(inputValue)) { + const v = parseInt(inputValue, 10); + if (!isNaN(v)) finalVal = v; + } + + reset(finalVal); + if (onDurationChange) onDurationChange(finalVal); + setIsEditing(false); + setIsExpanded(true); // Keep expanded per user request + }; + + const adjustEdit = (curr: number, delta: number) => { + return Math.max(0, curr + delta); + }; + + const isRunningOrPaused = status === 'RUNNING' || status === 'PAUSED'; + const isFinished = status === 'FINISHED'; + + // Base classes + const fabBaseClasses = `fixed bottom-24 right-6 shadow-elevation-3 transition-all duration-300 z-50 flex items-center justify-center font-medium`; + + // Dimensions and Colors + let fabClasses = fabBaseClasses; + let content = null; + + const handleInputBlur = () => { + let val = 0; + if (inputValue.includes(':')) { + const [m, s] = inputValue.split(':').map(Number); + if (!isNaN(m) && !isNaN(s)) val = m * 60 + s; + } else { + val = parseInt(inputValue, 10); + } + + if (!isNaN(val)) { + setEditValue(val); + setInputValue(formatSeconds(val)); // Re-format to standardized look + } else { + setInputValue(formatSeconds(editValue)); // Revert if invalid + } + }; + + if (isFinished) { + fabClasses += ` w-14 h-14 rounded-[16px] bg-error text-on-error animate-pulse`; + content = ; + } else if (isRunningOrPaused && !isExpanded) { + // Requirements: "when time is running, show digits of the countdown on the enlarged timer FAB even if the menu is collapsed" + // So it should be wide enough to show digits. + fabClasses += ` h-14 rounded-[16px] bg-primary-container text-on-primary-container px-4 min-w-[56px] cursor-pointer hover:brightness-95`; + content = ( +
+ {formatSeconds(timeLeft)} +
+ ); + } else if (isExpanded) { + // Expanded: No common background, just a column of buttons right-aligned (Speed Dial) + fabClasses += ` w-auto bg-transparent flex-col-reverse items-end justify-end pb-0 overflow-visible`; + + content = ( +
+ {/* Options List (Bottom to Top) */} + {isEditing ? ( +
+ + + {/* Manual Input Field */} +
+ setInputValue(e.target.value)} + onFocus={(e) => e.target.select()} + onBlur={handleInputBlur} + onKeyDown={(e) => { + if (e.key === 'Enter') e.currentTarget.blur(); + }} + /> +
+ + + +
+ ) : ( +
+ {/* Mini FABs */} + + + + + +
+ )} + + {/* Main Toggle Button (Bottom) */} + +
+ ); + } else { + // Idle state + fabClasses += ` w-14 h-14 rounded-[16px] bg-secondary-container text-on-secondary-container hover:brightness-95 cursor-pointer`; + content = ( +
+ +
+ ); + + } + + if (isExpanded) { + // Override base classes for expanded state - remove shadow/bg from container + fabClasses = `fixed bottom-24 right-6 z-50 flex flex-col items-end pointer-events-none`; // pointer-events-none prevents container from blocking clicks, children have pointer-events-auto + } + + return ( +
+ {content} +
+ ); +}; + +export default RestTimerFAB; diff --git a/src/hooks/useRestTimer.ts b/src/hooks/useRestTimer.ts new file mode 100644 index 0000000..6c2e09a --- /dev/null +++ b/src/hooks/useRestTimer.ts @@ -0,0 +1,120 @@ +import { useState, useEffect, useRef, useCallback } from 'react'; +import { playTimeUpSignal } from '../utils/audio'; + +export type TimerStatus = 'IDLE' | 'RUNNING' | 'PAUSED' | 'FINISHED'; + +interface UseRestTimerProps { + defaultTime: number; // in seconds + onFinish?: () => void; + autoStart?: boolean; +} + +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 + + const endTimeRef = useRef(null); + const rafRef = useRef(null); + const prevDefaultTimeRef = useRef(defaultTime); + + // Update internal duration when defaultTime changes + useEffect(() => { + if (prevDefaultTimeRef.current !== defaultTime) { + prevDefaultTimeRef.current = defaultTime; + setDuration(defaultTime); + // Only update visible time if IDLE. If running, it will apply on next reset. + if (status === 'IDLE') { + setTimeLeft(defaultTime); + } + } + }, [defaultTime, 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); + } else { + rafRef.current = requestAnimationFrame(tick); + } + }, [onFinish]); + + const start = useCallback(() => { + if (status === 'RUNNING') return; + + // If starting from IDLE or PAUSED + const targetSeconds = status === 'PAUSED' ? timeLeft : duration; + endTimeRef.current = Date.now() + targetSeconds * 1000; + + setStatus('RUNNING'); + rafRef.current = requestAnimationFrame(tick); + }, [status, timeLeft, duration, tick]); + + const pause = useCallback(() => { + if (status !== 'RUNNING') return; + setStatus('PAUSED'); + if (rafRef.current) cancelAnimationFrame(rafRef.current); + 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; + }, [duration]); + + const addTime = useCallback((seconds: number) => { + setDuration(prev => prev + seconds); + if (status === 'IDLE') { + setTimeLeft(prev => prev + seconds); + } else if (status === 'RUNNING') { + // Add to current target + if (endTimeRef.current) { + endTimeRef.current += seconds * 1000; + // Force immediate update to avoid flicker + const now = Date.now(); + setTimeLeft(Math.max(0, Math.ceil((endTimeRef.current - now) / 1000))); + } + } else if (status === 'PAUSED') { + setTimeLeft(prev => prev + seconds); + } + }, [status]); + + + return { + timeLeft, + status, + start, + pause, + reset, + addTime, + setDuration: (val: number) => { + setDuration(val); + if (status === 'IDLE') setTimeLeft(val); + } + }; +}; diff --git a/src/types.ts b/src/types.ts index 335a2e8..720d1e0 100644 --- a/src/types.ts +++ b/src/types.ts @@ -53,6 +53,7 @@ export interface PlannedSet { exerciseName: string; // Denormalized for easier display exerciseType: ExerciseType; isWeighted: boolean; // Prompt specifically asked for this flag + restTimeSeconds?: number; } export interface WorkoutPlan { @@ -74,6 +75,7 @@ export interface UserProfile { gender?: 'MALE' | 'FEMALE' | 'OTHER'; birthDate?: number | string; // timestamp or ISO string language?: Language; + restTimerDefault?: number; } export interface BodyWeightRecord { diff --git a/src/utils/audio.ts b/src/utils/audio.ts new file mode 100644 index 0000000..80870c4 --- /dev/null +++ b/src/utils/audio.ts @@ -0,0 +1,41 @@ +/** + * Plays a beep sound using the Web Audio API. + * @param duration Duration in milliseconds + * @param frequency Frequency in Hz + * @param volume Volume (0-1) + */ +export const playBeep = (duration = 200, frequency = 440, volume = 0.5) => { + try { + const AudioContext = window.AudioContext || (window as any).webkitAudioContext; + if (!AudioContext) return; + + const ctx = new AudioContext(); + const osc = ctx.createOscillator(); + const gainUrl = ctx.createGain(); + + osc.connect(gainUrl); + gainUrl.connect(ctx.destination); + + osc.type = 'sine'; + osc.frequency.value = frequency; + gainUrl.gain.setValueAtTime(volume, ctx.currentTime); + gainUrl.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + duration / 1000); + + osc.start(ctx.currentTime); + osc.stop(ctx.currentTime + duration / 1000); + } catch (e) { + console.error('Audio playback failed', e); + } +}; + +/** + * Plays a "Time Up" signal (3 beeps) + */ +export const playTimeUpSignal = async () => { + // 3 beeps: High-Low-High? Or just 3 Highs. + // Let's do 3 rapid beeps. + const now = Date.now(); + playBeep(300, 880, 0.5); + setTimeout(() => playBeep(300, 880, 0.5), 600); + setTimeout(() => playBeep(600, 1200, 0.5), 1200); +}; diff --git a/tests/rest-timer.spec.ts b/tests/rest-timer.spec.ts new file mode 100644 index 0000000..69c5731 --- /dev/null +++ b/tests/rest-timer.spec.ts @@ -0,0 +1,150 @@ +import { test, expect } from './fixtures'; + +test.describe('Rest Timer Feature', () => { + + // Helper to handle first login if needed (copied from core-auth) + async function handleFirstLogin(page: any) { + try { + const heading = page.getByRole('heading', { name: /Change Password/i }); + await expect(heading).toBeVisible({ timeout: 5000 }); + await page.getByLabel('New Password').fill('StrongNewPass123!'); + await page.getByRole('button', { name: /Save|Change/i }).click(); + await expect(page.getByText('Free Workout')).toBeVisible(); + } catch (e) { + if (await page.getByText('Free Workout').isVisible()) return; + // If login failed or other error + const error = page.locator('.text-error'); + if (await error.isVisible()) throw new Error(`Login failed: ${await error.textContent()}`); + } + } + + // Helper for logging in + const loginUser = async (page: any, email: string, pass: string) => { + await page.goto('/login'); + await page.getByLabel('Email').fill(email); + await page.getByLabel('Password').fill(pass); + await page.getByRole('button', { name: "Login" }).click(); + await handleFirstLogin(page); + await page.waitForURL('/tracker'); + }; + + test('TC-RT-01: Default timer value and manual adjustment in Free Session', async ({ page, createUniqueUser }) => { + // Register/Create user via API + const user = await createUniqueUser(); + await loginUser(page, user.email, user.password); + + // 1. Start a free session + await page.getByRole('button', { name: "Start Empty Session" }).click(); + + // 2. Check default timer value (should be 120s / 2:00) + const fab = page.locator('.fixed.bottom-24.right-6'); + await expect(fab).toBeVisible(); + await fab.click(); // Expand + + const timeDisplay = fab.getByText('2:00'); + await expect(timeDisplay).toBeVisible(); + + // 3. Adjust time to 90s + await fab.getByLabel('Edit').click(); // Using aria-label added in component + + // Decrease 3 times (120 -> 120-15 = 105s) + const minusBtn = fab.locator('button').filter({ has: page.locator('svg.lucide-minus') }); + await minusBtn.click(); + await minusBtn.click(); + await minusBtn.click(); + + // Save + const saveBtn = fab.locator('button').filter({ has: page.locator('svg.lucide-check') }); + await saveBtn.click(); + + // Verify display is 1:45 and visible immediately (menu stays expanded) + await expect(fab.getByText('1:45')).toBeVisible(); + + // 4. Persistence check: Quit and reload + await page.getByLabel('Options').click(); + await page.getByText('Quit without saving').click(); + await page.getByRole('button', { name: 'Confirm' }).click(); + + await page.reload(); // Reload page to ensure persistence from server + await page.getByRole('button', { name: "Start Empty Session" }).click(); + await fab.click(); // Expand + await expect(fab.getByText('1:45')).toBeVisible(); + }); + + test('TC-RT-02: Timer functionality (Start/Pause/Reset)', async ({ page, createUniqueUser }) => { + const user = await createUniqueUser(); + await loginUser(page, user.email, user.password); + + await page.getByRole('button', { name: "Start Empty Session" }).click(); + const fab = page.locator('.fixed.bottom-24.right-6'); + await fab.click(); // Expand + + // Start + const startBtn = fab.getByLabel('Start'); + await startBtn.click(); + + // Check if running + await expect(fab).toHaveText(/1:59|2:00/); + await expect(fab.getByText('1:5')).toBeVisible({ timeout: 4000 }); // Wait for it to tick down + + // Pause + const pauseBtn = fab.getByLabel('Pause'); + await pauseBtn.click(); + const pausedText = await fab.innerText(); + await page.waitForTimeout(2000); + const pausedTextAfter = await fab.innerText(); + expect(pausedText).toBe(pausedTextAfter); + + // Reset + const resetBtn = fab.getByLabel('Reset'); + await resetBtn.click(); + await expect(fab.getByText('2:00')).toBeVisible(); + }); + + test('TC-RT-03: Plan Integration - Rest Time per Step', async ({ page, createUniqueUser }) => { + const user = await createUniqueUser(); + await loginUser(page, user.email, user.password); + + // 1. Create Exercise (Inline helper) + await page.goto('/exercises'); + await page.getByRole('button', { name: 'Create New Exercise' }).click(); + await page.getByPlaceholder('Exercise Name').fill('Rest Bench Press'); + // Select Muscle Group (Mock selection or just save if defaults work? Validation requires muscle/type) + // Assuming defaults or simple selection + await page.getByText('Target Muscle').click(); + await page.getByText('Chest').click(); + await page.getByText('Exercise Type').click(); + await page.getByText('Strength').click(); + await page.getByRole('button', { name: 'Save Exercise' }).click(); + + // 2. Create Plan with Rest Time + await page.goto('/plans'); + await page.getByRole('button', { name: 'Create Plan' }).click(); + await page.getByPlaceholder('Plan Name').fill('Rest Test Plan'); + await page.getByRole('button', { name: 'Add Exercise' }).click(); + await page.getByText('Rest Bench Press').click(); + + // Set Rest Time to 60s + await page.getByPlaceholder('Rest (s)').fill('60'); + + await page.getByRole('button', { name: 'Save Plan' }).click(); + + // 3. Start Plan + await page.goto('/tracker'); + // Ensure plans list is refreshed + await page.reload(); + await page.getByText('Rest Test Plan').click(); + await page.getByRole('button', { name: 'Start Workout' }).click(); + + // 4. Log Set + // Needs input for Weight/Reps if Weighted? Default is unweighted. + await page.getByPlaceholder('Weight (kg)').fill('50'); + await page.getByPlaceholder('Reps').fill('10'); + await page.getByRole('button', { name: 'Log Set' }).click(); + + // 5. Verify Timer Auto-Start with 60s + const fab = page.locator('.fixed.bottom-24.right-6'); + // It should be running and showing ~1:00 or 0:59 + await expect(fab).toHaveText(/1:00|0:59/); + }); +});