Timer implemented. No working tests.
This commit is contained in:
File diff suppressed because one or more lines are too long
BIN
server/dev.db
BIN
server/dev.db
Binary file not shown.
@@ -1,5 +0,0 @@
|
||||
module.exports = {
|
||||
datasource: {
|
||||
url: process.env.DATABASE_URL,
|
||||
},
|
||||
};
|
||||
5
server/prisma.config.ts
Normal file
5
server/prisma.config.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export default {
|
||||
datasource: {
|
||||
url: process.env.DATABASE_URL || "file:./dev.db",
|
||||
},
|
||||
};
|
||||
Binary file not shown.
@@ -0,0 +1,5 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "PlanExercise" ADD COLUMN "restTime" INTEGER;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "UserProfile" ADD COLUMN "restTimerDefault" INTEGER DEFAULT 120;
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Binary file not shown.
BIN
server/prod.db
Normal file
BIN
server/prod.db
Normal file
Binary file not shown.
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
BIN
server/test.db
BIN
server/test.db
Binary file not shown.
@@ -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**
|
||||
|
||||
@@ -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<PlansProps> = ({ 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<PlansProps> = ({ 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<PlansProps> = ({ lang }) => {
|
||||
|
||||
<div className="flex-1">
|
||||
<div className="text-base font-medium text-on-surface">{step.exerciseName}</div>
|
||||
<label className="flex items-center gap-2 mt-1 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 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>
|
||||
<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>
|
||||
</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} />
|
||||
@@ -254,20 +279,19 @@ const Plans: React.FC<PlansProps> = ({ lang }) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showExerciseSelector && (
|
||||
<div className="fixed inset-0 bg-surface z-50 flex flex-col animate-in slide-in-from-bottom-full duration-200">
|
||||
<div className="px-4 py-3 border-b border-outline-variant flex justify-between items-center bg-surface-container shrink-0">
|
||||
<span className="font-medium text-on-surface">{t('select_exercise', lang)}</span>
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={() => setIsCreatingExercise(true)} variant="ghost" size="icon" className="text-primary hover:bg-primary-container/20">
|
||||
<Plus size={20} />
|
||||
</Button>
|
||||
<Button onClick={() => setShowExerciseSelector(false)} variant="ghost" size="icon">
|
||||
<X size={20} />
|
||||
</Button>
|
||||
</div>
|
||||
<Modal
|
||||
isOpen={showExerciseSelector}
|
||||
onClose={() => setShowExerciseSelector(false)}
|
||||
title={t('select_exercise', lang)}
|
||||
maxWidth="md"
|
||||
>
|
||||
<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 p-2">
|
||||
<div className="flex-1 overflow-y-auto -mx-6 px-6">
|
||||
{availableExercises
|
||||
.slice()
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
@@ -275,84 +299,80 @@ const Plans: React.FC<PlansProps> = ({ lang }) => {
|
||||
<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"
|
||||
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>{ex.name}</span>
|
||||
<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>
|
||||
|
||||
{isCreatingExercise && (
|
||||
<div className="fixed inset-0 bg-surface z-[60] flex flex-col animate-in slide-in-from-bottom-full duration-200">
|
||||
<div className="px-4 py-3 border-b border-outline-variant flex justify-between items-center bg-surface-container shrink-0">
|
||||
<h3 className="text-title-medium font-medium text-on-surface">{t('create_exercise', lang)}</h3>
|
||||
<Button onClick={() => setIsCreatingExercise(false)} variant="ghost" size="icon" className="text-on-surface-variant hover:bg-white/5">
|
||||
<X size={20} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="p-4 space-y-6 overflow-y-auto flex-1">
|
||||
<FilledInput
|
||||
label={t('ex_name', lang)}
|
||||
value={newExName}
|
||||
onChange={(e: any) => setNewExName(e.target.value)}
|
||||
type="text"
|
||||
autoFocus
|
||||
autocapitalize="words"
|
||||
onBlur={() => setNewExName(toTitleCase(newExName))}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs text-on-surface-variant font-medium mb-3">{t('ex_type', lang)}</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{[
|
||||
{ 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) => (
|
||||
<button
|
||||
key={type.id}
|
||||
onClick={() => setNewExType(type.id)}
|
||||
className={`px-4 py-2 rounded-lg flex items-center gap-2 text-xs font-medium border transition-all ${newExType === type.id
|
||||
? 'bg-secondary-container text-on-secondary-container border-transparent'
|
||||
: 'bg-transparent text-on-surface-variant border-outline hover:border-on-surface-variant'
|
||||
}`}
|
||||
>
|
||||
<type.icon size={14} /> {type.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{newExType === ExerciseType.BODYWEIGHT && (
|
||||
<FilledInput
|
||||
label={t('body_weight_percent', lang)}
|
||||
value={newExBwPercentage}
|
||||
onChange={(e: any) => setNewExBwPercentage(e.target.value)}
|
||||
icon={<Percent size={12} />}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end mt-4">
|
||||
<Button
|
||||
onClick={handleCreateExercise}
|
||||
fullWidth
|
||||
size="lg"
|
||||
>
|
||||
<CheckCircle size={20} className="mr-2" />
|
||||
{t('create_btn', lang)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
isOpen={isCreatingExercise}
|
||||
onClose={() => setIsCreatingExercise(false)}
|
||||
title={t('create_exercise', lang)}
|
||||
maxWidth="md"
|
||||
>
|
||||
<div className="space-y-6 pt-2">
|
||||
<FilledInput
|
||||
label={t('ex_name', lang)}
|
||||
value={newExName}
|
||||
onChange={(e: any) => setNewExName(e.target.value)}
|
||||
type="text"
|
||||
autoFocus
|
||||
autocapitalize="words"
|
||||
onBlur={() => setNewExName(toTitleCase(newExName))}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs text-on-surface-variant font-medium mb-3">{t('ex_type', lang)}</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{[
|
||||
{ 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) => (
|
||||
<button
|
||||
key={type.id}
|
||||
onClick={() => setNewExType(type.id)}
|
||||
className={`px-4 py-2 rounded-lg flex items-center gap-2 text-xs font-medium border transition-all ${newExType === type.id
|
||||
? 'bg-secondary-container text-on-secondary-container border-transparent'
|
||||
: 'bg-transparent text-on-surface-variant border-outline hover:border-on-surface-variant'
|
||||
}`}
|
||||
>
|
||||
<type.icon size={14} /> {type.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{newExType === ExerciseType.BODYWEIGHT && (
|
||||
<FilledInput
|
||||
label={t('body_weight_percent', lang)}
|
||||
value={newExBwPercentage}
|
||||
onChange={(e: any) => setNewExBwPercentage(e.target.value)}
|
||||
icon={<Percent size={12} />}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end mt-6">
|
||||
<Button
|
||||
onClick={handleCreateExercise}
|
||||
fullWidth
|
||||
size="lg"
|
||||
>
|
||||
<CheckCircle size={20} className="mr-2" />
|
||||
{t('create_btn', lang)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<typeof useTracker>;
|
||||
@@ -71,6 +74,61 @@ const ActiveSessionView: React.FC<ActiveSessionViewProps> = ({ 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<ActiveSessionViewProps> = ({ tracker, activeSe
|
||||
<SetLogger
|
||||
tracker={tracker}
|
||||
lang={lang}
|
||||
onLogSet={handleAddSet}
|
||||
onLogSet={handleLogSet}
|
||||
/>
|
||||
|
||||
{activeSession.sets.length > 0 && (
|
||||
@@ -397,6 +455,8 @@ const ActiveSessionView: React.FC<ActiveSessionViewProps> = ({ tracker, activeSe
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<RestTimerFAB timer={timer} onDurationChange={handleDurationChange} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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<typeof useTracker>;
|
||||
@@ -26,6 +30,31 @@ const SporadicView: React.FC<SporadicViewProps> = ({ 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<WorkoutSet[]>([]);
|
||||
const [editingSetId, setEditingSetId] = useState<string | null>(null);
|
||||
const [editingSet, setEditingSet] = useState<WorkoutSet | null>(null);
|
||||
@@ -72,7 +101,7 @@ const SporadicView: React.FC<SporadicViewProps> = ({ tracker, lang }) => {
|
||||
<SetLogger
|
||||
tracker={tracker}
|
||||
lang={lang}
|
||||
onLogSet={handleLogSporadicSet}
|
||||
onLogSet={handleLogSet}
|
||||
isSporadic={true}
|
||||
/>
|
||||
|
||||
@@ -301,6 +330,8 @@ const SporadicView: React.FC<SporadicViewProps> = ({ tracker, lang }) => {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<RestTimerFAB timer={tracker.timer} onDurationChange={handleDurationChange} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
211
src/components/ui/RestTimerFAB.tsx
Normal file
211
src/components/ui/RestTimerFAB.tsx
Normal file
@@ -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<typeof useRestTimer>;
|
||||
onDurationChange?: (newDuration: number) => void;
|
||||
}
|
||||
|
||||
const RestTimerFAB: React.FC<RestTimerFABProps> = ({ 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 = <Timer size={24} />;
|
||||
} 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 = (
|
||||
<div className="flex items-center gap-2 pointer-events-none">
|
||||
<span className="font-mono text-lg font-bold">{formatSeconds(timeLeft)}</span>
|
||||
</div>
|
||||
);
|
||||
} 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 = (
|
||||
<div className="flex flex-col items-end gap-3 pointer-events-auto">
|
||||
{/* Options List (Bottom to Top) */}
|
||||
{isEditing ? (
|
||||
<div className="flex flex-col items-end gap-2 animate-in slide-in-from-bottom-4 fade-in duration-200 mb-2 mr-1">
|
||||
<button onClick={(e) => setEditValue(v => adjustEdit(v, 5))} className="w-10 h-10 flex items-center justify-center bg-surface-container-high text-on-surface rounded-full shadow-elevation-2 hover:brightness-110"><Plus size={20} /></button>
|
||||
|
||||
{/* Manual Input Field */}
|
||||
<div className="bg-surface-container shadow-sm rounded px-2 py-1 my-1 flex items-center justify-center min-w-[60px]">
|
||||
<input
|
||||
type="text"
|
||||
className="bg-transparent text-on-surface font-mono font-bold text-lg text-center w-full focus:outline-none"
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
onFocus={(e) => e.target.select()}
|
||||
onBlur={handleInputBlur}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') e.currentTarget.blur();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button onClick={(e) => setEditValue(v => adjustEdit(v, -5))} className="w-10 h-10 flex items-center justify-center bg-surface-container-high text-on-surface rounded-full shadow-elevation-2 hover:brightness-110"><Minus size={20} /></button>
|
||||
<button onClick={saveEdit} className="w-10 h-10 flex items-center justify-center bg-primary text-on-primary rounded-full shadow-elevation-2 mt-1 hover:brightness-110"><Check size={20} /></button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-end gap-3 animate-in slide-in-from-bottom-4 fade-in duration-200 mb-4 mr-1">
|
||||
{/* Mini FABs */}
|
||||
<button onClick={handleEdit} className="w-10 h-10 flex items-center justify-center bg-surface-container-high text-on-surface hover:text-primary rounded-full shadow-elevation-2 hover:scale-110 transition-all" aria-label="Edit">
|
||||
<Edit2 size={18} />
|
||||
</button>
|
||||
|
||||
<button onClick={handleReset} className="w-10 h-10 flex items-center justify-center bg-surface-container-high text-on-surface hover:text-primary rounded-full shadow-elevation-2 hover:scale-110 transition-all" aria-label="Reset">
|
||||
<RotateCcw size={18} />
|
||||
</button>
|
||||
|
||||
<button onClick={handleStartPause} className="w-12 h-12 flex items-center justify-center bg-primary-container text-on-primary-container rounded-full shadow-elevation-2 hover:scale-110 transition-all" aria-label={status === 'RUNNING' ? 'Pause' : 'Start'}>
|
||||
{status === 'RUNNING' ? <Pause size={24} /> : <Play size={24} className="ml-1" />}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main Toggle Button (Bottom) */}
|
||||
<button
|
||||
onClick={handleToggle}
|
||||
className={`w-16 h-16 rounded-full flex items-center justify-center shadow-elevation-3 hover:scale-105 transition-all active:scale-95 ${isEditing
|
||||
? 'bg-error-container text-on-error-container hover:bg-error-container/80' // Light Red for cancel/close
|
||||
: 'bg-primary text-on-primary'
|
||||
}`}
|
||||
>
|
||||
{isEditing ? <X size={28} /> : <span className="font-mono text-sm font-bold">{formatSeconds(timeLeft)}</span>}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
// Idle state
|
||||
fabClasses += ` w-14 h-14 rounded-[16px] bg-secondary-container text-on-secondary-container hover:brightness-95 cursor-pointer`;
|
||||
content = (
|
||||
<div className="w-full h-full flex items-center justify-center pointer-events-none">
|
||||
<Timer size={24} />
|
||||
</div>
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className={fabClasses} onClick={!isExpanded ? handleToggle : undefined}>
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RestTimerFAB;
|
||||
120
src/hooks/useRestTimer.ts
Normal file
120
src/hooks/useRestTimer.ts
Normal file
@@ -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<TimerStatus>('IDLE');
|
||||
const [duration, setDuration] = useState(defaultTime); // The set duration to reset to
|
||||
|
||||
const endTimeRef = useRef<number | null>(null);
|
||||
const rafRef = useRef<number | null>(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);
|
||||
}
|
||||
};
|
||||
};
|
||||
@@ -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 {
|
||||
|
||||
41
src/utils/audio.ts
Normal file
41
src/utils/audio.ts
Normal file
@@ -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);
|
||||
};
|
||||
150
tests/rest-timer.spec.ts
Normal file
150
tests/rest-timer.spec.ts
Normal file
@@ -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/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user