Rest timer polishing. Rest timer sets done

This commit is contained in:
AG
2025-12-11 21:46:38 +02:00
parent c509a8be24
commit 138fe0c432
8 changed files with 286 additions and 134 deletions

File diff suppressed because one or more lines are too long

Binary file not shown.

View File

@@ -565,6 +565,60 @@ Comprehensive test plan for the GymFlow web application, covering authentication
**Expected Results:**
- Each set is logged with the correct specific metric (Height, Distance, Duration, etc.).
#### 3.16. C. Rest Timer - Manual Edit & Validation
**File:** `tests/rest-timer.spec.ts`
**Steps:**
1. Start a Free Workout (or Quick Log).
2. Expand the Rest Timer FAB.
3. Click 'Edit'.
4. Type '90' -> Verify '1:30' (if auto-format implemented) or manual '1:30'.
5. Attempt to type non-digits -> Verify they are ignored.
6. Attempt to type '10:99' (invalid seconds) -> Verify it corrects to '10:59'.
7. Save.
**Expected Results:**
- Input field accepts only valid characters.
- Seconds are clamped to 59.
- Timer value updates correctly upon save.
#### 3.17. C. Rest Timer - Context & Persistence
**File:** `tests/rest-timer.spec.ts`
**Steps:**
1. Start a Free Workout (Idle Timer defaults to 2:00 or user profile setting).
2. Edit timer to '0:45'.
3. Start timer.
4. Quit session (or navigate to Quick Log).
5. Start Quick Log (or new Free Session).
6. Verify timer default is now '0:45'.
**Expected Results:**
- Timer value persists across different session modes (active to sporadic).
- Last manually set value becomes the new default for manual modes.
#### 3.18. C. Rest Timer - Plan Integration
**File:** `tests/rest-timer.spec.ts`
**Steps:**
1. Create a Plan with Step A (Rest: 30s) and Step B (Rest: 60s).
2. Start the Plan.
3. Verify Timer shows '0:30'. Start it.
4. Log Set for Step A while timer is running.
5. Verify Timer continues running (does not reset).
6. Reset Timer manually (or wait for finish).
7. Verify Timer now shows '1:00' (for Step B).
**Expected Results:**
- Timer accurately reflects specific rest times per step.
- Active timer is NOT interrupted by logging sets (smart non-reset).
- Timer updates duration to next step's rest time once idle/reset, but remains PAUSED/IDLE (does not auto-start).
### 4. IV. Data & Progress

View File

@@ -110,10 +110,15 @@ The core feature. States: **Idle**, **Active Session**, **Sporadic Mode**.
* **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.
* **Auto-Set**: When a set is logged, the timer RESETS (updates duration) to the value defined for the *current* step, but does **NOT** start automatically.
* **Fallback**: If plan step has no `restTime`, use User's default.
* **Behavior**:
* **Start**: Manual trigger by user.
* **Start**: Manual trigger by user (NEVER auto-starts).
* **Edit Value**:
* **Input**: Manual entry via FAB expand menu.
* **Format**: Strict "MM:SS" format (digits and colon only).
* **Constraints**: Max value `99:59`. Seconds > 59 are automatically clamped to 59.
* **Alignment**: Digits are right-aligned; input width is fixed to tightly fit `00:00`.
* **Countdown**: Visual display.
* **Completion**:
* Audio signal (3 seconds).

View File

