Days off workouts on Tracker view

This commit is contained in:
AG
2025-12-13 19:40:40 +02:00
parent dbb4beb56d
commit 003c045621
9 changed files with 145 additions and 4 deletions

View File

@@ -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) { static async updateActiveSession(req: any, res: Response) {
try { try {
const userId = req.user.userId; const userId = req.user.userId;

View File

@@ -11,6 +11,7 @@ router.use(authenticateToken);
router.get('/', SessionController.getAllSessions); router.get('/', SessionController.getAllSessions);
router.post('/', validate(sessionSchema), SessionController.saveSession); router.post('/', validate(sessionSchema), SessionController.saveSession);
router.get('/active', SessionController.getActiveSession); router.get('/active', SessionController.getActiveSession);
router.get('/active/last', SessionController.getLastSession);
router.put('/active', validate(sessionSchema), SessionController.updateActiveSession); router.put('/active', validate(sessionSchema), SessionController.updateActiveSession);
router.get('/quick-log', SessionController.getTodayQuickLog); router.get('/quick-log', SessionController.getTodayQuickLog);
router.post('/quick-log/set', validate(logSetSchema), SessionController.logSetToQuickLog); router.post('/quick-log/set', validate(logSetSchema), SessionController.logSetToQuickLog);

View File

@@ -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) { static async saveSession(userId: string, data: any) {
const { id, startTime, endTime, userBodyWeight, note, planId, planName, sets } = data; const { id, startTime, endTime, userBodyWeight, note, planId, planName, sets } = data;

View File

@@ -599,6 +599,20 @@ Comprehensive test plan for the GymFlow web application, covering authentication
- Timer value persists across different session modes (active to sporadic). - Timer value persists across different session modes (active to sporadic).
- Last manually set value becomes the new default for manual modes. - 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 #### 3.18. C. Rest Timer - Plan Integration
**File:** `tests/rest-timer.spec.ts` **File:** `tests/rest-timer.spec.ts`

View File

@@ -74,6 +74,17 @@ Users can structure their training via Plans.
### 3.4. Workout Tracking (The "Tracker") ### 3.4. Workout Tracking (The "Tracker")
The core feature. States: **Idle**, **Active Session**, **Sporadic Mode**. 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)** * **3.4.1 Active Session (Standard)**
* **Initiation**: * **Initiation**:
* Can start "Free Workout" (no plan). * Can start "Free Workout" (no plan).

View File

@@ -18,9 +18,64 @@ const IdleView: React.FC<IdleViewProps> = ({ tracker, lang }) => {
plans, plans,
showPlanPrep, showPlanPrep,
setShowPlanPrep, setShowPlanPrep,
confirmPlanStart confirmPlanStart,
lastWorkoutDate
} = tracker; } = 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 ( return (
<div className="flex flex-col h-full p-4 md:p-8 overflow-y-auto relative"> <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"> <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} /> <Dumbbell size={40} />
</div> </div>
<div className="text-center"> <div className="text-center">
<h1 className="text-3xl font-normal text-on-surface">{t('ready_title', lang)}</h1> <h1 className={`text-3xl font-normal ${content.colorClass}`}>{content.title}</h1>
<p className="text-on-surface-variant text-sm">{t('ready_subtitle', lang)}</p> <p className="text-on-surface-variant text-sm">{content.subtitle}</p>
</div> </div>
</div> </div>

View File

@@ -51,6 +51,9 @@ export const useTracker = (props: any) => { // Props ignored/removed
const [isSporadicMode, setIsSporadicMode] = useState(false); const [isSporadicMode, setIsSporadicMode] = useState(false);
const [sporadicSuccess, setSporadicSuccess] = useState(false); const [sporadicSuccess, setSporadicSuccess] = useState(false);
// Last Workout State
const [lastWorkoutDate, setLastWorkoutDate] = useState<Date | null>(null);
// Hooks // Hooks
const elapsedTime = useSessionTimer(activeSession); const elapsedTime = useSessionTimer(activeSession);
// useWorkoutForm needs onUpdateSet. But context updateSet signature might be different? // 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()); 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(); loadQuickLogSession();
}; };
loadData(); loadData();
@@ -265,7 +279,8 @@ export const useTracker = (props: any) => { // Props ignored/removed
onRemoveSet: removeSet, onRemoveSet: removeSet,
updateSet: handleUpdateSetWrapper, updateSet: handleUpdateSetWrapper,
activeSession, // Need this in view activeSession, // Need this in view
timer // Expose timer to views timer, // Expose timer to views
lastWorkoutDate
}; };
}; };

View File

@@ -46,6 +46,9 @@ const translations = {
ready_subtitle: 'Start your workout and break records.', ready_subtitle: 'Start your workout and break records.',
my_weight: 'My Weight (kg)', my_weight: 'My Weight (kg)',
change_in_profile: 'Change in profile', 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', free_workout: 'Free Workout',
or_choose_plan: 'Or choose a plan', or_choose_plan: 'Or choose a plan',
exercises_count: 'exercises', exercises_count: 'exercises',
@@ -228,6 +231,9 @@ const translations = {
ready_subtitle: 'Начните тренировку и побейте рекорды.', ready_subtitle: 'Начните тренировку и побейте рекорды.',
my_weight: 'Мой вес (кг)', my_weight: 'Мой вес (кг)',
change_in_profile: 'Можно изменить в профиле', change_in_profile: 'Можно изменить в профиле',
last_workout_today: 'Последняя тренировка: Сегодня',
days_off: 'Дней без тренировок:',
first_workout_prompt: 'Проведи свою первую тренировку сегодня.',
free_workout: 'Свободная тренировка', free_workout: 'Свободная тренировка',
or_choose_plan: 'Или выберите план', or_choose_plan: 'Или выберите план',
exercises_count: 'упражнений', exercises_count: 'упражнений',

View File

@@ -504,4 +504,19 @@ test.describe('III. Workout Tracking', () => {
await expect(page.getByText('50 kg x 1 reps')).toBeVisible(); 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();
});
}); });