Days off workouts on Tracker view
This commit is contained in:
@@ -40,6 +40,17 @@ export class SessionController {
|
||||
}
|
||||
}
|
||||
|
||||
static async getLastSession(req: any, res: Response) {
|
||||
try {
|
||||
const userId = req.user.userId;
|
||||
const session = await SessionService.getLastWorkoutSession(userId);
|
||||
return sendSuccess(res, { session });
|
||||
} catch (error) {
|
||||
logger.error('Error in getLastSession', { error });
|
||||
return sendError(res, 'Server error', 500);
|
||||
}
|
||||
}
|
||||
|
||||
static async updateActiveSession(req: any, res: Response) {
|
||||
try {
|
||||
const userId = req.user.userId;
|
||||
|
||||
@@ -11,6 +11,7 @@ router.use(authenticateToken);
|
||||
router.get('/', SessionController.getAllSessions);
|
||||
router.post('/', validate(sessionSchema), SessionController.saveSession);
|
||||
router.get('/active', SessionController.getActiveSession);
|
||||
router.get('/active/last', SessionController.getLastSession);
|
||||
router.put('/active', validate(sessionSchema), SessionController.updateActiveSession);
|
||||
router.get('/quick-log', SessionController.getTodayQuickLog);
|
||||
router.post('/quick-log/set', validate(logSetSchema), SessionController.logSetToQuickLog);
|
||||
|
||||
@@ -25,6 +25,19 @@ export class SessionService {
|
||||
}));
|
||||
}
|
||||
|
||||
static async getLastWorkoutSession(userId: string) {
|
||||
const session = await prisma.workoutSession.findFirst({
|
||||
where: {
|
||||
userId,
|
||||
type: { not: 'QUICK_LOG' },
|
||||
endTime: { not: null }
|
||||
},
|
||||
orderBy: { endTime: 'desc' },
|
||||
select: { endTime: true }
|
||||
});
|
||||
return session;
|
||||
}
|
||||
|
||||
static async saveSession(userId: string, data: any) {
|
||||
const { id, startTime, endTime, userBodyWeight, note, planId, planName, sets } = data;
|
||||
|
||||
|
||||
@@ -599,6 +599,20 @@ Comprehensive test plan for the GymFlow web application, covering authentication
|
||||
- Timer value persists across different session modes (active to sporadic).
|
||||
- Last manually set value becomes the new default for manual modes.
|
||||
|
||||
#### 3.17. B. Idle State - Days Off Training Logic
|
||||
|
||||
**File:** `tests/workout-tracking.spec.ts`
|
||||
|
||||
**Steps:**
|
||||
1. Log in as a new user (0 workouts).
|
||||
2. Verify message: "Do your very first workout today.".
|
||||
3. Start and Finish a Free Workout.
|
||||
4. Verify message: "Last workout: Today".
|
||||
|
||||
**Expected Results:**
|
||||
- Messages update dynamically based on workout history.
|
||||
- "Ready?" text is NOT visible.
|
||||
|
||||
#### 3.18. C. Rest Timer - Plan Integration
|
||||
|
||||
**File:** `tests/rest-timer.spec.ts`
|
||||
|
||||
@@ -74,6 +74,17 @@ Users can structure their training via Plans.
|
||||
### 3.4. Workout Tracking (The "Tracker")
|
||||
The core feature. States: **Idle**, **Active Session**, **Sporadic Mode**.
|
||||
|
||||
* **3.4.0 Idle State (Visuals)**
|
||||
* **Days Off Logic**:
|
||||
* Displays "Do your very first workout today." for new users (0 workouts).
|
||||
* Displays "Last workout: Today" if `endTime` of last standard session was today.
|
||||
* Displays "Days off training: N" otherwise.
|
||||
* **Color Coding**:
|
||||
* **1 Day**: Green.
|
||||
* **2-4 Days**: Gradient (Green to Red).
|
||||
* **5+ Days**: Red.
|
||||
* **Constraint**: The text "Ready?" must NOT be displayed.
|
||||
|
||||
* **3.4.1 Active Session (Standard)**
|
||||
* **Initiation**:
|
||||
* Can start "Free Workout" (no plan).
|
||||
|
||||
@@ -18,9 +18,64 @@ const IdleView: React.FC<IdleViewProps> = ({ tracker, lang }) => {
|
||||
plans,
|
||||
showPlanPrep,
|
||||
setShowPlanPrep,
|
||||
confirmPlanStart
|
||||
confirmPlanStart,
|
||||
lastWorkoutDate
|
||||
} = tracker;
|
||||
|
||||
// Calculate days off
|
||||
const getDaysOffContent = () => {
|
||||
if (!lastWorkoutDate) {
|
||||
return {
|
||||
title: t('first_workout_prompt', lang),
|
||||
subtitle: null,
|
||||
colorClass: 'text-on-surface'
|
||||
};
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
now.setHours(0, 0, 0, 0);
|
||||
const last = new Date(lastWorkoutDate);
|
||||
last.setHours(0, 0, 0, 0);
|
||||
|
||||
const diffTime = Math.abs(now.getTime() - last.getTime());
|
||||
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (diffDays === 0) {
|
||||
return {
|
||||
title: t('last_workout_today', lang),
|
||||
subtitle: null,
|
||||
colorClass: 'text-on-surface'
|
||||
};
|
||||
}
|
||||
|
||||
const prefix = t('days_off', lang);
|
||||
|
||||
if (diffDays === 1) {
|
||||
return {
|
||||
title: `${prefix} ${diffDays}`,
|
||||
subtitle: null,
|
||||
colorClass: 'text-green-500' // 1 is green
|
||||
};
|
||||
}
|
||||
|
||||
if (diffDays >= 5) {
|
||||
return {
|
||||
title: `${prefix} ${diffDays}`,
|
||||
subtitle: null,
|
||||
colorClass: 'text-red-500' // 5 and more is red
|
||||
};
|
||||
}
|
||||
|
||||
// Gradient for 2-4
|
||||
return {
|
||||
title: `${prefix} ${diffDays}`,
|
||||
subtitle: null,
|
||||
colorClass: 'bg-gradient-to-r from-green-500 to-red-500 bg-clip-text text-transparent'
|
||||
};
|
||||
};
|
||||
|
||||
const content = getDaysOffContent();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full p-4 md:p-8 overflow-y-auto relative">
|
||||
<div className="flex-1 flex flex-col items-center justify-center space-y-12">
|
||||
@@ -29,8 +84,8 @@ const IdleView: React.FC<IdleViewProps> = ({ tracker, lang }) => {
|
||||
<Dumbbell size={40} />
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<h1 className="text-3xl font-normal text-on-surface">{t('ready_title', lang)}</h1>
|
||||
<p className="text-on-surface-variant text-sm">{t('ready_subtitle', lang)}</p>
|
||||
<h1 className={`text-3xl font-normal ${content.colorClass}`}>{content.title}</h1>
|
||||
<p className="text-on-surface-variant text-sm">{content.subtitle}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -51,6 +51,9 @@ export const useTracker = (props: any) => { // Props ignored/removed
|
||||
const [isSporadicMode, setIsSporadicMode] = useState(false);
|
||||
const [sporadicSuccess, setSporadicSuccess] = useState(false);
|
||||
|
||||
// Last Workout State
|
||||
const [lastWorkoutDate, setLastWorkoutDate] = useState<Date | null>(null);
|
||||
|
||||
// Hooks
|
||||
const elapsedTime = useSessionTimer(activeSession);
|
||||
// useWorkoutForm needs onUpdateSet. But context updateSet signature might be different?
|
||||
@@ -93,6 +96,17 @@ export const useTracker = (props: any) => { // Props ignored/removed
|
||||
setUserBodyWeight(userWeight.toString());
|
||||
}
|
||||
|
||||
try {
|
||||
const lastSessionRes = await api.get<any>('/sessions/active/last');
|
||||
if (lastSessionRes.success && lastSessionRes.data?.session?.endTime) {
|
||||
setLastWorkoutDate(new Date(lastSessionRes.data.session.endTime));
|
||||
} else {
|
||||
setLastWorkoutDate(null);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to load last session", err);
|
||||
}
|
||||
|
||||
loadQuickLogSession();
|
||||
};
|
||||
loadData();
|
||||
@@ -265,7 +279,8 @@ export const useTracker = (props: any) => { // Props ignored/removed
|
||||
onRemoveSet: removeSet,
|
||||
updateSet: handleUpdateSetWrapper,
|
||||
activeSession, // Need this in view
|
||||
timer // Expose timer to views
|
||||
timer, // Expose timer to views
|
||||
lastWorkoutDate
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -46,6 +46,9 @@ const translations = {
|
||||
ready_subtitle: 'Start your workout and break records.',
|
||||
my_weight: 'My Weight (kg)',
|
||||
change_in_profile: 'Change in profile',
|
||||
last_workout_today: 'Last workout: Today',
|
||||
days_off: 'Days off training:',
|
||||
first_workout_prompt: 'Do your very first workout today.',
|
||||
free_workout: 'Free Workout',
|
||||
or_choose_plan: 'Or choose a plan',
|
||||
exercises_count: 'exercises',
|
||||
@@ -228,6 +231,9 @@ const translations = {
|
||||
ready_subtitle: 'Начните тренировку и побейте рекорды.',
|
||||
my_weight: 'Мой вес (кг)',
|
||||
change_in_profile: 'Можно изменить в профиле',
|
||||
last_workout_today: 'Последняя тренировка: Сегодня',
|
||||
days_off: 'Дней без тренировок:',
|
||||
first_workout_prompt: 'Проведи свою первую тренировку сегодня.',
|
||||
free_workout: 'Свободная тренировка',
|
||||
or_choose_plan: 'Или выберите план',
|
||||
exercises_count: 'упражнений',
|
||||
|
||||
@@ -504,4 +504,19 @@ test.describe('III. Workout Tracking', () => {
|
||||
await expect(page.getByText('50 kg x 1 reps')).toBeVisible();
|
||||
});
|
||||
|
||||
test('3.17 B. Idle State - Days Off Training Logic', async ({ page, createUniqueUser }) => {
|
||||
const user = await loginAndSetup(page, createUniqueUser);
|
||||
|
||||
// 1. New User: Should see "Do your very first workout today."
|
||||
await expect(page.getByText('Do your very first workout today.')).toBeVisible();
|
||||
|
||||
// 2. Complete a workout
|
||||
await page.getByRole('button', { name: /Free Workout|Start Empty/i }).click();
|
||||
await page.getByRole('button', { name: 'Finish' }).click();
|
||||
await page.getByRole('button', { name: 'Confirm' }).click();
|
||||
|
||||
// 3. Should now see "Last workout: Today"
|
||||
await expect(page.getByText('Last workout: Today')).toBeVisible();
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user