@@ -110,7 +110,7 @@ const ActiveSessionView: React.FC<ActiveSessionViewProps> = ({ tracker, activeSe
if (timer.status !== 'RUNNING') {
timer.reset(nextTime);
timer.start();
// timer.start(); // Removed per user request: disable auto-start
}
};

View File

@@ -38,7 +38,7 @@ const SporadicView: React.FC<SporadicViewProps> = ({ tracker, lang }) => {
const handleLogSet = async () => {
await handleLogSporadicSet();
// Always usage default/current setting for sporadic
timer.start();
// timer.start(); // Removed per user request: disable auto-start
};
const handleDurationChange = async (newVal: number) => {

View File

@@ -139,22 +139,53 @@ const RestTimerFAB: React.FC<RestTimerFABProps> = ({ timer, onDurationChange })
<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]">
<div className="bg-surface-container shadow-sm rounded px-2 py-1 my-1 flex items-center justify-center">
<input
type="text"
className="bg-transparent text-on-surface font-mono font-bold text-lg text-center w-full focus:outline-none"
className="bg-transparent text-on-surface font-mono font-bold text-lg text-right w-[5.4ch] focus:outline-none"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onChange={(e) => {
let val = e.target.value;
// Filter non-digits and non-colon
val = val.replace(/[^0-9:]/g, '');
// Limit length to 5
if (val.length > 5) {
val = val.substring(0, 5);
}
// Validate Seconds part if colon exists
if (val.includes(':')) {
const parts = val.split(':');
if (parts.length > 1) {
const minutes = parts[0];
let seconds = parts[1];
// Clamp seconds to 59
if (seconds.length > 0) {
const secNum = parseInt(seconds, 10);
if (!isNaN(secNum) && secNum > 59) {
seconds = '59';
}
}
val = `${minutes}:${seconds}`;
}
}
setInputValue(val);
}}
onFocus={(e) => e.target.select()}
onBlur={handleInputBlur}
onKeyDown={(e) => {
if (e.key === 'Enter') e.currentTarget.blur();
}}
placeholder="00:00"
/>
</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>
<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" aria-label="Save"><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">
@@ -177,8 +208,8 @@ const RestTimerFAB: React.FC<RestTimerFABProps> = ({ timer, onDurationChange })
<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'
? '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>}

View File

@@ -1,150 +1,212 @@
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
test.describe('Rest Timer', () => {
test('should allow setting a rest time in a free session', async ({ page, createUniqueUser }) => {
const user = await createUniqueUser();
await loginUser(page, user.email, user.password);
await page.goto('/');
await page.getByLabel('Email').fill(user.email);
await page.getByLabel('Password').fill(user.password);
await page.getByRole('button', { name: 'Login' }).click();
// 1. Start a free session
await page.getByRole('button', { name: "Start Empty Session" }).click();
try {
await page.getByRole('heading', { name: 'Change Password' }).waitFor();
await page.getByLabel('New Password').fill('StrongNewPassword123!');
await page.getByRole('button', { name: 'Save & Login' }).click();
} catch (e) {
// Ignore if the change password screen is not visible
}
await expect(page.getByText('Free Workout')).toBeVisible();
// 2. Check default timer value (should be 120s / 2:00)
// Click the "Free Workout" button.
await page.getByRole('button', { name: 'Free Workout' }).click();
// The FAB timer should be visible (IDLE state: icon only)
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();
// Click on the rest timer FAB to expand it and reveal the time value.
await fab.click();
// 3. Adjust time to 90s
await fab.getByLabel('Edit').click(); // Using aria-label added in component
// Wait for expansion and Edit button visibility
const editBtn = fab.locator('button[aria-label="Edit"]');
await expect(editBtn).toBeVisible();
await editBtn.click();
// 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();
// Change the rest timer value to 90 seconds.
await page.getByRole('textbox').nth(1).fill('90');
// Save
const saveBtn = fab.locator('button').filter({ has: page.locator('svg.lucide-check') });
// Confirm the new rest timer value.
const saveBtn = fab.locator('button[aria-label="Save"]');
await expect(saveBtn).toBeVisible();
await saveBtn.click();
// Verify display is 1:45 and visible immediately (menu stays expanded)
await expect(fab.getByText('1:45')).toBeVisible();
// The timer should now be 90 seconds.
await expect(page.locator('div').filter({ hasText: /1:30|90/ }).first()).toBeVisible();
});
// 4. Persistence check: Quit and reload
await page.getByLabel('Options').click();
await page.getByText('Quit without saving').click();
test('should validate manual input in the rest timer', async ({ page, createUniqueUser }) => {
const user = await createUniqueUser();
await page.goto('/');
await page.getByLabel('Email').fill(user.email);
await page.getByLabel('Password').fill(user.password);
await page.getByRole('button', { name: 'Login' }).click();
try {
await page.getByRole('heading', { name: 'Change Password' }).waitFor();
await page.getByLabel('New Password').fill('StrongNewPassword123!');
await page.getByRole('button', { name: 'Save & Login' }).click();
} catch (e) {
// Ignore
}
await expect(page.getByText('Free Workout')).toBeVisible();
// Start a Free Workout
await page.getByRole('button', { name: 'Free Workout' }).click();
// Expand the Rest Timer FAB (Click first!)
const fab = page.locator('.fixed.bottom-24.right-6');
await fab.click();
// Click 'Edit'
const editBtn = fab.locator('button[aria-label="Edit"]');
await expect(editBtn).toBeVisible();
await editBtn.click();
// Type '90' -> Verify '1:30' (if auto-format implemented) or manual '1:30'.
const timerInput = page.getByRole('textbox').nth(1);
await timerInput.fill('90');
await expect(timerInput).toHaveValue('90');
// Attempt to type non-digits -> Verify they are ignored.
await timerInput.fill('90abc');
await expect(timerInput).toHaveValue('90');
// Attempt to type '10:99' (invalid seconds) -> Verify it corrects to '10:59'.
await timerInput.fill('10:99');
await expect(timerInput).toHaveValue('10:59');
// Save
const saveBtn = fab.locator('button[aria-label="Save"]');
await saveBtn.click();
// Verify updated value in FAB (might need to wait or check visibility)
// After save, it usually stays expanded per code "setIsExpanded(true)"
});
test('should persist the rest timer value across sessions', async ({ page, createUniqueUser }) => {
const user = await createUniqueUser();
await page.goto('/');
await page.getByLabel('Email').fill(user.email);
await page.getByLabel('Password').fill(user.password);
await page.getByRole('button', { name: 'Login' }).click();
try {
await page.getByRole('heading', { name: 'Change Password' }).waitFor();
await page.getByLabel('New Password').fill('StrongNewPassword123!');
await page.getByRole('button', { name: 'Save & Login' }).click();
} catch (e) {
// Ignore
}
await expect(page.getByText('Free Workout')).toBeVisible();
// Start a Free Workout
await page.getByRole('button', { name: 'Free Workout' }).click();
// Click FAB to expand
const fab = page.locator('.fixed.bottom-24.right-6');
await fab.click();
// Edit timer to '0:45'.
const editBtn = fab.locator('button[aria-label="Edit"]');
await editBtn.click();
const timerInput = page.getByRole('textbox').nth(1);
await timerInput.fill('45');
const saveBtn = fab.locator('button[aria-label="Save"]');
await saveBtn.click();
// Quit session
await page.getByRole('button', { name: 'Finish' }).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();
// Start Quick Log
await page.getByRole('button', { name: 'Quick Log' }).click();
// Verify timer default is now '0:45'.
const quickFab = page.locator('.fixed.bottom-24.right-6');
await quickFab.click();
await expect(page.locator('div').filter({ hasText: /0:45/ }).first()).toBeVisible();
});
test('TC-RT-02: Timer functionality (Start/Pause/Reset)', async ({ page, createUniqueUser }) => {
test('should integrate the rest timer with plans', async ({ page, createUniqueUser }) => {
const user = await createUniqueUser();
await loginUser(page, user.email, user.password);
await page.goto('/');
await page.getByLabel('Email').fill(user.email);
await page.getByLabel('Password').fill(user.password);
await page.getByRole('button', { name: 'Login' }).click();
await page.getByRole('button', { name: "Start Empty Session" }).click();
try {
await page.getByRole('heading', { name: 'Change Password' }).waitFor();
await page.getByLabel('New Password').fill('StrongNewPassword123!');
await page.getByRole('button', { name: 'Save & Login' }).click();
} catch (e) {
// Ignore
}
await expect(page.getByText('Free Workout')).toBeVisible();
// Navigate to the "Plans" page.
await page.getByRole('button', { name: 'Plans' }).click();
// Create a new plan.
await page.getByRole('button', { name: 'Create Plan' }).click();
// Name the plan "Timer Test Plan".
await page.getByRole('textbox', { name: 'Name' }).fill('Timer Test Plan');
// Add the first exercise.
await page.getByRole('button', { name: 'Add Exercise' }).click();
await page.getByRole('button', { name: 'New Exercise' }).click();
await page.locator('[id="_r_4_"]').fill('Bench Press');
await page.getByRole('button', { name: 'Free Weights & Machines' }).click();
await page.getByRole('button', { name: 'Create' }).click();
await page.getByPlaceholder('Rest (s)').fill('30');
// Add the second exercise.
await page.getByRole('button', { name: 'Add Exercise' }).click();
await page.getByRole('button', { name: 'New Exercise' }).click();
await page.locator('[id="_r_5_"]').fill('Squat');
await page.getByRole('button', { name: 'Free Weights & Machines' }).click();
await page.getByRole('button', { name: 'Create' }).click();
await page.getByPlaceholder('Rest (s)').nth(1).fill('60');
// Save the plan.
await page.getByRole('button', { name: 'Save' }).click();
// Start the plan.
await page.getByRole('button', { name: 'Start' }).click();
// Timer Update: Click FAB
const fab = page.locator('.fixed.bottom-24.right-6');
await fab.click(); // Expand
await fab.click();
// Start
const startBtn = fab.getByLabel('Start');
// Verify Timer shows '0:30'. Start it.
await expect(page.locator('div').filter({ hasText: /0:30/ }).first()).toBeVisible();
const startBtn = fab.locator('button[aria-label="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');
// Log Set for Step A while timer is running.
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/);
// Verify Timer continues running (does not reset).
await expect(page.locator('div').filter({ hasText: /0:2[0-9]/ }).first()).toBeVisible();
// Reset Timer manually (or wait for finish).
const resetBtn = fab.locator('button[aria-label="Reset"]');
await resetBtn.click();
// Verify Timer now shows '1:00' (for Step B).
await expect(page.locator('div').filter({ hasText: /1:00/ }).first()).toBeVisible();
});
});
});