From 003c045621d1684876473170cf022f6f300e1982 Mon Sep 17 00:00:00 2001 From: AG Date: Sat, 13 Dec 2025 19:40:40 +0200 Subject: [PATCH] Days off workouts on Tracker view --- server/src/controllers/session.controller.ts | 11 ++++ server/src/routes/sessions.ts | 1 + server/src/services/session.service.ts | 13 +++++ specs/gymflow-test-plan.md | 14 +++++ specs/requirements.md | 11 ++++ src/components/Tracker/IdleView.tsx | 61 +++++++++++++++++++- src/components/Tracker/useTracker.ts | 17 +++++- src/services/i18n.ts | 6 ++ tests/workout-tracking.spec.ts | 15 +++++ 9 files changed, 145 insertions(+), 4 deletions(-) diff --git a/server/src/controllers/session.controller.ts b/server/src/controllers/session.controller.ts index 69aa208..0416b33 100644 --- a/server/src/controllers/session.controller.ts +++ b/server/src/controllers/session.controller.ts @@ -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; diff --git a/server/src/routes/sessions.ts b/server/src/routes/sessions.ts index 41bba31..e924de7 100644 --- a/server/src/routes/sessions.ts +++ b/server/src/routes/sessions.ts @@ -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); diff --git a/server/src/services/session.service.ts b/server/src/services/session.service.ts index ca12400..0a39ce0 100644 --- a/server/src/services/session.service.ts +++ b/server/src/services/session.service.ts @@ -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; diff --git a/specs/gymflow-test-plan.md b/specs/gymflow-test-plan.md index c1a0c18..2051097 100644 --- a/specs/gymflow-test-plan.md +++ b/specs/gymflow-test-plan.md @@ -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` diff --git a/specs/requirements.md b/specs/requirements.md index f9a849b..8b74ee7 100644 --- a/specs/requirements.md +++ b/specs/requirements.md @@ -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). diff --git a/src/components/Tracker/IdleView.tsx b/src/components/Tracker/IdleView.tsx index 4b437d8..265adab 100644 --- a/src/components/Tracker/IdleView.tsx +++ b/src/components/Tracker/IdleView.tsx @@ -18,9 +18,64 @@ const IdleView: React.FC = ({ 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 (
@@ -29,8 +84,8 @@ const IdleView: React.FC = ({ tracker, lang }) => {
-

{t('ready_title', lang)}

-

{t('ready_subtitle', lang)}

+

{content.title}

+

{content.subtitle}

diff --git a/src/components/Tracker/useTracker.ts b/src/components/Tracker/useTracker.ts index 9decf39..343557c 100644 --- a/src/components/Tracker/useTracker.ts +++ b/src/components/Tracker/useTracker.ts @@ -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(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('/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 }; }; diff --git a/src/services/i18n.ts b/src/services/i18n.ts index 71987d4..f386d4d 100644 --- a/src/services/i18n.ts +++ b/src/services/i18n.ts @@ -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: 'упражнений', diff --git a/tests/workout-tracking.spec.ts b/tests/workout-tracking.spec.ts index 5193d9c..e08d698 100644 --- a/tests/workout-tracking.spec.ts +++ b/tests/workout-tracking.spec.ts @@ -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(); + }); + });