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) {
|
static async updateActiveSession(req: any, res: Response) {
|
||||||
try {
|
try {
|
||||||
const userId = req.user.userId;
|
const userId = req.user.userId;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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`
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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: 'упражнений',
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user